shipeasy-sdk 2.0.0 → 2.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: 97efde389fa7a5adc1ffdab3951d09837f435bd529d8f6c3b7e3d45387472628
4
- data.tar.gz: 46f183776d94ccbddd8781e39d3ea5e56e6795cf26f3292f1482c2886b7d0836
3
+ metadata.gz: 9f3b0db78c3e6319bafac4913bb823b3dbc24a2e5f7166b1fe9a820f5588fadc
4
+ data.tar.gz: f232c0e140c5891bfd19112e8cea8877b42159806c5aba46185f54bd52b8d3ec
5
5
  SHA512:
6
- metadata.gz: 010654d1322131ab21b467fdff8176914e427558a5a620762c4417378f68c5cc5f3b189e7b0cdb8de6b741d2232e7c1552b8fd551bc3fce19b09e91263e18d12
7
- data.tar.gz: 24730826e8f2de0ac3f1546ab4f42b2301bcd8f83bdaea0b9c50487d7b90d5b445470827199de73c4794bc16b2ddeed3e18fc44079c2000cd0c92aeb07f50e55
6
+ metadata.gz: 546b88ba03ff9944de78b6982c142b4beba2325dd2b07702bf83518ce7b29778ca2f963c69b3fdfa30fab4a65ff3327ef700666a648e07d4fc2f70d2f57fd03a
7
+ data.tar.gz: eb288754985044ef4d2c3094ce6eb78172eb107394e66a191d6a3740afe167b9b5621a4a4e3ee618d51d62aa1f1f7ce3d1daa873243996cbd4037e62e4ba390f
data/README.md CHANGED
@@ -1,10 +1,40 @@
1
+ <!--
2
+ This file is GENERATED by scripts/gen_readme.rb from docs/.
3
+ Do NOT edit by hand — edit the docs, then run: ruby scripts/gen_readme.rb
4
+ -->
5
+
1
6
  # shipeasy-sdk (Ruby)
2
7
 
