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 +4 -4
- data/README.md +109 -15
- data/lib/shai/api_client.rb +49 -0
- data/lib/shai/cli.rb +13 -3
- data/lib/shai/commands/auth.rb +154 -26
- data/lib/shai/commands/configurations.rb +318 -133
- data/lib/shai/commands/skills.rb +199 -0
- data/lib/shai/install_registry.rb +62 -0
- data/lib/shai/installed_projects.rb +163 -0
- data/lib/shai/skill_scanner.rb +99 -0
- data/lib/shai/version.rb +1 -1
- data/lib/shai.rb +38 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '00490a68be250015b1cd43dd40cc804d79e82bb74ddc6fc65a4ef90d27b7de69'
|
|
4
|
+
data.tar.gz: d694b3f181f571b3ac8ebbbe415c92c805c4fb780cd8f3dae4cce0f6b32e6a6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
93
|
-
|
|
|
94
|
-
| `shai login`
|
|
95
|
-
| `shai
|
|
96
|
-
| `shai
|
|
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
|
|
118
|
-
|
|
|
119
|
-
| `shai install <config>`
|
|
120
|
-
| `shai
|
|
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
|
-
|
|
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
|
---
|
data/lib/shai/api_client.rb
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
shell.say " install <config> Install a configuration
|
|
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"
|
data/lib/shai/commands/auth.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|