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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d4d5f46776b74f36b7f259d6edb67489f519dbc98b5ae5b0e44be82fed60d2d
4
- data.tar.gz: 9669269dd3ac52f2161a488e68364da7e58ecb0d32328ca901ed18014b7ed8ef
3
+ metadata.gz: 1ea758284c9ecb5985d8789033ee4368ec95757ac056a0b5870f9d8f523d9282
4
+ data.tar.gz: 046a439279ef5a6c2cf32eeb9f5d00f7656887cb310168c334306796cd73f713
5
5
  SHA512:
6
- metadata.gz: a4d113fbd60fbd071f71d47ece7aba4848e9160f41feda7bcdfe39612d0c029f96a85dc31ebe473121b964459d20d2264ce080cb83a4ada5238e619273a1590c
7
- data.tar.gz: 768577aecf1ec1e52301dededa606b7f2864fab148f1ad620bcc027a9fbc648e9559dedd1cbcea629fdaf3ad46c99830d99175307fd10a03f611f455003ef953
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 | 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
@@ -20,6 +20,21 @@ module Shai
20
20
  })
21
21
  end
22
22
 
23
+ # Device Flow Authentication (RFC 8628)
24
+ def device_authorize(client_name: nil)
25
+ client_name ||= default_client_name
26
+ post_without_auth("/api/v1/device/authorize", {
27
+ client_name: client_name
28
+ })
29
+ end
30
+
31
+ def device_token(device_code:)
32
+ response = @connection.post("/api/v1/device/token") do |req|
33
+ req.body = {device_code: device_code}
34
+ end
35
+ handle_device_token_response(response)
36
+ end
37
+
23
38
  # Configurations
24
39
  def list_configurations
25
40
  get("/api/v1/configurations")
@@ -64,6 +79,10 @@ module Shai
64
79
  put("/api/v1/configurations/#{encode_identifier(identifier)}/tree", {tree: tree})
65
80
  end
66
81
 
82
+ def record_install(identifier)
83
+ post_without_auth("/api/v1/configurations/#{encode_identifier(identifier)}/install", {})
84
+ end
85
+
67
86
  # Encode identifier for URL (handles owner/slug format)
68
87
  def encode_identifier(identifier)
69
88
  URI.encode_www_form_component(identifier)
@@ -94,6 +113,13 @@ module Shai
94
113
  handle_response(response)
95
114
  end
96
115
 
116
+ def post_without_auth(path, body)
117
+ response = @connection.post(path) do |req|
118
+ req.body = body
119
+ end
120
+ handle_response(response)
121
+ end
122
+
97
123
  def put(path, body)
98
124
  response = @connection.put(path) do |req|
99
125
  add_auth_header(req)
@@ -126,6 +152,29 @@ module Shai
126
152
  raise NotFoundError, response.body&.dig("error") || "Not found"
127
153
  when 422
128
154
  raise InvalidConfigurationError, response.body&.dig("error") || "Invalid request"
155
+ when 429
156
+ retry_after = response.headers["Retry-After"]&.to_i
157
+ raise RateLimitError.new(
158
+ response.body&.dig("error") || "Too many requests. Please try again later.",
159
+ retry_after: retry_after
160
+ )
161
+ else
162
+ raise Error, response.body&.dig("error") || "Request failed with status #{response.status}"
163
+ end
164
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError
165
+ raise NetworkError, "Could not connect to #{Shai.configuration.api_url}. Check your internet connection."
166
+ end
167
+
168
+ def handle_device_token_response(response)
169
+ case response.status
170
+ when 200
171
+ response.body
172
+ when 400
173
+ error_code = response.body&.dig("error")
174
+ interval = response.body&.dig("interval")
175
+ raise DeviceFlowError.new(error_code, interval: interval)
176
+ when 429
177
+ raise DeviceFlowError.new("slow_down", interval: 10)
129
178
  else
130
179
  raise Error, response.body&.dig("error") || "Request failed with status #{response.status}"
131
180
  end
data/lib/shai/cli.rb CHANGED
@@ -26,13 +26,15 @@ module Shai
26
26
  shell.say " shai <command> [options]"
27
27
  shell.say ""
28
28
  shell.say "AUTHENTICATION:"