3
- Ruby gem for the [Shipeasy](https://shipeasy.ai) hosted service. Server-side
4
- gate evaluation, runtime configs, experiments, and metric ingestion.
8
+ [![Tests](https://github.com/shipeasy-ai/sdk-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/shipeasy-ai/sdk-ruby/actions/workflows/test.yml)
9
+ [![Gem](https://img.shields.io/gem/v/shipeasy-sdk.svg)](https://rubygems.org/gems/shipeasy-sdk)
10
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-CC342D.svg)](https://www.ruby-lang.org/)
11
+
12
+ Server SDK for [Shipeasy](https://shipeasy.ai) — **feature flags, dynamic
13
+ configs, kill switches, A/B experiments, and metric tracking**, with Rails i18n
14
+ view helpers. Server-key only; never embed in a browser.
15
+
16
+ > 📚 **Full documentation:** **<https://shipeasy-ai.github.io/sdk-ruby/>** — also browsable under
17
+ > [`docs/`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs). This README is generated from those docs.
5
18
 
6
19
  > Source-available under the [Shipeasy-SAL 1.0](./LICENSE).
7
20
 
21
+ ## 🤖 Using an AI agent?
22
+
23
+ This SDK ships an installable **agent skill** — a copy-paste-ready guide to
24
+ `Shipeasy.configure` + `Client.new(user)`, testing, experiments, error
25
+ reporting, and more, with links the agent can pull for deeper docs:
26
+
27
+ - **Skill:** [`docs/skill/SKILL.md`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/skill/SKILL.md) · raw:
28
+ `https://shipeasy-ai.github.io/sdk-ruby/skill/SKILL.md`
29
+ - **Install it** (ships with the gem — no network):
30
+ `shipeasy-skill install` → `.claude/skills/shipeasy-ruby/SKILL.md`
31
+ (or via the Shipeasy CLI: `shipeasy docs skill --sdk ruby --install`)
32
+
33
+ **Humans:** you can copy that skill straight into your own project's agent skills
34
+ directory (e.g. `.claude/skills/shipeasy-ruby/SKILL.md`) so your coding agent
35
+ always uses the correct Shipeasy patterns. Every doc page and snippet is also
36
+ fetchable by URL — start from the manifest at `https://shipeasy-ai.github.io/sdk-ruby/manifest.json`.
37
+
8
38
  ## Install
9
39
 
10
40
  ```ruby
@@ -12,324 +42,86 @@ gate evaluation, runtime configs, experiments, and metric ingestion.
12
42
  gem "shipeasy-sdk"
13
43
  ```
14
44
 
15
- ## Quickstart (Rails)
45
+ Requires Ruby 3.0+. Per-framework setup (Rails / Sinatra / serverless) and the
46
+ anon-id middleware are on the [Installation](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/installation.md) page.
16
47
 
17
- Two parts: **configure once** at boot, then build a **user-bound
18
- `Shipeasy::Client`** per request via its constructor.
19
-
20
- `config/initializers/shipeasy.rb`:
48
+ ## Quickstart — `configure` once, then `Client.new(user)` per request
21
49
 
22
50
  ```ruby
51
+ # boot (config/initializers/shipeasy.rb)
23
52
  Shipeasy.configure do |c|
24
53
  c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
25
-
26
- # Optional: map YOUR user object → the Shipeasy attribute hash. Runs once,
27
- # in the Shipeasy::Client constructor. Omit it and the object you pass to
28
- # Shipeasy::Client.new IS the attribute hash (identity).
29
54
  c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
30
-
31
- c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
32
- c.profile = "default"
33
- end
34
- ```
35
-
36
- `configure` builds the single global engine for you and kicks off a one-shot
37
- fetch (fire-and-forget). Anywhere in your app, construct a bound client and call
38
- the getters with **no user argument** — the user is bound at construction:
39
-
40
- ```ruby
41
- flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
42
-
43
- if flags.get_flag("new_checkout") # NO user arg
44
- # ship it
45
55
  end
46
56
 
47
- color = flags.get_config("button_color")
48
- result = flags.get_experiment("checkout_cta", { label: "Buy now" })
49
- panic = flags.get_killswitch("payments")
50
- ```
57
+ # per request — construct once per callsite (cheap; binds the user)
58
+ flags = Shipeasy::Client.new(current_user)
51
59
 
52
- `Shipeasy::Client` is **cheap**: it delegates evaluation to the single engine
53
- built by `configure` — it never opens its own connection, fetches, or polls.
54
- Construct one per user / per request.
55
-
56
- Event ingestion (`track`) lives on the engine `Shipeasy.engine` is the global
57
- one `configure` registered:
58
-
59
- ```ruby
60
- Shipeasy.engine.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
60
+ flags.get_flag("new_checkout") # NO user arg bound at construction
61
+ flags.get_config("button_color")
62
+ result = flags.get_experiment("checkout_cta", { label: "Buy" })
63
+ flags.log_exposure("checkout_cta") # at the decision point
64
+ flags.track("purchase", { revenue: 49 }) # on conversion
65
+ flags.get_killswitch("payments")
61
66
  ```
62
67
 
63
- > **Upgrading from 1.x?** The heavyweight client was renamed
64
- > `Shipeasy::SDK::FlagsClient` → `Shipeasy::Engine`, and `Shipeasy::Client` is
65
- > now the lightweight user-bound handle. The legacy `Shipeasy.flags.get_flag(name, user)`
66
- > singleton still works.
68
+ Constructing `Shipeasy::Client.new(user)` before `Shipeasy.configure` raises
69
+ `Shipeasy::Error`.
67
70
 
68
- In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
69
-
70
- ```erb
71
- <%= i18n_head_tags %>
72
- <h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
73
- ```
74
-
75
- ### Anonymous visitors (zero-config bucketing)
76
-
77
- For logged-out traffic you need a *stable* unit so a fractional rollout buckets
78
- the same on the server and in the browser. In Rails this is automatic: a Railtie
79
- mounts `Shipeasy::SDK::RackMiddleware`, which mints the shared `__se_anon_id`
80
- first-party cookie (read + written by every Shipeasy SDK, including the browser)
81
- for any request without one. Evaluations then default to it with **no per-call
82
- wiring** — `get_flag` on an anonymous request just works:
83
-
84
- ```ruby
85
- # current_user is nil → buckets on the __se_anon_id cookie automatically
86
- Shipeasy::Client.new({}).get_flag("new_checkout")
87
- ```
88
-
89
- An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
90
- yourself it's also on the Rack env as `request.env["shipeasy.anon_id"]`. The
91
- cookie is non-`HttpOnly` by design so the browser SDK can bucket identically. A
92
- request with **no** unit still resolves a fully-rolled (100%) gate as on; only
93
- fractional gates need the id. Cookie name + format are a cross-SDK contract —
94
- see `18-identity-bucketing.md`.
95
-
96
- For **Sinatra / Hanami / bare Rack** (no Railtie), mount it yourself:
97
-
98
- ```ruby
99
- use Shipeasy::SDK::RackMiddleware
100
- ```
101
-
102
- ## Quickstart (plain Ruby / Sinatra / Hanami / scripts)
103
-
104
- Same pattern, just without `config/initializers`:
105
-
106
- ```ruby
107
- require "shipeasy-sdk"
108
-
109
- Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
110
-
111
- # With no `attributes` transform, the hash you pass IS the attribute map.
112
- Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout")
113
- ```
114
-
115
- The Rails view helpers (`i18n_*`) are not loaded outside Rails, so the
116
- gem doesn't pull Rails into Sinatra/Hanami apps.
117
-
118
- ## Lambda / Cloud Run / serverless
119
-
120
- Skip the auto-init facade — it spawns a poll thread you don't want in a
121
- short-lived function. Build the client explicitly and call `init_once`
122
- for a single synchronous fetch:
123
-
124
- ```ruby
125
- engine = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
126
- engine.init_once
127
- engine.get_flag("new_checkout", user)
128
- ```
129
-
130
- ## Lifecycle escape hatch
131
-
132
- If you want explicit shutdown control in a long-running worker, build the
133
- client yourself and skip the singleton:
134
-
135
- ```ruby
136
- client = Shipeasy::SDK.new_client # reads api_key + base_url from Shipeasy.config
137
- client.init
138
- at_exit { client.destroy }
139
- ```
140
-
141
- ## Server-side rendering (SSR)
142
-
143
- Emit the request's evaluated flags as a declarative `<script>` tag so the
144
- browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
145
- in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
146
- hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
147
- browser buckets identically to the server.
148
-
149
- ```ruby
150
- user = { "user_id" => "u_123" }
151
-
152
- # Two tags for the document <head>. The PUBLIC client key (not the server
153
- # key) goes on the i18n loader tag.
154
- head = client.bootstrap_script_tag(user, anon_id: anon_id) +
155
- client.i18n_script_tag(client_key, profile: "en:prod")
156
-
157
- # …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
158
- boot = client.evaluate(user)
159
- ```
160
-
161
- `bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
162
- `https://cdn.shipeasy.ai`). In **Rails**, the existing
163
- `Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
164
- loader tag from your app config.
165
-
166
- ## Default values
167
-
168
- `get_flag` and `get_config` take an optional `default:` returned **only when the
169
- value cannot be resolved** — never when a flag genuinely evaluates to `false`.
170
-
171
- ```ruby
172
- # Flag: default is returned only when the client isn't ready yet (no blob
173
- # fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
174
- # or outside its rollout) returns false, NOT the default.
175
- Shipeasy.flags.get_flag("new_checkout", user, default: true)
176
-
177
- # Config: default is returned when the config key is absent. A decode proc still
178
- # runs on a present value.
179
- Shipeasy.flags.get_config("button_color", default: "blue")
180
- Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)
181
- ```
182
-
183
- ## Evaluation detail
184
-
185
- `get_flag_detail(name, user)` returns the boolean **and the reason** it was
186
- reached, as a `FlagDetail` struct (`.value`, `.reason`). `get_flag` is built on
187
- top of it. The reason is one of the `REASON_*` constants:
188
-
189
- | Reason | Meaning |
190
- | ------------------ | --------------------------------------------------- |
191
- | `OVERRIDE` | answered by a local `override_flag` (no telemetry) |
192
- | `CLIENT_NOT_READY` | no flag blob fetched/loaded yet |
193
- | `FLAG_NOT_FOUND` | blob present, but this gate isn't in it |
194
- | `OFF` | gate present but disabled or killswitched |
195
- | `RULE_MATCH` | evaluated to `true` |
196
- | `DEFAULT` | evaluated to `false` (rollout/rule) |
197
-
198
- ```ruby
199
- detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
200
- detail.value # => true / false
201
- detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...
202
- ```
203
-
204
- The `gate` usage beacon fires exactly once per `get_flag_detail` call (never on
205
- the `OVERRIDE` short-circuit).
206
-
207
- ## Change listeners
208
-
209
- `on_change` registers a callback fired after a background poll fetches **new**
210
- flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
211
- returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
212
- no poll thread). A raising listener is isolated and logged, not propagated.
213
-
214
- ```ruby
215
- unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
216
- # ... later
217
- unsubscribe.call
218
- ```
219
-
220
- ## Offline snapshot
221
-
222
- For CI, air-gapped runs, or reproducing a production decision from a captured
223
- blob, build a **no-network** client that still runs the real evaluator against a
224
- snapshot. The snapshot JSON holds the raw response bodies of the two SDK
225
- endpoints:
226
-
227
- ```json
228
- { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
229
- ```
230
-
231
- ```ruby
232
- client = Shipeasy::Engine.from_file("snapshot.json")
233
- # or, from already-parsed blobs:
234
- client = Shipeasy::Engine.from_snapshot(flags: flags_body, experiments: exps_body)
235
-
236
- client.get_flag("new_checkout", user) # real evaluation, no network
237
- client.get_experiment("checkout_cta", user, {})
238
- ```
239
-
240
- `init` / `init_once` / `track` are no-ops and telemetry is off (it reuses the
241
- `for_testing` plumbing). Local `override_*` setters still apply on top of the
242
- snapshot.
243
-
244
- ## Evaluation details
71
+ ## Documentation
245
72
 
246
- - **Gates** rules matched in order; rollout bucket =
247
- `murmur3("#{salt}:#{uid}") % 10000 < rollout_pct`.
248
- - **Experiments** `status == "running"`, optional targeting gate,
249
- universe holdout range, allocation bucket, then group assignment by
250
- weight.
251
- - **MurmurHash3** — pure-Ruby x86_32 variant, seed 0.
252
- - **ETag caching** each poll sends `If-None-Match`; a 304 skips the
253
- JSON parse.
254
- - **Poll interval** defaults to 30 s; overridden by the
255
- `X-Poll-Interval` header from the flags endpoint.
73
+ | Page | What |
74
+ | --- | --- |
75
+ | [Overview](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/overview.md) | The `Shipeasy.configure` + `Client.new(user)` model. |
76
+ | [Installation](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/installation.md) | Install, frameworks (Rails / Sinatra / serverless), `configure` wiring. |
77
+ | [Configuration](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configuration.md) | Keys, `attributes`, one-shot vs poll, every option. |
78
+ | [Feature flags](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/flags.md) | `get_flag`, `get_flag_detail`, defaults. |
79
+ | [Dynamic configs](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configs.md) | `get_config`, typed decode, defaults. |
80
+ | [Kill switches](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/killswitches.md) | `get_killswitch`, named switches. |
81
+ | [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `get_experiment`, `log_exposure`, `track`. |
82
+ | [Internationalization](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/i18n.md) | Rails view helpers + the SSR loader tag. |
83
+ | [Error reporting](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/error-reporting.md) | `see()` structured error reporting. |
84
+ | [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) | `configure_for_testing` / `configure_for_offline`, overrides. |
85
+ | [OpenFeature](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/openfeature.md) | `Shipeasy::OpenFeature::Provider` (OpenFeature server provider). |
86
+ | [Advanced](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/advanced.md) | Anon-id middleware, private attributes, sticky bucketing, SSR. |
87
+
88
+ Copy-paste snippets live under [`docs/snippets/`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/snippets)
89
+ (release · metrics · i18n · ops); an installable agent skill is at
90
+ [`docs/skill/SKILL.md`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/skill/SKILL.md).
256
91
 
257
92
  ## Testing
258
93
 
259
- For unit/integration tests you want a client that does **zero network** and
260
- returns exactly the values you seed — no api_key, no fetch, no poll thread, no
261
- telemetry, no metric ingestion. Build one with `Shipeasy::Engine.for_testing` and
262
- seed each entity with the `override_*` setters (Statsig-style local overrides).
263
- An override always wins over the fetched blob, so the getters answer
264
- deterministically:
94
+ Use **`Shipeasy.configure_for_testing`** the test-mode sibling of [`Shipeasy.configure`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configuration.md). It does **zero network**, needs no api key, and seeds the values your code under test should see via override arguments. Then read through the ordinary `Shipeasy::Client.new(user)` — the *same* call your production code uses.
265
95
 
266
96
  ```ruby
267
97
  require "shipeasy-sdk"
268
98
 
269
- client = Shipeasy::Engine.for_testing
270
- # init / init_once are no-ops here — nothing is ever fetched.
271
-
272
- # Flags (boolean)
273
- client.override_flag("new_checkout", true)
274
- client.get_flag("new_checkout", { user_id: "u_1" }) # => true
99
+ Shipeasy.configure_for_testing(
100
+ flags: { "new_checkout" => true },
101
+ configs: { "billing_copy" => { "title" => "Welcome" } },
102
+ experiments: { "checkout_button" => ["treatment", { "color" => "green" }] },
103
+ )
275
104
 
276
- # Configs (any value; an optional decode proc still runs)
277
- client.override_config("button_color", "blue")
278
- client.get_config("button_color") # => "blue"
279
- client.override_config("limits", { "max" => 10 })
280
- client.get_config("limits", ->(v) { v["max"] }) # => 10
105
+ # construct once per callsite (cheap; binds the user)
106
+ client = Shipeasy::Client.new({ "user_id" => "u_123" })
281
107
 
282
- # Experiments — returns an in-experiment Eval::ExperimentResult
283
- client.override_experiment("checkout_cta", "treatment", { label: "Buy now" })
284
- r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
285
- r.in_experiment # => true
286
- r.group # => "treatment"
287
- r.params # => { label: "Buy now" }
108
+ client.get_flag("new_checkout") # => true
109
+ client.get_config("billing_copy") # => { "title" => "Welcome" }
288
110
 
289
- # track is a no-op (no thread, no network) assert call counts without stubbing.
290
- client.track("u_1", "checkout_completed", { revenue: 49.99 }) # => nil
111
+ result = client.get_experiment("checkout_button", { "color" => "blue" })
112
+ result.in_experiment # => true
113
+ result.group # => "treatment"
114
+ result.params # => { "color" => "green" }
291
115
 
292
- # Reset between examples
293
- client.clear_overrides
116
+ # track / log_exposure are no-ops in test mode — safe to call, send nothing
117
+ client.track("purchase", { amount: 49 })
294
118
  ```
295
119
 
296
- The same `override_flag` / `override_config` / `override_experiment` /
297
- `clear_overrides` setters also work on a **normal** live client (built with
298
- `Shipeasy::Engine.new(...)`), so you can pin one value in local development while the
299
- rest comes from the fetched blob.
300
-
301
- ### Global engine / bound client
302
-
303
- `Shipeasy.engine` (registered by `configure`) and `Shipeasy.flags` (legacy
304
- singleton) both fetch over the network, so in tests stub them to a
305
- `for_testing` engine. `Shipeasy::Client.new(user)` reads `Shipeasy.engine`, so
306
- stubbing the engine also covers the bound-client path:
307
-
308
- ```ruby
309
- # RSpec
310
- before do
311
- test_engine = Shipeasy::Engine.for_testing
312
- test_engine.override_flag("new_checkout", true)
313
- allow(Shipeasy).to receive(:engine).and_return(test_engine)
314
- allow(Shipeasy).to receive(:flags).and_return(test_engine) # legacy path
315
-
316
- # Shipeasy::Client.new(user).get_flag("new_checkout") now => true
317
- end
318
- ```
319
-
320
- ## Configuration
321
-
322
- | Parameter | Default | Description |
323
- | ------------ | ----------------------------- | ------------------------------------------------------------------- |
324
- | `api_key` | (required) | SDK key from the Shipeasy dashboard |
325
- | `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
326
- | `attributes` | identity (`->(u) { u }`) | Callable mapping your user object → the Shipeasy attribute hash |
327
-
328
- ## Documentation
329
-
330
- [docs.shipeasy.ai](https://docs.shipeasy.ai)
120
+ More the on-the-spot override helpers and a working example
121
+ `shipeasy-snapshot.json` on the [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) page.
331
122
 
332
123
  ## License
333
124
 
334
- [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use,
335
- permitted as a Shipeasy client.
125
+ [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use, permitted
126
+ as a Shipeasy client. Evaluation is tested against the cross-language
127
+ MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Opt-in installer for the bundled Shipeasy agent skill. See
5
+ # lib/shipeasy/sdk/skill.rb.
6
+ require "shipeasy/sdk/skill"
7
+
8
+ exit Shipeasy::SDK::Skill.main(ARGV)
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: shipeasy-ruby
3
+ description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/get_experiment/get_killswitch, track, testing, OpenFeature.
4
+ ---
5
+
6
+ # Shipeasy Ruby SDK
7
+
8
+ Server-side Ruby gem (`shipeasy-sdk`) for Shipeasy: feature gates, dynamic
9
+ configs, kill switches, A/B experiments, metrics, `see()` error reporting, and
10
+ Rails i18n view helpers. Server-key only — never embed in a browser. Ruby 3.0+.
11
+
12
+ Two things only: **`Shipeasy.configure`** once at boot, then
13
+ **`Shipeasy::Client.new(user)`** per request.
14
+
15
+ > **Pulling deeper docs.** Each section below links its full reference page and
16
+ > copy-paste snippets — fetch any of them as raw Markdown when you need more than
17
+ > this summary. Discover the whole tree from the manifest:
18
+ > `https://shipeasy-ai.github.io/sdk-ruby/manifest.json` (lists every
19
+ > `pages/<key>.md` and `snippets/<group>/<leaf>.md`). All URLs below are
20
+ > `https://shipeasy-ai.github.io/sdk-ruby/…`.
21
+
22
+ ## Configure once (boot)
23
+
24
+ ```ruby
25
+ # config/initializers/shipeasy.rb
26
+ Shipeasy.configure do |c|
27
+ c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
28
+ # Optional: map your user object → the attribute hash (runs once in Client.new).
29
+ c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
30
+ # i18n only (public client key + profile):
31
+ c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY")
32
+ c.profile = "default"
33
+ end
34
+ ```
35
+
36
+ Omit `c.attributes` if your user object is already the attribute hash. For a
37
+ long-running server set `c.poll = true` to keep the blob fresh in the background;
38
+ the default (one-shot fetch, no thread) is serverless-friendly.
39
+
40
+ → More: `pages/installation.md` (per-framework setup), `pages/configuration.md`
41
+ (every option).
42
+
43
+ ## Evaluate (bound `Client.new(user)` — NO user arg)
44
+
45
+ Bind the user once per request, then call without re-passing it — `track` and
46
+ `log_exposure` are on the bound client too, so experiments are end-to-end here:
47
+
48
+ ```ruby
49
+ flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
50
+
51
+ flags.get_flag("new_checkout") # bool; default: only when unresolved
52
+ flags.get_config("button_color", default: "blue")
53
+ flags.get_killswitch("payments") # true = killed; optional switch_key
54
+ result = flags.get_experiment("checkout_cta", { label: "Buy now" })
55
+ # result.in_experiment / result.group / result.params
56
+
57
+ flags.log_exposure("checkout_cta") # at the decision point
58
+ flags.track("purchase", { revenue: 49 }) # conversion / metric event
59
+ ```
60
+
61
+ `get_flag_detail` returns a `FlagDetail` (`.value`, `.reason`: `RULE_MATCH`,
62
+ `DEFAULT`, `OFF`, `OVERRIDE`, `FLAG_NOT_FOUND`, `CLIENT_NOT_READY`).
63
+
64
+ → More: pages `pages/flags.md` · `pages/configs.md` · `pages/killswitches.md`
65
+ (incl. named switches) · `pages/experiments.md`. Snippets
66
+ `snippets/release/{flags,configs,killswitches,experiments}.md` and
67
+ `snippets/metrics/track.md`.
68
+
69
+ ## Testing (no network)
70
+
71
+ Use the `configure` siblings — seed overrides, read through the same `Client`:
72
+
73
+ ```ruby
74
+ Shipeasy.configure_for_testing(
75
+ flags: { "new_checkout" => true },
76
+ configs: { "billing_copy" => { "title" => "Welcome" } },
77
+ experiments: { "checkout_button" => ["treatment", { "color" => "green" }] },
78
+ )
79
+ Shipeasy::Client.new({ "user_id" => "u_123" }).get_flag("new_checkout") # => true
80
+
81
+ # flip a value on the spot, mid-test:
82
+ Shipeasy.override_flag("new_checkout", false)
83
+ Shipeasy.clear_overrides
84
+ ```
85
+
86
+ Offline (real rules from a snapshot / file):
87
+
88
+ ```ruby
89
+ Shipeasy.configure_for_offline(path: "snapshot.json")
90
+ # or snapshot: { "flags" => {...}, "experiments" => {...} }, plus optional overrides
91
+ ```
92
+
93
+ → More: `pages/testing.md` (override helpers + a working example
94
+ `shipeasy-snapshot.json`).
95
+
96
+ ## OpenFeature
97
+
98
+ ```ruby
99
+ require "open_feature/sdk" # optional dep: gem "openfeature-sdk" (Ruby ≥ 3.4)
100
+ require "shipeasy/sdk/openfeature"
101
+
102
+ Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
103
+ OpenFeature::SDK.configure { |c| c.set_provider(Shipeasy::OpenFeature::Provider.new) } # uses the global
104
+ ```
105
+
106
+ Boolean → gate; string/number/object → config.
107
+
108
+ → More: `pages/openfeature.md` (reason mapping, type routing).
109
+
110
+ ## Error reporting — see()
111
+
112
+ ```ruby
113
+ begin
114
+ charge_card(order)
115
+ rescue => e
116
+ Shipeasy.see(e).causes_the("checkout").to("use the backup processor")
117
+ end
118
+ ```
119
+
120
+ `Shipeasy.see_violation(name)` for non-exception problems;
121
+ `Shipeasy.control_flow_exception(e).because(...)` marks expected control flow
122
+ (reports nothing).
123
+
124
+ → More: `pages/error-reporting.md` · snippets `snippets/ops/see.md`
125
+ (`.extras`, violations, control-flow exceptions).
126
+
127
+ ## i18n (Rails)
128
+
129
+ ```erb
130
+ <%= i18n_head_tags %>
131
+ <h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
132
+ ```
133
+
134
+ Outside Rails: `Shipeasy.i18n_script_tag(client_key, profile: "en:prod")` emits
135
+ the loader tag (public client key).
136
+
137
+ → More: `pages/i18n.md` · snippets `snippets/i18n/{setup,render}.md`.
138
+
139
+ ## Other surfaces
140
+
141
+ - Anon bucketing: `Shipeasy::SDK::RackMiddleware` mints the shared `__se_anon_id`
142
+ cookie (Rails Railtie auto-mounts it); anonymous `get_flag` then just works.
143
+ - `c.private_attributes = ["email"]` strips keys from outbound events.
144
+ - `c.sticky_store = Shipeasy::SDK::InMemoryStickyStore.new` pins experiment assignment.
145
+ - SSR: `Shipeasy.bootstrap_script_tag(user)` + `Shipeasy.i18n_script_tag(client_key, "en:prod")`.
146
+ - `Shipeasy.on_change { ... }` (requires `c.poll = true`) fires after a poll fetches new data.
147
+
148
+ → More: `pages/advanced.md`.
@@ -55,6 +55,15 @@ module Shipeasy
55
55
  def get_killswitch(name, switch_key = nil)
56
56
  @engine.get_killswitch(name, switch_key)
57
57
  end
58
+
59
+ def track(event_name, props = {})
60
+ id = @attributes["user_id"] || @attributes["anonymous_id"]
61
+ @engine.track(id, event_name, props)
62
+ end
63
+
64
+ def log_exposure(experiment_name)
65
+ @engine.log_exposure(@attributes, experiment_name)
66
+ end
58
67
  end
59
68
 
60
69
  # Raised by Shipeasy::Client when constructed before Shipeasy.configure.
@@ -22,6 +22,27 @@ module Shipeasy
22
22
  # ---- experimentation / SDK ----
23
23
  attr_accessor :api_key, :base_url
24
24
 
25
+ # Advanced `configure` options — threaded into the global Engine `configure`
26
+ # builds, so callers never construct an Engine themselves:
27
+ # - env (default "prod"): deployment tag on see() events + usage telemetry.
28
+ # - disable_telemetry (default false): opt out of per-eval usage telemetry.
29
+ # - telemetry_url: override the telemetry endpoint (rarely needed).
30
+ # - private_attributes: attribute keys stripped from every outbound event
31
+ # before it leaves the process (they still drive targeting locally).
32
+ # - sticky_store: pin a user's experiment group across re-buckets.
33
+ attr_accessor :env, :disable_telemetry, :telemetry_url,
34
+ :private_attributes, :sticky_store
35
+
36
+ # Fetch lifecycle for the global engine `configure` builds:
37
+ # - init (default true): fire a one-shot fetch fire-and-forget so the first
38
+ # `Shipeasy::Client.new(user).get_flag(...)` resolves against real rules
39
+ # (ideal for serverless / short-lived processes).
40
+ # - poll (default false): start the background poll (initial fetch +
41
+ # periodic refresh) for a long-running server, so flags stay fresh
42
+ # without a redeploy. Configuration owns the lifecycle — you never call
43
+ # `engine.init` yourself.
44
+ attr_accessor :init, :poll
45
+
25
46
  # Optional transform from YOUR user object (any shape) to the Shipeasy
26
47
  # attribute hash every flag/experiment evaluation uses. A callable
27
48
  # (lambda/proc or anything responding to #call). Default = identity (the
@@ -42,6 +63,13 @@ module Shipeasy
42
63
  def initialize
43
64
  @base_url = "https://edge.shipeasy.dev"
44
65
  @attributes = nil
66
+ @init = true
67
+ @poll = false
68
+ @env = "prod"
69
+ @disable_telemetry = false
70
+ @telemetry_url = nil
71
+ @private_attributes = nil
72
+ @sticky_store = nil
45
73
 
46
74
  @profile = "default"
47
75
  @default_chunk = "index"
@@ -105,23 +133,180 @@ module Shipeasy
105
133
  @engine
106
134
  end
107
135
 
108
- # Build + register the one global engine (first-config-wins). Fires the
109
- # one-shot fetch fire-and-forget. Idempotent within a process.
136
+ # Build + register the one global engine (first-config-wins). Kicks off the
137
+ # configured fetch lifecycle (one-shot by default; the background poll when
138
+ # `c.poll = true`) fire-and-forget. Idempotent within a process.
110
139
  def register_engine!(cfg)
111
140
  return @engine if @engine && @engine_pid == Process.pid
112
141
  @engine_pid = Process.pid
113
- engine = Engine.new(api_key: cfg.api_key, base_url: cfg.base_url)
142
+ engine = Engine.new(
143
+ api_key: cfg.api_key,
144
+ base_url: cfg.base_url,
145
+ env: cfg.env,
146
+ disable_telemetry: cfg.disable_telemetry,
147
+ telemetry_url: cfg.telemetry_url,
148
+ private_attributes: cfg.private_attributes,
149
+ sticky_store: cfg.sticky_store,
150
+ )
114
151
  @engine = engine
115
152
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
116
153
  # reset/reconfigure could nil out before the thread runs).
117
- Thread.new do
118
- engine.init_once
119
- rescue => e
120
- warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
154
+ if cfg.poll
155
+ Thread.new do
156
+ engine.init # initial fetch + background poll thread
157
+ rescue => e
158
+ warn "[shipeasy] configure(poll) background poll failed: #{e.message}"
159
+ end
160
+ elsif cfg.init
161
+ Thread.new do
162
+ engine.init_once
163
+ rescue => e
164
+ warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
165
+ end
121
166
  end
122
167
  engine
123
168
  end
124
169
 
170
+ # ---- configure() test/offline siblings -----------------------------------
171
+ #
172
+ # Drop-in siblings of `Shipeasy.configure` for tests and offline evaluation.
173
+ # Unlike `configure` (first-config-wins), these REPLACE the registered global
174
+ # engine, so a suite can reconfigure between cases. After either, you read the
175
+ # same way: `Shipeasy::Client.new(user)`.
176
+
177
+ # Configure Shipeasy in TEST MODE — no api key, zero network, ever. Seed the
178
+ # values your code under test should see via the override args, then read
179
+ # through the ordinary `Shipeasy::Client.new(user)`:
180
+ #
181
+ # Shipeasy.configure_for_testing(flags: { "new_checkout" => true })
182
+ # Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout") # => true
183
+ #
184
+ # flags: { name => bool } forced get_flag results
185
+ # configs: { name => value } forced get_config results
186
+ # experiments: { name => [group, params] } forced enrolments
187
+ # attributes: same transform as configure (default identity)
188
+ def configure_for_testing(flags: nil, configs: nil, experiments: nil, attributes: nil)
189
+ engine = Engine.for_testing
190
+ apply_overrides(engine, flags, configs, experiments)
191
+ install_global_engine(engine, attributes)
192
+ end
193
+
194
+ # Configure Shipeasy OFFLINE — evaluate the REAL rules from an in-memory
195
+ # snapshot or a JSON file, with no network. Provide exactly one source:
196
+ #
197
+ # snapshot: { "flags" => <body of /sdk/flags>, "experiments" => <body of /sdk/experiments> }
198
+ # path: "snapshot.json" (a JSON file of the same shape)
199
+ #
200
+ # Optional flags/configs/experiments overrides layer on top (same shapes as
201
+ # configure_for_testing). Replaces any previously-configured engine.
202
+ def configure_for_offline(snapshot: nil, path: nil, flags: nil, configs: nil, experiments: nil, attributes: nil)
203
+ engine =
204
+ if path
205
+ Engine.from_file(path)
206
+ elsif snapshot
207
+ s = snapshot.transform_keys(&:to_s)
208
+ Engine.from_snapshot(flags: s["flags"], experiments: s["experiments"])
209
+ else
210
+ raise Error, "Shipeasy.configure_for_offline requires snapshot: or path:"
211
+ end
212
+ apply_overrides(engine, flags, configs, experiments)
213
+ install_global_engine(engine, attributes)
214
+ end
215
+
216
+ # ---- package-level helpers (so callers never name the Engine) -------------
217
+
218
+ # On-the-spot overrides layered on top of whatever configure_for_testing /
219
+ # configure_for_offline (or a live configure) set up — they win over the blob
220
+ # until clear_overrides. Require a prior configure* call.
221
+ def override_flag(name, value)
222
+ require_engine("override_flag").override_flag(name, value)
223
+ nil
224
+ end
225
+
226
+ def override_config(name, value)
227
+ require_engine("override_config").override_config(name, value)
228
+ nil
229
+ end
230
+
231
+ def override_experiment(name, group, params)
232
+ require_engine("override_experiment").override_experiment(name, group, params)
233
+ nil
234
+ end
235
+
236
+ # Drop EVERY override — including the seed from configure_for_testing (test
237
+ # mode has no blob beneath); under configure_for_offline it reverts to the
238
+ # snapshot.
239
+ def clear_overrides
240
+ require_engine("clear_overrides").clear_overrides
241
+ nil
242
+ end
243
+
244
+ # Register a poll listener fired after a background poll fetches NEW data
245
+ # (HTTP 200, not 304). Requires configure(poll: true). Returns an unsubscribe
246
+ # proc. Accepts a block or any callable.
247
+ def on_change(callable = nil, &block)
248
+ require_engine("on_change").on_change(callable, &block)
249
+ end
250
+
251
+ # SSR tag helpers — delegate to the configured global engine, so you never
252
+ # touch it. i18n_script_tag carries the PUBLIC client key (not the server
253
+ # key); bootstrap_script_tag embeds no key.
254
+ def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
255
+ require_engine("i18n_script_tag").i18n_script_tag(client_key, profile: profile, base_url: base_url)
256
+ end
257
+
258
+ def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
259
+ require_engine("bootstrap_script_tag").bootstrap_script_tag(
260
+ user, anon_id: anon_id, i18n_profile: i18n_profile, base_url: base_url
261
+ )
262
+ end
263
+
264
+ # see() structured error reporting — package-level, dispatched through the
265
+ # last-constructed default client (the engine configure built). Never raises
266
+ # into caller code; a call before any client exists warns and no-ops.
267
+ def see(problem)
268
+ Shipeasy::SDK.see(problem)
269
+ end
270
+
271
+ def see_violation(name)
272
+ Shipeasy::SDK.see_violation(name)
273
+ end
274
+
275
+ def control_flow_exception(err)
276
+ Shipeasy::SDK.control_flow_exception(err)
277
+ end
278
+
279
+ # Replace the registered global engine + attributes transform (used by the
280
+ # configure_for_* siblings — unlike configure, they replace so a test suite
281
+ # can reconfigure between cases). Returns the engine.
282
+ def install_global_engine(engine, attributes)
283
+ config.attributes = attributes
284
+ @engine = engine
285
+ @engine_pid = Process.pid
286
+ engine
287
+ end
288
+
289
+ # Apply the configure_for_* override args onto an engine.
290
+ def apply_overrides(engine, flags, configs, experiments)
291
+ (flags || {}).each { |name, value| engine.override_flag(name, value) }
292
+ (configs || {}).each { |name, value| engine.override_config(name, value) }
293
+ (experiments || {}).each do |name, spec|
294
+ group, params = spec # spec is [group, params]
295
+ engine.override_experiment(name, group, params)
296
+ end
297
+ end
298
+
299
+ # The global engine, or raise a helpful error naming the package-level fn the
300
+ # caller used before any configure*.
301
+ def require_engine(fn_name)
302
+ e = engine
303
+ return e unless e.nil?
304
+
305
+ raise Error, "Shipeasy.#{fn_name} called before Shipeasy.configure " \
306
+ "{ |c| c.api_key = … } (or configure_for_testing / " \
307
+ "configure_for_offline). Call one once at app boot."
308
+ end
309
+
125
310
  # Reset the config back to defaults — primarily for tests.
126
311
  def reset_config!
127
312
  @config = nil
@@ -90,15 +90,19 @@ module Shipeasy
90
90
 
91
91
  # Build a no-network, immediately-usable client for tests. Telemetry is
92
92
  # disabled, init/init_once/track are no-ops (never fetch), and no api_key
93
- # is required. Seed it with override_flag / override_config /
93
+ # is required. The client is immediately READY against an empty blob (so a
94
+ # missing gate resolves FLAG_NOT_FOUND, not CLIENT_NOT_READY — parity with
95
+ # the other SDKs). Seed it with override_flag / override_config /
94
96
  # override_experiment, then call the normal getters.
95
97
  def self.for_testing(env: "prod")
96
- new(
98
+ client = new(
97
99
  api_key: "test",
98
100
  env: env,
99
101
  disable_telemetry: true,
100
102
  test_mode: true,
101
103
  )
104
+ client.send(:load_snapshot, {}, {})
105
+ client
102
106
  end
103
107
 
104
108
  # Build an offline client from a JSON snapshot file. The file holds the
@@ -300,17 +304,21 @@ module Shipeasy
300
304
 
301
305
  # Read a killswitch from the cached flags blob. Without +switch_key+,
302
306
  # returns true when the whole killswitch is killed. With +switch_key+,
303
- # returns true when that specific per-key switch is on. Unknown
304
- # killswitches / switches return false. Not user-scoped.
307
+ # returns true when that specific named per-key switch is on — and when
308
+ # the key isn't configured on the killswitch, FALLS BACK to the top-level
309
+ # value (so an unconfigured key behaves exactly like the no-key call).
310
+ # Unknown killswitches return false. Not user-scoped.
305
311
  def get_killswitch(name, switch_key = nil)
306
312
  @telemetry.emit("ks", name)
307
313
  ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
308
314
  return false unless ks
309
- if switch_key.nil?
310
- Eval.enabled?(ks["killed"])
311
- else
312
- Eval.enabled?(ks.dig("switches", switch_key.to_s))
315
+ unless switch_key.nil?
316
+ switches = ks["switches"] || {}
317
+ key = switch_key.to_s
318
+ return Eval.enabled?(switches[key]) if switches.key?(key)
319
+ # key not configured → fall through to the top-level value
313
320
  end
321
+ Eval.enabled?(ks["killed"])
314
322
  end
315
323
 
316
324
  # Batch-evaluate every loaded gate, config and experiment for +user+ into
@@ -11,11 +11,10 @@
11
11
  # require "open_feature/sdk"
12
12
  # require "shipeasy/sdk/openfeature"
13
13
  #
14
- # client = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
15
- # client.init
14
+ # Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
16
15
  #
17
16
  # OpenFeature::SDK.configure do |config|
18
- # config.set_provider(Shipeasy::OpenFeature::Provider.new(client))
17
+ # config.set_provider(Shipeasy::OpenFeature::Provider.new) # uses the configured global
19
18
  # end
20
19
  #
21
20
  # of = OpenFeature::SDK.build_client
@@ -38,6 +37,8 @@ rescue LoadError => e
38
37
  end
39
38
 
40
39
  require_relative "../engine"
40
+ require_relative "../config"
41
+ require_relative "../client"
41
42
 
42
43
  module Shipeasy
43
44
  module OpenFeature
@@ -66,7 +67,17 @@ module Shipeasy
66
67
 
67
68
  attr_reader :metadata
68
69
 
69
- def initialize(client)
70
+ # Construct the provider. With no argument it resolves the global engine
71
+ # configured via `Shipeasy.configure(...)`, so callers never build an
72
+ # Engine themselves — construct it AFTER your `Shipeasy.configure` call.
73
+ # Pass an explicit engine only for advanced/multi-key setups.
74
+ def initialize(client = nil)
75
+ client ||= Shipeasy.engine
76
+ if client.nil?
77
+ raise Shipeasy::Error, "Shipeasy::OpenFeature::Provider.new needs " \
78
+ "Shipeasy.configure { |c| c.api_key = … } to have run first " \
79
+ "(or pass an explicit engine)."
80
+ end
70
81
  @client = client
71
82
  @metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
72
83
  end
@@ -123,12 +134,28 @@ module Shipeasy
123
134
  end
124
135
 
125
136
  # OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
126
- def track(tracking_event_name, evaluation_context: nil, details: {})
137
+ def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
127
138
  ctx = normalize_context(evaluation_context)
128
139
  user_id = ctx["targeting_key"] || ctx["user_id"]
129
140
  return if user_id.nil? || user_id.to_s.empty?
130
141
 
131
- props = details.is_a?(Hash) ? details : {}
142
+ # Base props = the evaluation-context attributes (minus the identity
143
+ # keys), with the tracking-event details merged on top.
144
+ props = ctx.reject { |k, _| k == "targeting_key" || k == "user_id" }
145
+
146
+ detail_fields = if tracking_event_details.respond_to?(:fields)
147
+ tracking_event_details.fields.transform_keys(&:to_s)
148
+ elsif tracking_event_details.is_a?(Hash)
149
+ tracking_event_details.transform_keys(&:to_s)
150
+ else
151
+ {}
152
+ end
153
+ props = props.merge(detail_fields)
154
+
155
+ if tracking_event_details.respond_to?(:value) && !tracking_event_details.value.nil?
156
+ props["value"] = tracking_event_details.value
157
+ end
158
+
132
159
  @client.track(user_id, tracking_event_name, props)
133
160
  end
134
161
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Shipeasy
6
+ module SDK
7
+ # `shipeasy-skill` — install the bundled Shipeasy agent skill into a project.
8
+ #
9
+ # RubyGems has no safe post-install hook (gems don't run code on install;
10
+ # installers run non-interactively), so installing the skill is an explicit,
11
+ # opt-in command:
12
+ #
13
+ # shipeasy-skill install # → .claude/skills/shipeasy-ruby/SKILL.md
14
+ # shipeasy-skill install --dir path/ # custom destination (file or dir)
15
+ # shipeasy-skill install --force # overwrite an existing file
16
+ # shipeasy-skill print # write the skill to stdout
17
+ #
18
+ # The skill (`docs/skill/SKILL.md`) is shipped inside the gem, so this reads
19
+ # it with no network — relative to this file, which works both from an
20
+ # installed gem and a source checkout.
21
+ module Skill
22
+ DEFAULT_DEST = ".claude/skills/shipeasy-ruby/SKILL.md"
23
+
24
+ # The bundled SKILL.md, read from docs/skill/SKILL.md (a sibling of lib/ in
25
+ # both the installed gem and a source checkout).
26
+ def self.skill_text
27
+ path = File.expand_path("../../../docs/skill/SKILL.md", __dir__)
28
+ File.read(path)
29
+ end
30
+
31
+ # Copy the skill to +dest+ (a file, or a directory it's written into).
32
+ def self.install(dest, force: false)
33
+ dest = File.join(dest, "SKILL.md") if File.directory?(dest) || File.extname(dest).empty?
34
+ if File.exist?(dest) && !force
35
+ warn "shipeasy-skill: refusing to overwrite #{dest} — pass --force"
36
+ return 1
37
+ end
38
+ FileUtils.mkdir_p(File.dirname(dest))
39
+ File.write(dest, skill_text)
40
+ puts "shipeasy-skill: installed the Shipeasy agent skill → #{dest}"
41
+ 0
42
+ end
43
+
44
+ def self.main(argv)
45
+ cmd = argv.shift
46
+ case cmd
47
+ when "install"
48
+ dest = DEFAULT_DEST
49
+ force = false
50
+ while (arg = argv.shift)
51
+ case arg
52
+ when "--dir" then dest = argv.shift
53
+ when "--force" then force = true
54
+ else
55
+ warn "shipeasy-skill: unknown argument #{arg}"
56
+ return 1
57
+ end
58
+ end
59
+ install(dest, force: force)
60
+ when "print"
61
+ puts skill_text
62
+ 0
63
+ else
64
+ puts <<~USAGE
65
+ shipeasy-skill — install the Shipeasy Ruby agent skill into your project.
66
+
67
+ Usage:
68
+ shipeasy-skill install [--dir PATH] [--force] copy SKILL.md (default: #{DEFAULT_DEST})
69
+ shipeasy-skill print print the skill to stdout
70
+ USAGE
71
+ 0
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "2.0.0"
3
+ VERSION = "2.1.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-25 00:00:00.000000000 Z
11
+ date: 2026-06-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -58,12 +58,15 @@ description: Server SDK for Shipeasy. Polls /sdk/flags and /sdk/experiments, eva
58
58
  / i18n_t view helpers for the Shipeasy string-manager CDN.
59
59
  email:
60
60
  - sdk@shipeasy.ai
61
- executables: []
61
+ executables:
62
+ - shipeasy-skill
62
63
  extensions: []
63
64
  extra_rdoc_files: []
64
65
  files:
65
66
  - LICENSE
66
67
  - README.md
68
+ - bin/shipeasy-skill
69
+ - docs/skill/SKILL.md
67
70
  - lib/shipeasy-sdk.rb
68
71
  - lib/shipeasy/client.rb
69
72
  - lib/shipeasy/config.rb
@@ -78,6 +81,7 @@ files:
78
81
  - lib/shipeasy/sdk/rack_middleware.rb
79
82
  - lib/shipeasy/sdk/railtie.rb
80
83
  - lib/shipeasy/sdk/see.rb
84
+ - lib/shipeasy/sdk/skill.rb
81
85
  - lib/shipeasy/sdk/sticky_store.rb
82
86
  - lib/shipeasy/sdk/telemetry.rb
83
87
  - lib/shipeasy/sdk/version.rb