ruby_coded 0.2.2 → 0.3.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +88 -3
  4. data/lib/ruby_coded/auth/jwt_decoder.rb +14 -0
  5. data/lib/ruby_coded/chat/app.rb +23 -4
  6. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +68 -10
  7. data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +20 -0
  8. data/lib/ruby_coded/chat/codex_models.rb +36 -7
  9. data/lib/ruby_coded/chat/command_handler/custom_commands.rb +91 -0
  10. data/lib/ruby_coded/chat/command_handler/model_commands.rb +8 -1
  11. data/lib/ruby_coded/chat/command_handler.rb +64 -36
  12. data/lib/ruby_coded/chat/help.txt +0 -20
  13. data/lib/ruby_coded/chat/renderer/model_selector.rb +4 -1
  14. data/lib/ruby_coded/chat/renderer/status_bar.rb +7 -0
  15. data/lib/ruby_coded/chat/state/context_window.rb +59 -0
  16. data/lib/ruby_coded/chat/state/message_token_tracking.rb +16 -0
  17. data/lib/ruby_coded/chat/state.rb +19 -3
  18. data/lib/ruby_coded/commands/catalog.rb +170 -0
  19. data/lib/ruby_coded/commands/command_definition.rb +30 -0
  20. data/lib/ruby_coded/commands/core_provider.rb +94 -0
  21. data/lib/ruby_coded/commands/markdown_loader.rb +101 -0
  22. data/lib/ruby_coded/commands/markdown_provider.rb +45 -0
  23. data/lib/ruby_coded/commands/plugin_provider.rb +38 -0
  24. data/lib/ruby_coded/commands.rb +8 -0
  25. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +5 -18
  26. data/lib/ruby_coded/tools/git_add_tool.rb +55 -0
  27. data/lib/ruby_coded/tools/git_base_tool.rb +67 -0
  28. data/lib/ruby_coded/tools/git_commit_tool.rb +45 -0
  29. data/lib/ruby_coded/tools/git_diff_tool.rb +23 -0
  30. data/lib/ruby_coded/tools/git_status_tool.rb +17 -0
  31. data/lib/ruby_coded/tools/registry.rb +12 -2
  32. data/lib/ruby_coded/tools/run_command_tool.rb +8 -1
  33. data/lib/ruby_coded/tools/system_prompt.rb +3 -0
  34. data/lib/ruby_coded/version.rb +1 -1
  35. data/lib/ruby_coded.rb +1 -0
  36. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1980b8f52a5fefcd383aec24380904430c10654b4cffe7bef6288b1b55e1eed
4
- data.tar.gz: 1e54670c24db8f43ce9a0e4391889adf3d2d1c75f0923d43ff3260bc210e2e96
3
+ metadata.gz: 45aa4262fb573ca03510db92e2d3bd9795136b935fe7ffe8ba02821a570b107b
4
+ data.tar.gz: ed299052fcaa75af5a03951d18fbae6021fccd2463d28f73ea7f57cacdca28d7
5
5
  SHA512:
