shai-cli 0.1.0 → 0.2.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: f13b2f27c4542d4f5fe83f06e781589bd69bde8bd618e7b8ce494fd1d4ab621b
4
- data.tar.gz: 94129b59f660fcff737602a475b891d87526968bd3ee4b28d495012a225d6cd5
3
+ metadata.gz: 1ea758284c9ecb5985d8789033ee4368ec95757ac056a0b5870f9d8f523d9282
4
+ data.tar.gz: 046a439279ef5a6c2cf32eeb9f5d00f7656887cb310168c334306796cd73f713
5
5
  SHA512:
6
- metadata.gz: 4d89e81bdb2d09f584d53fb9c652e61d38ec7e40378e2ccca88b12b75bed765dd0aa18692fd0599a17095ec331f0e5aabfdcabea1da5c89df9ad927cacb396dc
7
- data.tar.gz: e636e3d6f766962c11df07de32e70fd62944e2e09c4a0e2acbb723291b75e5425e2b922a98e55e110bb12c3fd277d27993c9e9eda912713065f6897086ed5248
6
+ metadata.gz: 3f158f9131a3d10a7717b4d2109e9ae37682b4c2d2f68de5ab516c0c4e789ad86556dff17b973c7a8daa23527486064057c1ef1b72c0064d206141a3f87bc204
7
+ data.tar.gz: e266d13cb6135d0530f5363e1f568a57fb2558a0a0b747b1b2820c4e921247d5114624727f4865711f44758d619f22bd233cd6464326612f2944aaba1b2514cc
data/README.md CHANGED
@@ -1,6 +1,26 @@
1
1
  # shai-cli
2
2
 