29
- shell.say " login Log in to shaicli.dev"
29
+ shell.say " login Log in via browser (device flow)"
30
+ shell.say " login --password Log in with email/password directly"
30
31
  shell.say " logout Log out and remove stored credentials"
31
32
  shell.say " whoami Show current authentication status"
32
33
  shell.say ""
33
34
  shell.say "DISCOVERY:"
34
35
  shell.say " list List your configurations"
35
36
  shell.say " search <query> Search public configurations"
37
+ shell.say " open <config> Open a configuration in the browser"
36
38
  shell.say ""
37
39
  shell.say "USING CONFIGURATIONS (install to current project):"
38
40
  shell.say " install <config> Install a configuration to local project"
@@ -56,6 +58,7 @@ module Shai
56
58
  shell.say "EXAMPLES:"
57
59
  shell.say " shai login"
58
60
  shell.say " shai search \"claude code\""
61
+ shell.say " shai open anthropic/claude-expert"
59
62
  shell.say " shai install anthropic/claude-expert"
60
63
  shell.say " shai init"
61
64
  shell.say " shai push"
@@ -1,38 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "launchy"
4
+
3
5
  module Shai
4
6
  module Commands
5
7
  module Auth
8
+ DEVICE_FLOW_TIMEOUT = 600 # 10 minutes
9
+
6
10
  def self.included(base)
7
11
  base.class_eval do
8
12
  desc "login", "Log in to shaicli.dev"
13
+ option :password, type: :boolean, default: false, desc: "Use password authentication instead of device flow"
9
14
  def login
10
- identifier = ui.ask("Email or username:")
11
- password = ui.mask("Password:")
12
-
13
- ui.blank
14
-
15
- begin
16
- response = ui.spinner("Logging in...") do
17
- api.login(identifier: identifier, password: password)
18
- end
19
-
20
- data = response["data"]
21
- credentials.save(
22
- token: data["token"],
23
- expires_at: data["expires_at"],
24
- user: data["user"]
25
- )
26
-
27
- ui.success("Logged in as #{data.dig("user", "username")}")
28
- ui.indent("Token expires: #{format_date(data["expires_at"])}")
29
- ui.indent("Token stored in #{Shai.configuration.credentials_path}")
30
- rescue AuthenticationError
31
- ui.error("Invalid credentials")
32
- exit EXIT_AUTH_REQUIRED
33
- rescue NetworkError => e
34
- ui.error(e.message)
35
- exit EXIT_NETWORK_ERROR
15
+ if options[:password]
16
+ login_with_password
17
+ else
18
+ login_with_device_flow
36
19
  end
37
20
  end
38
21
 
@@ -52,6 +35,151 @@ module Shai
52
35
  ui.info("Not logged in. Run `shai login` to authenticate.")
53
36
  end
54
37
  end
