ruby_coded 0.1.1 → 0.2.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +3 -1
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +15 -10
  5. data/lib/ruby_coded/auth/auth_manager.rb +20 -5
  6. data/lib/ruby_coded/auth/jwt_decoder.rb +29 -0
  7. data/lib/ruby_coded/auth/providers/openai.rb +19 -5
  8. data/lib/ruby_coded/chat/app/event_dispatch.rb +23 -3
  9. data/lib/ruby_coded/chat/app/login_handler.rb +79 -0
  10. data/lib/ruby_coded/chat/app/oauth_handler.rb +105 -0
  11. data/lib/ruby_coded/chat/app.rb +65 -6
  12. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +93 -0
  13. data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +104 -0
  14. data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +136 -0
  15. data/lib/ruby_coded/chat/codex_bridge/token_manager.rb +45 -0
  16. data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +51 -0
  17. data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +128 -0
  18. data/lib/ruby_coded/chat/codex_bridge.rb +126 -0
  19. data/lib/ruby_coded/chat/codex_models.rb +41 -0
  20. data/lib/ruby_coded/chat/command_handler/login_commands.rb +33 -0
  21. data/lib/ruby_coded/chat/command_handler/model_commands.rb +19 -6
  22. data/lib/ruby_coded/chat/command_handler.rb +6 -2
  23. data/lib/ruby_coded/chat/help.txt +2 -0
  24. data/lib/ruby_coded/chat/input_handler/login_inputs.rb +66 -0
  25. data/lib/ruby_coded/chat/input_handler.rb +3 -0
  26. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +1 -1
  27. data/lib/ruby_coded/chat/renderer/login_flow.rb +105 -0
  28. data/lib/ruby_coded/chat/renderer/login_flow_layout.rb +65 -0
  29. data/lib/ruby_coded/chat/renderer/status_bar.rb +2 -1
  30. data/lib/ruby_coded/chat/renderer.rb +12 -3
  31. data/lib/ruby_coded/chat/state/login_flow.rb +117 -0
  32. data/lib/ruby_coded/chat/state/login_flow_steps.rb +69 -0
  33. data/lib/ruby_coded/chat/state.rb +25 -2
  34. data/lib/ruby_coded/initializer.rb +14 -3
  35. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +1 -0
  36. data/lib/ruby_coded/strategies/oauth_strategy.rb +4 -3
  37. data/lib/ruby_coded/version.rb +1 -1
  38. metadata +33 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f81632e5f552b3eb5e4e85d15ae4ecda0767c2f530606ccc083c9c1adcf9c92f
4
- data.tar.gz: 33759f15b1568c1f0e2c88fbc32d9e97ca3772662aeaa0112acceb1819ab7a9a
3
+ metadata.gz: 35cd5d9fc4b17b8a4aa4cefce67e02f6be01053fef3cb73046281804434a3d85
4
+ data.tar.gz: 4b53ca7258244edcc8cb1e7c31dfadc91c2c897b9b24acea3e5cdac21d086485
5
5
  SHA512:
6
- metadata.gz: d004abb1a5c5197cffc6b28fffb44172ed6b4cb22b62cf3438941d8c67b2869d7244df4d7c60cc63d1a28ce0dbd6dd20e5ab6fadff6b3e3f4bc89780d7c54d7a
7
- data.tar.gz: ca3ff79da79a9fba4b2166deedb5eaa52626aaad0a3429dcb9ef75134fd831c852f4e1e3ff66d8d71cc234dc23e1a0c60b90f8dcab7166deaa5675e806052294
6
+ metadata.gz: 00d9c124eeadef16b3492f6c2e394a439cff10a92516afedb053d9626a83d2029d96ceaa1898f8da4f4d4a29ae8c1876ef181f0e20259fb401b30e76a972f341
7
+ data.tar.gz: eb10ce9285ba09b7cf9cd3e24caf6dd28938586328b49e0cb155560ab58697d5e2fbda3ce8da3b61492ba7c23ccd2e8236307f260b1588430cbd63102aee82ff
data/.rubocop_todo.yml CHANGED
@@ -55,10 +55,12 @@ Metrics/AbcSize:
55
55
  Exclude:
56
56
  - 'lib/ruby_coded/chat/state.rb'
57
57
 
58
- # Offense count: 1
58
+ # Offense count: 3
59
59
  # Configuration parameters: CountComments, Max, CountAsOne.
60
60
  Metrics/ClassLength:
61
61
  Exclude:
62
+ - 'lib/ruby_coded/auth/auth_manager.rb'
63
+ - 'lib/ruby_coded/chat/app.rb'
62
64
  - 'lib/ruby_coded/chat/state.rb'
63
65
 
64
66
  # Offense count: 2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1] - 2026-04-17
4
+
5
+ ### Fixed
6
+
7
+ - **Startup crash when stored model provider is not authenticated**: The CLI no longer raises `RubyLLM::ConfigurationError` at startup when the model saved in `~/.ruby_coded/config.yaml` belongs to a provider that has no credentials on the current machine (e.g. switching computers with only Anthropic authenticated but a GPT model stored). Instead, the app falls back to the default model of the authenticated provider and shows an in-chat system message suggesting `/login` or `/model` to adjust.
8
+
9
+ ### Added
10
+
11
+ - `AuthManager#provider_for_model` and `AuthManager#model_provider_authenticated?` helpers to detect the provider of a given model name and validate that its credentials are available.
12
+
13
+ ## [0.2.0] - 2026-04-16
14
+
15
+ ### Added
16
+
17
+ - **ChatGPT Plus/Pro OAuth authentication**: Use your ChatGPT subscription to access GPT-5.x models via the Codex backend — no API credits required
18
+ - **`/login` command**: Authenticate with providers (OpenAI, Anthropic) directly from the TUI without restarting
19
+ - **In-TUI login wizard**: Native multi-step login flow with OAuth and API key support, replacing the old TUI-suspend approach
20
+ - **CodexBridge**: Dedicated HTTP client for the ChatGPT Codex Responses API with SSE streaming, stateless conversation history, tool calls, and automatic token refresh
21
+ - **Codex model catalog**: Local catalog of ChatGPT-available models (gpt-5.4, gpt-5.2, gpt-5.2-codex, etc.) integrated with the `/model` selector
22
+ - **Status bar indicator**: Shows `(ChatGPT)` or `(API)` next to the model name to distinguish authentication mode
23
+ - **JWT decoder**: Extracts `chatgpt_account_id` from OAuth tokens for Codex API authentication
24
+
25
+ ### Changed
26
+
27
+ - OpenAI OAuth now authenticates via ChatGPT Codex backend instead of the OpenAI Platform API
28
+ - OAuth scopes expanded to `openid profile email offline_access` with Codex-specific auth params
29
+ - AuthManager skips OpenAI OAuth credentials for RubyLLM configuration (handled by CodexBridge)
30
+ - Default OpenAI model updated to `gpt-5.4`
31
+
3
32
  ## [0.1.0] - 2026-04-15
4
33
 
5
34
  - Initial release