3
- Command-line tool for managing and sharing AI agent configurations via [shaicli.dev](https://shaicli.dev).
3
+ Command-line tool for creating, sharing, and installing AI agent configurations.
4
+
5
+ shai lets you treat AI configurations like dotfiles: reproducible, shareable, and easy to install across machines.
6
+
7
+ Website: https://shaicli.dev
8
+
9
+ ---
10
+
11
+ ## Why this exists
12
+
13
+ I kept tweaking AI prompts, agent setups, and configuration files, then losing track of what actually worked. Copy-pasting from notes, Slack, or old repos didn’t scale.
14
+
15
+ shai is a small tool I built to make AI configurations:
16
+
17
+ - **Reproducible** (same setup everywhere)
18
+ - **Shareable** (public or private)
19
+ - **Easy to install** (one command)
20
+
21
+ This is an early project and intentionally minimal. It’s built to solve a real problem I had, and I’m sharing it to see if it’s useful to others.
22
+
23
+ ---
4
24
 
5
25
  ## Installation
6
26
 
@@ -25,6 +45,8 @@ bundle install
25
45
  bundle exec rake install
26
46
  ```
27
47
 
48
+ ---
49
+
28
50
  ## Quick Start
29
51
 
30
52
  ```bash
@@ -42,59 +64,140 @@ shai init
42
64
  shai push
43
65
  ```
44
66
 
67
+ ---
68
+
45
69
  ## Commands
46
70
 
71
+ ### No Login Required
72
+
73
+ These commands work without authentication for public configurations:
74
+
75
+ | Command | Description |
76
+ | ------------------------- | ------------------------------------------------------ |
77
+ | `shai search <query>` | Search public configurations |
78
+ | `shai install <config>` | Install a public configuration (use `owner/slug` format) |
79
+ | `shai uninstall <config>` | Uninstall a public configuration |
80
+
81
+ ```bash
82
+ # No login needed for public configs
83
+ shai search "claude code"
84
+ shai install anthropic/claude-expert
85
+ shai uninstall anthropic/claude-expert
86
+ ```
87
+
88
+ ---
89
+
47
90
  ### Authentication
48
91
 
49
- | Command | Description |
50
- |---------|-------------|
51
- | `shai login` | Log in to shaicli.dev |
52
- | `shai logout` | Log out and remove stored credentials |
53
- | `shai whoami` | Show current authentication status |
92
+ | Command | Description |
93
+ | ---------------------- | ---------------------------------------------- |
94
+ | `shai login` | Log in via browser (device flow, recommended) |
95
+ | `shai login --password`| Log in with email/password directly |
96
+ | `shai logout` | Log out and remove stored credentials |
97
+ | `shai whoami` | Show current authentication status |
98
+
99
+ **Device Flow (default):**
100
+
101
+ The default login uses OAuth 2.0 Device Authorization Grant - a secure flow that doesn't require entering your password in the terminal:
102
+
103
+ ```bash
104
+ $ shai login
105
+ Starting device authorization...
106
+
107
+ To authorize this device:
108
+
109
+ 1. Visit: https://shaicli.dev/device
110
+ 2. Enter code: ABCD-1234
111
+
112
+ Open browser automatically? (Y/n) y
113
+
114
+ [⠋] Waiting for authorization...
115
+
116
+ ✓ Logged in as johndoe
117
+ Token expires: March 11, 2026
118
+ Token stored in ~/.config/shai/credentials
119
+ ```
120
+
121
+ **Password Flow (legacy):**
122
+
123
+ If you prefer to enter credentials directly:
124
+
125
+ ```bash
126
+ $ shai login --password
127
+ Email or username: johndoe
128
+ Password: ********
129
+
130
+ ✓ Logged in as johndoe
131
+ ```
132
+
133
+ **Check Status:**
134
+
135
+ ```bash
136
+ $ shai whoami
137
+ Logged in as johndoe (John Doe)
138
+ Token expires: March 11, 2026
139
+ ```
140
+
141
+ ---
54
142
 
55
143
  ### Discovery
56
144
 
57
- | Command | Description |
58
- |---------|-------------|
59
- | `shai list` | List your configurations |
145
+ | Command | Description |
146
+ | --------------------- | ---------------------------- |
147
+ | `shai list` | List your configurations |
60
148
  | `shai search <query>` | Search public configurations |
61
149
 
150
+ ---
151
+
62
152
  ### Using Configurations
63
153
 
64
- | Command | Description |
65
- |---------|-------------|
66
- | `shai install <config>` | Install a configuration to local project |
67
- | `shai uninstall <config>` | Remove an installed configuration |
154
+ | Command | Description |
155
+ | ------------------------- | ---------------------------------------- |
156
+ | `shai install <config>` | Install a configuration to local project |
157
+ | `shai uninstall <config>` | Remove an installed configuration |
158
+
159
+ ```bash
160
+ $ shai uninstall anthropic/claude-expert
161
+ [✔] Fetching anthropic/claude-expert...
162
+ Remove 3 files and 1 folder from 'anthropic/claude-expert'? (y/N) y
163
+
164
+ ✓ Uninstalled anthropic/claude-expert
165
+ ```
166
+
167
+ ---
68
168
 
69
169
  ### Authoring Configurations
70
170
 
71
- | Command | Description |
72
- |---------|-------------|
73
- | `shai init` | Initialize a new configuration |
74
- | `shai push` | Push local changes to remote |
75
- | `shai status` | Show local changes vs remote |
76
- | `shai diff` | Show diff between local and remote |
77
- | `shai config show` | Show configuration details |
78
- | `shai config set <key> <value>` | Update configuration metadata |
79
- | `shai delete <slug>` | Delete a configuration from remote |
171
+ | Command | Description |
172
+ | ------------------------------- | ---------------------------------- |
173
+ | `shai init` | Initialize a new configuration |
174
+ | `shai push` | Push local changes to remote |
175
+ | `shai status` | Show local changes vs remote |
176
+ | `shai diff` | Show diff between local and remote |
177
+ | `shai config show` | Show configuration details |
178
+ | `shai config set <key> <value>` | Update configuration metadata |
179
+ | `shai delete <slug>` | Delete a configuration from remote |
180
+
181
+ ---
80
182
 
81
183
  ## Configuration
82
184
 
83
185
  ### Environment Variables
84
186
 
85
- | Variable | Description | Default |
86
- |----------|-------------|---------|
87
- | `SHAI_API_URL` | API endpoint URL | `https://shaicli.dev` |
88
- | `SHAI_CONFIG_DIR` | Directory for credentials | `~/.config/shai` |
89
- | `SHAI_TOKEN` | Override authentication token | - |
90
- | `NO_COLOR` | Disable colored output | - |
187
+ | Variable | Description | Default |
188
+ | ----------------- | ----------------------------- | --------------------- |
189
+ | `SHAI_API_URL` | API endpoint URL | `https://shaicli.dev` |
190
+ | `SHAI_CONFIG_DIR` | Directory for credentials | `~/.config/shai` |
191
+ | `SHAI_TOKEN` | Override authentication token | - |
192
+ | `NO_COLOR` | Disable colored output | - |
193
+
194
+ ---
91
195
 
92
196
  ### .shairc File
93
197
 
94
198
  When authoring configurations, a `.shairc` file is created in your project root:
95
199
 
96
200
  ```yaml
97
- # .shairc - Shai configuration
98
201
  slug: my-config
99
202
  include:
100
203
  - .claude/**
@@ -104,89 +207,63 @@ exclude:
104
207
  - "**/.env"
105
208
  ```
106
209
 
107
- | Field | Description |
108
- |-------|-------------|
109
- | `slug` | Unique identifier for your configuration |
110
- | `include` | Glob patterns for files to include |
111
- | `exclude` | Glob patterns for files to exclude |
112
-
113
- ## Examples
210
+ | Field | Description |
211
+ | --------- | ---------------------------------------- |
212
+ | `slug` | Unique identifier for your configuration |
213
+ | `include` | Glob patterns for files to include |
214
+ | `exclude` | Glob patterns for files to exclude |
114
215
 
115
- ### Search by tags
216
+ ---
116
217
 
117
- ```bash
118
- shai search --tag claude --tag coding
119
- ```
120
-
121
- ### Install to specific directory
218
+ ## Examples
122
219
 
123
220
  ```bash
221
+ # Install to a specific directory
124
222
  shai install anthropic/claude-expert --path ./my-project
125
- ```
126
223
 
127
- ### Preview installation without making changes
128
-
129
- ```bash
224
+ # Preview installation without making changes
130
225
  shai install anthropic/claude-expert --dry-run
131
- ```
132
-
133
- ### Force overwrite existing files
134
226
 
135
- ```bash
227
+ # Force overwrite existing files
136
228
  shai install anthropic/claude-expert --force
137
229
  ```
138
230
 
139
- ### Create a public configuration
140
-
141
- ```bash
142
- shai init
143
- # Follow prompts, select "public" visibility
144
- shai push
145
- ```
231
+ ---
146
232
 
147
- ### Update configuration metadata
233
+ ## Feedback and discussion
148
234
 
149
- ```bash
150
- shai config set name "My Updated Config"
151
- shai config set visibility public
152
- shai config set description "A better description"
153
- ```
235
+ Feedback is very welcome.
154
236
 
155
- ## Development
237
+ - Use **GitHub Discussions** for ideas, use cases, or open-ended thoughts
238
+ - Use **Issues** for concrete bugs, confusing behavior, or specific improvements
156
239
 
157
- ### Setup
240
+ There are two pinned issues to guide feedback:
158
241
 
159
- ```bash
160
- bundle install
161
- ```
242
+ - **Feedback** — what’s unclear, missing, or not useful
243
+ - **Ideas** — possible improvements or directions
162
244
 
163
- ### Run tests
245
+ This project is intentionally small, so not every idea will be built. The goal right now is learning and signal.
164
246
 
165
- ```bash
166
- bundle exec rspec
167
- ```
247
+ ---
168
248
 
169
- ### Run linter
249
+ ## Development
170
250
 
171
251
  ```bash
252
+ bundle install
253
+ bundle exec rspec
172
254
  bundle exec standardrb
173
255
  ```
174
256
 
175
- ### Local development
176
-
177
- Create `.env.development`:
257
+ Local development:
178
258
 
179
259
  ```bash
180
260
  SHAI_API_URL=http://localhost:3001
181
261
  SHAI_CONFIG_DIR=.config/shai-dev
182
- ```
183
-
184
- Run commands in development mode:
185
-
186
- ```bash
187
262
  SHAI_ENV=development bundle exec bin/shai <command>
188
263
  ```
189
264
 
265
+ ---
266
+
190
267
  ## License
191
268
 
192
269
  MIT
@@ -20,6 +20,21 @@ module Shai
20
20
  })
