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
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubyCoded
6
+ module Commands
7
+ # Loads project-local markdown command files.
8
+ class MarkdownLoader
9
+ def initialize(project_root:)
10
+ @project_root = project_root
11
+ end
12
+
13
+ def load_files
14
+ load_report[:entries]
15
+ end
16
+
17
+ def load_report
18
+ return empty_report unless Dir.exist?(commands_dir)
19
+
20
+ build_report(command_paths)
21
+ end
22
+
23
+ private
24
+
25
+ def empty_report
26
+ { entries: [], invalid_count: 0, invalid_files: [] }
27
+ end
28
+
29
+ def build_report(paths)
30
+ entries, invalid_files = paths.each_with_object([[], []]) do |path, memo|
31
+ collect_report_entry(path, *memo)
32
+ end
33
+
34
+ {
35
+ entries: entries,
36
+ invalid_count: invalid_files.size,
37
+ invalid_files: invalid_files
38
+ }
39
+ end
40
+
41
+ def collect_report_entry(path, entries, invalid_files)
42
+ parsed = parse_file(path)
43
+ parsed ? entries << parsed : invalid_files << File.basename(path)
44
+ end
45
+
46
+ def command_paths
47
+ Dir.glob(File.join(commands_dir, "*.md"))
48
+ end
49
+
50
+ def commands_dir
51
+ File.join(@project_root, ".ruby_coded", "commands")
52
+ end
53
+
54
+ def parse_file(path)
55
+ frontmatter, body = extract_frontmatter(File.read(path))
56
+ return nil unless frontmatter
57
+
58
+ build_entry(path, extract_attributes(frontmatter, body))
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def extract_attributes(frontmatter, body)
64
+ data = YAML.safe_load(frontmatter) || {}
65
+ {
66
+ command: data["command"]&.strip,
67
+ description: data["description"]&.strip,
68
+ usage: data["usage"]&.strip,
69
+ content: body.to_s.strip
70
+ }
71
+ end
72
+
73
+ def build_entry(path, attrs)
74
+ return nil unless valid_entry?(attrs)
75
+
76
+ attrs.merge(path: path)
77
+ end
78
+
79
+ def valid_entry?(attrs)
80
+ valid_command_name?(attrs[:command]) &&
81
+ !attrs[:description].to_s.empty? &&
82
+ !attrs[:content].to_s.empty?
83
+ end
84
+
85
+ def extract_frontmatter(raw)
86
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
87
+ return [nil, nil] unless match
88
+
89
+ [match[1], match[2]]
90
+ end
91
+
92
+ def valid_command_name?(name)
93
+ return false if name.to_s.empty?
94
+ return false unless name.start_with?("/")
95
+ return false if name.include?(" ")
96
+
97
+ true
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_definition"
4
+ require_relative "markdown_loader"
5
+
6
+ module RubyCoded
7
+ module Commands
8
+ # Converts markdown command files into command definitions.
9
+ class MarkdownProvider
10
+ def initialize(project_root:)
11
+ @loader = MarkdownLoader.new(project_root: project_root)
12
+ end
13
+
14
+ def definitions
15
+ load_report[:definitions]
16
+ end
17
+
18
+ def load_report
19
+ report = @loader.load_report
20
+ {
21
+ definitions: build_definitions(report[:entries]),
22
+ invalid_count: report[:invalid_count],
23
+ invalid_files: report[:invalid_files]
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def build_definitions(entries)
30
+ entries.map { |entry| build_definition(entry) }
31
+ end
32
+
33
+ def build_definition(entry)
34
+ CommandDefinition.new(
35
+ name: entry[:command],
36
+ description: entry[:description],
37
+ source: :markdown,
38
+ usage: entry[:usage] || entry[:command],
39
+ content: entry[:content],
40
+ path: entry[:path]
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_definition"
4
+
5
+ module RubyCoded
6
+ module Commands
7
+ # Adapts plugin-registered commands to the unified command catalog.
8
+ class PluginProvider
9
+ def initialize(registry:)
10
+ @registry = registry
11
+ end
12
+
13
+ def definitions
14
+ commands.map { |name, handler| build_definition(name, handler) }
15
+ end
16
+
17
+ private
18
+
19
+ def commands
20
+ @registry.all_commands
21
+ end
22
+
23
+ def descriptions
24
+ @registry.all_command_descriptions
25
+ end
26
+
27
+ def build_definition(name, handler)
28
+ CommandDefinition.new(
29
+ name: name,
30
+ description: descriptions[name] || "Plugin command",
31
+ handler: handler,
32
+ source: :plugin,
33
+ usage: name
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/command_definition"
4
+ require_relative "commands/core_provider"
5
+ require_relative "commands/plugin_provider"
6
+ require_relative "commands/markdown_loader"
7
+ require_relative "commands/markdown_provider"
8
+ require_relative "commands/catalog"
@@ -5,19 +5,6 @@ module RubyCoded
5
5
  module CommandCompletion
6
6
  # Mixed into Chat::State to add command-completion tracking.
7
7
  module StateExtension
8
- COMMAND_INFO = {
9
- "/help" => "Show help message",
10
- "/model" => "Select or switch model",
11
- "/clear" => "Clear conversation history",
12
- "/history" => "Show conversation summary",
13
- "/tokens" => "Show detailed token usage and cost",
14
- "/agent" => "Toggle agent mode (on/off)",
15
- "/plan" => "Toggle plan mode (on/off/save)",
16
- "/login" => "Authenticate with an AI provider",
17
- "/exit" => "Exit the chat",
18
- "/quit" => "Exit the chat"
19
- }.freeze
20
-
21
8
  def self.included(base)
22
9
  base.attr_reader :command_completion_index
23
10
  end
@@ -36,8 +23,8 @@ module RubyCoded
36
23
  def command_suggestions
37
24
  prefix = @input_buffer.downcase
38
25
  all_descriptions = merged_command_descriptions
39
- all_descriptions.select { |cmd, _| cmd.start_with?(prefix) }
40
- .sort_by { |cmd, _| cmd }
26
+ all_descriptions.select { |cmd, _| cmd.downcase.start_with?(prefix) }
27
+ .sort_by { |cmd, _| cmd.downcase }
41
28
  end
42
29
 
43
30
  def current_command_suggestion
@@ -81,9 +68,9 @@ module RubyCoded
81
68
  private
82
69
 
83
70
  def merged_command_descriptions
84
- base = COMMAND_INFO.dup
85
- base.merge!(RubyCoded.plugin_registry.all_command_descriptions) if RubyCoded.respond_to?(:plugin_registry)
86
- base
71
+ return {} unless respond_to?(:command_catalog) && command_catalog
72
+
73
+ command_catalog.command_descriptions
87
74
  end
88
75
  end
89
76
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Stage specific files or all current changes in the git repository.
8
+ class GitAddTool < GitBaseTool
9
+ description "Stage files in the git repository. Provide paths or set all to true to stage everything."
10
+ risk :confirm
11
+
12
+ params do
13
+ array :paths, of: :string,
14
+ description: "Relative file paths to stage",
15
+ required: false
16
+ boolean :all, description: "Stage all tracked and untracked changes", required: false
17
+ end
18
+
19
+ def execute(paths: nil, all: false)
20
+ return stage_all if all
21
+
22
+ stage_paths(paths)
23
+ end
24
+
25
+ private
26
+
27
+ def stage_all
28
+ result = run_git_command("add", "--all")
29
+ return result if result.is_a?(Hash)
30
+
31
+ "Staged all changes.\n#{result}"
32
+ end
33
+
34
+ def stage_paths(paths)
35
+ normalized_paths = Array(paths).map(&:to_s).reject(&:empty?)
36
+ return { error: "Provide at least one path or set all to true." } if normalized_paths.empty?
37
+
38
+ invalid = invalid_paths(normalized_paths)
39
+ return { error: "Paths are outside the project directory: #{invalid.join(", ")}" } unless invalid.empty?
40
+
41
+ result = run_git_command("add", *normalized_paths)
42
+ return result if result.is_a?(Hash)
43
+
44
+ "Staged paths: #{normalized_paths.join(", ")}\n#{result}"
45
+ end
46
+
47
+ def invalid_paths(normalized_paths)
48
+ normalized_paths.filter_map do |path|
49
+ validated = validate_path!(path)
50
+ path if validated.is_a?(Hash)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require_relative "base_tool"
6
+
7
+ module RubyCoded
8
+ module Tools
9
+ # Shared helpers for git-specific tools.
10
+ class GitBaseTool < BaseTool
11
+ GIT_ENV = {
12
+ "GIT_EDITOR" => "true",
13
+ "EDITOR" => "true",
14
+ "VISUAL" => "true",
15
+ "GIT_PAGER" => "cat",
16
+ "PAGER" => "cat"
17
+ }.freeze
18
+
19
+ MAX_OUTPUT_CHARS = 5000
20
+
21
+ def git_repo?
22
+ Dir.exist?(File.join(@project_root, ".git"))
23
+ end
24
+
25
+ def ensure_git_repo!
26
+ return nil if git_repo?
27
+
28
+ { error: "Not a git repository: #{@project_root}" }
29
+ end
30
+
31
+ def run_git_command(*)
32
+ repo_error = ensure_git_repo!
33
+ return repo_error if repo_error
34
+
35
+ stdout, stderr, status = Open3.capture3(GIT_ENV, "git", *, chdir: @project_root)
36
+ format_git_result(stdout, stderr, status)
37
+ rescue Errno::ENOENT => e
38
+ { error: "Git executable not found: #{e.message}" }
39
+ rescue StandardError => e
40
+ { error: "Git command failed: #{e.message}" }
41
+ end
42
+
43
+ def format_git_result(stdout, stderr, status)
44
+ output = String.new
45
+ output << stdout unless stdout.empty?
46
+ output << "\nSTDERR:\n#{stderr}" unless stderr.empty?
47
+ output = output.strip
48
+ output = "(no output)" if output.empty?
49
+ output = truncate_output(output)
50
+
51
+ return output if status.success?
52
+
53
+ { error: output }
54
+ end
55
+
56
+ def truncate_output(output)
57
+ return output if output.length <= MAX_OUTPUT_CHARS
58
+
59
+ "#{output[0, MAX_OUTPUT_CHARS]}...(truncated, #{output.length} total characters)"
60
+ end
61
+
62
+ def shell_join(parts)
63
+ parts.map { |part| Shellwords.escape(part.to_s) }.join(" ")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Create a non-interactive git commit.
8
+ class GitCommitTool < GitBaseTool
9
+ description "Create a git commit with a message. Supports staging all changes first if requested."
10
+ risk :confirm
11
+
12
+ params do
13
+ string :message, description: "Commit message"
14
+ boolean :add_all, description: "Stage all changes before committing", required: false
15
+ end
16
+
17
+ def execute(message:, add_all: false)
18
+ msg = message.to_s.strip
19
+ return { error: "Commit message cannot be empty." } if msg.empty?
20
+
21
+ if add_all
22
+ add_result = run_git_command("add", "--all")
23
+ return add_result if add_result.is_a?(Hash)
24
+ end
25
+
26
+ result = run_git_command("commit", "-m", msg)
27
+ return enhance_commit_error(result) if result.is_a?(Hash)
28
+
29
+ prefix = add_all ? "Staged all changes and created commit." : "Created commit."
30
+ "#{prefix}\n#{result}"
31
+ end
32
+
33
+ private
34
+
35
+ def enhance_commit_error(result)
36
+ message = result[:error].to_s
37
+
38
+ return { error: "Nothing to commit. Working tree clean or no staged changes." } if message.include?("nothing to commit")
39
+ return { error: "Git user identity is not configured. Set user.name and user.email before committing." } if message.include?("Author identity unknown")
40
+
41
+ result
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Show git diff output for the project repository.
8
+ class GitDiffTool < GitBaseTool
9
+ description "Show git diff output for the project repository. By default shows unstaged changes."
10
+ risk :safe
11
+
12
+ params do
13
+ boolean :staged, description: "Show staged changes instead of unstaged changes", required: false
14
+ end
15
+
16
+ def execute(staged: false)
17
+ args = ["diff"]
18
+ args << "--cached" if staged
19
+ run_git_command(*args)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Show the current git working tree status.
8
+ class GitStatusTool < GitBaseTool
9
+ description "Show the current git working tree status for the project repository"
10
+ risk :safe
11
+
12
+ def execute
13
+ run_git_command("status", "--short", "--branch")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -7,6 +7,10 @@ require_relative "edit_file_tool"
7
7
  require_relative "create_directory_tool"
8
8
  require_relative "delete_path_tool"
9
9
  require_relative "run_command_tool"
10
+ require_relative "git_status_tool"
11
+ require_relative "git_diff_tool"
12
+ require_relative "git_add_tool"
13
+ require_relative "git_commit_tool"
10
14
 
11
15
  module RubyCoded
12
16
  module Tools
@@ -14,7 +18,9 @@ module RubyCoded
14
18
  class Registry
15
19
  READONLY_TOOL_CLASSES = [
16
20
  ReadFileTool,
17
- ListDirectoryTool
21
+ ListDirectoryTool,
22
+ GitStatusTool,
23
+ GitDiffTool
18
24
  ].freeze
19
25
 
20
26
  TOOL_CLASSES = [
@@ -23,7 +29,11 @@ module RubyCoded
23
29
  EditFileTool,
24
30
  CreateDirectoryTool,
25
31
  DeletePathTool,
26
- RunCommandTool
32
+ RunCommandTool,
33
+ GitStatusTool,
34
+ GitDiffTool,
35
+ GitAddTool,
36
+ GitCommitTool
27
37
  ].freeze
28
38
 
29
39
  def initialize(project_root:)
@@ -12,6 +12,13 @@ module RubyCoded
12
12
 
13
13
  TIMEOUT_SECONDS = 30
14
14
  MAX_OUTPUT_CHARS = 5000
15
+ COMMAND_ENV = {
16
+ "GIT_EDITOR" => "true",
17
+ "EDITOR" => "true",
18
+ "VISUAL" => "true",
19
+ "GIT_PAGER" => "cat",
20
+ "PAGER" => "cat"
21
+ }.freeze
15
22
 
16
23
  params do
17
24
  string :command, description: "The shell command to execute"
@@ -35,7 +42,7 @@ module RubyCoded
35
42
  private
36
43
 
37
44
  def run_with_timeout(command)
38
- stdin, stdout_io, stderr_io, wait_thr = Open3.popen3(command, chdir: @project_root)
45
+ stdin, stdout_io, stderr_io, wait_thr = Open3.popen3(COMMAND_ENV, command, chdir: @project_root)
39
46
  stdin.close
40
47
 
41
48
  unless wait_thr.join(TIMEOUT_SECONDS)
@@ -15,6 +15,9 @@ module RubyCoded
15
15
  - The user will be asked to confirm destructive operations (write, edit, delete).
16
16
  - When listing directories, start with the project root to orient yourself.
17
17
  - Be concise in your explanations but thorough in your actions.
18
+ - Git workflows are allowed when the user asks for them. Prefer dedicated git tools for status, diff, add, and commit.
19
+ - For git commits, always use a non-interactive commit message and never rely on opening an editor.
20
+ - Do not push, pull, fetch, rebase, or perform other remote/history-rewriting git actions unless the user explicitly asks.
18
21
 
19
22
  Efficiency:
20
23
  - You have a budget of %<max_write_rounds>d write/edit/delete tool calls that auto-resets when reached, and a hard limit of %<max_total_rounds>d total tool calls per request.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # This module contains the version of the RubyCoded gem
4
4
  module RubyCoded
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.1"
6
6
 
7
7
  def self.gem_version
8
8
  Gem::Version.new(VERSION).freeze
data/lib/ruby_coded.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "ruby_coded/config/user_config"
5
5
  require_relative "ruby_coded/auth/auth_manager"
6
6
  require_relative "ruby_coded/initializer"
7
7
  require_relative "ruby_coded/plugins"
8
+ require_relative "ruby_coded/commands"
8
9
 
9
10
  raise "This gem requires Ruby 3.3.0 or higher" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.3.0")
10
11
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_coded
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cesar Rodriguez
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-17 00:00:00.000000000 Z
11
+ date: 2026-06-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -137,6 +137,7 @@ files:
137
137
  - lib/ruby_coded/chat/codex_models.rb
138
138
  - lib/ruby_coded/chat/command_handler.rb
139
139
  - lib/ruby_coded/chat/command_handler/agent_commands.rb
140
+ - lib/ruby_coded/chat/command_handler/custom_commands.rb
140
141
  - lib/ruby_coded/chat/command_handler/history_commands.rb
141
142
  - lib/ruby_coded/chat/command_handler/login_commands.rb
142
143
  - lib/ruby_coded/chat/command_handler/model_commands.rb
@@ -165,6 +166,7 @@ files:
165
166
  - lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb
166
167
  - lib/ruby_coded/chat/renderer/status_bar.rb
167
168
  - lib/ruby_coded/chat/state.rb
169
+ - lib/ruby_coded/chat/state/context_window.rb
168
170
  - lib/ruby_coded/chat/state/login_flow.rb
169
171
  - lib/ruby_coded/chat/state/login_flow_steps.rb
170
172
  - lib/ruby_coded/chat/state/message_assistant.rb
@@ -175,6 +177,13 @@ files:
175
177
  - lib/ruby_coded/chat/state/scrollable.rb
176
178
  - lib/ruby_coded/chat/state/token_cost.rb
177
179
  - lib/ruby_coded/chat/state/tool_confirmation.rb
180
+ - lib/ruby_coded/commands.rb
181
+ - lib/ruby_coded/commands/catalog.rb
182
+ - lib/ruby_coded/commands/command_definition.rb
183
+ - lib/ruby_coded/commands/core_provider.rb
184
+ - lib/ruby_coded/commands/markdown_loader.rb
185
+ - lib/ruby_coded/commands/markdown_provider.rb
186
+ - lib/ruby_coded/commands/plugin_provider.rb
178
187
  - lib/ruby_coded/config/user_config.rb
179
188
  - lib/ruby_coded/errors/auth_error.rb
180
189
  - lib/ruby_coded/initializer.rb
@@ -195,6 +204,11 @@ files:
195
204
  - lib/ruby_coded/tools/create_directory_tool.rb
196
205
  - lib/ruby_coded/tools/delete_path_tool.rb
197
206
  - lib/ruby_coded/tools/edit_file_tool.rb
207
+ - lib/ruby_coded/tools/git_add_tool.rb
208
+ - lib/ruby_coded/tools/git_base_tool.rb
209
+ - lib/ruby_coded/tools/git_commit_tool.rb
210
+ - lib/ruby_coded/tools/git_diff_tool.rb
211
+ - lib/ruby_coded/tools/git_status_tool.rb
198
212
  - lib/ruby_coded/tools/list_directory_tool.rb
199
213
  - lib/ruby_coded/tools/plan_system_prompt.rb
200
214
  - lib/ruby_coded/tools/read_file_tool.rb