tastytrade 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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/release-pr.md +108 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  4. data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
  5. data/.github/dependabot.yml +11 -0
  6. data/.github/workflows/main.yml +75 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +101 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +100 -0
  11. data/CLAUDE.md +78 -0
  12. data/CODE_OF_CONDUCT.md +81 -0
  13. data/CONTRIBUTING.md +89 -0
  14. data/DISCLAIMER.md +54 -0
  15. data/LICENSE.txt +24 -0
  16. data/README.md +235 -0
  17. data/ROADMAP.md +157 -0
  18. data/Rakefile +17 -0
  19. data/SECURITY.md +48 -0
  20. data/docs/getting_started.md +48 -0
  21. data/docs/python_sdk_analysis.md +181 -0
  22. data/exe/tastytrade +8 -0
  23. data/lib/tastytrade/cli.rb +604 -0
  24. data/lib/tastytrade/cli_config.rb +79 -0
  25. data/lib/tastytrade/cli_helpers.rb +178 -0
  26. data/lib/tastytrade/client.rb +117 -0
  27. data/lib/tastytrade/keyring_store.rb +72 -0
  28. data/lib/tastytrade/models/account.rb +129 -0
  29. data/lib/tastytrade/models/account_balance.rb +75 -0
  30. data/lib/tastytrade/models/base.rb +47 -0
  31. data/lib/tastytrade/models/current_position.rb +155 -0
  32. data/lib/tastytrade/models/user.rb +23 -0
  33. data/lib/tastytrade/models.rb +7 -0
  34. data/lib/tastytrade/session.rb +164 -0
  35. data/lib/tastytrade/session_manager.rb +160 -0
  36. data/lib/tastytrade/version.rb +5 -0
  37. data/lib/tastytrade.rb +31 -0
  38. data/sig/tastytrade.rbs +4 -0
  39. data/spec/exe/tastytrade_spec.rb +104 -0
  40. data/spec/spec_helper.rb +26 -0
  41. data/spec/tastytrade/cli_accounts_spec.rb +166 -0
  42. data/spec/tastytrade/cli_auth_spec.rb +216 -0
  43. data/spec/tastytrade/cli_config_spec.rb +180 -0
  44. data/spec/tastytrade/cli_helpers_spec.rb +248 -0
  45. data/spec/tastytrade/cli_interactive_spec.rb +54 -0
  46. data/spec/tastytrade/cli_logout_spec.rb +121 -0
  47. data/spec/tastytrade/cli_select_spec.rb +174 -0
  48. data/spec/tastytrade/cli_status_spec.rb +206 -0
  49. data/spec/tastytrade/client_spec.rb +210 -0
  50. data/spec/tastytrade/keyring_store_spec.rb +168 -0
  51. data/spec/tastytrade/models/account_balance_spec.rb +247 -0
  52. data/spec/tastytrade/models/account_spec.rb +206 -0
  53. data/spec/tastytrade/models/base_spec.rb +61 -0
  54. data/spec/tastytrade/models/current_position_spec.rb +444 -0
  55. data/spec/tastytrade/models/user_spec.rb +58 -0
  56. data/spec/tastytrade/session_manager_spec.rb +296 -0
  57. data/spec/tastytrade/session_spec.rb +392 -0
  58. data/spec/tastytrade_spec.rb +9 -0
  59. metadata +303 -0
