shai-cli 0.1.1 → 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 +42 -5
- 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 -128
- 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
|
@@ -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
|
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
|
|
|
@@ -5,8 +5,6 @@ require "time"
|
|
|
5
5
|
module Shai
|
|
6
6
|
module Commands
|
|
7
7
|
module Configurations
|
|
8
|
-
INSTALLED_FILE = ".shai-installed"
|
|
9
|
-
|
|
10
8
|
def self.included(base)
|
|
11
9
|
base.class_eval do
|
|
12
10
|
desc "list", "List your configurations"
|
|
@@ -82,37 +80,31 @@ module Shai
|
|
|
82
80
|
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
83
81
|
base_path = File.expand_path(options[:path])
|
|
84
82
|
shairc_path = File.join(base_path, ".shairc")
|
|
85
|
-
installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
unless options[:force]
|
|
89
|
-
existing_slug = nil
|
|
84
|
+
installed = InstalledProjects.new(base_path)
|
|
90
85
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
existing_slug = existing_config["slug"]
|
|
86
|
+
# Check if this exact project is already installed
|
|
87
|
+
if installed.has_project?(display_name) && !options[:force]
|
|
88
|
+
ui.error("'#{display_name}' is already installed in this directory.")
|
|
89
|
+
ui.info("Use --force to reinstall, or run `shai uninstall #{display_name}` first.")
|
|
90
|
+
exit EXIT_INVALID_INPUT
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if a .shairc exists (authored config)
|
|
94
|
+
if File.exist?(shairc_path) && !options[:force]
|
|
95
|
+
existing_config = begin
|
|
96
|
+
YAML.safe_load_file(shairc_path)
|
|
97
|
+
rescue
|
|
98
|
+
{}
|
|
105
99
|
end
|
|
100
|
+
existing_slug = existing_config["slug"]
|
|
106
101
|
|
|
107
102
|
if existing_slug
|
|
108
|
-
ui.error("
|
|
103
|
+
ui.error("This directory contains an authored configuration (.shairc).")
|
|
109
104
|
ui.indent("Existing: #{existing_slug}")
|
|
110
105
|
ui.blank
|
|
111
|
-
ui.info("
|
|
112
|
-
ui.
|
|
113
|
-
ui.indent("2. Then run `shai install #{display_name}`")
|
|
114
|
-
ui.blank
|
|
115
|
-
ui.info("Or use --force to install anyway (may cause conflicts)")
|
|
106
|
+
ui.info("Installing here may cause conflicts with your authored config.")
|
|
107
|
+
ui.info("Use --force to install anyway.")
|
|
116
108
|
exit EXIT_INVALID_INPUT
|
|
117
109
|
end
|
|
118
110
|
end
|
|
@@ -127,39 +119,65 @@ module Shai
|
|
|
127
119
|
# Security: Validate all paths before any file operations
|
|
128
120
|
validate_tree_paths!(tree, base_path)
|
|
129
121
|
|
|
130
|
-
#
|
|
131
|
-
|
|
122
|
+
# Get file paths from tree (excluding folders)
|
|
123
|
+
new_files = tree.reject { |n| n["kind"] == "folder" }.map { |n| n["path"] }
|
|
124
|
+
|
|
125
|
+
# Check for conflicts with existing local files
|
|
126
|
+
local_conflicts = []
|
|
132
127
|
tree.each do |node|
|
|
133
128
|
next if node["kind"] == "folder"
|
|
134
|
-
|
|
135
129
|
local_path = File.join(base_path, node["path"])
|
|
136
|
-
|
|
130
|
+
local_conflicts << node["path"] if File.exist?(local_path)
|
|
137
131
|
end
|
|
138
132
|
|
|
133
|
+
# Check for conflicts with other installed projects
|
|
134
|
+
project_conflicts = installed.find_conflicts(new_files)
|
|
135
|
+
|
|
139
136
|
if options[:dry_run]
|
|
140
137
|
ui.header("Would install:")
|
|
141
138
|
tree.each { |node| ui.display_file_operation(:would_create, node["path"]) }
|
|
139
|
+
|
|
140
|
+
if project_conflicts.any?
|
|
141
|
+
ui.blank
|
|
142
|
+
ui.warning("Would conflict with installed projects:")
|
|
143
|
+
project_conflicts.each do |file, owner|
|
|
144
|
+
ui.indent("#{file} (from #{owner})")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
142
148
|
ui.blank
|
|
143
149
|
ui.info("No changes made (dry run)")
|
|
144
150
|
return
|
|
145
151
|
end
|
|
146
152
|
|
|
147
153
|
# Handle conflicts
|
|
148
|
-
if
|
|
149
|
-
ui.blank
|
|
150
|
-
ui.warning("The following files already exist:")
|
|
151
|
-
conflicts.each { |path| ui.display_file_operation(:conflict, path) }
|
|
154
|
+
if (local_conflicts.any? || project_conflicts.any?) && !options[:force]
|
|
152
155
|
ui.blank
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
if project_conflicts.any?
|
|
158
|
+
ui.warning("The following files conflict with already installed projects:")
|
|
159
|
+
project_conflicts.each do |file, owner|
|
|
160
|
+
ui.indent("#{file} #{ui.dim("(from #{owner})")}")
|
|
161
|
+
end
|
|
162
|
+
ui.blank
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
other_local_conflicts = local_conflicts - project_conflicts.keys
|
|
166
|
+
if other_local_conflicts.any?
|
|
167
|
+
ui.warning("The following local files will be overwritten:")
|
|
168
|
+
other_local_conflicts.each { |path| ui.display_file_operation(:conflict, path) }
|
|
169
|
+
ui.blank
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
choice = ui.select("How would you like to proceed?", [
|
|
173
|
+
{name: "Overwrite files (conflicting projects will be updated)", value: :yes},
|
|
174
|
+
{name: "Cancel installation", value: :no},
|
|
157
175
|
{name: "Show diff", value: :diff}
|
|
158
176
|
])
|
|
159
177
|
|
|
160
178
|
if choice == :diff
|
|
161
|
-
show_install_diff(tree, base_path,
|
|
162
|
-
return unless ui.yes?("
|
|
179
|
+
show_install_diff(tree, base_path, local_conflicts)
|
|
180
|
+
return unless ui.yes?("Proceed with installation?")
|
|
163
181
|
elsif choice == :no
|
|
164
182
|
ui.info("Installation cancelled")
|
|
165
183
|
return
|
|
@@ -169,8 +187,17 @@ module Shai
|
|
|
169
187
|
ui.header("Installing #{display_name}...")
|
|
170
188
|
ui.blank
|
|
171
189
|
|
|
190
|
+
# Remove conflicting files from their original projects
|
|
191
|
+
if project_conflicts.any?
|
|
192
|
+
affected_projects = project_conflicts.values.uniq
|
|
193
|
+
affected_projects.each do |project_slug|
|
|
194
|
+
files_to_remove = project_conflicts.select { |_, owner| owner == project_slug }.keys
|
|
195
|
+
installed.remove_files_from_project(project_slug, files_to_remove)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
172
199
|
# Create folders and files
|
|
173
|
-
|
|
200
|
+
created_files = []
|
|
174
201
|
tree.sort_by { |n| (n["kind"] == "folder") ? 0 : 1 }.each do |node|
|
|
175
202
|
local_path = File.join(base_path, node["path"])
|
|
176
203
|
|
|
@@ -181,20 +208,26 @@ module Shai
|
|
|
181
208
|
FileUtils.mkdir_p(File.dirname(local_path))
|
|
182
209
|
File.write(local_path, node["content"])
|
|
183
210
|
ui.display_file_operation(:created, node["path"])
|
|
211
|
+
created_files << node["path"]
|
|
184
212
|
end
|
|
185
|
-
created_count += 1
|
|
186
213
|
end
|
|
187
214
|
|
|
188
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
# Track the installed project
|
|
216
|
+
installed.add_project(display_name, created_files)
|
|
217
|
+
|
|
218
|
+
# Record the install for analytics (fire and forget)
|
|
219
|
+
begin
|
|
220
|
+
api.record_install(display_name)
|
|
221
|
+
rescue
|
|
222
|
+
# Silently ignore install tracking errors
|
|
223
|
+
end
|
|
195
224
|
|
|
196
225
|
ui.blank
|
|
197
|
-
ui.success("Installed #{
|
|
226
|
+
ui.success("Installed #{display_name}")
|
|
227
|
+
|
|
228
|
+
if installed.project_count > 1
|
|
229
|
+
ui.indent("#{installed.project_count} configurations now installed in this directory")
|
|
230
|
+
end
|
|
198
231
|
rescue NotFoundError
|
|
199
232
|
ui.error("Configuration '#{display_name}' not found.")
|
|
200
233
|
exit EXIT_NOT_FOUND
|
|
@@ -207,116 +240,198 @@ module Shai
|
|
|
207
240
|
end
|
|
208
241
|
end
|
|
209
242
|
|
|
243
|
+
desc "open CONFIGURATION", "Open a configuration in the browser"
|
|
244
|
+
def open(configuration)
|
|
245
|
+
owner, slug = parse_configuration_name(configuration)
|
|
246
|
+
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
247
|
+
base_url = Shai.configuration.api_url
|
|
248
|
+
|
|
249
|
+
# Fetch configuration details to determine ownership and visibility
|
|
250
|
+
begin
|
|
251
|
+
config = ui.spinner("Fetching #{display_name}...") do
|
|
252
|
+
api.get_configuration(display_name)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
config_owner = config["owner"]
|
|
256
|
+
config_slug = config["slug"]
|
|
257
|
+
visibility = config["visibility"]
|
|
258
|
+
current_username = credentials.authenticated? ? credentials.username : nil
|
|
259
|
+
|
|
260
|
+
# Determine the appropriate URL based on ownership
|
|
261
|
+
if current_username && config_owner == current_username
|
|
262
|
+
# User owns this config - open in configuration_projects
|
|
263
|
+
url = "#{base_url}/configuration_projects/#{config_slug}"
|
|
264
|
+
elsif visibility == "public"
|
|
265
|
+
# Public config owned by someone else - open in explore
|
|
266
|
+
url = "#{base_url}/explore/#{config_owner}/#{config_slug}"
|
|
267
|
+
else
|
|
268
|
+
# Private config not owned by user - can't access
|
|
269
|
+
ui.error("Configuration '#{display_name}' is private and you don't have access.")
|
|
270
|
+
exit EXIT_PERMISSION_DENIED
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
ui.info("Opening #{display_name} in browser...")
|
|
274
|
+
|
|
275
|
+
begin
|
|
276
|
+
require "launchy"
|
|
277
|
+
Launchy.open(url)
|
|
278
|
+
rescue LoadError
|
|
279
|
+
ui.warning("Could not open browser automatically.")
|
|
280
|
+
ui.info("Visit: #{url}")
|
|
281
|
+
rescue Launchy::Error => e
|
|
282
|
+
ui.warning("Could not open browser: #{e.message}")
|
|
283
|
+
ui.info("Visit: #{url}")
|
|
284
|
+
end
|
|
285
|
+
rescue NotFoundError
|
|
286
|
+
if owner
|
|
287
|
+
ui.error("Configuration '#{display_name}' not found.")
|
|
288
|
+
ui.info("It may be private or doesn't exist.")
|
|
289
|
+
else
|
|
290
|
+
ui.error("Configuration '#{display_name}' not found in your projects.")
|
|
291
|
+
ui.info("Use 'owner/slug' format to open someone else's public configuration.")
|
|
292
|
+
end
|
|
293
|
+
exit EXIT_NOT_FOUND
|
|
294
|
+
rescue PermissionDeniedError
|
|
295
|
+
ui.error("You don't have permission to access '#{display_name}'.")
|
|
296
|
+
ui.info("This configuration may be private.")
|
|
297
|
+
exit EXIT_PERMISSION_DENIED
|
|
298
|
+
rescue NetworkError => e
|
|
299
|
+
ui.error(e.message)
|
|
300
|
+
exit EXIT_NETWORK_ERROR
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
210
304
|
desc "uninstall [CONFIGURATION]", "Remove an installed configuration from local project"
|
|
211
305
|
option :dry_run, type: :boolean, default: false, desc: "Show what would be removed"
|
|
212
306
|
option :path, type: :string, default: ".", desc: "Path where configuration is installed"
|
|
213
307
|
def uninstall(configuration = nil)
|
|
214
|
-
# No auth required - uninstall just fetches tree (works for public configs)
|
|
215
|
-
# and removes local files
|
|
216
|
-
|
|
217
308
|
base_path = File.expand_path(options[:path])
|
|
218
|
-
|
|
309
|
+
installed = InstalledProjects.new(base_path)
|
|
219
310
|
|
|
220
|
-
# If no configuration specified,
|
|
311
|
+
# If no configuration specified, determine which to uninstall
|
|
221
312
|
if configuration.nil?
|
|
222
|
-
|
|
223
|
-
ui.error("No
|
|
313
|
+
if installed.empty?
|
|
314
|
+
ui.error("No configurations installed in this directory.")
|
|
224
315
|
ui.info("Usage: shai uninstall <configuration>")
|
|
225
316
|
exit EXIT_INVALID_INPUT
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
317
|
+
elsif installed.project_count == 1
|
|
318
|
+
configuration = installed.project_slugs.first
|
|
319
|
+
ui.info("Uninstalling #{configuration}...")
|
|
320
|
+
else
|
|
321
|
+
ui.info("Multiple configurations installed:")
|
|
322
|
+
configuration = ui.select("Which configuration do you want to uninstall?",
|
|
323
|
+
installed.project_slugs.map { |s| {name: s, value: s} } + [{name: "Cancel", value: nil}])
|
|
230
324
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
325
|
+
if configuration.nil?
|
|
326
|
+
ui.info("Uninstall cancelled")
|
|
327
|
+
return
|
|
328
|
+
end
|
|
234
329
|
end
|
|
235
330
|
end
|
|
236
331
|
|
|
237
332
|
owner, slug = parse_configuration_name(configuration)
|
|
238
333
|
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
239
334
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
api.get_tree(display_name)
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
tree = response.is_a?(Array) ? response : response["tree"]
|
|
246
|
-
|
|
247
|
-
# Security: Validate all paths before any file operations
|
|
248
|
-
validate_tree_paths!(tree, base_path)
|
|
249
|
-
|
|
250
|
-
# Find files that exist locally
|
|
251
|
-
files_to_remove = []
|
|
252
|
-
folders_to_remove = []
|
|
253
|
-
|
|
254
|
-
tree.each do |node|
|
|
255
|
-
local_path = File.join(base_path, node["path"])
|
|
335
|
+
# Get files to remove - either from tracking or from remote tree
|
|
336
|
+
tracked_files = installed.files_for_project(display_name)
|
|
256
337
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
338
|
+
if tracked_files.empty?
|
|
339
|
+
# Fall back to fetching from remote (for v1 migrations or manual installs)
|
|
340
|
+
begin
|
|
341
|
+
response = ui.spinner("Fetching #{display_name}...") do
|
|
342
|
+
api.get_tree(display_name)
|
|
261
343
|
end
|
|
262
|
-
end
|
|
263
344
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
345
|
+
tree = response.is_a?(Array) ? response : response["tree"]
|
|
346
|
+
validate_tree_paths!(tree, base_path)
|
|
347
|
+
|
|
348
|
+
tracked_files = tree.reject { |n| n["kind"] == "folder" }.map { |n| n["path"] }
|
|
349
|
+
rescue NotFoundError
|
|
350
|
+
ui.error("Configuration '#{display_name}' not found and no tracked files.")
|
|
351
|
+
exit EXIT_NOT_FOUND
|
|
352
|
+
rescue PermissionDeniedError
|
|
353
|
+
ui.error("You don't have permission to access '#{display_name}'.")
|
|
354
|
+
exit EXIT_PERMISSION_DENIED
|
|
355
|
+
rescue NetworkError => e
|
|
356
|
+
ui.error(e.message)
|
|
357
|
+
exit EXIT_NETWORK_ERROR
|
|
267
358
|
end
|
|
359
|
+
end
|
|
268
360
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
361
|
+
# Find files that exist locally
|
|
362
|
+
files_to_remove = []
|
|
363
|
+
folders_to_check = Set.new
|
|
364
|
+
|
|
365
|
+
tracked_files.each do |path|
|
|
366
|
+
local_path = File.join(base_path, path)
|
|
367
|
+
if File.exist?(local_path)
|
|
368
|
+
files_to_remove << path
|
|
369
|
+
# Track parent folders for potential removal
|
|
370
|
+
dir = File.dirname(path)
|
|
371
|
+
while dir != "."
|
|
372
|
+
folders_to_check << dir
|
|
373
|
+
dir = File.dirname(dir)
|
|
374
|
+
end
|
|
276
375
|
end
|
|
376
|
+
end
|
|
277
377
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
378
|
+
if files_to_remove.empty?
|
|
379
|
+
ui.info("No files from '#{display_name}' found in #{base_path}")
|
|
380
|
+
|
|
381
|
+
# Still remove from tracking if it exists
|
|
382
|
+
if installed.has_project?(display_name)
|
|
383
|
+
installed.remove_project(display_name)
|
|
384
|
+
installed.delete! if installed.empty?
|
|
281
385
|
end
|
|
386
|
+
return
|
|
387
|
+
end
|
|
282
388
|
|
|
283
|
-
|
|
389
|
+
if options[:dry_run]
|
|
390
|
+
ui.header("Would remove:")
|
|
391
|
+
files_to_remove.each { |path| ui.display_file_operation(:would_create, path) }
|
|
284
392
|
ui.blank
|
|
393
|
+
ui.info("No changes made (dry run)")
|
|
394
|
+
return
|
|
395
|
+
end
|
|
285
396
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
ui.display_file_operation(:deleted, path)
|
|
291
|
-
end
|
|
397
|
+
unless ui.yes?("Remove #{files_to_remove.length} files from '#{display_name}'?")
|
|
398
|
+
ui.info("Uninstall cancelled")
|
|
399
|
+
return
|
|
400
|
+
end
|
|
292
401
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
402
|
+
ui.header("Uninstalling #{display_name}...")
|
|
403
|
+
ui.blank
|
|
404
|
+
|
|
405
|
+
# Remove files
|
|
406
|
+
files_to_remove.each do |path|
|
|
407
|
+
local_path = File.join(base_path, path)
|
|
408
|
+
File.delete(local_path)
|
|
409
|
+
ui.display_file_operation(:deleted, path)
|
|
410
|
+
end
|
|
301
411
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
412
|
+
# Remove empty folders (deepest first)
|
|
413
|
+
folders_to_check.to_a.sort.reverse_each do |path|
|
|
414
|
+
local_path = File.join(base_path, path)
|
|
415
|
+
if Dir.exist?(local_path) && Dir.empty?(local_path)
|
|
416
|
+
Dir.rmdir(local_path)
|
|
417
|
+
ui.display_file_operation(:deleted, path + "/")
|
|
307
418
|
end
|
|
419
|
+
end
|
|
308
420
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
ui.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
421
|
+
# Update tracking
|
|
422
|
+
installed.remove_project(display_name)
|
|
423
|
+
|
|
424
|
+
# Remove tracking file if no more projects
|
|
425
|
+
if installed.empty?
|
|
426
|
+
installed.delete!
|
|
427
|
+
ui.display_file_operation(:deleted, InstalledProjects::FILENAME)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
ui.blank
|
|
431
|
+
ui.success("Uninstalled #{display_name}")
|
|
432
|
+
|
|
433
|
+
if installed.project_count > 0
|
|
434
|
+
ui.indent("#{installed.project_count} configuration(s) still installed")
|
|
320
435
|
end
|
|
321
436
|
end
|
|
322
437
|
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Shai
|
|
7
|
+
class InstalledProjects
|
|
8
|
+
FILENAME = ".shai-installed"
|
|
9
|
+
CURRENT_VERSION = 2
|
|
10
|
+
|
|
11
|
+
attr_reader :base_path
|
|
12
|
+
|
|
13
|
+
def initialize(base_path)
|
|
14
|
+
@base_path = File.expand_path(base_path)
|
|
15
|
+
@data = load_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def file_path
|
|
19
|
+
File.join(base_path, FILENAME)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?
|
|
23
|
+
File.exist?(file_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def projects
|
|
27
|
+
@data["projects"] || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def project_slugs
|
|
31
|
+
projects.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
projects.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def project_count
|
|
39
|
+
projects.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def has_project?(slug)
|
|
43
|
+
projects.key?(slug)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_project(slug)
|
|
47
|
+
projects[slug]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def files_for_project(slug)
|
|
51
|
+
projects.dig(slug, "files") || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def all_installed_files
|
|
55
|
+
projects.values.flat_map { |p| p["files"] || [] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Find which project owns a specific file
|
|
59
|
+
def project_for_file(file_path)
|
|
60
|
+
projects.each do |slug, data|
|
|
61
|
+
return slug if (data["files"] || []).include?(file_path)
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check for conflicts between new files and already installed projects
|
|
67
|
+
# Returns hash of { file_path => owning_project_slug }
|
|
68
|
+
def find_conflicts(new_files)
|
|
69
|
+
conflicts = {}
|
|
70
|
+
new_files.each do |file|
|
|
71
|
+
owner = project_for_file(file)
|
|
72
|
+
conflicts[file] = owner if owner
|
|
73
|
+
end
|
|
74
|
+
conflicts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add a new project with its files
|
|
78
|
+
def add_project(slug, files)
|
|
79
|
+
@data["projects"][slug] = {
|
|
80
|
+
"installed_at" => Time.now.iso8601,
|
|
81
|
+
"files" => files.sort
|
|
82
|
+
}
|
|
83
|
+
save!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Remove a project and return its files
|
|
87
|
+
def remove_project(slug)
|
|
88
|
+
project = @data["projects"].delete(slug)
|
|
89
|
+
save! if project
|
|
90
|
+
project&.dig("files") || []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Remove specific files from a project (when being overwritten)
|
|
94
|
+
def remove_files_from_project(slug, files_to_remove)
|
|
95
|
+
return unless @data["projects"][slug]
|
|
96
|
+
|
|
97
|
+
current_files = @data["projects"][slug]["files"] || []
|
|
98
|
+
@data["projects"][slug]["files"] = current_files - files_to_remove
|
|
99
|
+
|
|
100
|
+
# If no files left, remove the project entirely
|
|
101
|
+
if @data["projects"][slug]["files"].empty?
|
|
102
|
+
@data["projects"].delete(slug)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
save!
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def save!
|
|
109
|
+
content = YAML.dump(@data)
|
|
110
|
+
header = "# Installed by shai - do not edit manually\n"
|
|
111
|
+
File.write(file_path, header + content)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def delete!
|
|
115
|
+
File.delete(file_path) if exists?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def load_data
|
|
121
|
+
return default_data unless exists?
|
|
122
|
+
|
|
123
|
+
raw = YAML.safe_load_file(file_path) || {}
|
|
124
|
+
|
|
125
|
+
# Handle old format (version 1 / no version)
|
|
126
|
+
if raw["version"].nil? || raw["version"] < CURRENT_VERSION
|
|
127
|
+
migrate_from_v1(raw)
|
|
128
|
+
else
|
|
129
|
+
raw
|
|
130
|
+
end
|
|
131
|
+
rescue
|
|
132
|
+
# If file is corrupted, start fresh
|
|
133
|
+
default_data
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def default_data
|
|
137
|
+
{
|
|
138
|
+
"version" => CURRENT_VERSION,
|
|
139
|
+
"projects" => {}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def migrate_from_v1(old_data)
|
|
144
|
+
# Old format had: { "slug" => "name", "installed_at" => "..." }
|
|
145
|
+
slug = old_data["slug"]
|
|
146
|
+
installed_at = old_data["installed_at"]
|
|
147
|
+
|
|
148
|
+
if slug
|
|
149
|
+
{
|
|
150
|
+
"version" => CURRENT_VERSION,
|
|
151
|
+
"projects" => {
|
|
152
|
+
slug => {
|
|
153
|
+
"installed_at" => installed_at || Time.now.iso8601,
|
|
154
|
+
"files" => [] # We don't know the files from v1, will be populated on next operation
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
default_data
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/shai/version.rb
CHANGED
data/lib/shai.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "shai/version"
|
|
|
4
4
|
require_relative "shai/configuration"
|
|
5
5
|
require_relative "shai/credentials"
|
|
6
6
|
require_relative "shai/api_client"
|
|
7
|
+
require_relative "shai/installed_projects"
|
|
7
8
|
require_relative "shai/cli"
|
|
8
9
|
|
|
9
10
|
module Shai
|
|
@@ -19,6 +20,41 @@ module Shai
|
|
|
19
20
|
|
|
20
21
|
class InvalidConfigurationError < Error; end
|
|
21
22
|
|
|
23
|
+
class RateLimitError < Error
|
|
24
|
+
attr_reader :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(message = "Too many requests", retry_after: nil)
|
|
27
|
+
@retry_after = retry_after
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class DeviceFlowError < Error
|
|
33
|
+
attr_reader :error_code, :interval
|
|
34
|
+
|
|
35
|
+
def initialize(error_code, interval: nil)
|
|
36
|
+
@error_code = error_code
|
|
37
|
+
@interval = interval
|
|
38
|
+
super(error_code)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def authorization_pending?
|
|
42
|
+
error_code == "authorization_pending"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def slow_down?
|
|
46
|
+
error_code == "slow_down"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def access_denied?
|
|
50
|
+
error_code == "access_denied"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def expired?
|
|
54
|
+
error_code == "expired_token"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
22
58
|
# Exit codes as specified in tech spec
|
|
23
59
|
EXIT_SUCCESS = 0
|
|
24
60
|
EXIT_GENERAL_ERROR = 1
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shai-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Jimenez
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - "~>"
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '3.4'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: launchy
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.5'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.5'
|
|
110
124
|
description: A command-line interface for shaicli.dev - download, share, and sync
|
|
111
125
|
AI agent configurations (Claude, Cursor, etc.) across projects and teams.
|
|
112
126
|
email:
|
|
@@ -128,6 +142,7 @@ files:
|
|
|
128
142
|
- lib/shai/commands/sync.rb
|
|
129
143
|
- lib/shai/configuration.rb
|
|
130
144
|
- lib/shai/credentials.rb
|
|
145
|
+
- lib/shai/installed_projects.rb
|
|
131
146
|
- lib/shai/ui.rb
|
|
132
147
|
- lib/shai/version.rb
|
|
133
148
|
homepage: https://shaicli.dev
|