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 +4 -4
- data/README.md +158 -81
- data/lib/shai/api_client.rb +49 -0
- data/lib/shai/cli.rb +4 -1
- data/lib/shai/commands/auth.rb +154 -26
- data/lib/shai/commands/configurations.rb +243 -127
- data/lib/shai/installed_projects.rb +163 -0
- data/lib/shai/version.rb +1 -1
- data/lib/shai.rb +36 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ea758284c9ecb5985d8789033ee4368ec95757ac056a0b5870f9d8f523d9282
|
|
4
|
+
data.tar.gz: 046a439279ef5a6c2cf32eeb9f5d00f7656887cb310168c334306796cd73f713
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
| `shai login`
|
|
52
|
-
| `shai
|
|
53
|
-
| `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:**
|
|
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
|
|
58
|
-
|
|
59
|
-
| `shai list`
|
|
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
|
|
65
|
-
|
|
66
|
-
| `shai install <config>`
|
|
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
|
|
72
|
-
|
|
73
|
-
| `shai init`
|
|
74
|
-
| `shai push`
|
|
75
|
-
| `shai status`
|
|
76
|
-
| `shai diff`
|
|
77
|
-
| `shai config show`
|
|
78
|
-
| `shai config set <key> <value>` | Update configuration metadata
|
|
79
|
-
| `shai delete <slug>`
|
|
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
|
|
86
|
-
|
|
87
|
-
| `SHAI_API_URL`
|
|
88
|
-
| `SHAI_CONFIG_DIR` | Directory for credentials
|
|
89
|
-
| `SHAI_TOKEN`
|
|
90
|
-
| `NO_COLOR`
|
|
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
|
|
108
|
-
|
|
109
|
-
| `slug`
|
|
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
|
-
|
|
216
|
+
---
|
|
116
217
|
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
# Force overwrite existing files
|
|
136
228
|
shai install anthropic/claude-expert --force
|
|
137
229
|
```
|
|
138
230
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
shai init
|
|
143
|
-
# Follow prompts, select "public" visibility
|
|
144
|
-
shai push
|
|
145
|
-
```
|
|
231
|
+
---
|
|
146
232
|
|
|
147
|
-
|
|
233
|
+
## Feedback and discussion
|
|
148
234
|
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
There are two pinned issues to guide feedback:
|
|
158
241
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
```
|
|
242
|
+
- **Feedback** — what’s unclear, missing, or not useful
|
|
243
|
+
- **Ideas** — possible improvements or directions
|
|
162
244
|
|
|
163
|
-
|
|
245
|
+
This project is intentionally small, so not every idea will be built. The goal right now is learning and signal.
|
|
164
246
|
|
|
165
|
-
|
|
166
|
-
bundle exec rspec
|
|
167
|
-
```
|
|
247
|
+
---
|
|
168
248
|
|
|
169
|
-
|
|
249
|
+
## Development
|
|
170
250
|
|
|
171
251
|
```bash
|
|
252
|
+
bundle install
|
|
253
|
+
bundle exec rspec
|
|
172
254
|
bundle exec standardrb
|
|
173
255
|
```
|
|
174
256
|
|
|
175
|
-
|
|
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
|
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
|
@@ -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
|
|
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"
|
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
|
|