@@ -0,0 +1,604 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "tty-table"
5
+ require "bigdecimal"
6
+ require_relative "cli_helpers"
7
+ require_relative "cli_config"
8
+ require_relative "session_manager"
9
+
10
+ module Tastytrade
11
+ # Main CLI class for Tastytrade gem
12
+ class CLI < Thor
13
+ include Tastytrade::CLIHelpers
14
+
15
+ package_name "Tastytrade"
16
+
17
+ # Map common version flags to version command
18
+ map %w[--version -v] => :version
19
+
20
+ class_option :test, type: :boolean, default: false, desc: "Use sandbox environment"
21
+
22
+ desc "version", "Display version information"
23
+ def version
24
+ puts "Tastytrade CLI v#{Tastytrade::VERSION}"
25
+ end
26
+
27
+ desc "login", "Login to Tastytrade"
28
+ option :username, aliases: "-u", desc: "Username"
29
+ option :remember, aliases: "-r", type: :boolean, default: false, desc: "Remember credentials"
30
+ def login
31
+ credentials = login_credentials
32
+ environment = options[:test] ? "sandbox" : "production"
33
+
34
+ info "Logging in to #{environment} environment..."
35
+ session = authenticate_user(credentials)
36
+
37
+ save_user_session(session, credentials, environment)
38
+
39
+ # Enter interactive mode after successful login
40
+ @current_session = session
41
+ interactive_mode
42
+ rescue Tastytrade::InvalidCredentialsError => e
43
+ error "Invalid credentials: #{e.message}"
44
+ exit 1
45
+ rescue Tastytrade::SessionExpiredError => e
46
+ error "Session expired: #{e.message}"
47
+ info "Please login again"
48
+ exit 1
49
+ rescue Tastytrade::NetworkTimeoutError => e
50
+ error "Network timeout: #{e.message}"
51
+ info "Check your internet connection and try again"
52
+ exit 1
53
+ rescue Tastytrade::Error => e
54
+ error e.message
55
+ exit 1
56
+ rescue StandardError => e
57
+ error "Login failed: #{e.message}"
58
+ exit 1
59
+ end
60
+
61
+ private
62
+
63
+ def format_time_remaining(seconds)
64
+ return "unknown time" unless seconds && seconds > 0
65
+
66
+ hours = (seconds / 3600).to_i
67
+ minutes = ((seconds % 3600) / 60).to_i
68
+
69
+ if hours > 0
70
+ "#{hours}h #{minutes}m"
71
+ else
72
+ "#{minutes}m"
73
+ end
74
+ end
75
+
76
+ def login_credentials
77
+ {
78
+ username: options[:username] || prompt.ask("Username:"),
79
+ password: prompt.mask("Password:"),
80
+ remember: options[:remember]
81
+ }
82
+ end
83
+
84
+ def authenticate_user(credentials)
85
+ session = Session.new(
86
+ username: credentials[:username],
87
+ password: credentials[:password],
88
+ remember_me: credentials[:remember],
89
+ is_test: options[:test]
90
+ )
91
+ session.login
92
+ success "Successfully logged in as #{session.user.email}"
93
+ session
94
+ end
95
+
96
+ def save_user_session(session, credentials, environment)
97
+ manager = SessionManager.new(
98
+ username: credentials[:username],
99
+ environment: environment
100
+ )
101
+
102
+ if manager.save_session(session, password: credentials[:password], remember: credentials[:remember])
103
+ info "Session saved securely" if credentials[:remember]
104
+ else
105
+ warning "Failed to save session credentials"
106
+ end
107
+ end
108
+
109
+ public
110
+
111
+ desc "logout", "Logout from Tastytrade"
112
+ def logout
113
+ session_info = current_session_info
114
+ return warning("No active session found") unless session_info
115
+
116
+ clear_user_session(session_info)
117
+ end
118
+
119
+ private
120
+
121
+ def current_session_info
122
+ username = config.get("current_username")
123
+ return nil unless username
124
+
125
+ {
126
+ username: username,
127
+ environment: config.get("environment") || "production"
128
+ }
129
+ end
130
+
131
+ def clear_user_session(session_info)
132
+ manager = SessionManager.new(
133
+ username: session_info[:username],
134
+ environment: session_info[:environment]
135
+ )
136
+
137
+ if manager.clear_session! && clear_config_data?
138
+ success "Successfully logged out"
139
+ else
140
+ error "Failed to logout completely"
141
+ exit 1
142
+ end
143
+ end
144
+
145
+ def clear_config_data?
146
+ config.delete("current_username")
147
+ config.delete("environment")
148
+ config.delete("last_login")
149
+ true
150
+ end
151
+
152
+ public
153
+
154
+ desc "accounts", "List all accounts"
155
+ def accounts
156
+ require_authentication!
157
+ info "Fetching accounts..."
158
+
159
+ accounts = fetch_accounts
160
+ return if accounts.nil? || accounts.empty?
161
+
162
+ display_accounts_table(accounts)
163
+ handle_account_selection(accounts)
164
+ rescue Tastytrade::Error => e
165
+ error "Failed to fetch accounts: #{e.message}"
166
+ exit 1
167
+ rescue StandardError => e
168
+ error "Unexpected error: #{e.message}"
169
+ exit 1
170
+ end
171
+
172
+ private
173
+
174
+ def fetch_accounts
175
+ accounts = Tastytrade::Models::Account.get_all(current_session)
176
+ if accounts.empty?
177
+ warning "No accounts found"
178
+ nil
179
+ else
180
+ accounts
181
+ end
182
+ end
183
+
184
+ def display_accounts_table(accounts)
185
+ current_account_number = config.get("current_account_number")
186
+ headers = ["", "Account", "Nickname", "Type", "Status"]
187
+ rows = build_account_rows(accounts, current_account_number)
188
+
189
+ render_table(headers, rows)
190
+ puts "\n#{pastel.dim("Total accounts:")} #{accounts.size}"
191
+ end
192
+
193
+ def build_account_rows(accounts, current_account_number)
194
+ accounts.map do |account|
195
+ indicator = account.account_number == current_account_number ? "→" : " "
196
+ [
197
+ indicator,
198
+ account.account_number,
199
+ account.nickname || "-",
200
+ account.account_type_name || "Unknown",
201
+ pastel.green("Active")
202
+ ]
203
+ end
204
+ end
205
+
206
+ def render_table(headers, rows)
207
+ table = TTY::Table.new(headers, rows)
208
+ puts table.render(:unicode, padding: [0, 1])
209
+ rescue StandardError
210
+ # Fallback for testing or non-TTY environments
211
+ puts headers.join(" | ")
212
+ puts "-" * 50
213
+ rows.each { |row| puts row.join(" | ") }
214
+ end
215
+
216
+ def handle_account_selection(accounts)
217
+ current_account_number = config.get("current_account_number")
218
+
219
+ if accounts.size == 1
220
+ config.set("current_account_number", accounts.first.account_number)
221
+ info "Using account: #{accounts.first.account_number}"
222
+ elsif !current_account_number || accounts.none? { |a| a.account_number == current_account_number }
223
+ info "Use 'tastytrade select' to choose an account"
224
+ end
225
+ end
226
+
227
+ public
228
+
229
+ desc "select", "Select an account to use"
230
+ def select
231
+ require_authentication!
232
+
233
+ accounts = fetch_accounts
234
+ return if accounts.nil? || accounts.empty?
235
+
236
+ handle_single_account(accounts) || prompt_for_account_selection(accounts)
237
+ rescue Tastytrade::Error => e
238
+ error "Failed to fetch accounts: #{e.message}"
239
+ exit 1
240
+ rescue StandardError => e
241
+ error "Unexpected error: #{e.message}"
242
+ exit 1
243
+ end
244
+
245
+ private
246
+
247
+ def create_vim_prompt
248
+ menu_prompt = TTY::Prompt.new
249
+ @exit_requested = false
250
+
251
+ # Add vim-style navigation
252
+ menu_prompt.on(:keypress) do |event|
253
+ case event.value
254
+ when "j"
255
+ menu_prompt.trigger(:keydown)
256
+ when "k"
257
+ menu_prompt.trigger(:keyup)
258
+ when "q", "\e", "\e[" # q or ESC key
259
+ @exit_requested = true
260
+ menu_prompt.trigger(:keyenter) # Select current item to exit the menu
261
+ end
262
+ end
263
+
264
+ menu_prompt
265
+ end
266
+
267
+ def handle_single_account(accounts)
268
+ return false unless accounts.size == 1
269
+
270
+ account = accounts.first
271
+ config.set("current_account_number", account.account_number)
272
+ @current_account = account # Cache the account object
273
+ success "Using account: #{account.account_number}"
274
+ true
275
+ end
276
+
277
+ def prompt_for_account_selection(accounts)
278
+ choices = build_account_choices(accounts)
279
+ selected = prompt.select("Choose an account:", choices)
280
+
281
+ config.set("current_account_number", selected)
282
+ # Cache the selected account object
283
+ @current_account = accounts.find { |a| a.account_number == selected }
284
+ success "Selected account: #{selected}"
285
+ end
286
+
287
+ def build_account_choices(accounts)
288
+ current_account_number = config.get("current_account_number")
289
+
290
+ accounts.map do |account|
291
+ label = build_account_label(account, current_account_number)
292
+ { name: label, value: account.account_number }
293
+ end
294
+ end
295
+
296
+ def build_account_label(account, current_account_number)
297
+ label = account.account_number.to_s
298
+ label += " - #{account.nickname}" if account.nickname
299
+ label += " (#{account.account_type_name})" if account.account_type_name
300
+ label += " [current]" if account.account_number == current_account_number
301
+ label
302
+ end
303
+
304
+ public
305
+
306
+ desc "balance", "Display account balance"
307
+ option :all, type: :boolean, desc: "Show balances for all accounts"
308
+ def balance
309
+ require_authentication!
310
+
311
+ if options[:all]
312
+ display_all_account_balances
313
+ else
314
+ account = current_account
315
+ unless account
316
+ account = select_account_interactively
317
+ return unless account
318
+ end
319
+ display_account_balance(account)
320
+ end
321
+ rescue => e
322
+ error "Failed to fetch balance: #{e.message}"
323
+ exit 1
324
+ end
325
+
326
+ desc "status", "Check session status"
327
+ def status
328
+ session = current_session
329
+ unless session
330
+ warning "No active session"
331
+ info "Run 'tastytrade login' to authenticate"
332
+ return
333
+ end
334
+
335
+ puts "Session Status:"
336
+ puts " User: #{session.user.email}"
337
+ puts " Environment: #{config.get("environment") || "production"}"
338
+
339
+ if session.session_expiration
340
+ if session.expired?
341
+ puts " Status: #{pastel.red("Expired")}"
342
+ else
343
+ time_left = format_time_remaining(session.time_until_expiry)
344
+ puts " Status: #{pastel.green("Active")}"
345
+ puts " Expires in: #{time_left}"
346
+ end
347
+ else
348
+ puts " Status: #{pastel.green("Active")}"
349
+ puts " Expires in: Unknown"
350
+ end
351
+
352
+ puts " Remember token: #{session.remember_token ? pastel.green("Available") : pastel.red("Not available")}"
353
+ puts " Auto-refresh: #{session.remember_token ? pastel.green("Enabled") : pastel.yellow("Disabled")}"
354
+ end
355
+
356
+ desc "refresh", "Refresh the current session"
357
+ def refresh
358
+ session = current_session
359
+ unless session
360
+ error "No active session to refresh"
361
+ exit 1
362
+ end
363
+
364
+ unless session.remember_token
365
+ error "No remember token available for refresh"
366
+ info "Login with --remember flag to enable session refresh"
367
+ exit 1
368
+ end
369
+
370
+ info "Refreshing session..."
371
+
372
+ begin
373
+ session.refresh_session
374
+
375
+ # Save refreshed session
376
+ manager = SessionManager.new(
377
+ username: session.user.email,
378
+ environment: config.get("environment") || "production"
379
+ )
380
+ manager.save_session(session)
381
+
382
+ success "Session refreshed successfully"
383
+ info "Session expires in #{format_time_remaining(session.time_until_expiry)}" if session.time_until_expiry
384
+ rescue Tastytrade::TokenRefreshError => e
385
+ error "Failed to refresh session: #{e.message}"
386
+ exit 1
387
+ end
388
+ end
389
+
390
+ desc "interactive", "Enter interactive mode"
391
+ def interactive
392
+ require_authentication!
393
+ interactive_mode
394
+ end
395
+
396
+ private
397
+
398
+ def interactive_mode
399
+ info "Welcome to Tastytrade!"
400
+
401
+ loop do
402
+ choice = show_main_menu
403
+
404
+ case choice
405
+ when :accounts
406
+ interactive_accounts
407
+ when :select
408
+ interactive_select
409
+ when :balance
410
+ interactive_balance
411
+ when :portfolio
412
+ info "Portfolio command not yet implemented"
413
+ when :positions
414
+ info "Positions command not yet implemented"
415
+ when :orders
416
+ info "Orders command not yet implemented"
417
+ when :settings
418
+ info "Settings command not yet implemented"
419
+ when :exit
420
+ break
421
+ end
422
+ end
423
+
424
+ info "Goodbye!"
425
+ end
426
+
427
+ def show_main_menu
428
+ account_info = current_account_number ? " (Account: #{current_account_number})" : " (No account selected)"
429
+
430
+ menu_prompt = create_vim_prompt
431
+
432
+ result = menu_prompt.select("Main Menu#{account_info}", per_page: 10) do |menu|
433
+ menu.enum "." # Enable number shortcuts with . delimiter
434
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-8, q or ESC to quit)"
435
+
436
+ menu.choice "Accounts - View all accounts", :accounts
437
+ menu.choice "Select Account - Choose active account", :select
438
+ menu.choice "Balance - View account balance", :balance
439
+ menu.choice "Portfolio - View holdings", :portfolio
440
+ menu.choice "Positions - View open positions", :positions
441
+ menu.choice "Orders - View recent orders", :orders
442
+ menu.choice "Settings - Configure preferences", :settings
443
+ menu.choice "Exit", :exit
444
+ end
445
+
446
+ # Handle q or ESC key press
447
+ @exit_requested ? :exit : result
448
+ rescue Interrupt
449
+ # Handle Ctrl+C gracefully
450
+ :exit
451
+ end
452
+
453
+ def interactive_accounts
454
+ accounts = fetch_accounts
455
+ return if accounts.nil? || accounts.empty?
456
+
457
+ display_accounts_table(accounts)
458
+
459
+ prompt.keypress("\nPress any key to continue...")
460
+ rescue Tastytrade::Error => e
461
+ error "Failed to fetch accounts: #{e.message}"
462
+ prompt.keypress("\nPress any key to continue...")
463
+ end
464
+
465
+ def interactive_select
466
+ accounts = fetch_accounts
467
+ return if accounts.nil? || accounts.empty?
468
+
469
+ if accounts.size == 1
470
+ handle_single_account(accounts)
471
+ else
472
+ prompt_for_account_selection(accounts)
473
+ end
474
+
475
+ prompt.keypress("\nPress any key to continue...")
476
+ rescue Tastytrade::Error => e
477
+ error "Failed to fetch accounts: #{e.message}"
478
+ prompt.keypress("\nPress any key to continue...")
479
+ end
480
+
481
+ def interactive_balance
482
+ # Try to use cached account first, only fetch if needed
483
+ account = @current_account
484
+
485
+ # If no cached account, check if we have an account number saved
486
+ if !account && current_account_number
487
+ begin
488
+ account = Tastytrade::Models::Account.get(current_session, current_account_number)
489
+ @current_account = account # Cache it
490
+ rescue => e
491
+ # Don't show error here, will try to select account below
492
+ account = nil
493
+ end
494
+ end
495
+
496
+ # If still no account, let user select one
497
+ account ||= select_account_interactively
498
+ return unless account
499
+
500
+ display_account_balance(account)
501
+
502
+ menu_prompt = create_vim_prompt
503
+
504
+ action = menu_prompt.select("What would you like to do?", per_page: 10) do |menu|
505
+ menu.enum "." # Enable number shortcuts with . delimiter
506
+ menu.help "(Use ↑/↓ arrows, vim j/k, numbers 1-4, q or ESC to go back)"
507
+
508
+ menu.choice "View positions", :positions
509
+ menu.choice "Switch account", :switch
510
+ menu.choice "Refresh", :refresh
511
+ menu.choice "Back to main menu", :back
512
+ end
513
+
514
+ # Handle q or ESC key press
515
+ return if @exit_requested || action == :back
516
+
517
+ case action
518
+ when :positions
519
+ info "Positions view not yet implemented"
520
+ prompt.keypress("\nPress any key to continue...")
521
+ interactive_balance # Show balance menu again
522
+ when :switch
523
+ interactive_select
524
+ interactive_balance if current_account_number # Check for account number instead of making API call
525
+ when :refresh
526
+ @current_account = nil # Clear cache to force refresh
527
+ interactive_balance
528
+ end
529
+ rescue Interrupt
530
+ # Handle Ctrl+C gracefully - go back to main menu
531
+ nil
532
+ rescue => e
533
+ error "Failed to fetch balance: #{e.message}"
534
+ prompt.keypress("\nPress any key to continue...")
535
+ end
536
+
537
+ def display_account_balance(account)
538
+ balance = account.get_balances(current_session)
539
+
540
+ table = TTY::Table.new(
541
+ title: "#{account.nickname || account.account_number} - Account Balance",
542
+ header: ["Metric", "Value"],
543
+ rows: [
544
+ ["Cash Balance", format_currency(balance.cash_balance)],
545
+ ["Net Liquidating Value", format_currency(balance.net_liquidating_value)],
546
+ ["Equity Buying Power", format_currency(balance.equity_buying_power)],
547
+ ["Day Trading BP", format_currency(balance.day_trading_buying_power)],
548
+ ["Available Trading Funds", format_currency(balance.available_trading_funds)],
549
+ ["BP Usage", "#{balance.buying_power_usage_percentage.to_s("F")}%"]
550
+ ]
551
+ )
552
+
553
+ puts
554
+ begin
555
+ puts table.render(:unicode, padding: [0, 1])
556
+ rescue StandardError
557
+ # Fallback for testing or non-TTY environments
558
+ puts "#{account.nickname || account.account_number} - Account Balance"
559
+ puts "-" * 50
560
+ table.rows.each do |row|
561
+ puts "#{row[0]}: #{row[1]}"
562
+ end
563
+ end
564
+
565
+ # Add color-coded BP warning if > 80%
566
+ if balance.high_buying_power_usage?
567
+ puts
568
+ warning "High buying power usage: #{balance.buying_power_usage_percentage.to_s("F")}%"
569
+ end
570
+ end
571
+
572
+ def display_all_account_balances
573
+ info "Fetching balances for all accounts..."
574
+ accounts = Tastytrade::Models::Account.get_all(current_session)
575
+ total_nlv = BigDecimal("0")
576
+
577
+ accounts.each do |account|
578
+ balance = account.get_balances(current_session)
579
+ total_nlv += balance.net_liquidating_value
580
+ display_account_balance(account)
581
+ puts
582
+ end
583
+
584
+ puts
585
+ info "Total Net Liquidating Value: #{format_currency(total_nlv)}"
586
+ end
587
+
588
+ def select_account_interactively
589
+ accounts = fetch_accounts
590
+ return nil if accounts.nil? || accounts.empty?
591
+
592
+ if accounts.size == 1
593
+ accounts.first
594
+ else
595
+ choices = accounts.map do |account|
596
+ label = build_account_label(account, nil)
597
+ [label, account]
598
+ end.to_h
599
+
600
+ prompt.select("Select an account:", choices)
601
+ end
602
+ end
603
+ end
604
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Tastytrade
7
+ # Configuration management for Tastytrade CLI
8
+ class CLIConfig
9
+ CONFIG_DIR = File.expand_path("~/.config/tastytrade")
10
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
11
+
12
+ DEFAULT_CONFIG = {
13
+ "default_account" => nil,
14
+ "environment" => "production",
15
+ "auto_refresh" => true
16
+ }.freeze
17
+
18
+ attr_reader :data
19
+
20
+ def initialize
21
+ @data = load_config
22
+ end
23
+
24
+ # Get a configuration value
25
+ def get(key)
26
+ @data[key.to_s]
27
+ end
28
+
29
+ # Set a configuration value
30
+ def set(key, value)
31
+ @data[key.to_s] = value
32
+ save_config
33
+ end
34
+
35
+ # Delete a configuration value
36
+ def delete(key)
37
+ @data.delete(key.to_s)
38
+ save_config
39
+ end
40
+
41
+ # Check if config exists
42
+ def exists?
43
+ File.exist?(CONFIG_FILE)
44
+ end
45
+
46
+ # Reset to defaults
47
+ def reset!
48
+ @data = DEFAULT_CONFIG.dup
49
+ save_config
50
+ end
51
+
52
+ private
53
+
54
+ def load_config
55
+ ensure_config_dir_exists
56
+ return DEFAULT_CONFIG.dup unless File.exist?(CONFIG_FILE)
57
+
58
+ begin
59
+ config = YAML.load_file(CONFIG_FILE)
60
+ # Ensure we have a hash and merge with defaults
61
+ DEFAULT_CONFIG.merge(config.is_a?(Hash) ? config : {})
62
+ rescue StandardError => e
63
+ warn "Warning: Failed to load config file: #{e.message}"
64
+ DEFAULT_CONFIG.dup
65
+ end
66
+ end
67
+
68
+ def save_config
69
+ ensure_config_dir_exists
70
+ File.write(CONFIG_FILE, @data.to_yaml)
71
+ rescue StandardError => e
72
+ warn "Warning: Failed to save config file: #{e.message}"
73
+ end
74
+
75
+ def ensure_config_dir_exists
76
+ FileUtils.mkdir_p(CONFIG_DIR)
77
+ end
78
+ end
79
+ end