21
21
  end
22
22
 
23
+ # Device Flow Authentication (RFC 8628)
24
+ def device_authorize(client_name: nil)
25
+ client_name ||= default_client_name
26
+ post_without_auth("/api/v1/device/authorize", {
27
+ client_name: client_name
28
+ })
29
+ end
30
+
31
+ def device_token(device_code:)
32
+ response = @connection.post("/api/v1/device/token") do |req|
33
+ req.body = {device_code: device_code}
34
+ end
35
+ handle_device_token_response(response)
36
+ end
37
+
23
38
  # Configurations
24
39
  def list_configurations
25
40
  get("/api/v1/configurations")
@@ -64,6 +79,10 @@ module Shai
64
79
  put("/api/v1/configurations/#{encode_identifier(identifier)}/tree", {tree: tree})
65
80
  end
66
81
 
82
+ def record_install(identifier)
83
+ post_without_auth("/api/v1/configurations/#{encode_identifier(identifier)}/install", {})
84
+ end
85
+
67
86
  # Encode identifier for URL (handles owner/slug format)
68
87
  def encode_identifier(identifier)
69
88
  URI.encode_www_form_component(identifier)
@@ -94,6 +113,13 @@ module Shai
94
113
  handle_response(response)
95
114
  end
96
115
 
