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