6
- metadata.gz: c4d75ef0c6c7d0b3d1da17287b08bbdec2d9ee29bcc12c8551409ae2dbf2276562a10fc96294a9685b55a41f36d4711e448a640e97bb2711a9da9e0b1231e5ad
7
- data.tar.gz: 75d2f43c84b9c8d027d17ac6d61b2f250fef6ce2dca88463b3ab580f74927bb16933b5a6f96eb7fed6b26e5138c621e9df8b56435f364dcbe3173c72f3a10114
6
+ metadata.gz: f06c04df1359024f169e00185535ab2f8be8f24a6eee94f3f231c130dc31d385a48163072a843330ac83a79060e553d94a59dc4224e5a93679066894d5642e81
7
+ data.tar.gz: 66e264de72737cbe9b3158267faf941de67156c2e05b855e7cf679ca640ceb3ecaa16c15f694aa5c2e549578004b8851aeb2ed7513a358bcb8396603eb217f34
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.1] - 2026-06-28
4
+
5
+ ### Added
6
+
7
+ - **Git integration tool**: New git tool for common git workflows in projects (status, diff, add, commit) backed by a shared `GitBaseTool`.
8
+
9
+ ### Changed
10
+
11
+ - **Agent mode enabled by default**: Sessions now start in Agent mode automatically instead of requiring manual activation.
12
+
13
+ ## [0.3.0] - 2026-04-24
14
+
15
+ ### Added
16
+
17
+ - **Pro-only Codex model handling**: `gpt-5.3-codex-spark` and `gpt-5.2-codex` are tagged as `pro_only` in the Codex model catalog and render with a `(Pro only)` marker in the `/model` selector. Plus/free JWT plan claims now hide Pro-only models from the selector, while unknown plans keep the previous behavior to avoid breaking Pro users.
18
+ - **Automatic Codex model fallback**: When the Codex backend rejects a model with `not supported when using Codex with a ChatGPT account`, the request is transparently retried against `gpt-5.4` and the user is informed via a system message.
19
+ - **Provider metadata in `/model`**: `CodexModel` now exposes provider metadata so the selector shows `(openai)` instead of `(unknown)`.
20
+ - **Unified command catalog**: New `RubyCoded::Commands` module with a central `Catalog`, `CommandDefinition`, and provider abstraction (`CoreProvider`, `PluginProvider`, `MarkdownProvider`) consolidating all slash commands in a single source of truth, replacing the hand-rolled `help.txt` and ad-hoc registrations.
21
+ - **Markdown custom commands**: Users can now drop `*.md` files under `~/.ruby_coded/commands/` (or the project-local `.ruby_coded/commands/`) to define reusable slash commands with front-matter metadata, loaded via the new `MarkdownLoader` / `MarkdownProvider`.
22
+ - **Context window tracking**: New `State::ContextWindow` module plus status bar integration that shows live context usage per model, driven by per-turn token tracking.
23
+
24
+ ### Changed
25
+
26
+ - **Status bar polish**: The status bar now renders context window usage alongside the model/auth indicators and hides noisy fields when data is unavailable.
27
+ - **Command handler refactor**: `CommandHandler` is now powered by the unified catalog (core + plugin + markdown providers) and delegates custom command execution to the new `CustomCommands` mixin.
28
+
29
+ ### Fixed
30
+
31
+ - **Context window indicator for ChatGPT Plus/Pro users**: `CodexBridge` now parses the `response.completed` SSE event and feeds `input`/`output`/`reasoning`/`cached` token usage into `State`, mirroring the API path. `session_context_tokens_used` reflects the last turn's live prompt size via the new `State#last_turn_context_tokens` helper, eliminating the double-counting that happened because both bridges re-send the full history on each request.
32
+
3
33
  ## [0.2.2] - 2026-04-17
4
34
 
5
35
  ### Fixed
data/README.md CHANGED
@@ -28,7 +28,7 @@ An AI-powered terminal coding assistant built in Ruby. Chat with LLMs, let an ag
28
28
  - **Tool confirmation** — Write and dangerous operations require explicit approval; safe operations (read, list) run automatically
29
29
  - **Token & cost tracking** — Live status bar showing token usage, estimated session cost, and auth mode indicator
30
30
  - **Plugin system** — Extend the chat with custom state, input handlers, renderer overlays, and commands
31
- - **Slash commands** — `/agent`, `/plan`, `/model`, `/login`, `/history`, `/tokens`, `/help`, and more
31
+ - **Slash commands** — `/agent`, `/plan`, `/model`, `/login`, `/history`, `/tokens`, `/commands reload`, `/commands list`, `/help`, and more
32
32
 
33
33
  ## Requirements
34
34
 
@@ -62,6 +62,8 @@ On first launch you'll be asked to authenticate with a provider. After that, you
62
62
  | `/plan save` | Save the current plan to a file |
