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,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "diffy"
5
+
6
+ module Shai
7
+ module Commands
8
+ module Sync
9
+ SHAIRC_FILE = ".shairc"
10
+
11
+ def self.included(base)
12
+ base.class_eval do
13
+ desc "init", "Initialize a new configuration"
14
+ def init
15
+ require_auth!
16
+
17
+ if File.exist?(SHAIRC_FILE)
18
+ ui.error("A #{SHAIRC_FILE} file already exists in this directory.")
19
+ ui.info("Use `shai push` to upload changes to the existing configuration.")
20
+ exit EXIT_INVALID_INPUT
21
+ end
22
+
23
+ name = ui.ask("Configuration name:")
24
+ description = ui.ask("Description (optional):")
25
+ visibility = ui.select("Visibility:", %w[private public], default: "private")
26
+ include_patterns = ui.ask("Include paths (glob patterns, comma-separated):", default: ".claude/**,.cursor/**")
27
+
28
+ ui.blank
29
+
30
+ begin
31
+ response = ui.spinner("Creating configuration...") do
32
+ api.create_configuration(
33
+ name: name,
34
+ description: description.to_s.empty? ? nil : description,
35
+ visibility: visibility
36
+ )
37
+ end
38
+
39
+ config = response["configuration"] || response
40
+ slug = config["slug"]
41
+ username = credentials.username
42
+
43
+ # Write .shairc file
44
+ shairc_content = <<~YAML
45
+ # .shairc - Shai configuration
46
+ slug: #{slug}
47
+ include:
48
+ #{include_patterns.split(",").map { |p| " - #{p.strip}" }.join("\n")}
49
+ exclude:
50
+ - "**/*.local.*"
51
+ - "**/.env"
52
+ YAML
53
+
54
+ File.write(SHAIRC_FILE, shairc_content)
55
+
56
+ ui.success("Created #{slug}")
57
+ ui.indent("Remote: #{Shai.configuration.api_url}/#{username}/#{slug}")
58
+ ui.blank
59
+ ui.info("Next steps:")
60
+ ui.indent("1. Add or modify files matching your include patterns")
61
+ ui.indent("2. Run `shai push` to upload your configuration")
62
+ rescue InvalidConfigurationError => e
63
+ ui.error(e.message)
64
+ exit EXIT_INVALID_INPUT
65
+ rescue NetworkError => e
66
+ ui.error(e.message)
67
+ exit EXIT_NETWORK_ERROR
68
+ end
69
+ end
70
+
71
+ desc "push", "Push local changes to remote"
72
+ option :dry_run, type: :boolean, default: false, desc: "Show what would be pushed"
73
+ option :message, type: :string, aliases: "-m", desc: "Add a message (future feature)"
74
+ def push
75
+ require_auth!
76
+
77
+ shairc = load_shairc
78
+ slug = shairc["slug"]
79
+
80
+ # Build tree from local files
81
+ tree = build_local_tree(shairc)
82
+
83
+ if tree.empty?
84
+ ui.warning("No files found matching include patterns.")
85
+ ui.info("Check your .shairc include patterns.")
86
+ exit EXIT_INVALID_INPUT
87
+ end
88
+
89
+ display_name = "#{credentials.username}/#{slug}"
90
+
91
+ if options[:dry_run]
92
+ ui.header("Would push to #{display_name}:")
93
+ tree.each { |node| ui.display_file_operation(:uploaded, node[:path]) }
94
+ ui.blank
95
+ ui.info("No changes made (dry run)")
96
+ return
97
+ end
98
+
99
+ ui.header("Pushing to #{display_name}...")
100
+ ui.blank
101
+
102
+ tree.each { |node| ui.display_file_operation(:uploaded, node[:path]) }
103
+
104
+ begin
105
+ ui.spinner("Uploading...") do
106
+ api.update_tree(slug, tree)
107
+ end
108
+
109
+ ui.blank
110
+ ui.success("Pushed #{tree.length} items")
111
+ ui.indent("View at: #{Shai.configuration.api_url}/#{credentials.username}/#{slug}")
112
+ rescue NotFoundError
113
+ ui.error("Configuration '#{slug}' not found. Run `shai init` first.")
114
+ exit EXIT_NOT_FOUND
115
+ rescue PermissionDeniedError
116
+ ui.error("You don't have permission to modify '#{display_name}'.")
117
+ exit EXIT_PERMISSION_DENIED
118
+ rescue NetworkError => e
119
+ ui.error(e.message)
120
+ exit EXIT_NETWORK_ERROR
121
+ end
122
+ end
123
+
124
+ desc "status", "Show local changes"
125
+ def status
126
+ require_auth!
127
+
128
+ shairc = load_shairc
129
+ slug = shairc["slug"]
130
+
131
+ display_name = "#{credentials.username}/#{slug}"
132
+
133
+ begin
134
+ remote_tree = ui.spinner("Fetching remote state...") do
135
+ api.get_tree(slug)["tree"]
136
+ end
137
+
138
+ local_tree = build_local_tree(shairc)
139
+
140
+ # Compare trees
141
+ remote_files = remote_tree.select { |n| n["kind"] == "file" }
142
+ .each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
143
+ local_files = local_tree.select { |n| n[:kind] == "file" }
144
+ .each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
145
+
146
+ modified = []
147
+ new_files = []
148
+ deleted = []
149
+
150
+ local_files.each do |path, content|
151
+ if remote_files.key?(path)
152
+ modified << path if remote_files[path] != content
153
+ else
154
+ new_files << path
155
+ end
156
+ end
157
+
158
+ remote_files.each_key do |path|
159
+ deleted << path unless local_files.key?(path)
160
+ end
161
+
162
+ ui.header("Configuration: #{display_name}")
163
+
164
+ if modified.empty? && new_files.empty? && deleted.empty?
165
+ ui.info("Status: Up to date")
166
+ ui.blank
167
+ ui.info("No local changes detected.")
168
+ else
169
+ ui.info("Status: Local changes")
170
+ ui.blank
171
+
172
+ if modified.any?
173
+ ui.info("Modified:")
174
+ modified.each { |path| ui.indent(path) }
175
+ ui.blank
176
+ end
177
+
178
+ if new_files.any?
179
+ ui.info("New:")
180
+ new_files.each { |path| ui.indent(path) }
181
+ ui.blank
182
+ end
183
+
184
+ if deleted.any?
185
+ ui.info("Deleted (remote only):")
186
+ deleted.each { |path| ui.indent(path) }
187
+ ui.blank
188
+ end
189
+
190
+ ui.info("Run `shai push` to upload changes.")
191
+ end
192
+ rescue NotFoundError
193
+ ui.error("Configuration '#{slug}' not found on remote.")
194
+ exit EXIT_NOT_FOUND
195
+ rescue NetworkError => e
196
+ ui.error(e.message)
197
+ exit EXIT_NETWORK_ERROR
198
+ end
199
+ end
200
+
201
+ desc "pull", "Pull remote changes to local"
202
+ option :dry_run, type: :boolean, default: false, desc: "Show what would be pulled"
203
+ option :force, type: :boolean, default: false, aliases: "-f", desc: "Overwrite local files without prompting"
204
+ def pull
205
+ require_auth!
206
+
207
+ shairc = load_shairc
208
+ slug = shairc["slug"]
209
+
210
+ display_name = "#{credentials.username}/#{slug}"
211
+
212
+ begin
213
+ remote_tree = ui.spinner("Fetching remote state...") do
214
+ api.get_tree(slug)["tree"]
215
+ end
216
+
217
+ # Security: Validate all paths before any file operations
218
+ validate_tree_paths!(remote_tree, Dir.pwd)
219
+
220
+ local_tree = build_local_tree(shairc)
221
+
222
+ # Build lookup maps
223
+ remote_files = remote_tree.select { |n| n["kind"] == "file" }
224
+ .each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
225
+ local_files = local_tree.select { |n| n[:kind] == "file" }
226
+ .each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
227
+
228
+ # Categorize changes
229
+ to_create = []
230
+ to_update = []
231
+
232
+ remote_files.each do |path, content|
233
+ if local_files.key?(path)
234
+ to_update << {path: path, content: content} if local_files[path] != content
235
+ else
236
+ to_create << {path: path, content: content}
237
+ end
238
+ end
239
+
240
+ if to_create.empty? && to_update.empty?
241
+ ui.info("Already up to date with #{display_name}")
242
+ return
243
+ end
244
+
245
+ # Dry run mode
246
+ if options[:dry_run]
247
+ ui.header("Would pull from #{display_name}:")
248
+ ui.blank
249
+ to_create.each { |f| ui.display_file_operation(:would_create, f[:path]) }
250
+ to_update.each { |f| ui.display_file_operation(:would_update, f[:path]) }
251
+ ui.blank
252
+ ui.info("No changes made (dry run)")
253
+ return
254
+ end
255
+
256
+ # Check for conflicts (files that would be overwritten)
257
+ unless options[:force] || to_update.empty?
258
+ ui.warning("The following local files will be overwritten:")
259
+ to_update.each { |f| ui.indent(f[:path]) }
260
+ ui.blank
261
+
262
+ unless ui.yes?("Continue and overwrite these files?")
263
+ ui.info("Pull cancelled.")
264
+ return
265
+ end
266
+ end
267
+
268
+ # Apply changes
269
+ ui.header("Pulling from #{display_name}...")
270
+ ui.blank
271
+
272
+ # Create necessary folders
273
+ all_folders = Set.new
274
+ (to_create + to_update).each do |file|
275
+ parts = File.dirname(file[:path]).split("/")
276
+ parts.each_with_index do |_, i|
277
+ folder_path = parts[0..i].join("/")
278
+ all_folders << folder_path unless folder_path == "."
279
+ end
280
+ end
281
+
282
+ all_folders.sort.each do |folder|
283
+ unless Dir.exist?(folder)
284
+ FileUtils.mkdir_p(folder)
285
+ ui.display_file_operation(:created, folder)
286
+ end
287
+ end
288
+
289
+ # Create new files
290
+ to_create.each do |file|
291
+ File.write(file[:path], file[:content])
292
+ ui.display_file_operation(:created, file[:path])
293
+ end
294
+
295
+ # Update existing files
296
+ to_update.each do |file|
297
+ File.write(file[:path], file[:content])
298
+ ui.display_file_operation(:updated, file[:path])
299
+ end
300
+
301
+ ui.blank
302
+ ui.success("Pulled #{to_create.length + to_update.length} items from #{display_name}")
303
+ rescue NotFoundError
304
+ ui.error("Configuration '#{slug}' not found on remote.")
305
+ exit EXIT_NOT_FOUND
306
+ rescue PermissionDeniedError
307
+ ui.error("You don't have permission to access '#{display_name}'.")
308
+ exit EXIT_PERMISSION_DENIED
309
+ rescue NetworkError => e
310
+ ui.error(e.message)
311
+ exit EXIT_NETWORK_ERROR
312
+ end
313
+ end
314
+
315
+ desc "diff", "Show diff between local and remote"
316
+ def diff
317
+ require_auth!
318
+
319
+ shairc = load_shairc
320
+ slug = shairc["slug"]
321
+
322
+ begin
323
+ remote_tree = ui.spinner("Fetching remote state...") do
324
+ api.get_tree(slug)["tree"]
325
+ end
326
+
327
+ local_tree = build_local_tree(shairc)
328
+
329
+ remote_files = remote_tree.select { |n| n["kind"] == "file" }
330
+ .each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
331
+ local_files = local_tree.select { |n| n[:kind] == "file" }
332
+ .each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
333
+
334
+ has_diff = false
335
+
336
+ # Modified files
337
+ local_files.each do |path, content|
338
+ if remote_files.key?(path) && remote_files[path] != content
339
+ has_diff = true
340
+ ui.info("--- remote #{path}")
341
+ ui.info("+++ local #{path}")
342
+ file_diff = Diffy::Diff.new(remote_files[path], content, context: 3)
343
+ ui.diff(file_diff.to_s)
344
+ ui.blank
345
+ end
346
+ end
347
+
348
+ # New files
349
+ local_files.each do |path, content|
350
+ next if remote_files.key?(path)
351
+
352
+ has_diff = true
353
+ ui.info("--- /dev/null")
354
+ ui.info("+++ local #{path}")
355
+ file_diff = Diffy::Diff.new("", content, context: 3)
356
+ ui.diff(file_diff.to_s)
357
+ ui.blank
358
+ end
359
+
360
+ # Deleted files
361
+ remote_files.each do |path, content|
362
+ next if local_files.key?(path)
363
+
364
+ has_diff = true
365
+ ui.info("--- remote #{path}")
366
+ ui.info("+++ /dev/null")
367
+ file_diff = Diffy::Diff.new(content, "", context: 3)
368
+ ui.diff(file_diff.to_s)
369
+ ui.blank
370
+ end
371
+
372
+ ui.info("No differences found.") unless has_diff
373
+ rescue NotFoundError
374
+ ui.error("Configuration '#{slug}' not found on remote.")
375
+ exit EXIT_NOT_FOUND
376
+ rescue NetworkError => e
377
+ ui.error(e.message)
378
+ exit EXIT_NETWORK_ERROR
379
+ end
380
+ end
381
+ end
382
+ end
383
+
384
+ private
385
+
386
+ # Security: Validate that a path is safe and doesn't escape base_path
387
+ def safe_path?(path, base_path)
388
+ return false if path.nil? || path.empty?
389
+ return false if path.start_with?("/") # No absolute paths
390
+ return false if path.include?("..") # No directory traversal
391
+ return false if path.include?("\0") # No null bytes
392
+
393
+ # Verify resolved path stays within base_path
394
+ full_path = File.expand_path(path, base_path)
395
+ full_path.start_with?(File.expand_path(base_path) + "/") || full_path == File.expand_path(base_path)
396
+ end
397
+
398
+ def validate_tree_paths!(tree, base_path)
399
+ tree.each do |node|
400
+ path = node["path"] || node[:path]
401
+ unless safe_path?(path, base_path)
402
+ raise SecurityError, "Invalid path detected: #{path.inspect}"
403
+ end
404
+ end
405
+ end
406
+
407
+ def load_shairc
408
+ unless File.exist?(SHAIRC_FILE)
409
+ ui.error("No #{SHAIRC_FILE} file found. Run `shai init` to create one.")
410
+ exit EXIT_INVALID_INPUT
411
+ end
412
+
413
+ shairc = YAML.safe_load_file(SHAIRC_FILE)
414
+
415
+ unless shairc["slug"]
416
+ ui.error("Invalid #{SHAIRC_FILE} file. Missing required field: slug")
417
+ exit EXIT_INVALID_INPUT
418
+ end
419
+
420
+ shairc
421
+ rescue Psych::SyntaxError => e
422
+ ui.error("Invalid #{SHAIRC_FILE} file: #{e.message}")
423
+ exit EXIT_INVALID_INPUT
424
+ end
425
+
426
+ def build_local_tree(shairc)
427
+ include_patterns = shairc["include"] || []
428
+ exclude_patterns = shairc["exclude"] || []
429
+
430
+ files = []
431
+ folders = Set.new
432
+
433
+ include_patterns.each do |pattern|
434
+ Dir.glob(pattern).each do |path|
435
+ next if File.directory?(path)
436
+ next if excluded?(path, exclude_patterns)
437
+
438
+ # Track folders
439
+ parts = File.dirname(path).split("/")
440
+ parts.each_with_index do |_, i|
441
+ folder_path = parts[0..i].join("/")
442
+ folders << folder_path unless folder_path == "."
443
+ end
444
+
445
+ files << {
446
+ kind: "file",
447
+ path: path,
448
+ content: File.read(path)
449
+ }
450
+ end
451
+ end
452
+
453
+ tree = folders.sort.map { |f| {kind: "folder", path: f} }
454
+ tree + files.sort_by { |f| f[:path] }
455
+ end
456
+
457
+ def excluded?(path, patterns)
458
+ patterns.any? do |pattern|
459
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
460
+ end
461
+ end
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Shai
6
+ class Configuration
7
+ class InsecureConnectionError < StandardError; end
8
+
9
+ attr_accessor :config_dir, :token
10
+ attr_reader :api_url
11
+
12
+ def initialize
13
+ self.api_url = ENV.fetch("SHAI_API_URL", "https://shaicli.dev")
14
+ @config_dir = expand_path(ENV.fetch("SHAI_CONFIG_DIR", default_config_dir))
15
+ @token = ENV["SHAI_TOKEN"]
16
+ end
17
+
18
+ def api_url=(url)
19
+ validate_url_security!(url)
20
+ @api_url = url
21
+ end
22
+
23
+ def credentials_path
24
+ File.join(config_dir, "credentials")
25
+ end
26
+
27
+ def color_enabled?
28
+ !ENV.key?("NO_COLOR")
29
+ end
30
+
31
+ private
32
+
33
+ def validate_url_security!(url)
34
+ uri = URI.parse(url)
35
+
36
+ # Allow HTTP only for localhost/127.0.0.1 (development)
37
+ return if uri.scheme == "https"
38
+ return if uri.scheme == "http" && local_host?(uri.host)
39
+
40
+ raise InsecureConnectionError,
41
+ "HTTPS is required for API connections. " \
42
+ "HTTP is only allowed for localhost/127.0.0.1 during development."
43
+ end
44
+
45
+ def local_host?(host)
46
+ return false if host.nil?
47
+
48
+ # Strip brackets from IPv6 addresses (e.g., "[::1]" -> "::1")
49
+ normalized = host.downcase.delete("[]")
50
+ %w[localhost 127.0.0.1 ::1].include?(normalized)
51
+ end
52
+
53
+ def default_config_dir
54
+ if Gem.win_platform?
55
+ File.join(ENV.fetch("APPDATA", Dir.home), "shai")
56
+ else
57
+ File.join(Dir.home, ".config", "shai")
58
+ end
59
+ end
60
+
61
+ def expand_path(path)
62
+ File.expand_path(path)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Shai
7
+ class Credentials
8
+ attr_reader :token, :expires_at, :user
9
+
10
+ def initialize
11
+ load_credentials
12
+ end
13
+
14
+ def authenticated?
15
+ !!(token && !expired?)
16
+ end
17
+
18
+ def expired?
19
+ return false unless expires_at
20
+
21
+ Time.parse(expires_at) < Time.now
22
+ end
23
+
24
+ def save(token:, expires_at:, user:)
25
+ @token = token
26
+ @expires_at = expires_at
27
+ @user = user
28
+
29
+ ensure_config_dir
30
+ File.write(credentials_path, credentials_json)
31
+ File.chmod(0o600, credentials_path)
32
+ end
33
+
34
+ def clear
35
+ @token = nil
36
+ @expires_at = nil
37
+ @user = nil
38
+
39
+ FileUtils.rm_f(credentials_path)
40
+ end
41
+
42
+ def username
43
+ user&.dig("username")
44
+ end
45
+
46
+ def display_name
47
+ user&.dig("display_name")
48
+ end
49
+
50
+ private
51
+
52
+ def load_credentials
53
+ return unless File.exist?(credentials_path)
54
+
55
+ data = JSON.parse(File.read(credentials_path))
56
+ @token = data["token"]
57
+ @expires_at = data["expires_at"]
58
+ @user = data["user"]
59
+ rescue JSON::ParserError
60
+ clear
61
+ end
62
+
63
+ def credentials_json
64
+ JSON.pretty_generate({
65
+ token: token,
66
+ expires_at: expires_at,
67
+ user: user
68
+ })
69
+ end
70
+
71
+ def credentials_path
72
+ Shai.configuration.credentials_path
73
+ end
74
+
75
+ def ensure_config_dir
76
+ FileUtils.mkdir_p(File.dirname(credentials_path))
77
+ end
78
+ end
79
+ end