116
+ def post_without_auth(path, body)
117
+ response = @connection.post(path) do |req|
118
+ req.body = body
119
+ end
120
+ handle_response(response)
121
+ end
122
+
97
123
  def put(path, body)
98
124
  response = @connection.put(path) do |req|
99
125
  add_auth_header(req)
@@ -126,6 +152,29 @@ module Shai
126
152
  raise NotFoundError, response.body&.dig("error") || "Not found"
127
153
  when 422
128
154
  raise InvalidConfigurationError, response.body&.dig("error") || "Invalid request"
155
+ when 429
156
+ retry_after = response.headers["Retry-After"]&.to_i
157
+ raise RateLimitError.new(
158
+ response.body&.dig("error") || "Too many requests. Please try again later.",
159
+ retry_after: retry_after
160
+ )
161
+ else
162
+ raise Error, response.body&.dig("error") || "Request failed with status #{response.status}"
163
+ end
164
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError
165
+ raise NetworkError, "Could not connect to #{Shai.configuration.api_url}. Check your internet connection."
166
+ end
167
+
168
+ def handle_device_token_response(response)
169
+ case response.status
170
+ when 200
171
+ response.body
172
+ when 400
173
+ error_code = response.body&.dig("error")
174
+ interval = response.body&.dig("interval")
175
+ raise DeviceFlowError.new(error_code, interval: interval)
176
+ when 429
177
+ raise DeviceFlowError.new("slow_down", interval: 10)
129
178
  else
130
179
  raise Error, response.body&.dig("error") || "Request failed with status #{response.status}"
131
180
  end
data/lib/shai/cli.rb CHANGED
@@ -26,13 +26,15 @@ module Shai
26
26
  shell.say " shai <command> [options]"
27
27
  shell.say ""
28
28
  shell.say "AUTHENTICATION:"