data/README.md CHANGED
@@ -22,16 +22,18 @@ An AI-powered terminal coding assistant built in Ruby. Chat with LLMs, let an ag
22
22
  - **Chat mode** — Talk to an LLM directly in a full terminal UI (TUI) built with [ratatui](https://github.com/nicholasgasior/ratatui-ruby)
23
23
  - **Agent mode** — The model can read, write, edit, and delete files in your project, create directories, and run shell commands with user confirmation
24
24
  - **Plan mode** — Generate structured plans before implementing, with interactive clarification questions and auto-switch to agent mode when ready
25
+ - **ChatGPT Plus/Pro support** — Use your ChatGPT subscription to access GPT-5.x models via the Codex backend — no API credits required
25
26
  - **Multi-provider support** — Works with OpenAI and Anthropic out of the box (OAuth and API key authentication)
27
+ - **In-session login** — Authenticate or switch providers at any time with `/login`, no restart needed
26
28
  - **Tool confirmation** — Write and dangerous operations require explicit approval; safe operations (read, list) run automatically
27
- - **Token & cost tracking** — Live status bar showing token usage and estimated session cost
29
+ - **Token & cost tracking** — Live status bar showing token usage, estimated session cost, and auth mode indicator
28
30
  - **Plugin system** — Extend the chat with custom state, input handlers, renderer overlays, and commands
29
- - **Slash commands** — `/agent`, `/plan`, `/model`, `/history`, `/tokens`, `/help`, and more
31
+ - **Slash commands** — `/agent`, `/plan`, `/model`, `/login`, `/history`, `/tokens`, `/help`, and more
30
32
 
31
33
  ## Requirements
32
34
 
33
35
  - Ruby >= 3.3.0
34
- - An OpenAI or Anthropic account (API key or OAuth)
36
+ - An OpenAI or Anthropic account (ChatGPT Plus/Pro subscription, or API key)
35
37
 
36
38
  ## Installation
37
39
 
@@ -59,6 +61,7 @@ On first launch you'll be asked to authenticate with a provider. After that, you
59
61
  | `/plan off` | Disable plan mode |
60
62
  | `/plan save` | Save the current plan to a file |
61
63
  | `/model` | Switch to a different model |
64
+ | `/login` | Authenticate with a provider (OpenAI, Anthropic) |
62
65
  | `/tokens` | Show detailed token usage breakdown |
63
66
  | `/history` | Show conversation history |
64
67
  | `/clear` | Clear the conversation |
@@ -113,13 +116,15 @@ bundle exec exe/ruby_coded
113
116
 
114
117
  ## What's next
115
118
 
116
- - Find a way to update the autocomplete plugin when a new command is added []
117
- - Display context window size (depending on the model) []
118
- - UI element to indicate the AI is performing a task []
119
- - Add the possibility to create custom commands []
120
- - Skills implementation []
121
- - Implement Google Auth for Gemini []
122
- - Session recovery system by ID []
119
+ - Find a way to update the autocomplete plugin when a new command is added
120
+ - Display context window size (depending on the model)
121
+ - UI element to indicate the AI is performing a task
122
+ - Add the possibility to create custom commands
123
+ - Skills implementation
124
+ - Implement Google Auth for Gemini
125
+ - Local LLM support
126
+ - Session recovery system by ID
127
+ - Context summarization when approaching the model's context limit
123
128
 
124
129
  ## Contributing
125
130
 
@@ -48,6 +48,23 @@ module RubyCoded
48
48
  PROVIDERS.keys.select { |name| credential_store.retrieve(name) }
49
49
  end
50
50
 
51
+ def provider_for_model(model_name)
52
+ return nil if model_name.nil? || model_name.to_s.strip.empty?
53
+
54
+ normalized = model_name.to_s.downcase
55
+ return :openai if normalized.match?(/\A(gpt|o\d)/)
56
+ return :anthropic if normalized.start_with?("claude")
57
+
58
+ nil
59
+ end
60
+
61
+ def model_provider_authenticated?(model_name)
62
+ provider = provider_for_model(model_name)
63
+ return false unless provider
64
+
65
+ authenticated_provider_names.include?(provider)
66
+ end
67
+
51
68
  def check_authentication
52
69
  return if configured_providers.any? { |name| credential_store.retrieve(name) }
53
70
 
@@ -67,10 +84,10 @@ module RubyCoded
67
84
  PROVIDERS.each do |name, provider|
68
85
  credentials = credential_store.retrieve(name)
69
86
  next unless credentials
87
+ next if name == :openai && credentials["auth_method"] == "oauth"
70
88
 
71
89
  credentials = refresh_if_expired(name, provider, credentials)
72
- key = extract_api_key(credentials)
73
- config.send("#{provider.ruby_llm_key}=", key)
90
+ config.send("#{provider.ruby_llm_key}=", extract_api_key(credentials))
74
91
  end
75
92
  end
76
93
  end
@@ -82,9 +99,7 @@ module RubyCoded
82
99
  end
83
100
 
84
101
  def choose_provider
85
- prompt.select("Please select the AI provider you want to log in:",
86
- configured_providers,
87
- per_page: 10)
102
+ prompt.select("Please select the AI provider you want to log in:", configured_providers, per_page: 10)
88
103
  end
89
104
 
90
105
  def strategy_for(provider)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module RubyCoded
7
+ module Auth
8
+ # Decodes JWT tokens without external dependencies.
9
+ # Used to extract the ChatGPT account ID from OAuth access tokens.
10
+ module JWTDecoder
11
+ JWT_CLAIM_PATH = "https://api.openai.com/auth"
12
+
13
+ def self.decode(token)
14
+ parts = token.to_s.split(".")
15
+ return nil unless parts.size == 3
16
+
17
+ padded = parts[1] + ("=" * ((4 - (parts[1].length % 4)) % 4))
18
+ JSON.parse(Base64.urlsafe_decode64(padded))
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def self.extract_account_id(token)
24
+ payload = decode(token)
25
+ payload&.dig(JWT_CLAIM_PATH, "chatgpt_account_id")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,7 +3,9 @@
3
3
  module RubyCoded
4
4
  module Auth
5
5
  module Providers
6
- # OpenAI provider's configuration
6
+ # OpenAI provider's configuration.
7
+ # OAuth authenticates via ChatGPT Plus/Pro subscription (Codex backend).
8
+ # API key authenticates via OpenAI Platform API credits.
7
9
  module OpenAI
8
10
  def self.display_name
9
11
  "OpenAI"
@@ -16,9 +18,9 @@ module RubyCoded
16
18
  def self.auth_methods
17
19
  [
18
20
  { key: :oauth,
19
- label: "With your OpenAI account (requires API credits, " \
20
- "your ChatGPT subscription does not cover API usage)" },
21
- { key: :api_key, label: "With an OpenAI API key (requires API credits at platform.openai.com)" }
21
+ label: "With your ChatGPT Plus/Pro subscription (no API credits needed)" },
22
+ { key: :api_key,
23
+ label: "With an OpenAI API key (requires API credits at platform.openai.com)" }
22
24
  ]