63
63
  | `/model` | Switch to a different model |
64
64
  | `/login` | Authenticate with a provider (OpenAI, Anthropic) |
65
+ | `/commands reload` | Reload custom markdown commands from the current project |
66
+ | `/commands list` | List currently loaded custom markdown commands |
65
67
  | `/tokens` | Show detailed token usage breakdown |
66
68
  | `/history` | Show conversation history |
67
69
  | `/clear` | Clear the conversation |
@@ -80,6 +82,10 @@ When agent mode is active, the model has access to these tools:
80
82
  | `create_directory` | Confirm | Create a new directory |
81
83
  | `delete_path` | Dangerous | Delete a file or directory |
82
84
  | `run_command` | Dangerous | Execute a shell command |
85
+ | `git_status` | Safe | Show repository status |
86
+ | `git_diff` | Safe | Show staged or unstaged diff |
87
+ | `git_add` | Confirm | Stage files or all changes |
88
+ | `git_commit` | Confirm | Create a local git commit with a message |
83
89
 
84
90
  Safe tools run without asking. Confirm and dangerous tools show the operation details and wait for your approval (`y` to approve, `n` to reject, `a` to approve all remaining).
85
91
 
@@ -87,6 +93,86 @@ Safe tools run without asking. Confirm and dangerous tools show the operation de
87
93
 
88
94
  Plan mode restricts the model to read-only tools and a planning-oriented system prompt. The model will analyze your project and propose a structured plan. If the model needs clarification, it presents interactive options you can select or answer with custom text.
89
95
 
96
+ ### Custom commands
97
+
98
+ You can define your own project-specific slash commands using Markdown files inside:
99
+
100
+ ```bash
101
+ .ruby_coded/commands/
102
+ ```
103
+
104
+ Each `.md` file represents one custom command. RubyCoded loads these commands into:
105
+
106
+ - the slash-command autocomplete list
107
+ - `/help`
108
+ - command execution
109
+
110
+ #### File format
111
+
112
+ Use YAML frontmatter followed by the command body:
113
+
114
+ ```md
115
+ ---
116
+ command: /review-auth
117
+ description: Review auth implementation
118
+ usage: /review-auth [file]
119
+ ---
120
+
121
+ Review the authentication implementation and suggest improvements.
122
+ Focus on risks, bugs, and refactoring opportunities.
123
+ ```
124
+
125
+ #### Required fields
126
+
127
+ - `command` — command name, must start with `/`
128
+ - `description` — short description shown in autocomplete and help
129
+
130
+ #### Optional fields
131
+
132
+ - `usage` — custom usage text shown in `/help`
133
+
134
+ #### How it works
135
+
136
+ The Markdown body is used as the prompt template for the command. For example:
137
+
138
+ ```bash
139
+ /review-auth lib/ruby_coded/chat/command_handler.rb
140
+ ```
141
+
142
+ RubyCoded will send the command body to the model and append the extra user input as additional context.
143
+
144
+ #### Managing commands
145
+
146
+ Custom commands are loaded from the current project. After adding, editing, or deleting command files, you can manage them without restarting the app.
147
+
148
+ Reload command definitions:
149
+
150
+ ```bash
151
+ /commands reload
152
+ ```
153
+
154
+ The reload command reports:
155
+
156
+ - how many custom commands were added
157
+ - how many were removed
158
+ - how many are currently available
159
+ - how many invalid files were ignored
160
+ - how many conflicting commands were ignored
161
+ - invalid file names
162
+ - conflicting command names
163
+
164
+ List currently loaded custom commands:
165
+
166
+ ```bash
167
+ /commands list
168
+ ```
169
+
170
+ #### Notes
171
+
172
+ - Core commands take precedence over custom commands.
173
+ - Plugin commands also take precedence over custom Markdown commands.
174
+ - Invalid Markdown command files are ignored during reload.
175
+
90
176
  ## Keyboard shortcuts
91
177
 