29
- shell.say " login Log in to shaicli.dev"
29
+ shell.say " login Log in via browser (device flow)"
30
+ shell.say " login --password Log in with email/password directly"
30
31
  shell.say " logout Log out and remove stored credentials"
31
32
  shell.say " whoami Show current authentication status"
32
33
  shell.say ""
33
34
  shell.say "DISCOVERY:"
34
35
  shell.say " list List your configurations"
35
36
  shell.say " search <query> Search public configurations"
37
+ shell.say " open <config> Open a configuration in the browser"
36
38
  shell.say ""
37
39
  shell.say "USING CONFIGURATIONS (install to current project):"
38
40
  shell.say " install <config> Install a configuration to local project"
@@ -56,6 +58,7 @@ module Shai
56
58
  shell.say "EXAMPLES:"
57
59
  shell.say " shai login"
58
60
  shell.say " shai search \"claude code\""
61
+ shell.say " shai open anthropic/claude-expert"
59
62
  shell.say " shai install anthropic/claude-expert"
60
63
  shell.say " shai init"
61
64
  shell.say " shai push"
@@ -1,38 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "launchy"
4
+
3
5
  module Shai
4
6
  module Commands
5
7
  module Auth
8
+ DEVICE_FLOW_TIMEOUT = 600 # 10 minutes
9
+
6
10
  def self.included(base)
7
11
  base.class_eval do
8
12
  desc "login", "Log in to shaicli.dev"
13
+ option :password, type: :boolean, default: false, desc: "Use password authentication instead of device flow"
9
14
  def login
10
- identifier = ui.ask("Email or username:")
11
- password = ui.mask("Password:")
12
-
13
- ui.blank
14
-
15
- begin
16
- response = ui.spinner("Logging in...") do
17
- api.login(identifier: identifier, password: password)
18
- end
19
-
20
- data = response["data"]
21
- credentials.save(
22
- token: data["token"],
23
- expires_at: data["expires_at"],
24
- user: data["user"]
25
- )
26
-
27
- ui.success("Logged in as #{data.dig("user", "username")}")
28
- ui.indent("Token expires: #{format_date(data["expires_at"])}")
29
- ui.indent("Token stored in #{Shai.configuration.credentials_path}")
30
- rescue AuthenticationError
31
- ui.error("Invalid credentials")
32
- exit EXIT_AUTH_REQUIRED
33
- rescue NetworkError => e
34
- ui.error(e.message)
35
- exit EXIT_NETWORK_ERROR
15
+ if options[:password]
16
+ login_with_password
17
+ else
18
+ login_with_device_flow
36
19
  end
37
20
  end
38
21
 
@@ -52,6 +35,151 @@ module Shai
52
35
  ui.info("Not logged in. Run `shai login` to authenticate.")
53
36
  end
54
37
  end