38
+
39
+ no_commands do
40
+ def login_with_password
41
+ identifier = ui.ask("Email or username:")
42
+ password = ui.mask("Password:")
43
+
44
+ ui.blank
45
+
46
+ begin
47
+ response = ui.spinner("Logging in...") do
48
+ api.login(identifier: identifier, password: password)
49
+ end
50
+
51
+ save_credentials_from_response(response)
52
+ rescue AuthenticationError
53
+ ui.error("Invalid credentials")
54
+ exit EXIT_AUTH_REQUIRED
55
+ rescue RateLimitError
56
+ ui.error("Too many login attempts. Please try again later.")
57
+ exit EXIT_GENERAL_ERROR
58
+ rescue NetworkError => e
59
+ ui.error(e.message)
60
+ exit EXIT_NETWORK_ERROR
61
+ end
62
+ end
63
+
64
+ def login_with_device_flow
65
+ ui.info("Starting device authorization...")
66
+ ui.blank
67
+
68
+ begin
69
+ response = api.device_authorize
70
+ rescue RateLimitError => e
71
+ retry_msg = e.retry_after ? " Try again in #{e.retry_after} seconds." : ""
72
+ ui.error("Too many authorization attempts.#{retry_msg}")
73
+ exit EXIT_GENERAL_ERROR
74
+ rescue NetworkError => e
75
+ ui.error(e.message)
76
+ exit EXIT_NETWORK_ERROR
77
+ end
78
+
79
+ device_code = response["device_code"]
80
+ user_code = response["user_code"]
81
+ verification_uri = response["verification_uri"]
82
+ verification_uri_complete = response["verification_uri_complete"]
83
+ interval = response["interval"] || 5
84
+
85
+ display_device_code_instructions(
86
+ user_code: user_code,
87
+ verification_uri: verification_uri
88
+ )
89
+
90
+ ui.blank
91
+ if ui.yes?("Open browser automatically?", default: true)
92
+ open_browser(verification_uri_complete || verification_uri)
93
+ end
94
+
95
+ ui.blank
96
+ poll_for_authorization(device_code: device_code, interval: interval)
97
+ end
98
+
99
+ def display_device_code_instructions(user_code:, verification_uri:)
100
+ ui.info("To authorize this device:")
101
+ ui.blank
102
+ ui.indent("1. Visit: #{ui.cyan(verification_uri)}")
103
+ ui.indent("2. Enter code: #{ui.bold(user_code)}")
104
+ end
105
+
106
+ def open_browser(url)
107
+ Launchy.open(url)
108
+ rescue Launchy::CommandNotFoundError
109
+ ui.warning("Could not open browser automatically. Please visit the URL manually.")
110
+ end
111
+
112
+ def poll_for_authorization(device_code:, interval:)
113
+ start_time = Time.now
114
+ current_interval = interval
115
+
116
+ spinner = TTY::Spinner.new("[:spinner] Waiting for authorization...", format: :dots)
117
+ spinner.auto_spin
118
+
119
+ loop do
120
+ elapsed = Time.now - start_time
121
+ if elapsed > DEVICE_FLOW_TIMEOUT
122
+ spinner.error
123
+ ui.error("Authorization timed out. Please try again.")
124
+ exit EXIT_AUTH_REQUIRED
125
+ end
126
+
127
+ sleep(current_interval)
128
+
129
+ begin
130
+ response = api.device_token(device_code: device_code)
131
+
132
+ # Success - we got a token
133
+ spinner.success
134
+ save_credentials_from_response(response)
135
+ return
136
+ rescue DeviceFlowError => e
137
+ case e.error_code
138
+ when "authorization_pending"
139
+ # Keep polling
140
+ next
141
+ when "slow_down"
142
+ current_interval = e.interval || (current_interval + 5)
143
+ next
144
+ when "access_denied"
145
+ spinner.error
146
+ ui.blank
147
+ ui.error("Authorization denied by user.")
148
+ exit EXIT_AUTH_REQUIRED
149
+ when "expired_token"
150
+ spinner.error
151
+ ui.blank
152
+ ui.error("Device code expired. Please try again.")
153
+ exit EXIT_AUTH_REQUIRED
154
+ else
155
+ spinner.error
156
+ ui.blank
157
+ ui.error("Authorization failed: #{e.error_code}")
158
+ exit EXIT_AUTH_REQUIRED
159
+ end
160
+ rescue NetworkError => e
161
+ spinner.error
162
+ ui.blank
163
+ ui.error(e.message)
164
+ exit EXIT_NETWORK_ERROR
165
+ end
166
+ end
167
+ end
168
+
169
+ def save_credentials_from_response(response)
170
+ data = response["data"]
171
+ credentials.save(
172
+ token: data["token"],
173
+ expires_at: data["expires_at"],
174
+ user: data["user"]
175
+ )
176
+
177
+ ui.blank
178
+ ui.success("Logged in as #{data.dig("user", "username")}")
179
+ ui.indent("Token expires: #{format_date(data["expires_at"])}")
180
+ ui.indent("Token stored in #{Shai.configuration.credentials_path}")
181
+ end
182
+ end
55
183
  end
56
184
  end
57
185
 
@@ -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
- # Check if a configuration is already installed/initialized
88
- unless options[:force]
89
- existing_slug = nil
84
+ installed = InstalledProjects.new(base_path)
90
85
 