92
178
  | Key | Action |
@@ -116,10 +202,9 @@ bundle exec exe/ruby_coded
116
202
 
117
203
  ## What's next
118
204
 
119
- - Find a way to update the autocomplete plugin when a new command is added
120
205
  - Display context window size (depending on the model)
121
206
  - UI element to indicate the AI is performing a task
122
- - Add the possibility to create custom commands
207
+ - Improve custom commands further (validation, conflict reporting, management UX)
123
208
  - Skills implementation
124
209
  - Implement Google Auth for Gemini
125
210
  - Local LLM support
@@ -24,6 +24,20 @@ module RubyCoded
24
24
  payload = decode(token)
25
25
  payload&.dig(JWT_CLAIM_PATH, "chatgpt_account_id")
26
26
  end
27
+
28
+ PLAN_CLAIM_KEYS = %w[chatgpt_plan_type planType plan_type].freeze
29
+
30
+ def self.extract_plan_type(token)
31
+ payload = decode(token)
32
+ return nil unless payload
33
+
34
+ auth_claims = payload[JWT_CLAIM_PATH].is_a?(Hash) ? payload[JWT_CLAIM_PATH] : {}
35
+ PLAN_CLAIM_KEYS.each do |key|
36
+ value = auth_claims[key] || payload[key]
37
+ return value.to_s.downcase if value
38
+ end
39
+ nil
40
+ end
27
41
  end
28
42
  end
29
43
  end
@@ -13,6 +13,7 @@ require_relative "command_handler"
13
13
  require_relative "llm_bridge"
14
14
  require_relative "codex_bridge"
15
15
  require_relative "codex_models"
16
+ require_relative "../commands/catalog"
16
17
  require_relative "../auth/credentials_store"
17
18
  require_relative "../auth/jwt_decoder"
18
19
  require_relative "../auth/pkce"
@@ -36,11 +37,17 @@ module RubyCoded
36
37
  @fallback_from_model = fallback_from_model
37
38
  apply_plugin_extensions!
38
39
  build_components!
40
+ enable_default_agent_mode!
39
41
  announce_model_fallback
40
42
  end
41
43
 
42
44
  def build_components!
43
- @state = State.new(model: @model)
45
+ @command_catalog = RubyCoded::Commands::Catalog.new(
46
+ project_root: Dir.pwd,
47
+ plugin_registry: RubyCoded.plugin_registry
48
+ )
49
+
50
+ @state = State.new(model: @model, command_catalog: @command_catalog)
44
51
  @credentials_store = Auth::CredentialsStore.new(user_config: @user_config)
45
52
  @llm_bridge = create_bridge
46
53
  @input_handler = InputHandler.new(@state)
@@ -96,8 +103,14 @@ module RubyCoded
96
103
  end
97
104
 
98
105
  def build_command_handler
99
- CommandHandler.new(@state, llm_bridge: @llm_bridge, user_config: @user_config,
100
- credentials_store: @credentials_store, auth_manager: @auth_manager)
106
+ CommandHandler.new(
107
+ @state,
108
+ llm_bridge: @llm_bridge,
109
+ user_config: @user_config,
110
+ credentials_store: @credentials_store,
111
+ auth_manager: @auth_manager,
112
+ command_catalog: @command_catalog
113
+ )
101
114
  end
102
115
 
103
116
  def announce_model_fallback
@@ -111,6 +124,12 @@ module RubyCoded
111
124
  )
112
125
  end
113
126
 
127
+ def enable_default_agent_mode!
128
+ return if @llm_bridge.agentic_mode
129
+
130
+ @llm_bridge.toggle_agentic_mode!(true)
131
+ end
132
+
114
133
  def apply_selected_model
115
134
  selected = @state.selected_model
116
135
  return @state.exit_model_select! unless selected
@@ -150,6 +169,7 @@ module RubyCoded
150
169
  end
151
170
 
152
171
  def recreate_bridge!