23
25
  end
24
26
 
@@ -43,12 +45,24 @@ module RubyCoded
43
45
  end
44
46
 
45
47
  def self.scopes
46
- "offline_access"
48
+ "openid profile email offline_access"
47
49
  end
48
50
 
49
51
  def self.ruby_llm_key
50
52
  :openai_api_key
51
53
  end
54
+
55
+ def self.codex_auth_params
56
+ {
57
+ id_token_add_organizations: "true",
58
+ codex_cli_simplified_flow: "true",
59
+ originator: "codex_cli_rs"
60
+ }
61
+ end
62
+
63
+ def self.codex_base_url
64
+ "https://chatgpt.com/backend-api"
65
+ end
52
66
  end
53
67
  end
54
68
  end
@@ -5,17 +5,27 @@ module RubyCoded
5
5
  class App
6
6
  # Routes TUI events to the appropriate handler methods.
7
7
  module EventDispatch
8
+ PLAN_ACTIONS = %i[plan_clarification_selected plan_clarification_custom plan_clarification_skip].freeze
9
+ LOGIN_ACTIONS = %i[
10
+ login_provider_selected login_method_selected login_key_submitted login_oauth_cancel login_cancel
11
+ ].freeze
12
+
8
13
  private
9
14
 
10
15
  def dispatch_event(event)
11
16
  action = @input_handler.process(event)
17
+ return :quit if action == :quit
18
+
19
+ route_action(action)
20
+ end
21
+
22
+ def route_action(action)
12
23
  case action
13
- when :quit then :quit
14
24
  when :submit then handle_submit
15
25
  when :model_selected, :model_select_cancel then dispatch_model_action(action)
16
26
  when :cancel_streaming, :tool_approved, :tool_approved_all, :tool_rejected then dispatch_llm_action(action)
17
- when :plan_clarification_selected, :plan_clarification_custom, :plan_clarification_skip
18
- dispatch_plan_clarification(action)
27
+ when *PLAN_ACTIONS then dispatch_plan_clarification(action)
28
+ when *LOGIN_ACTIONS then dispatch_login_action(action)
19
29
  when :scroll_up, :scroll_down, :scroll_top, :scroll_bottom then handle_scroll(action)
