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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +3 -1
- data/CHANGELOG.md +29 -0
- data/README.md +15 -10
- data/lib/ruby_coded/auth/auth_manager.rb +20 -5
- data/lib/ruby_coded/auth/jwt_decoder.rb +29 -0
- data/lib/ruby_coded/auth/providers/openai.rb +19 -5
- data/lib/ruby_coded/chat/app/event_dispatch.rb +23 -3
- data/lib/ruby_coded/chat/app/login_handler.rb +79 -0
- data/lib/ruby_coded/chat/app/oauth_handler.rb +105 -0
- data/lib/ruby_coded/chat/app.rb +65 -6
- data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +93 -0
- data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +104 -0
- data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +136 -0
- data/lib/ruby_coded/chat/codex_bridge/token_manager.rb +45 -0
- data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +51 -0
- data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +128 -0
- data/lib/ruby_coded/chat/codex_bridge.rb +126 -0
- data/lib/ruby_coded/chat/codex_models.rb +41 -0
- data/lib/ruby_coded/chat/command_handler/login_commands.rb +33 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +19 -6
- data/lib/ruby_coded/chat/command_handler.rb +6 -2
- data/lib/ruby_coded/chat/help.txt +2 -0
- data/lib/ruby_coded/chat/input_handler/login_inputs.rb +66 -0
- data/lib/ruby_coded/chat/input_handler.rb +3 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +1 -1
- data/lib/ruby_coded/chat/renderer/login_flow.rb +105 -0
- data/lib/ruby_coded/chat/renderer/login_flow_layout.rb +65 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +2 -1
- data/lib/ruby_coded/chat/renderer.rb +12 -3
- data/lib/ruby_coded/chat/state/login_flow.rb +117 -0
- data/lib/ruby_coded/chat/state/login_flow_steps.rb +69 -0
- data/lib/ruby_coded/chat/state.rb +25 -2
- data/lib/ruby_coded/initializer.rb +14 -3
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +1 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +4 -3
- data/lib/ruby_coded/version.rb +1 -1
- metadata +33 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35cd5d9fc4b17b8a4aa4cefce67e02f6be01053fef3cb73046281804434a3d85
|
|
4
|
+
data.tar.gz: 4b53ca7258244edcc8cb1e7c31dfadc91c2c897b9b24acea3e5cdac21d086485
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
18
|
-
|
|
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
|
data/lib/ruby_coded/chat/app.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|