172
+ @command_catalog.reload!
153
173
  agentic = @llm_bridge.agentic_mode
154
174
  plan = @llm_bridge.plan_mode
155
175
  @llm_bridge = create_bridge
@@ -157,7 +177,6 @@ module RubyCoded
157
177
  @llm_bridge.toggle_plan_mode!(plan) if plan
158
178
  @command_handler = build_command_handler
159
179
  end
160
-
161
180
  end
162
181
  end
163
182
  end
@@ -4,12 +4,15 @@ module RubyCoded
4
4
  module Chat
5
5
  class CodexBridge
6
6
  # Retry logic and error message formatting for the Codex API client.
7
+ # rubocop:disable Metrics/ModuleLength
7
8
  module ErrorHandling
8
9
  AGENT_SWITCH_PATTERN = /
9
10
  \b(implement|go[ ]ahead|proceed|execut|ejecutar?|comenz|
10
11
  comienz|hazlo|constru[iy]|adelante|dale|do[ ]it|build[ ]it)\b
11
12
  /ix
12
13
 
14
+ UNSUPPORTED_MODEL_PATTERN = /not supported when using Codex with a ChatGPT account/i
15
+
13
16
  private
14
17
 
15
18
  def build_connection
@@ -43,19 +46,56 @@ module RubyCoded
43
46
  "Plan mode disabled — switching to agent mode to implement the plan.")
44
47
  end
45
48
 
46
- def attempt_with_retries(input, retries = 0)
49
+ def attempt_with_retries(input, retries = 0, fallback_attempted: false)
47
50
  perform_codex_request(input)
48
51
  rescue Tools::AgentCancelledError, Tools::AgentIterationLimitError, Tools::ToolRejectedError => e
49
52
  @state.add_message(:system, e.message)
50
53
  rescue CodexAPIError => e
51
- @state.fail_last_assistant(e, friendly_message: codex_error_message(e))
54
+ handle_codex_api_error(e, input, retries, fallback_attempted)
52
55
  rescue Faraday::TooManyRequestsError => e
53
- retry if (retries = handle_rate_limit_retry(e, retries))
54
- @state.fail_last_assistant(e, friendly_message: rate_limit_message(e))
56
+ handle_rate_limit_error(e, retries, input, fallback_attempted)
55
57
  rescue StandardError => e
56
58
  @state.fail_last_assistant(e, friendly_message: "Codex API error: #{e.message}")
57
59
  end
58
60
 
61
+ def handle_codex_api_error(error, input, retries, fallback_attempted)
62
+ return fail_codex_request(error) unless should_fallback_to_default_model?(error, fallback_attempted)
63
+
64
+ switch_to_default_model!(error)
65
+ attempt_with_retries(input, retries, fallback_attempted: true)
66
+ end
67
+
68
+ def fail_codex_request(error)
69
+ @state.fail_last_assistant(error, friendly_message: codex_error_message(error))
70
+ end
71
+
72
+ def handle_rate_limit_error(error, retries, input, fallback_attempted)
73
+ next_retries = handle_rate_limit_retry(error, retries)
74
+ return attempt_with_retries(input, next_retries, fallback_attempted: fallback_attempted) if next_retries
75
+
76
+ @state.fail_last_assistant(error, friendly_message: rate_limit_message(error))
77
+ end
78
+
79
+ def should_fallback_to_default_model?(error, already_attempted)
80
+ return false if already_attempted
81
+ return false unless error.status == 400
82
+ return false unless error.message.match?(UNSUPPORTED_MODEL_PATTERN)
83
+
84
+ @model != DEFAULT_MODEL
85
+ end
86
+
87
+ def switch_to_default_model!(error)
88
+ previous_model = @model
89
+ @model = DEFAULT_MODEL
90
+ @state.model = DEFAULT_MODEL
91
+ @state.reset_last_assistant_content
92
+ @state.add_message(
93
+ :system,
94
+ "Model '#{previous_model}' requires ChatGPT Pro. " \
95
+ "Falling back to #{DEFAULT_MODEL} and retrying. Detail: #{error.message}"
96
+ )
97
+ end
98
+
59
99
  def handle_rate_limit_retry(error, retries)