20
30
  end
21
31
  end
@@ -64,6 +74,16 @@ module RubyCoded
64
74
  @llm_bridge.send_async(response)
65
75
  end
66
76
 
77
+ def dispatch_login_action(action)
78
+ case action
79
+ when :login_provider_selected then handle_login_provider_selected
80
+ when :login_method_selected then handle_login_method_selected
81
+ when :login_key_submitted then handle_login_key_submitted
82
+ when :login_oauth_cancel then handle_login_oauth_cancel
83
+ when :login_cancel then handle_login_cancel
84
+ end
85
+ end
86
+
67
87
  def handle_scroll(action)
68
88
  case action
69
89
  when :scroll_up then @state.scroll_up
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class App
6
+ # Handles login step navigation: provider selection, auth method selection,
7
+ # API key submission, and login cancellation.
8
+ module LoginHandler
9
+ private
10
+
11
+ def handle_login_provider_selected
12
+ selected = @state.login_selected_item
13
+ return @state.exit_login_flow! unless selected
14
+
15
+ provider_name = selected[:key]
16
+ provider = Auth::AuthManager::PROVIDERS[provider_name]
17
+ methods = provider.auth_methods
18
+
19
+ if methods.size == 1
20
+ @state.login_advance_to_api_key!(provider_name, methods.first[:key])
21
+ else
22
+ @state.login_advance_to_auth_method!(provider_name)
23
+ end
24
+ end
25
+
26
+ def handle_login_method_selected
27
+ selected = @state.login_selected_item
28
+ return @state.exit_login_flow! unless selected
29
+
30
+ case selected[:key]
31
+ when :oauth then start_oauth_flow
32
+ when :api_key then @state.login_advance_to_api_key!(@state.login_provider, :api_key)
33
+ end
34
+ end
35
+
36
+ def handle_login_key_submitted
37
+ key = @state.login_key_buffer.strip
38
+ provider = @state.login_provider_module
39
+ unless provider.key_pattern.match?(key)
40
+ @state.login_set_error!("Invalid API key format for #{provider.display_name}.")
41
+ return
42
+ end
43
+ save_api_key_credentials(key, provider)
44
+ rescue StandardError => e
45
+ @state.login_set_error!("Login failed: #{e.message}")
46
+ end
47
+
48
+ def save_api_key_credentials(key, provider)
49
+ credentials = { "auth_method" => "api_key", "key" => key }
50
+ @credentials_store.store(@state.login_provider, credentials)
51
+ @auth_manager&.configure_ruby_llm!
52
+ recreate_bridge!
53
+ @state.exit_login_flow!
54
+ @state.add_message(:system, "Logged in to #{provider.display_name} with API key.")
55
+ end
56
+
57
+ def handle_login_oauth_cancel
58
+ cleanup_oauth!
59
+ @state.exit_login_flow!
60
+ @state.add_message(:system, "Login cancelled.")
61
+ end
62
+
63
+ def handle_login_cancel
64
+ cleanup_oauth! if @state.login_step == :oauth_waiting
65
+ @state.exit_login_flow!
66
+ end
67
+
68
+ def cleanup_oauth!
69
+ @oauth_server&.shutdown
70
+ @oauth_wait_thread&.kill
71
+ @oauth_server = nil
72
+ @oauth_wait_thread = nil
73
+ @oauth_pkce = nil
74
+ @oauth_state = nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class App
6
+ # Manages the OAuth authorization flow: browser launch, callback server,
7
+ # token exchange, and credential storage.
8
+ module OAuthHandler
9
+ private
10
+
11
+ def start_oauth_flow
12
+ provider_name = @state.login_provider
13
+ provider = Auth::AuthManager::PROVIDERS[provider_name]
14
+ @oauth_pkce = Auth::PKCE.generate
15
+ @oauth_state = SecureRandom.hex(16)
16
+ @oauth_server = Auth::OAuthCallbackServer.new
17
+ @oauth_server.start
18
+ open_browser(build_oauth_url(provider, @oauth_pkce[:challenge], @oauth_state))
19
+ @state.login_advance_to_oauth!(provider_name)
20
+ start_oauth_callback_thread!
21
+ end
22
+
23
+ def start_oauth_callback_thread!
24
+ @oauth_wait_thread = Thread.new do
25
+ result = @oauth_server.wait_for_callback
26
+ @state.login_set_oauth_result!(result)
27
+ rescue StandardError => e
28
+ @state.login_set_oauth_result!({ error: e.message })
29
+ end
30
+ end
31
+
32
+ def poll_oauth_result
33
+ result = @state.login_oauth_result
34
+ return unless result
35
+
36
+ @state.login_clear_oauth_result!
37
+ process_oauth_result(result)
38
+ end
39
+
40
+ def process_oauth_result(result)
41
+ return fail_oauth!("OAuth failed: #{result[:error]}") if result[:error]
42
+ return fail_oauth!("OAuth failed: state mismatch.") if result[:state] != @oauth_state
43
+
44
+ complete_oauth_login(result)
45
+ rescue StandardError => e
46
+ fail_oauth!("OAuth failed: #{e.message}")
47
+ end
48
+
49
+ def fail_oauth!(message)
50
+ @state.exit_login_flow!
51
+ @state.add_message(:system, message)
52
+ end
53
+
54
+ def complete_oauth_login(result)
55
+ provider_name = @state.login_provider
56
+ provider = Auth::AuthManager::PROVIDERS[provider_name]
57
+ tokens = exchange_oauth_code(provider, result[:code], @oauth_pkce[:verifier])
58
+ @credentials_store.store(provider_name, build_oauth_credentials(tokens))
59
+ @auth_manager&.configure_ruby_llm!
60
+ recreate_bridge!
61
+ @state.exit_login_flow!
62
+ @state.add_message(:system, "Logged in to #{provider.display_name} with OAuth.")
63
+ end
64
+
65
+ def build_oauth_url(provider, challenge, state)
66
+ params = {
67
+ client_id: provider.client_id, redirect_uri: provider.redirect_uri,
68
+ response_type: "code", scope: provider.scopes, code_challenge: challenge,
69
+ code_challenge_method: "S256", state: state
70
+ }
71
+ params.merge!(provider.codex_auth_params) if provider.respond_to?(:codex_auth_params)
72
+ "#{provider.auth_url}?#{URI.encode_www_form(params)}"
73
+ end
74
+
75
+ def exchange_oauth_code(provider, code, verifier)
76
+ response = Faraday.post(provider.token_url, {
77
+ "grant_type" => "authorization_code",
78
+ "code" => code,
79
+ "redirect_uri" => provider.redirect_uri,
80
+ "client_id" => provider.client_id,
81
+ "code_verifier" => verifier
82
+ })
83
+ JSON.parse(response.body)
84
+ end
85
+
86
+ def build_oauth_credentials(tokens)
87
+ {
88
+ "auth_method" => "oauth",
89
+ "access_token" => tokens["access_token"],
90
+ "refresh_token" => tokens["refresh_token"],
91
+ "expires_at" => (Time.now + tokens["expires_in"].to_i).iso8601
92
+ }
93
+ end
94
+
95
+ def open_browser(url)
96
+ case RbConfig::CONFIG["host_os"]
97
+ when /darwin/ then system("open", url)
98
+ when /linux/ then system("xdg-open", url)
99
+ when /mswin|mingw/ then system("start", url)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,29 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ratatui_ruby"
4
+ require "securerandom"
5
+ require "faraday"
6
+ require "json"
7
+ require "uri"
4
8
 