38
+
39
+ no_commands do
40
+ def login_with_password
41
+ identifier = ui.ask("Email or username:")
42
+ password = ui.mask("Password:")
43
+
44
+ ui.blank
45
+
46
+ begin
47
+ response = ui.spinner("Logging in...") do
48
+ api.login(identifier: identifier, password: password)
49
+ end
50
+
51
+ save_credentials_from_response(response)
52
+ rescue AuthenticationError
53
+ ui.error("Invalid credentials")
54
+ exit EXIT_AUTH_REQUIRED
55
+ rescue RateLimitError
56
+ ui.error("Too many login attempts. Please try again later.")
57
+ exit EXIT_GENERAL_ERROR
58
+ rescue NetworkError => e
59
+ ui.error(e.message)
60
+ exit EXIT_NETWORK_ERROR
61
+ end
62
+ end
63
+
64
+ def login_with_device_flow
65
+ ui.info("Starting device authorization...")
66
+ ui.blank
67
+
68
+ begin
69
+ response = api.device_authorize
70
+ rescue RateLimitError => e
71
+ retry_msg = e.retry_after ? " Try again in #{e.retry_after} seconds." : ""
72
+ ui.error("Too many authorization attempts.#{retry_msg}")
73
+ exit EXIT_GENERAL_ERROR
74
+ rescue NetworkError => e
75
+ ui.error(e.message)
76
+ exit EXIT_NETWORK_ERROR
77
+ end
78
+
79
+ device_code = response["device_code"]
80
+ user_code = response["user_code"]
81
+ verification_uri = response["verification_uri"]
82
+ verification_uri_complete = response["verification_uri_complete"]
83
+ interval = response["interval"] || 5
84
+
85
+ display_device_code_instructions(
86
+ user_code: user_code,
87
+ verification_uri: verification_uri
88
+ )
89
+
90
+ ui.blank
91
+ if ui.yes?("Open browser automatically?", default: true)
92
+ open_browser(verification_uri_complete || verification_uri)
93
+ end
94
+
95
+ ui.blank
96
+ poll_for_authorization(device_code: device_code, interval: interval)
97
+ end
98
+
99
+ def display_device_code_instructions(user_code:, verification_uri:)
100
+ ui.info("To authorize this device:")
101
+ ui.blank
102
+ ui.indent("1. Visit: #{ui.cyan(verification_uri)}")
103
+ ui.indent("2. Enter code: #{ui.bold(user_code)}")
104
+ end
105
+
106
+ def open_browser(url)
107
+ Launchy.open(url)
108
+ rescue Launchy::CommandNotFoundError
109
+ ui.warning("Could not open browser automatically. Please visit the URL manually.")
110
+ end
111
+
112
+ def poll_for_authorization(device_code:, interval:)
113
+ start_time = Time.now
114
+ current_interval = interval
115
+
116
+ spinner = TTY::Spinner.new("[:spinner] Waiting for authorization...", format: :dots)
117
+ spinner.auto_spin
118
+
119
+ loop do
120
+ elapsed = Time.now - start_time
121
+ if elapsed > DEVICE_FLOW_TIMEOUT
122
+ spinner.error
123
+ ui.error("Authorization timed out. Please try again.")
124
+ exit EXIT_AUTH_REQUIRED
125
+ end
126
+
127
+ sleep(current_interval)
128
+
129
+ begin
130
+ response = api.device_token(device_code: device_code)
131
+
132
+ # Success - we got a token
133
+ spinner.success
134
+ save_credentials_from_response(response)
135
+ return
136
+ rescue DeviceFlowError => e
137
+ case e.error_code
138
+ when "authorization_pending"
139
+ # Keep polling
140
+ next
141
+ when "slow_down"
142
+ current_interval = e.interval || (current_interval + 5)
143
+ next
144
+ when "access_denied"
145
+ spinner.error
146
+ ui.blank
147
+ ui.error("Authorization denied by user.")
148
+ exit EXIT_AUTH_REQUIRED
149
+ when "expired_token"
150
+ spinner.error
151
+ ui.blank
152
+ ui.error("Device code expired. Please try again.")
153
+ exit EXIT_AUTH_REQUIRED
154
+ else
155
+ spinner.error
156
+ ui.blank
157
+ ui.error("Authorization failed: #{e.error_code}")
158
+ exit EXIT_AUTH_REQUIRED
159
+ end
160
+ rescue NetworkError => e
161
+ spinner.error
162
+ ui.blank
163
+ ui.error(e.message)
164
+ exit EXIT_NETWORK_ERROR
165
+ end
166
+ end
167
+ end
168
+
169
+ def save_credentials_from_response(response)
170
+ data = response["data"]
171
+ credentials.save(
172
+ token: data["token"],
173
+ expires_at: data["expires_at"],
174
+ user: data["user"]
175
+ )
176
+
177
+ ui.blank
178
+ ui.success("Logged in as #{data.dig("user", "username")}")
179
+ ui.indent("Token expires: #{format_date(data["expires_at"])}")
180
+ ui.indent("Token stored in #{Shai.configuration.credentials_path}")
181
+ end
182
+ end
55
183
  end
56
184
  end
57
185