shai-cli 0.1.1 → 0.3.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: 2d4d5f46776b74f36b7f259d6edb67489f519dbc98b5ae5b0e44be82fed60d2d
4
- data.tar.gz: 9669269dd3ac52f2161a488e68364da7e58ecb0d32328ca901ed18014b7ed8ef
3
+ metadata.gz: '00490a68be250015b1cd43dd40cc804d79e82bb74ddc6fc65a4ef90d27b7de69'
4
+ data.tar.gz: d694b3f181f571b3ac8ebbbe415c92c805c4fb780cd8f3dae4cce0f6b32e6a6b
5
5
  SHA512:
6
- metadata.gz: a4d113fbd60fbd071f71d47ece7aba4848e9160f41feda7bcdfe39612d0c029f96a85dc31ebe473121b964459d20d2264ce080cb83a4ada5238e619273a1590c
7
- data.tar.gz: 768577aecf1ec1e52301dededa606b7f2864fab148f1ad620bcc027a9fbc648e9559dedd1cbcea629fdaf3ad46c99830d99175307fd10a03f611f455003ef953
6
+ metadata.gz: '08980b4e9a5623c01215cdbe58b1ad8d2ade01cd07ca4d84a3c61db346cbf0513efd663037740570fd17c8c2e61e14b7597d1444bd6701afad6a595ab2f95763'
7
+ data.tar.gz: 94016712713d1f3731b82b39a75e0d4d23b37f4149a2503bcfe8ae09aabf1ad59f03be7107f1dad0b1177e2168981fc07a5ab3ceaff123822adf1192220be7f0
data/README.md CHANGED
@@ -56,8 +56,8 @@ shai login
56
56
  # Search for public configurations
57
57
  shai search "claude code"
58
58
 
59
- # Install a configuration to your project
60
- shai install anthropic/claude-expert
59
+ # Install a configuration (--global for ~/, --local for ./)
60
+ shai install anthropic/claude-expert --local
61
61
 
62
62
  # Create and share your own configuration
63
63
  shai init
@@ -76,12 +76,12 @@ These commands work without authentication for public configurations:
76
76
  | ------------------------- | ------------------------------------------------------ |
77
77
  | `shai search <query>` | Search public configurations |
78
78
  | `shai install <config>` | Install a public configuration (use `owner/slug` format) |
79
- | `shai uninstall <config>` | Uninstall a public configuration |
79
+ | `shai uninstall <config>` | Uninstall a configuration |
80
80
 
81
81
  ```bash
82
82
  # No login needed for public configs
83
83
  shai search "claude code"
84
- shai install anthropic/claude-expert
84
+ shai install anthropic/claude-expert --local
85
85
  shai uninstall anthropic/claude-expert
86
86
  ```
87
87
 
@@ -89,11 +89,48 @@ shai uninstall anthropic/claude-expert
89
89
 
90
90
  ### Authentication
91
91
 
92
- | Command | Description |
93
- | ------------- | ------------------------------------- |
94
- | `shai login` | Log in to shaicli.dev |
95
- | `shai logout` | Log out and remove stored credentials |
96
- | `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:**
97
134
 
98
135
  ```bash
99
136
  $ shai whoami
@@ -114,21 +151,69 @@ Token expires: March 11, 2026
114
151
 
115
152
  ### Using Configurations
116
153
 
117
- | Command | Description |
118
- | ------------------------- | ---------------------------------------- |
119
- | `shai install <config>` | Install a configuration to local project |
120
- | `shai uninstall <config>` | Remove an installed configuration |
154
+ | Command | Description |
155
+ | -------------------------------------- | ---------------------------------------- |
156
+ | `shai install <config>` | Install a configuration (prompts for location) |
157
+ | `shai install <config> --global` | Install to home directory (`~/`) |
158
+ | `shai install <config> --local` | Install to current directory (`./`) |
159
+ | `shai install <config> --path <dir>` | Install to a specific directory |
160
+ | `shai uninstall <config>` | Remove an installed configuration |
161
+ | `shai uninstall <config> --global` | Uninstall from home directory |
162
+ | `shai uninstall <config> --local` | Uninstall from current directory |
163
+
164
+ **Global vs Local installs:**
165
+
166
+ Configurations can be installed globally (to `~/`) or locally (to `./`). Global installs apply across all projects — useful for AI agent config files like `CLAUDE.md` or `.cursorrules`. Local installs are project-specific.
167
+
168
+ ```bash
169
+ # Install globally (applies everywhere)
170
+ $ shai install anthropic/claude-expert --global
171
+
172
+ # Install locally (current project only)
173
+ $ shai install anthropic/claude-expert --local
174
+
175
+ # If you don't specify, shai will ask
176
+ $ shai install anthropic/claude-expert
177
+ ? Where do you want to install?
178
+ ./ (local - current directory)
179
+ ~/ (global - home directory)
180
+ ```
181
+
182
+ **Uninstalling:**
183
+
184
+ shai tracks where each configuration was installed, so uninstall works without needing to remember the path:
121
185
 