5
9
  require_relative "state"
6
10
  require_relative "input_handler"
7
11
  require_relative "renderer"
8
12
  require_relative "command_handler"
9
13
  require_relative "llm_bridge"
14
+ require_relative "codex_bridge"
15
+ require_relative "codex_models"
10
16
  require_relative "../auth/credentials_store"
17
+ require_relative "../auth/jwt_decoder"
18
+ require_relative "../auth/pkce"
19
+ require_relative "../auth/oauth_callback_server"
11
20
  require_relative "app/event_dispatch"
21
+ require_relative "app/login_handler"
22
+ require_relative "app/oauth_handler"
12
23
 
13
24
  module RubyCoded
14
25
  module Chat
15
26
  # Main class for the AI chat interface
16
27
  class App
17
28
  include EventDispatch
29
+ include LoginHandler
30
+ include OAuthHandler
18
31
 
19
- def initialize(model:, user_config: nil)
32
+ def initialize(model:, user_config: nil, auth_manager: nil, fallback_from_model: nil)
20
33
  @model = model
21
34
  @user_config = user_config
35
+ @auth_manager = auth_manager
36
+ @fallback_from_model = fallback_from_model
22
37
  apply_plugin_extensions!
23
- @state = State.new(model: model)
24
- @llm_bridge = LLMBridge.new(@state)
25
- @input_handler = InputHandler.new(@state)
38
+ build_components!
39
+ announce_model_fallback
40
+ end
41
+
42
+ def build_components!
43
+ @state = State.new(model: @model)
26
44
  @credentials_store = Auth::CredentialsStore.new