60
100
  return unless retries < MAX_RATE_LIMIT_RETRIES && !@cancel_requested
61
101
 
@@ -69,18 +109,35 @@ module RubyCoded
69
109
  end
70
110
 
71
111
  def codex_error_message(error)
112
+ return unsupported_model_message(error) if unsupported_model_error?(error)
113
+
114
+ status_message(error) || "Codex API error: #{error.message}"
115
+ end
116
+
117
+ def unsupported_model_error?(error)
118
+ error.status == 400 && error.message.match?(UNSUPPORTED_MODEL_PATTERN)
119
+ end
120
+
121
+ def unsupported_model_message(error)
122
+ "The selected model requires ChatGPT Pro. " \
123
+ "Use /model to pick one without the 'Pro only' tag. (#{error.message})"
124
+ end
125
+
126
+ def status_message(error)
72
127
  case error.status
73
- when 401
74
- "Authentication failed. Your OAuth session may have expired. " \
75
- "Try /login to re-authenticate. (#{error.message})"
76
- when 403
77
- "Access denied. Your ChatGPT subscription may not include Codex access. (#{error.message})"
128
+ when 400 then "Codex API error: #{error.message}"
129
+ when 401 then authentication_error_message(error)
130
+ when 403 then "Access denied. Your ChatGPT subscription may not include Codex access. (#{error.message})"
78
131
  when 404 then "Codex endpoint not found. The API may have changed. (#{error.message})"
79
132
  when 429 then rate_limit_message(error)
80
- else "Codex API error: #{error.message}"
81
133
  end
82
134
  end
83
135
 
136
+ def authentication_error_message(error)
137
+ "Authentication failed. Your OAuth session may have expired. " \
138
+ "Try /login to re-authenticate. (#{error.message})"
139
+ end
140
+
84
141
  def rate_limit_message(error)
85
142
  <<~MSG.strip
86
143
  ChatGPT usage limit reached. This may be a 5-hour or weekly limit on your Plus/Pro subscription.
@@ -88,6 +145,7 @@ module RubyCoded
88
145
  MSG
89
146
  end
90
147
  end
148
+ # rubocop:enable Metrics/ModuleLength
91
149
  end
92
150
  end
93
151
  end
@@ -5,6 +5,7 @@ module RubyCoded
5
5
  class CodexBridge
6
6
  # Parses Server-Sent Events from the Codex streaming response and
7
7
  # dispatches content deltas, tool calls, and completion signals.
8
+ # rubocop:disable Metrics/ModuleLength
8
9
  module SSEParser
