shai-cli 0.1.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.
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Shai
6
+ module Commands
7
+ module Config
8
+ SHAIRC_FILE = ".shairc"
9
+
10
+ def self.included(base)
11
+ base.class_eval do
12
+ desc "config SUBCOMMAND", "Manage configuration metadata"
13
+ def config(subcommand = nil, *args)
14
+ case subcommand
15
+ when "show"
16
+ config_show
17
+ when "set"
18
+ config_set(args)
19
+ when nil
20
+ ui.error("Missing subcommand. Use `shai config show` or `shai config set`")
21
+ exit EXIT_INVALID_INPUT
22
+ else
23
+ ui.error("Unknown subcommand: #{subcommand}")
24
+ exit EXIT_INVALID_INPUT
25
+ end
26
+ end
27
+
28
+ desc "delete SLUG", "Delete a configuration"
29
+ def delete(slug)
30
+ require_auth!
31
+
32
+ unless ui.yes?("Are you sure you want to delete '#{slug}'? This cannot be undone.")
33
+ ui.info("Cancelled")
34
+ return
35
+ end
36
+
37
+ begin
38
+ ui.spinner("Deleting...") do
39
+ api.delete_configuration(slug)
40
+ end
41
+
42
+ ui.success("Configuration '#{slug}' deleted")
43
+ rescue NotFoundError
44
+ ui.error("Configuration '#{slug}' not found.")
45
+ exit EXIT_NOT_FOUND
46
+ rescue PermissionDeniedError
47
+ ui.error("You don't have permission to delete '#{slug}'.")
48
+ exit EXIT_PERMISSION_DENIED
49
+ rescue NetworkError => e
50
+ ui.error(e.message)
51
+ exit EXIT_NETWORK_ERROR
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def config_show
60
+ require_auth!
61
+
62
+ shairc = load_shairc_for_config
63
+ slug = shairc["slug"]
64
+
65
+ begin
66
+ response = ui.spinner("Fetching configuration...") do
67
+ api.get_configuration(slug)
68
+ end
69
+
70
+ config = response["configuration"] || response
71
+
72
+ ui.info("Configuration: #{config["slug"]}")
73
+ ui.info("Name: #{config["name"]}")
74
+ ui.info("Description: #{config["description"] || "(none)"}")
75
+ ui.info("Visibility: #{config["visibility"]}")
76
+ owner = config["owner"]
77
+ owner_name = owner.is_a?(Hash) ? owner["username"] : owner
78
+ ui.info("Owner: #{owner_name || credentials.username}")
79
+ ui.info("Stars: #{config["stars_count"] || 0}")
80
+ ui.info("URL: #{Shai.configuration.api_url}/#{credentials.username}/#{config["slug"]}")
81
+ ui.info("Created: #{format_config_date(config["created_at"])}")
82
+ ui.info("Updated: #{format_config_date(config["updated_at"])}")
83
+ rescue NotFoundError
84
+ ui.error("Configuration '#{slug}' not found.")
85
+ exit EXIT_NOT_FOUND
86
+ rescue NetworkError => e
87
+ ui.error(e.message)
88
+ exit EXIT_NETWORK_ERROR
89
+ end
90
+ end
91
+
92
+ def config_set(args)
93
+ require_auth!
94
+
95
+ if args.length < 2
96
+ ui.error("Usage: shai config set <key> <value>")
97
+ ui.info("Valid keys: name, description, visibility")
98
+ exit EXIT_INVALID_INPUT
99
+ end
100
+
101
+ key = args[0]
102
+ value = args[1..].join(" ")
103
+
104
+ valid_keys = %w[name description visibility]
105
+ unless valid_keys.include?(key)
106
+ ui.error("Invalid key: #{key}")
107
+ ui.info("Valid keys: #{valid_keys.join(", ")}")
108
+ exit EXIT_INVALID_INPUT
109
+ end
110
+
111
+ if key == "visibility" && !%w[public private].include?(value)
112
+ ui.error("Visibility must be 'public' or 'private'")
113
+ exit EXIT_INVALID_INPUT
114
+ end
115
+
116
+ shairc = load_shairc_for_config
117
+ slug = shairc["slug"]
118
+
119
+ begin
120
+ ui.spinner("Updating...") do
121
+ api.update_configuration(slug, key.to_sym => value)
122
+ end
123
+
124
+ ui.success("Updated #{key} to '#{value}'")
125
+ rescue NotFoundError
126
+ ui.error("Configuration '#{slug}' not found.")
127
+ exit EXIT_NOT_FOUND
128
+ rescue PermissionDeniedError
129
+ ui.error("You don't have permission to modify this configuration.")
130
+ exit EXIT_PERMISSION_DENIED
131
+ rescue InvalidConfigurationError => e
132
+ ui.error(e.message)
133
+ exit EXIT_INVALID_INPUT
134
+ rescue NetworkError => e
135
+ ui.error(e.message)
136
+ exit EXIT_NETWORK_ERROR
137
+ end
138
+ end
139
+
140
+ def load_shairc_for_config
141
+ unless File.exist?(SHAIRC_FILE)
142
+ ui.error("No #{SHAIRC_FILE} file found. Run `shai init` to create one.")
143
+ exit EXIT_INVALID_INPUT
144
+ end
145
+
146
+ shairc = YAML.safe_load_file(SHAIRC_FILE)
147
+
148
+ unless shairc["slug"]
149
+ ui.error("Invalid #{SHAIRC_FILE} file. Missing required field: slug")
150
+ exit EXIT_INVALID_INPUT
151
+ end
152
+
153
+ shairc
154
+ rescue Psych::SyntaxError => e
155
+ ui.error("Invalid #{SHAIRC_FILE} file: #{e.message}")
156
+ exit EXIT_INVALID_INPUT
157
+ end
158
+
159
+ def format_config_date(date_string)
160
+ return "(unknown)" unless date_string
161
+
162
+ Time.parse(date_string).strftime("%B %-d, %Y")
163
+ rescue ArgumentError
164
+ date_string
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Shai
6
+ module Commands
7
+ module Configurations
8
+ INSTALLED_FILE = ".shai-installed"
9
+
10
+ def self.included(base)
11
+ base.class_eval do
12
+ desc "list", "List your configurations"
13
+ def list
14
+ require_auth!
15
+
16
+ begin
17
+ response = ui.spinner("Fetching configurations...") do
18
+ api.list_configurations
19
+ end
20
+
21
+ configs = response.is_a?(Array) ? response : response["configurations"]
22
+
23
+ if configs.empty?
24
+ ui.info("You don't have any configurations yet.")
25
+ ui.info("Run `shai init` to create one.")
26
+ else
27
+ ui.header("Your configurations:")
28
+ ui.blank
29
+ configs.each do |config|
30
+ ui.display_configuration(config)
31
+ ui.indent("Updated: #{time_ago(config["updated_at"])}")
32
+ ui.blank
33
+ end
34
+ end
35
+ rescue NetworkError => e
36
+ ui.error(e.message)
37
+ exit EXIT_NETWORK_ERROR
38
+ end
39
+ end
40
+
41
+ desc "search QUERY", "Search public configurations"
42
+ option :tag, type: :array, default: [], desc: "Filter by tags"
43
+ def search(query = nil)
44
+ tags = options[:tag] || []
45
+
46
+ if query.nil? && tags.empty?
47
+ ui.error("Please provide a search query or tags")
48
+ exit EXIT_INVALID_INPUT
49
+ end
50
+
51
+ begin
52
+ response = ui.spinner("Searching...") do
53
+ api.search_configurations(query: query, tags: tags)
54
+ end
55
+
56
+ configs = response.is_a?(Array) ? response : response["configurations"]
57
+ search_term = query ? "\"#{query}\"" : "tags: #{tags.join(", ")}"
58
+
59
+ if configs.empty?
60
+ ui.info("No configurations found for #{search_term}")
61
+ else
62
+ ui.header("Search results for #{search_term}:")
63
+ ui.blank
64
+ configs.each do |config|
65
+ ui.display_configuration(config, detailed: true)
66
+ ui.blank
67
+ end
68
+ ui.info("Found #{configs.length} configuration(s). Use `shai install <name>` to install.")
69
+ end
70
+ rescue NetworkError => e
71
+ ui.error(e.message)
72
+ exit EXIT_NETWORK_ERROR
73
+ end
74
+ end
75
+
76
+ desc "install CONFIGURATION", "Install a configuration to local project"
77
+ option :force, type: :boolean, aliases: "-f", default: false, desc: "Overwrite existing files"
78
+ option :dry_run, type: :boolean, default: false, desc: "Show what would be installed"
79
+ option :path, type: :string, default: ".", desc: "Install to specific directory"
80
+ def install(configuration)
81
+ owner, slug = parse_configuration_name(configuration)
82
+ display_name = owner ? "#{owner}/#{slug}" : slug
83
+ base_path = File.expand_path(options[:path])
84
+ shairc_path = File.join(base_path, ".shairc")
85
+ installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
86
+
87
+ # Check if a configuration is already installed/initialized
88
+ unless options[:force]
89
+ existing_slug = nil
90
+
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"]
105
+ end
106
+
107
+ if existing_slug
108
+ ui.error("A configuration is already present in this directory.")
109
+ ui.indent("Existing: #{existing_slug}")
110
+ 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)")
116
+ exit EXIT_INVALID_INPUT
117
+ end
118
+ end
119
+
120
+ begin
121
+ response = ui.spinner("Fetching #{display_name}...") do
122
+ api.get_tree(display_name)
123
+ end
124
+
125
+ tree = response["tree"]
126
+
127
+ # Security: Validate all paths before any file operations
128
+ validate_tree_paths!(tree, base_path)
129
+
130
+ # Check for conflicts
131
+ conflicts = []
132
+ tree.each do |node|
133
+ next if node["kind"] == "folder"
134
+
135
+ local_path = File.join(base_path, node["path"])
136
+ conflicts << node["path"] if File.exist?(local_path)
137
+ end
138
+
139
+ if options[:dry_run]
140
+ ui.header("Would install:")
141
+ tree.each { |node| ui.display_file_operation(:would_create, node["path"]) }
142
+ ui.blank
143
+ ui.info("No changes made (dry run)")
144
+ return
145
+ end
146
+
147
+ # 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) }
152
+ ui.blank
153
+
154
+ choice = ui.select("Overwrite existing files?", [
155
+ {name: "Yes", value: :yes},
156
+ {name: "No (abort)", value: :no},
157
+ {name: "Show diff", value: :diff}
158
+ ])
159
+
160
+ if choice == :diff
161
+ show_install_diff(tree, base_path, conflicts)
162
+ return unless ui.yes?("Overwrite existing files?")
163
+ elsif choice == :no
164
+ ui.info("Installation cancelled")
165
+ return
166
+ end
167
+ end
168
+
169
+ ui.header("Installing #{display_name}...")
170
+ ui.blank
171
+
172
+ # Create folders and files
173
+ created_count = 0
174
+ tree.sort_by { |n| (n["kind"] == "folder") ? 0 : 1 }.each do |node|
175
+ local_path = File.join(base_path, node["path"])
176
+
177
+ if node["kind"] == "folder"
178
+ FileUtils.mkdir_p(local_path)
179
+ ui.display_file_operation(:created, node["path"] + "/")
180
+ else
181
+ FileUtils.mkdir_p(File.dirname(local_path))
182
+ File.write(local_path, node["content"])
183
+ ui.display_file_operation(:created, node["path"])
184
+ end
185
+ created_count += 1
186
+ end
187
+
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)
195
+
196
+ ui.blank
197
+ ui.success("Installed #{created_count} items from #{display_name}")
198
+ rescue NotFoundError
199
+ ui.error("Configuration '#{display_name}' not found.")
200
+ exit EXIT_NOT_FOUND
201
+ rescue PermissionDeniedError
202
+ ui.error("You don't have permission to access '#{display_name}'.")
203
+ exit EXIT_PERMISSION_DENIED
204
+ rescue NetworkError => e
205
+ ui.error(e.message)
206
+ exit EXIT_NETWORK_ERROR
207
+ end
208
+ end
209
+
210
+ desc "uninstall [CONFIGURATION]", "Remove an installed configuration from local project"
211
+ option :dry_run, type: :boolean, default: false, desc: "Show what would be removed"
212
+ option :path, type: :string, default: ".", desc: "Path where configuration is installed"
213
+ def uninstall(configuration = nil)
214
+ require_auth!
215
+
216
+ base_path = File.expand_path(options[:path])
217
+ installed_path = File.join(base_path, INSTALLED_FILE)
218
+
219
+ # If no configuration specified, try to read from .shai-installed
220
+ if configuration.nil?
221
+ unless File.exist?(installed_path)
222
+ ui.error("No configuration specified and no .shai-installed file found.")
223
+ ui.info("Usage: shai uninstall <configuration>")
224
+ exit EXIT_INVALID_INPUT
225
+ end
226
+
227
+ installed_config = YAML.safe_load_file(installed_path)
228
+ configuration = installed_config["slug"]
229
+
230
+ unless configuration
231
+ ui.error("Could not read configuration from .shai-installed")
232
+ exit EXIT_INVALID_INPUT
233
+ end
234
+ end
235
+
236
+ owner, slug = parse_configuration_name(configuration)
237
+ display_name = owner ? "#{owner}/#{slug}" : slug
238
+
239
+ begin
240
+ response = ui.spinner("Fetching #{display_name}...") do
241
+ api.get_tree(display_name)
242
+ end
243
+
244
+ tree = response.is_a?(Array) ? response : response["tree"]
245
+
246
+ # Security: Validate all paths before any file operations
247
+ validate_tree_paths!(tree, base_path)
248
+
249
+ # Find files that exist locally
250
+ files_to_remove = []
251
+ folders_to_remove = []
252
+
253
+ tree.each do |node|
254
+ local_path = File.join(base_path, node["path"])
255
+
256
+ if node["kind"] == "folder"
257
+ folders_to_remove << node["path"] if Dir.exist?(local_path)
258
+ elsif File.exist?(local_path)
259
+ files_to_remove << node["path"]
260
+ end
261
+ end
262
+
263
+ if files_to_remove.empty? && folders_to_remove.empty?
264
+ ui.info("No files from '#{display_name}' found in #{base_path}")
265
+ return
266
+ end
267
+
268
+ if options[:dry_run]
269
+ ui.header("Would remove:")
270
+ files_to_remove.each { |path| ui.display_file_operation(:would_create, path) }
271
+ folders_to_remove.sort.reverse_each { |path| ui.display_file_operation(:would_create, path + "/") }
272
+ ui.blank
273
+ ui.info("No changes made (dry run)")
274
+ return
275
+ end
276
+
277
+ unless ui.yes?("Remove #{files_to_remove.length} files and #{folders_to_remove.length} folders from '#{display_name}'?")
278
+ ui.info("Uninstall cancelled")
279
+ return
280
+ end
281
+
282
+ ui.header("Uninstalling #{display_name}...")
283
+ ui.blank
284
+
285
+ # Remove files first
286
+ files_to_remove.each do |path|
287
+ local_path = File.join(base_path, path)
288
+ File.delete(local_path)
289
+ ui.display_file_operation(:deleted, path)
290
+ end
291
+
292
+ # Remove folders (deepest first)
293
+ folders_to_remove.sort.reverse_each do |path|
294
+ local_path = File.join(base_path, path)
295
+ if Dir.exist?(local_path) && Dir.empty?(local_path)
296
+ Dir.rmdir(local_path)
297
+ ui.display_file_operation(:deleted, path + "/")
298
+ end
299
+ end
300
+
301
+ # Remove installation tracking file
302
+ installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
303
+ if File.exist?(installed_path)
304
+ File.delete(installed_path)
305
+ ui.display_file_operation(:deleted, Shai::Commands::Configurations::INSTALLED_FILE)
306
+ end
307
+
308
+ ui.blank
309
+ ui.success("Uninstalled #{display_name}")
310
+ rescue NotFoundError
311
+ ui.error("Configuration '#{display_name}' not found.")
312
+ exit EXIT_NOT_FOUND
313
+ rescue PermissionDeniedError
314
+ ui.error("You don't have permission to access '#{display_name}'.")
315
+ exit EXIT_PERMISSION_DENIED
316
+ rescue NetworkError => e
317
+ ui.error(e.message)
318
+ exit EXIT_NETWORK_ERROR
319
+ end
320
+ end
321
+ end
322
+ end
323
+
324
+ private
325
+
326
+ def parse_configuration_name(name)
327
+ if name.include?("/")
328
+ name.split("/", 2)
329
+ else
330
+ [nil, name]
331
+ end
332
+ end
333
+
334
+ # Security: Validate that a path is safe and doesn't escape base_path
335
+ def safe_path?(path, base_path)
336
+ return false if path.nil? || path.empty?
337
+ return false if path.start_with?("/") # No absolute paths
338
+ return false if path.include?("..") # No directory traversal
339
+ return false if path.include?("\0") # No null bytes
340
+
341
+ # Verify resolved path stays within base_path
342
+ full_path = File.expand_path(path, base_path)
343
+ full_path.start_with?(File.expand_path(base_path) + "/") || full_path == File.expand_path(base_path)
344
+ end
345
+
346
+ def validate_tree_paths!(tree, base_path)
347
+ tree.each do |node|
348
+ unless safe_path?(node["path"], base_path)
349
+ raise SecurityError, "Invalid path detected: #{node["path"].inspect}"
350
+ end
351
+ end
352
+ end
353
+
354
+ def time_ago(timestamp)
355
+ return "unknown" unless timestamp
356
+
357
+ seconds = Time.now - Time.parse(timestamp)
358
+ case seconds
359
+ when 0..59
360
+ "just now"
361
+ when 60..3599
362
+ "#{(seconds / 60).to_i} minutes ago"
363
+ when 3600..86399
364
+ hours = (seconds / 3600).to_i
365
+ (hours == 1) ? "1 hour ago" : "#{hours} hours ago"
366
+ when 86400..604799
367
+ days = (seconds / 86400).to_i
368
+ (days == 1) ? "yesterday" : "#{days} days ago"
369
+ else
370
+ Time.parse(timestamp).strftime("%B %-d, %Y")
371
+ end
372
+ rescue ArgumentError
373
+ timestamp
374
+ end
375
+
376
+ def show_install_diff(tree, base_path, conflicts)
377
+ require "diffy"
378
+
379
+ conflicts.each do |path|
380
+ local_path = File.join(base_path, path)
381
+ local_content = File.read(local_path)
382
+ remote_content = tree.find { |n| n["path"] == path }&.dig("content") || ""
383
+
384
+ ui.info("--- local #{path}")
385
+ ui.info("+++ remote #{path}")
386
+ diff = Diffy::Diff.new(local_content, remote_content, context: 3)
387
+ ui.diff(diff.to_s)
388
+ ui.blank
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end