91
- if File.exist?(installed_path)
92
- existing_config = begin
93
- YAML.safe_load_file(installed_path)
94
- rescue
95
- {}
96
- end
97
- existing_slug = existing_config["slug"]
98
- elsif File.exist?(shairc_path)
99
- existing_config = begin
100
- YAML.safe_load_file(shairc_path)
101
- rescue
102
- {}
103
- end
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("A configuration is already present in this directory.")
103
+ ui.error("This directory contains an authored configuration (.shairc).")
109
104
  ui.indent("Existing: #{existing_slug}")
110
105
  ui.blank
111
- ui.info("To install a different configuration:")
112
- ui.indent("1. Run `shai uninstall #{existing_slug}` to remove the current configuration")
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
- # Check for conflicts
131
- conflicts = []
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
- conflicts << node["path"] if File.exist?(local_path)
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 conflicts.any? && !options[:force]
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
- choice = ui.select("Overwrite existing files?", [
155
- {name: "Yes", value: :yes},
156
- {name: "No (abort)", value: :no},
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, conflicts)
162
- return unless ui.yes?("Overwrite existing files?")
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
- created_count = 0
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
- # Write installation tracking file
189
- installed_content = <<~YAML
190
- # Installed by shai - do not edit manually
191
- slug: "#{display_name}"
192
- installed_at: "#{Time.now.iso8601}"
193
- YAML
194
- File.write(installed_path, installed_content)
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 #{created_count} items from #{display_name}")
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
- installed_path = File.join(base_path, INSTALLED_FILE)
309
+ installed = InstalledProjects.new(base_path)
219
310
 
220
- # If no configuration specified, try to read from .shai-installed
311
+ # If no configuration specified, determine which to uninstall
221
312
  if configuration.nil?
222
- unless File.exist?(installed_path)
223
- ui.error("No configuration specified and no .shai-installed file found.")
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
- end
227
-
228
- installed_config = YAML.safe_load_file(installed_path)
229
- configuration = installed_config["slug"]
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
- unless configuration
232
- ui.error("Could not read configuration from .shai-installed")
233
- exit EXIT_INVALID_INPUT
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
- begin
241
- response = ui.spinner("Fetching #{display_name}...") do
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
- if node["kind"] == "folder"
258
- folders_to_remove << node["path"] if Dir.exist?(local_path)
259
- elsif File.exist?(local_path)
260
- files_to_remove << node["path"]
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
- if files_to_remove.empty? && folders_to_remove.empty?
265
- ui.info("No files from '#{display_name}' found in #{base_path}")
266
- return
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
- if options[:dry_run]
270
- ui.header("Would remove:")
271
- files_to_remove.each { |path| ui.display_file_operation(:would_create, path) }
272
- folders_to_remove.sort.reverse_each { |path| ui.display_file_operation(:would_create, path + "/") }
273
- ui.blank
274
- ui.info("No changes made (dry run)")
275
- return
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
- unless ui.yes?("Remove #{files_to_remove.length} files and #{folders_to_remove.length} folders from '#{display_name}'?")
279
- ui.info("Uninstall cancelled")
280
- return
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
- ui.header("Uninstalling #{display_name}...")
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
- # Remove files first
287
- files_to_remove.each do |path|
288
- local_path = File.join(base_path, path)
289
- File.delete(local_path)
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
- # Remove folders (deepest first)
294
- folders_to_remove.sort.reverse_each do |path|
295
- local_path = File.join(base_path, path)
296
- if Dir.exist?(local_path) && Dir.empty?(local_path)
297
- Dir.rmdir(local_path)
298
- ui.display_file_operation(:deleted, path + "/")
299
- end
300
- end
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
- # Remove installation tracking file
303
- installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
304
- if File.exist?(installed_path)
305
- File.delete(installed_path)
306
- ui.display_file_operation(:deleted, Shai::Commands::Configurations::INSTALLED_FILE)
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
- ui.blank
310
- ui.success("Uninstalled #{display_name}")
311
- rescue NotFoundError
312
- ui.error("Configuration '#{display_name}' not found.")
313
- exit EXIT_NOT_FOUND
314
- rescue PermissionDeniedError
315
- ui.error("You don't have permission to access '#{display_name}'.")
316
- exit EXIT_PERMISSION_DENIED
317
- rescue NetworkError => e
318
- ui.error(e.message)
319
- exit EXIT_NETWORK_ERROR
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shai
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.1
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