9
10
  StreamContext = Struct.new(
10
11
  :assistant_text, :pending_tool_calls, :buffer, :raw_body, :status, keyword_init: true
@@ -70,6 +71,7 @@ module RubyCoded
70
71
  when "response.function_call_arguments.delta" then handle_function_args_delta(event, pending_tool_calls)
71
72
  when "response.function_call_arguments.done" then handle_function_call_done(event, pending_tool_calls)
72
73
  when "response.output_item.added" then handle_output_item_added(event, pending_tool_calls)
74
+ when "response.completed" then handle_response_completed(event)
73
75
  end
74
76
  end
75
77
 
@@ -107,6 +109,23 @@ module RubyCoded
107
109
  tc[:arguments] = event["arguments"] || tc[:arguments] if tc
108
110
  end
109
111
 
112
+ # The Responses API emits a `response.completed` event at the end of the
113
+ # stream carrying the `usage` block. We mirror what `LLMBridge` does and
114
+ # feed those counters into `State` so that the status bar and the
115
+ # context-window indicator work for ChatGPT Plus/Pro users too.
116
+ def handle_response_completed(event)
117
+ usage = event.dig("response", "usage") || event["usage"]
118
+ return unless usage.is_a?(Hash)
119
+
120
+ @state.update_last_message_tokens(
121
+ model: @model,
122
+ input_tokens: usage["input_tokens"],
123
+ output_tokens: usage["output_tokens"],
124
+ thinking_tokens: usage.dig("output_tokens_details", "reasoning_tokens"),
125
+ cached_tokens: usage.dig("input_tokens_details", "cached_tokens")
126
+ )
127
+ end
128
+
110
129
  def finalize_response(assistant_text)
111
130
  @conversation_history << { role: "assistant", content: assistant_text } unless assistant_text.empty?
112
131
  end
@@ -131,6 +150,7 @@ module RubyCoded
131
150
  nil
132
151
  end
133
152
  end
153
+ # rubocop:enable Metrics/ModuleLength
134
154
  end
135
155
  end
136
156
  end
@@ -6,23 +6,34 @@ module RubyCoded
6
6
  # These models are not listed in RubyLLM.models because they use a
7
7
  # different API (Responses API via chatgpt.com/backend-api).
8
8
  module CodexModels
9
- CodexModel = Struct.new(:id, :display_name, :context_window, :max_output, keyword_init: true) do
9
+ PROVIDER = :openai
10
+
11
+ CodexModel = Struct.new(:id, :display_name, :context_window, :max_output, :pro_only,
12
+ keyword_init: true) do
10
13
  def to_s
11
14
  id
12
15
  end
16
+
17
+ def provider
18
+ PROVIDER
19
+ end
20
+
21
+ def pro_only?
22
+ pro_only == true
23
+ end
13
24
  end
14
25
 
15
26
  MODELS = [
16
27
  CodexModel.new(id: "gpt-5.4", display_name: "GPT 5.4 (Recommended)",
17
- context_window: 272_000, max_output: 128_000),
28
+ context_window: 272_000, max_output: 128_000, pro_only: false),
18
29
  CodexModel.new(id: "gpt-5.4-mini", display_name: "GPT 5.4 Mini",
19
- context_window: 272_000, max_output: 128_000),
20
- CodexModel.new(id: "gpt-5.3-codex-spark", display_name: "GPT 5.3 Codex Spark (Pro only)",
21
- context_window: 272_000, max_output: 128_000),
30
+ context_window: 272_000, max_output: 128_000, pro_only: false),
31
+ CodexModel.new(id: "gpt-5.3-codex-spark", display_name: "GPT 5.3 Codex Spark",
32
+ context_window: 272_000, max_output: 128_000, pro_only: true),
22
33
  CodexModel.new(id: "gpt-5.2-codex", display_name: "GPT 5.2 Codex",
23
- context_window: 272_000, max_output: 128_000),
34
+ context_window: 272_000, max_output: 128_000, pro_only: true),
24
35
  CodexModel.new(id: "gpt-5.2", display_name: "GPT 5.2",
25
- context_window: 272_000, max_output: 128_000)
36
+ context_window: 272_000, max_output: 128_000, pro_only: false)
26
37
  ].freeze
27
38
 
28
39
  def self.all
@@ -36,6 +47,24 @@ module RubyCoded
36
47
  def self.codex_model?(id)
37
48
  MODELS.any? { |m| m.id == id }
38
49
  end
50
+
51
+ def self.pro_only?(id)
52
+ model = find(id)
53
+ model ? model.pro_only? : false
54
+ end
55
+
56
+ PRO_TIER_PLANS = %w[pro team enterprise business edu].freeze
57
+
58
+ def self.available_for_plan(plan)
59
+ normalized = plan.to_s.downcase
60
+ return MODELS if normalized.empty? || PRO_TIER_PLANS.any? { |p| normalized.include?(p) }
61
+
62
+ if normalized.include?("plus") || normalized.include?("free")
63
+ MODELS.reject(&:pro_only?)
64
+ else
65
+ MODELS
66
+ end
67
+ end
39
68
  end
