@smbcloud / cli / commits / 3943158

Add Client class to Ruby gem for ergonomic auth API usage

- Introduce `SmbCloud::Auth::Client` for stateful API calls - Update README with client usage and available methods - Refactor module-level wrappers to delegate to `Client` - Update RBS signatures for new class and methods - Expand .gitignore to match native gem conventions - Add skill doc for Ruby native gem packaging and build rules

Seto Elkahfi committed Mar 29, 2026 at 15:23 UTC 3943158d22adb43954d779658e9be58c5cc6b0a2
5 files changed +244 -35
.agents/skills/smbcloud-ruby-native-gem/SKILL.md new
+144
@@ -0,0 +1,144 @@
1 +---
2 +name: smbcloud-ruby-native-gem
3 +description: Use when building, packaging, or publishing smbCloud Ruby gems backed by Rust native extensions through Magnus and rb_sys, especially under sdk/gems/model and sdk/gems/auth. Covers gem layout, path wiring to workspace crates, rake build/compile flows, pkg output, .gitignore conventions, and RubyGems release preflight.
4 +---
5 +
6 +# smbCloud Ruby Native Gem
7 +
8 +Use this skill when work touches smbCloud Ruby gems that wrap Rust crates through Magnus.
9 +
10 +Applies to:
11 +
12 +- `sdk/gems/model`
13 +- `sdk/gems/auth`
14 +- new gems following the same pattern
15 +- `ext/*/Cargo.toml` path wiring
16 +- `auth.gemspec` or `model.gemspec`
17 +- `Rakefile`, `extconf.rb`, `lib/*.rb`, `sig/*.rbs`
18 +- local build and RubyGems publish steps
19 +
20 +## Source of truth
21 +
22 +For each gem, inspect these files first:
23 +
24 +- `sdk/gems/<name>/<name>.gemspec`
25 +- `sdk/gems/<name>/Rakefile`
26 +- `sdk/gems/<name>/ext/<name>/Cargo.toml`
27 +- `sdk/gems/<name>/ext/<name>/extconf.rb`
28 +- `sdk/gems/<name>/lib/<name>.rb`
29 +- `sdk/gems/<name>/lib/<name>/version.rb`
30 +- `sdk/gems/<name>/.gitignore`
31 +
32 +The Rust crate under `crates/` remains the API source of truth. The gem should stay a thin Ruby-facing adapter.
33 +
34 +## Expected layout
35 +
36 +Use the same structure as `sdk/gems/model` and `sdk/gems/auth`:
37 +
38 +- workspace `Cargo.toml` at gem root
39 +- native extension crate at `ext/<name>`
40 +- Ruby entrypoint at `lib/<name>.rb`
41 +- version file at `lib/<name>/version.rb`
42 +- type signature file under `sig/`
43 +- `Rakefile` using `RbSys::ExtensionTask`
44 +- `extconf.rb` using `create_rust_makefile`
45 +
46 +Do not invent a different packaging layout unless there is a strong reason.
47 +
48 +## Cargo path wiring
49 +
50 +Be careful with relative paths from `sdk/gems/<name>/ext/<name>/Cargo.toml` to workspace crates.
51 +
52 +From that directory, `crates/...` is typically five levels up:
53 +
54 +- `./../../../../../crates/<crate-name>`
55 +
56 +Do not assume four levels. Verify the actual path before building.
57 +
58 +## Ruby API design
59 +
60 +Keep the gem framework-agnostic.
61 +
62 +Preferred layering:
63 +
64 +1. Rust crate owns transport and domain logic
65 +2. native Magnus extension bridges into Ruby
66 +3. plain Ruby wrapper exposes the public API
67 +
68 +For reusable SDKs, prefer a client object such as:
69 +
70 +- `SmbCloud::Auth::Client.new(...)`
71 +
72 +Keep Rails-specific helpers, sessions, and controller concerns out of the core gem. If needed later, create a separate Rails adapter gem.
73 +
74 +## Build workflow
75 +
76 +Use the standard rake tasks.
77 +
78 +From `sdk/gems/<name>`:
79 +
80 +- `bundle install`
81 +- `bundle exec rake compile`
82 +- `bundle exec rake build`
83 +
84 +Expected behavior:
85 +
86 +- native build output goes under `target/`
87 +- packaged gem goes under `pkg/`
88 +
89 +For Rust-only verification, also use:
90 +
91 +- `cargo check`
92 +- `cargo fmt --manifest-path ext/<name>/Cargo.toml`
93 +
94 +Prefer formatting the extension crate directly if the parent workspace has unrelated manifest issues.
95 +
96 +## .gitignore conventions
97 +
98 +Match the `sdk/gems/model` pattern.
99 +
100 +Ignore:
101 +
102 +- `/.bundle/`
103 +- `/pkg/`
104 +- `/tmp/`
105 +- `target/`
106 +- `*.bundle`
107 +- native object outputs such as `*.so`, `*.o`, `*.a`
108 +- `mkmf.log`
109 +
110 +Do not track built `.gem` artifacts in git. They belong in `pkg/` and should be ignored.
111 +
112 +## Release preflight
113 +
114 +Before publishing:
115 +
116 +1. confirm the version in `lib/<name>/version.rb`
117 +2. build the gem with `bundle exec rake build`
118 +3. verify the artifact exists in `pkg/`
119 +4. inspect warnings from `gem build` or `rake build`
120 +5. confirm RubyGems credentials exist
121 +
122 +Credentials can come from either:
123 +
124 +- `~/.gem/credentials`
125 +- `GEM_HOST_API_KEY`
126 +
127 +Without one of those, `gem push` will fail.
128 +
129 +## Publish
130 +
131 +From `sdk/gems/<name>`:
132 +
133 +- `bundle exec gem push pkg/<name>-<version>.gem`
134 +
135 +Do not claim a publish succeeded unless the push completed successfully.
136 +
137 +## Common mistakes
138 +
139 +- wrong relative path from `ext/<name>/Cargo.toml` to `crates/...`
140 +- exposing Ruby methods that the native extension does not actually export
141 +- putting release artifacts in git
142 +- making the gem Rails-specific when the crate should stay generic
143 +- using `gem build` ad hoc while ignoring the repo's `rake build` and `pkg/` convention
144 +- attempting `gem push` without RubyGems credentials configured
sdk/gems/auth/.gitignore
+12 -2
@@ -1,4 +1,14 @@
1 +/.bundle/
2 +/.yardoc
3 +/_yardoc/
4 +/coverage/
5 +/doc/
6 /pkg/
2 -/target/
3 -/lib/auth/*.bundle
7 +/spec/reports/
8 /tmp/
9 +*.bundle
10 +*.so
11 +*.o
12 +*.a
13 +mkmf.log
14 +target/
sdk/gems/auth/README.md
+30 -2
@@ -13,11 +13,39 @@ gem install smbcloud-auth
13 ```ruby
14 require "auth"
15
16 -result = SmbCloud::Auth.signup_with_client(
16 +client = SmbCloud::Auth::Client.new(
17 environment: SmbCloud::Auth::Environment::PRODUCTION,
18 app_id: "app-id",
19 - app_secret: "app-secret",
19 + app_secret: "app-secret"
20 +)
21 +
22 +signup = client.signup(
23 + email: "name@example.com",
24 + password: "password123"
25 +)
26 +
27 +login = client.login(
28 email: "name@example.com",
29 password: "password123"
30 )
31 +
32 +me = client.me(access_token: login[:access_token])
33 ```
34 +
35 +## API
36 +
37 +`SmbCloud::Auth::Client` exposes:
38 +
39 +- `signup(email:, password:)`
40 +- `login(email:, password:)`
41 +- `me(access_token:)`
42 +- `logout(access_token:)`
43 +- `remove(access_token:)`
44 +
45 +For simple scripting, module-level convenience wrappers are also available:
46 +
47 +- `SmbCloud::Auth.signup_with_client(...)`
48 +- `SmbCloud::Auth.login_with_client(...)`
49 +- `SmbCloud::Auth.me_with_client(...)`
50 +- `SmbCloud::Auth.logout_with_client(...)`
51 +- `SmbCloud::Auth.remove_with_client(...)`
sdk/gems/auth/lib/auth.rb
+44 -28
@@ -24,53 +24,69 @@ module SmbCloud
24 PRODUCTION = 'production'
25 end
26
27 - class << self
28 - def signup_with_client(environment:, app_id:, app_secret:, email:, password:)
29 - parse_json(__signup_with_client(environment, app_id, app_secret, email, password))
30 - rescue RuntimeError => e
31 - raise normalize_error(e)
27 + class Client
28 + attr_reader :environment, :app_id, :app_secret
29 +
30 + def initialize(environment:, app_id:, app_secret:)
31 + @environment = environment
32 + @app_id = app_id
33 + @app_secret = app_secret
34 end
35
34 - def login_with_client(environment:, app_id:, app_secret:, email:, password:)
35 - parse_json(__login_with_client(environment, app_id, app_secret, email, password))
36 + def signup(email:, password:)
37 + Auth.send(:parse_json, Auth.__signup_with_client(environment, app_id, app_secret, email, password))
38 rescue RuntimeError => e
37 - raise normalize_error(e)
39 + raise Auth.send(:normalize_error, e)
40 end
41
40 - def me_with_client(environment:, app_id:, app_secret:, access_token:)
41 - parse_json(__me_with_client(environment, app_id, app_secret, access_token))
42 + def login(email:, password:)
43 + Auth.send(:parse_json, Auth.__login_with_client(environment, app_id, app_secret, email, password))
44 rescue RuntimeError => e
43 - raise normalize_error(e)
45 + raise Auth.send(:normalize_error, e)
46 end
47
46 - def logout_with_client(environment:, app_id:, app_secret:, access_token:)
47 - __logout_with_client(environment, app_id, app_secret, access_token)
48 + def me(access_token:)
49 + Auth.send(:parse_json, Auth.__me_with_client(environment, app_id, app_secret, access_token))
50 rescue RuntimeError => e
49 - raise normalize_error(e)
51 + raise Auth.send(:normalize_error, e)
52 end
53
52 - def remove_with_client(environment:, app_id:, app_secret:, access_token:)
53 - __remove_with_client(environment, app_id, app_secret, access_token)
54 + def logout(access_token:)
55 + Auth.__logout_with_client(environment, app_id, app_secret, access_token)
56 rescue RuntimeError => e
55 - raise normalize_error(e)
57 + raise Auth.send(:normalize_error, e)
58 end
59
58 - def reset_password(environment:, app_id:, token:, password:)
59 - parse_json(__reset_password(environment, app_id, token, password))
60 + def remove(access_token:)
61 + Auth.__remove_with_client(environment, app_id, app_secret, access_token)
62 rescue RuntimeError => e
61 - raise normalize_error(e)
63 + raise Auth.send(:normalize_error, e)
64 end
65 + end
66
64 - def resend_email_verification(environment:, app_id:, email:)
65 - parse_json(__resend_email_verification(environment, app_id, email))
66 - rescue RuntimeError => e
67 - raise normalize_error(e)
67 + class << self
68 + def client(environment:, app_id:, app_secret:)
69 + Client.new(environment:, app_id:, app_secret:)
70 end
71
70 - def resend_reset_password_instruction(environment:, app_id:, email:)
71 - parse_json(__resend_reset_password_instruction(environment, app_id, email))
72 - rescue RuntimeError => e
73 - raise normalize_error(e)
72 + def signup_with_client(environment:, app_id:, app_secret:, email:, password:)
73 + client(environment:, app_id:, app_secret:).signup(email:, password:)
74 + end
75 +
76 + def login_with_client(environment:, app_id:, app_secret:, email:, password:)
77 + client(environment:, app_id:, app_secret:).login(email:, password:)
78 + end
79 +
80 + def me_with_client(environment:, app_id:, app_secret:, access_token:)
81 + client(environment:, app_id:, app_secret:).me(access_token:)
82 + end
83 +
84 + def logout_with_client(environment:, app_id:, app_secret:, access_token:)
85 + client(environment:, app_id:, app_secret:).logout(access_token:)
86 + end
87 +
88 + def remove_with_client(environment:, app_id:, app_secret:, access_token:)
89 + client(environment:, app_id:, app_secret:).remove(access_token:)
90 end
91
92 private
sdk/gems/auth/sig/auth.rbs
+14 -3
@@ -14,13 +14,24 @@ module SmbCloud
14 PRODUCTION: String
15 end
16
17 + class Client
18 + attr_reader environment: String
19 + attr_reader app_id: String
20 + attr_reader app_secret: String
21 +
22 + def initialize: (environment: String, app_id: String, app_secret: String) -> void
23 + def signup: (email: String, password: String) -> Hash[Symbol, untyped]
24 + def login: (email: String, password: String) -> Hash[Symbol, untyped]
25 + def me: (access_token: String) -> Hash[Symbol, untyped]
26 + def logout: (access_token: String) -> bool
27 + def remove: (access_token: String) -> bool
28 + end
29 +
30 + def self.client: (environment: String, app_id: String, app_secret: String) -> Client
31 def self.signup_with_client: (environment: String, app_id: String, app_secret: String, email: String, password: String) -> Hash[Symbol, untyped]
32 def self.login_with_client: (environment: String, app_id: String, app_secret: String, email: String, password: String) -> Hash[Symbol, untyped]
33 def self.me_with_client: (environment: String, app_id: String, app_secret: String, access_token: String) -> Hash[Symbol, untyped]
34 def self.logout_with_client: (environment: String, app_id: String, app_secret: String, access_token: String) -> bool
35 def self.remove_with_client: (environment: String, app_id: String, app_secret: String, access_token: String) -> bool
22 - def self.reset_password: (environment: String, app_id: String, token: String, password: String) -> Hash[Symbol, untyped]
23 - def self.resend_email_verification: (environment: String, app_id: String, email: String) -> Hash[Symbol, untyped]
24 - def self.resend_reset_password_instruction: (environment: String, app_id: String, email: String) -> Hash[Symbol, untyped]
36 end
37 end