45
+ @llm_bridge = create_bridge
46
+ @input_handler = InputHandler.new(@state)
27
47
  @command_handler = build_command_handler
28
48
  end
29
49
 
@@ -47,6 +67,7 @@ module RubyCoded
47
67
  def run_event_loop
48
68
  loop do
49
69
  refresh_screen
70
+ poll_oauth_result if @state.login_active? && @state.login_step == :oauth_waiting
50
71
  event = @tui.poll_event(timeout: poll_timeout)
51
72
  next if event.none?
52
73
 
@@ -75,8 +96,18 @@ module RubyCoded
75
96
  end
76
97
 
77
98
  def build_command_handler
78
- CommandHandler.new(
79
- @state, llm_bridge: @llm_bridge, user_config: @user_config, credentials_store: @credentials_store
99
+ CommandHandler.new(@state, llm_bridge: @llm_bridge, user_config: @user_config,
100
+ credentials_store: @credentials_store, auth_manager: @auth_manager)
101
+ end
102
+
103
+ def announce_model_fallback
104
+ return unless @fallback_from_model && !@fallback_from_model.to_s.strip.empty?
105
+ return if @fallback_from_model == @model
106
+
107
+ @state.add_message(
108
+ :system,
109
+ "Model #{@fallback_from_model} is not available (provider not authenticated). " \
110
+ "Switched to #{@model}. Use /login to authenticate or /model to change."
80
111
  )
81
112
  end
82
113
 
@@ -99,6 +130,34 @@ module RubyCoded
99
130
  @state.add_message(:system, "Model switched to #{model_name}.")
100
131
  end
101
132
 
133
+ def create_bridge
134
+ openai_creds = @credentials_store.retrieve(:openai)
135
+ if openai_creds && openai_creds["auth_method"] == "oauth"
136
+ @state.codex_mode = true
137
+ ensure_valid_codex_model!
138
+ CodexBridge.new(@state, credentials_store: @credentials_store, auth_manager: @auth_manager)
139
+ else
140
+ @state.codex_mode = false
141
+ LLMBridge.new(@state)
142
+ end
143
+ end
144
+
145
+ def ensure_valid_codex_model!
146
+ return if CodexModels.codex_model?(@state.model)
147
+
148
+ @state.model = CodexBridge::DEFAULT_MODEL
149
+ @user_config&.set_config("model", CodexBridge::DEFAULT_MODEL)
150
+ end
151
+
152
+ def recreate_bridge!
153
+ agentic = @llm_bridge.agentic_mode
154
+ plan = @llm_bridge.plan_mode
155
+ @llm_bridge = create_bridge
156
+ @llm_bridge.toggle_agentic_mode!(agentic) if agentic
157
+ @llm_bridge.toggle_plan_mode!(plan) if plan
158
+ @command_handler = build_command_handler
159
+ end
160
+
102
161
  end
103
162
  end
104
163
  end