40
69
  end
41
70
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CommandHandler
6
+ # Slash commands for managing custom markdown commands.
7
+ module CustomCommands
8
+ private
9
+
10
+ def cmd_commands(rest)
11
+ case rest&.strip&.downcase
12
+ when "reload"
13
+ reload_commands
14
+ when "list"
15
+ list_commands
16
+ else
17
+ @state.add_message(:system, "Usage: /commands [reload|list]")
18
+ end
19
+ end
20
+
21
+ def reload_commands
22
+ return missing_command_catalog unless @command_catalog
23
+
24
+ report = @command_catalog.reload!
25
+ @commands = build_command_map
26
+ @state.add_message(:system, format_reload_message(report))
27
+ end
28
+
29
+ def list_commands
30
+ return missing_command_catalog unless @command_catalog
31
+
32
+ commands = @command_catalog.definitions_for_source(:markdown)
33
+ return show_empty_custom_commands if commands.empty?
34
+
35
+ @state.add_message(:system, formatted_custom_commands(commands))
36
+ end
37
+
38
+ def missing_command_catalog
39
+ @state.add_message(:system, "Command catalog is not available.")
40
+ end
41
+
42
+ def show_empty_custom_commands
43
+ @state.add_message(
44
+ :system,
45
+ "No custom commands loaded. Add markdown files under .ruby_coded/commands " \
46
+ "and run /commands reload."
47
+ )
48
+ end
49
+
50
+ def formatted_custom_commands(commands)
51
+ lines = ["Custom commands:"]
52
+ commands.sort_by { |definition| definition.name.downcase }.each do |definition|
53
+ lines << formatted_command_line(definition)
54
+ end
55
+ lines.join("\n")
56
+ end
57
+
58
+ def format_reload_message(report)
59
+ message = reload_summary(report)
60
+ details = reload_details(report)
61
+ return message if details.empty?
62
+
63
+ "#{message}\n#{details.join("\n")}"
64
+ end
65
+
66
+ def reload_summary(report)
67
+ "Commands reloaded. " \
68
+ "Added: #{report[:added]}, removed: #{report[:removed]}, " \
69
+ "total custom commands: #{report[:total]}, " \
70
+ "invalid files ignored: #{report[:invalid]}, " \
71
+ "conflicts ignored: #{report[:conflicts]}."
72
+ end
73
+
74
+ def reload_details(report)
75
+ details = []
76
+ invalid_files = Array(report[:invalid_files])
77
+ conflict_commands = Array(report[:conflict_commands])
78
+
79
+ details << "Invalid files: #{invalid_files.join(", ")}" if invalid_files.any?
80
+ details << "Conflicting commands: #{conflict_commands.join(", ")}" if conflict_commands.any?
81
+ details
82
+ end
83
+
84
+ def formatted_command_line(definition)
85
+ usage = definition.usage || definition.name
86
+ " #{usage.ljust(28)} #{definition.description}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../model_filter"
4
4
  require_relative "../codex_models"
5
+ require_relative "../../auth/jwt_decoder"
5
6
 
6
7
  module RubyCoded
7
8
  module Chat
@@ -74,12 +75,18 @@ module RubyCoded
74
75
 
75
76
  def models_for_provider(name, creds)
76
77
  if name == :openai && creds["auth_method"] == "oauth"
77
- CodexModels.all
78
+ codex_models_for_plan(creds)
78
79
  else
79
80
  RubyLLM.models.by_provider(name).chat_models.to_a
80
81
  end
81
82
  end
82
83
 
84
+ def codex_models_for_plan(creds)
85
+ token = creds["access_token"]
86
+ plan = token ? Auth::JWTDecoder.extract_plan_type(token) : nil
87
+ CodexModels.available_for_plan(plan)
88
+ end
89
+
83
90
  def fetch_chat_models
84
91
  RubyLLM.models.chat_models.to_a
85
92
  rescue StandardError