122
186
  ```bash
123
187
  $ shai uninstall anthropic/claude-expert
124
- [✔] Fetching anthropic/claude-expert...
125
- Remove 3 files and 1 folder from 'anthropic/claude-expert'? (y/N) y
188
+ Remove 3 files from 'anthropic/claude-expert'? (y/N) y
126
189
 
127
190
  ✓ Uninstalled anthropic/claude-expert
128
191
  ```
129
192
 
130
193
  ---
131
194
 
195
+ ### Skills
196
+
197
+ | Command | Description |
198
+ | ------------------------------------------------ | ---------------------------------------- |
199
+ | `shai skills` | List all AI agent skills and their status |
200
+ | `shai skills enable <name>` | Enable a disabled skill |
201
+ | `shai skills enable <name> --global` | Enable a global skill |
202
+ | `shai skills disable <name>` | Disable an enabled skill |
203
+ | `shai skills disable <name> --local` | Disable a local skill |
204
+ | `shai skills disable <name> --agent codex` | Target a specific agent |
205
+
206
+ Skills are `SKILL.md` files discovered across multiple AI agent directories:
207
+
208
+ | Agent | Global path | Local path |
209
+ | ------ | ------------------------------ | ----------------------------- |
210
+ | Claude | `~/.claude/skills/*/SKILL.md` | `./.claude/skills/*/SKILL.md` |
211
+ | Codex | `~/.agents/skills/*/SKILL.md` | `./.agents/skills/*/SKILL.md` |
212
+
213
+ Disabling a skill renames `SKILL.md` to `SKILL.md.disabled` so the AI tool no longer loads it. Re-enabling reverses the rename. Use `--agent` to target a specific agent when the same skill name exists in multiple agents.
214
+
215
+ ---
216
+
132
217
  ### Authoring Configurations
133
218
 
134
219
  | Command | Description |
@@ -181,6 +266,12 @@ exclude:
181
266
  ## Examples
182
267
 
183
268
  ```bash
269
+ # Install globally (to ~/)
270
+ shai install anthropic/claude-expert --global
271
+
272
+ # Install locally (to ./)
273
+ shai install anthropic/claude-expert --local
274
+
184
275
  # Install to a specific directory
185
276
  shai install anthropic/claude-expert --path ./my-project
186
277
 
@@ -189,6 +280,9 @@ shai install anthropic/claude-expert --dry-run
189
280
 
190
281
  # Force overwrite existing files
191
282
  shai install anthropic/claude-expert --force
283
+
284
+ # Uninstall (auto-detects where it was installed)
285
+ shai uninstall anthropic/claude-expert
192
286
  ```
193
287
 
194
288
  ---
@@ -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
@@ -5,6 +5,7 @@ require_relative "commands/auth"
5
5
  require_relative "commands/configurations"
6
6
  require_relative "commands/sync"
7
7
  require_relative "commands/config"
8
+ require_relative "commands/skills"
8
9
  require_relative "ui"
9
10
 
10
11
  module Shai
@@ -13,6 +14,7 @@ module Shai
13
14
  include Commands::Configurations
14
15
  include Commands::Sync
15
16
  include Commands::Config
17
+ include Commands::Skills
16
18
 
17
19
  def self.exit_on_failure?
18
20
  true
@@ -26,18 +28,25 @@ module Shai
26
28
  shell.say " shai <command> [options]"
27
29
  shell.say ""
28
30
  shell.say "AUTHENTICATION:"
29
- shell.say " login Log in to shaicli.dev"
31
+ shell.say " login Log in via browser (device flow)"
32
+ shell.say " login --password Log in with email/password directly"
30
33
  shell.say " logout Log out and remove stored credentials"
31
34
  shell.say " whoami Show current authentication status"
32
35
  shell.say ""
33
36
  shell.say "DISCOVERY:"
34
37
  shell.say " list List your configurations"
35
38
  shell.say " search <query> Search public configurations"
39
+ shell.say " open <config> Open a configuration in the browser"
36
40
  shell.say ""
37
- shell.say "USING CONFIGURATIONS (install to current project):"
38
- shell.say " install <config> Install a configuration to local project"
41
+ shell.say "USING CONFIGURATIONS:"
42
+ shell.say " install <config> Install a configuration (--global, --local, or --path)"
39
43
  shell.say " uninstall <config> Remove an installed configuration"
40
44
  shell.say ""
45
+ shell.say "SKILLS:"
46
+ shell.say " skills List all AI agent skills and their status"
47
+ shell.say " skills enable <n> Enable a disabled skill (--global, --local, --agent)"
48
+ shell.say " skills disable <n> Disable an enabled skill (--global, --local, --agent)"
49
+ shell.say ""
41
50
  shell.say "AUTHORING CONFIGURATIONS (create and publish):"
42
51
  shell.say " init Initialize a new configuration"
43
52
  shell.say " push Push local changes to remote"
@@ -56,6 +65,7 @@ module Shai
56
65
  shell.say "EXAMPLES:"
57
66
  shell.say " shai login"
58
67
  shell.say " shai search \"claude code\""
68
+ shell.say " shai open anthropic/claude-expert"
59
69
  shell.say " shai install anthropic/claude-expert"
60
70
  shell.say " shai init"
61
71
  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