crimson-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +150 -0
  4. data/exe/crimson +207 -0
  5. data/lib/crimson/agent/event_emitter.rb +56 -0
  6. data/lib/crimson/agent/events.rb +43 -0
  7. data/lib/crimson/agent/steering.rb +91 -0
  8. data/lib/crimson/agent/tool_executor.rb +114 -0
  9. data/lib/crimson/agent.rb +564 -0
  10. data/lib/crimson/client/anthropic_adapter.rb +206 -0
  11. data/lib/crimson/client/base.rb +25 -0
  12. data/lib/crimson/client/factory.rb +27 -0
  13. data/lib/crimson/client/openai_adapter.rb +188 -0
  14. data/lib/crimson/compactor.rb +129 -0
  15. data/lib/crimson/config.rb +95 -0
  16. data/lib/crimson/cost_tracker.rb +62 -0
  17. data/lib/crimson/formatter.rb +93 -0
  18. data/lib/crimson/message.rb +177 -0
  19. data/lib/crimson/output_handler.rb +252 -0
  20. data/lib/crimson/project_context.rb +184 -0
  21. data/lib/crimson/providers.rb +49 -0
  22. data/lib/crimson/repl.rb +310 -0
  23. data/lib/crimson/retry_handler.rb +104 -0
  24. data/lib/crimson/session_entry.rb +145 -0
  25. data/lib/crimson/session_manager.rb +219 -0
  26. data/lib/crimson/setup.rb +134 -0
  27. data/lib/crimson/skill_router.rb +165 -0
  28. data/lib/crimson/token_counter.rb +84 -0
  29. data/lib/crimson/tool_registry.rb +112 -0
  30. data/lib/crimson/tools/diff_util.rb +44 -0
  31. data/lib/crimson/tools/edit_file.rb +145 -0
  32. data/lib/crimson/tools/file_mutation_queue.rb +30 -0
  33. data/lib/crimson/tools/glob.rb +49 -0
  34. data/lib/crimson/tools/index.rb +20 -0
  35. data/lib/crimson/tools/list_directory.rb +42 -0
  36. data/lib/crimson/tools/read_file.rb +92 -0
  37. data/lib/crimson/tools/run_command.rb +138 -0
  38. data/lib/crimson/tools/schema.rb +60 -0
  39. data/lib/crimson/tools/search_files.rb +107 -0
  40. data/lib/crimson/tools/truncator.rb +94 -0
  41. data/lib/crimson/tools/write_file.rb +53 -0
  42. data/lib/crimson/trust_manager.rb +102 -0
  43. data/lib/crimson/version.rb +6 -0
  44. data/lib/crimson.rb +55 -0
  45. data/skills/coding.md +49 -0
  46. data/skills/debugging.md +32 -0
  47. data/skills/git.md +37 -0
  48. data/skills/planning.md +56 -0
  49. data/skills/refactoring.md +37 -0
  50. data/skills/research.md +37 -0
  51. data/skills/review.md +37 -0
  52. data/skills/security.md +42 -0
  53. data/skills/testing.md +37 -0
  54. data/skills/writing.md +43 -0
  55. metadata +294 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # Token counter using tiktoken when available, with fallback estimation.
5
+ class TokenCounter
6
+ # @param model [String, nil] model name for encoder selection
7
+ # @param provider [String, Symbol, nil] provider name
8
+ def initialize(model: nil, provider: nil)
9
+ @model = model
10
+ @provider = provider ? provider.to_sym : nil
11
+ @encoder = nil
12
+ @encoder_loaded = false
13
+ end
14
+
15
+ # Count tokens in a text string.
16
+ # @param text [String, nil]
17
+ # @return [Integer]
18
+ def count(text)
19
+ return 0 if text.nil? || text.empty?
20
+ encoder = load_encoder
21
+ return estimate(text) unless encoder
22
+ encoder.encode(text).length
23
+ rescue => e
24
+ estimate(text)
25
+ end
26
+
27
+ # Count total tokens for an array of messages, including per-message overhead.
28
+ # @param messages [Array<Message::Base>]
29
+ # @return [Integer]
30
+ def count_messages(messages)
31
+ total = 0
32
+ messages.each do |msg|
33
+ total += 4
34
+ total += count(msg.content.to_s)
35
+ if msg.respond_to?(:tool_calls) && msg.tool_calls
36
+ msg.tool_calls.each do |tc|
37
+ total += count(tc.name.to_s)
38
+ total += count(tc.arguments.to_s)
39
+ end
40
+ end
41
+ end
42
+ total
43
+ end
44
+
45
+ private
46
+
47
+ # @api private
48
+ def load_encoder
49
+ return @encoder if @encoder_loaded
50
+ @encoder_loaded = true
51
+
52
+ require "tiktoken_ruby"
53
+
54
+ @encoder = if openai_sdk?
55
+ load_openai_encoder
56
+ else
57
+ nil
58
+ end
59
+ rescue LoadError
60
+ @encoder = nil
61
+ rescue => e
62
+ @encoder = nil
63
+ end
64
+
65
+ # @api private
66
+ def openai_sdk?
67
+ return true if @provider.nil? && @model&.match?(/gpt|o1|o3|davinci|curie|babbage|ada/)
68
+ return false unless @provider
69
+ PROVIDERS.dig(@provider, :sdk) == :openai
70
+ end
71
+
72
+ # @api private
73
+ def load_openai_encoder
74
+ enc = @model ? ::Tiktoken.encoding_for_model(@model) : nil
75
+ enc || ::Tiktoken.get_encoding("cl100k_base")
76
+ end
77
+
78
+ # Fallback token estimation based on character count.
79
+ # @api private
80
+ def estimate(text)
81
+ (text.length / 4.0).ceil
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Crimson
6
+ # Central registry for tool registration, execution, and schema generation.
7
+ class ToolRegistry
8
+ def initialize
9
+ @tools = {}
10
+ @openai_defs = nil
11
+ @anthropic_defs = nil
12
+ end
13
+
14
+ # Register a tool module by its TOOL_NAME constant.
15
+ # @param tool_module [Module] a tool module with TOOL_NAME, .call, and .definition
16
+ # @return [void]
17
+ def register(tool_module)
18
+ name = tool_module.const_get(:TOOL_NAME)
19
+ @tools[name] = tool_module
20
+ @openai_defs = nil
21
+ @anthropic_defs = nil
22
+ end
23
+
24
+ # Execute a tool by name with the given arguments.
25
+ # @param tool_name [String]
26
+ # @param arguments [Hash, String] argument hash or JSON string
27
+ # @param abort_signal [AbortSignal, nil]
28
+ # @return [String] tool result (or error message prefixed with "Error")
29
+ def execute(tool_name, arguments, abort_signal: nil)
30
+ tool = @tools[tool_name]
31
+ return "Error: Unknown tool '#{tool_name}'" unless tool
32
+
33
+ args = if arguments.is_a?(String)
34
+ JSON.parse(arguments, symbolize_names: true)
35
+ else
36
+ arguments.transform_keys(&:to_sym)
37
+ end
38
+
39
+ if tool.respond_to?(:prepare_arguments)
40
+ begin
41
+ prepared = tool.prepare_arguments(args.transform_keys(&:to_s))
42
+ args = prepared.transform_keys(&:to_sym)
43
+ rescue => e
44
+ return "Error preparing arguments for #{tool_name}: #{e.message}"
45
+ end
46
+ end
47
+
48
+ result = if tool.respond_to?(:call_with_signal) && abort_signal
49
+ tool.call_with_signal(**args, signal: abort_signal)
50
+ else
51
+ tool.call(**args)
52
+ end
53
+
54
+ result = apply_truncation(tool_name, result)
55
+
56
+ result
57
+ rescue JSON::ParserError
58
+ "Error: Invalid JSON arguments for #{tool_name}"
59
+ rescue ArgumentError => e
60
+ "Error: Wrong arguments for #{tool_name}: #{e.message}"
61
+ rescue => e
62
+ "Error executing #{tool_name}: #{e.message}"
63
+ end
64
+
65
+ # @return [Array<Hash>] OpenAI-compatible tool definitions
66
+ def openai_definitions
67
+ @openai_defs ||= @tools.values.map(&:definition)
68
+ end
69
+
70
+ # @return [Array<Hash>] Anthropic-compatible tool definitions
71
+ def anthropic_definitions
72
+ @anthropic_defs ||= @tools.values.map(&:anthropic_definition)
73
+ end
74
+
75
+ # Look up a tool module by name.
76
+ # @param tool_name [String]
77
+ # @return [Module, nil]
78
+ def lookup(tool_name)
79
+ @tools[tool_name]
80
+ end
81
+
82
+ # @return [Array<String>] all registered tool names
83
+ def tool_names
84
+ @tools.keys
85
+ end
86
+
87
+ # Load all skill markdown files from a directory.
88
+ # @param skills_dir [String] directory path
89
+ # @return [String] concatenated skill content
90
+ def load_skills(skills_dir)
91
+ return "" unless Dir.exist?(skills_dir)
92
+
93
+ Dir.glob(File.join(skills_dir, "*.md")).sort.filter_map do |file|
94
+ File.read(file).strip
95
+ end.join("\n\n")
96
+ end
97
+
98
+ private
99
+
100
+ # @api private
101
+ def apply_truncation(tool_name, result)
102
+ return result unless result.is_a?(String)
103
+ return result if result.start_with?("Error")
104
+ return result if result.bytesize <= Tools::Truncator::DEFAULT_MAX_BYTES
105
+
106
+ truncation = Tools::Truncator.truncate(result)
107
+ output = truncation.text
108
+ output += "\n\n(full output saved to #{truncation.full_output_path})" if truncation.full_output_path
109
+ output
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+ require "pastel"
5
+
6
+ module Crimson
7
+ module Tools
8
+ # Diff utility using the diff/lcs library with ANSI-colored output.
9
+ module DiffUtil
10
+ # Produce a colored unified-format diff between two texts.
11
+ # @param old_text [String] original content
12
+ # @param new_text [String] new content
13
+ # @param path [String] file path for header display
14
+ # @return [String] ANSI-colored diff output
15
+ def self.format_diff(old_text, new_text, path)
16
+ pastel = Pastel.new
17
+ old_lines = old_text.lines.map(&:chomp)
18
+ new_lines = new_text.lines.map(&:chomp)
19
+
20
+ changes = Diff::LCS.sdiff(old_lines, new_lines)
21
+
22
+ output = []
23
+ output << pastel.dim("--- #{path}")
24
+ output << pastel.dim("+++ #{path}")
25
+
26
+ changes.each do |change|
27
+ case change.action
28
+ when "-"
29
+ output << pastel.red("- #{change.old_element}")
30
+ when "+"
31
+ output << pastel.green("+ #{change.new_element}")
32
+ when "!"
33
+ output << pastel.red("- #{change.old_element}")
34
+ output << pastel.green("+ #{change.new_element}")
35
+ when "="
36
+ output << pastel.dim(" #{change.old_element}")
37
+ end
38
+ end
39
+
40
+ output.join("\n")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Tools
5
+ # Edit files by replacing strings, with single and multi-edit modes.
6
+ # Handles BOM, CRLF/LF line endings, and produces diffs.
7
+ module EditFile
8
+ TOOL_NAME = "edit_file"
9
+
10
+ # Tool parameter definitions.
11
+ PARAMS = {
12
+ path: { type: "string", description: "The path to the file to edit" },
13
+ old_string: { type: "string", description: "The exact string to find and replace (single edit mode)" },
14
+ new_string: { type: "string", description: "The string to replace it with (single edit mode)" },
15
+ replace_all: { type: "boolean", description: "Replace all occurrences (default: false)" },
16
+ edits: {
17
+ type: "array",
18
+ description: "Array of edits for multiple replacements in one call. Each edit has old_string, new_string, and optional replace_all.",
19
+ items: {
20
+ type: "object",
21
+ properties: {
22
+ old_string: { type: "string", description: "The exact string to find" },
23
+ new_string: { type: "string", description: "The replacement string" },
24
+ replace_all: { type: "boolean", description: "Replace all occurrences (default: false)" }
25
+ },
26
+ required: %w[old_string new_string]
27
+ }
28
+ }
29
+ }.freeze
30
+
31
+ MUTATION_QUEUE = FileMutationQueue.new
32
+
33
+ # @api private
34
+ def self.prepare_arguments(args)
35
+ if args["edits"].is_a?(Array)
36
+ args["edits"].each { |e| e["replace_all"] = !!e["replace_all"] if e.key?("replace_all") }
37
+ end
38
+ args["replace_all"] = !!args["replace_all"] if args.key?("replace_all")
39
+ args
40
+ end
41
+
42
+ # @return [Hash] OpenAI-compatible tool definition
43
+ def self.definition
44
+ Schema.build(name: TOOL_NAME, description: "Replace strings in a file. Supports single edit or multiple edits in one call.", parameters: PARAMS, required: ["path"])
45
+ end
46
+
47
+ # @return [Hash] Anthropic-compatible tool definition
48
+ def self.anthropic_definition
49
+ Schema.build_anthropic(name: TOOL_NAME, description: "Replace strings in a file. Supports single edit or multiple edits in one call.", parameters: PARAMS, required: ["path"])
50
+ end
51
+
52
+ # Execute the tool.
53
+ # @param path [String] file path
54
+ # @param old_string [String, nil] text to find
55
+ # @param new_string [String, nil] replacement text
56
+ # @param replace_all [Boolean] replace all occurrences
57
+ # @param edits [Array<Hash>, nil] multiple edits
58
+ # @return [String] result message with diff or error
59
+ def self.call(path:, old_string: nil, new_string: nil, replace_all: false, edits: nil)
60
+ return "Error: No path provided" if path.nil? || path.strip.empty?
61
+
62
+ expanded = File.expand_path(path)
63
+
64
+ MUTATION_QUEUE.with_file(expanded) do
65
+ return "Error: File not found: #{path}" unless File.exist?(expanded)
66
+ return "Error: Not a file: #{path}" unless File.file?(expanded)
67
+
68
+ content = File.binread(expanded)
69
+ has_bom = content.start_with?("\xEF\xBB\xBF")
70
+ content = content.byteslice(3..) if has_bom
71
+ content = content.force_encoding("UTF-8")
72
+
73
+ line_ending = detect_line_ending(content)
74
+ content = content.gsub("\r\n", "\n") if line_ending == :crlf
75
+
76
+ old_content = content.dup
77
+
78
+ if edits.is_a?(Array) && !edits.empty?
79
+ count = 0
80
+ edits.each do |e|
81
+ result = apply_edit(content, e["old_string"], e["new_string"], e["replace_all"])
82
+ return result[:error] if result[:error]
83
+ content = result[:content]
84
+ count += result[:count]
85
+ end
86
+ elsif old_string
87
+ return "Error: No old_string provided" if old_string.nil? || old_string.empty?
88
+
89
+ result = apply_edit(content, old_string, new_string, replace_all)
90
+ return result[:error] if result[:error]
91
+
92
+ content = result[:content]
93
+ count = result[:count]
94
+ else
95
+ return "Error: Provide either old_string/new_string or edits array"
96
+ end
97
+
98
+ content = content.gsub("\n", "\r\n") if line_ending == :crlf
99
+ content = "\xEF\xBB\xBF#{content}" if has_bom
100
+
101
+ File.binwrite(expanded, content)
102
+
103
+ clean_old = old_content
104
+ clean_new = has_bom ? content.byteslice(3..) : content
105
+ diff = DiffUtil.format_diff(clean_old, clean_new, path)
106
+ "Successfully edited #{path} (#{count} replacement#{'s' if count != 1})\n#{diff}"
107
+ end
108
+ rescue => e
109
+ "Error editing file: #{e.message}"
110
+ end
111
+
112
+ class << self
113
+ private
114
+
115
+ # @api private
116
+ def apply_edit(content, old_string, new_string, replace_all = false)
117
+ return { error: "Error: old_string not provided" } if old_string.nil? || old_string.empty?
118
+ return { error: "Error: new_string not provided" } if new_string.nil?
119
+
120
+ count = content.scan(Regexp.escape(old_string)).length
121
+
122
+ if count == 0
123
+ return { error: "Error: old_string not found in file. Make sure it matches exactly." }
124
+ end
125
+
126
+ if !replace_all && count > 1
127
+ return { error: "Error: old_string found #{count} times. It must be unique, or use replace_all: true." }
128
+ end
129
+
130
+ new_content = replace_all ? content.gsub(old_string, new_string) : content.sub(old_string, new_string)
131
+ { content: new_content, count: count }
132
+ end
133
+
134
+ # @api private
135
+ def detect_line_ending(content)
136
+ crlf_pos = content.index("\r\n")
137
+ lf_pos = content.index("\n")
138
+ return :lf if lf_pos.nil?
139
+ return :lf if crlf_pos.nil?
140
+ crlf_pos < lf_pos ? :crlf : :lf
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Tools
5
+ # Per-file mutex queue to serialize write/edit operations on the same path.
6
+ class FileMutationQueue
7
+ def initialize
8
+ @queues = {}
9
+ @global_mutex = Mutex.new
10
+ end
11
+
12
+ # Execute a block with exclusive access to the given file.
13
+ # @param path [String] file path
14
+ # @yield block to run under the file's mutex
15
+ # @return [Object] the block's result
16
+ def with_file(path)
17
+ normalized = File.expand_path(path)
18
+ queue = @global_mutex.synchronize do
19
+ @queues[normalized] ||= Mutex.new
20
+ end
21
+
22
+ queue.synchronize { yield }
23
+ ensure
24
+ @global_mutex.synchronize do
25
+ @queues.delete(normalized) if queue && !queue.locked?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Tools
5
+ # Find files matching a glob pattern with configurable search root.
6
+ module Glob
7
+ TOOL_NAME = "glob"
8
+
9
+ # Tool parameter definitions.
10
+ PARAMS = {
11
+ pattern: { type: "string", description: "The glob pattern to match files against" },
12
+ path: { type: "string", description: "The directory to search in. Defaults to current directory." }
13
+ }.freeze
14
+
15
+ # @return [Hash] OpenAI-compatible tool definition
16
+ def self.definition
17
+ Schema.build(name: TOOL_NAME, description: "Find files matching a glob pattern (e.g. '**/*.rb', 'src/**/*.ts'). Returns sorted file paths.", parameters: PARAMS, required: ["pattern"])
18
+ end
19
+
20
+ # @return [Hash] Anthropic-compatible tool definition
21
+ def self.anthropic_definition
22
+ Schema.build_anthropic(name: TOOL_NAME, description: "Find files matching a glob pattern (e.g. '**/*.rb', 'src/**/*.ts'). Returns sorted file paths.", parameters: PARAMS, required: ["pattern"])
23
+ end
24
+
25
+ # Execute the tool.
26
+ # @param pattern [String] glob pattern
27
+ # @param path [String] search root (default ".")
28
+ # @return [String] sorted file paths or error
29
+ def self.call(pattern:, path: ".")
30
+ return "Error: No pattern provided" if pattern.nil? || pattern.strip.empty?
31
+
32
+ expanded = File.expand_path(path)
33
+ return "Error: Directory not found: #{path}" unless Dir.exist?(expanded)
34
+
35
+ files = Dir.glob(File.join(expanded, pattern)).sort
36
+
37
+ if files.empty?
38
+ "No files found matching pattern: #{pattern}"
39
+ elsif files.length > 200
40
+ "#{files.first(200).join("\n")}\n... (truncated, #{files.length - 200} more files)"
41
+ else
42
+ files.join("\n")
43
+ end
44
+ rescue => e
45
+ "Error searching files: #{e.message}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema"
4
+ require_relative "diff_util"
5
+ require_relative "truncator"
6
+ require_relative "file_mutation_queue"
7
+ require_relative "read_file"
8
+ require_relative "write_file"
9
+ require_relative "edit_file"
10
+ require_relative "list_directory"
11
+ require_relative "run_command"
12
+ require_relative "search_files"
13
+ require_relative "glob"
14
+
15
+ module Crimson
16
+ module Tools
17
+ # All built-in tool modules available for registration.
18
+ ALL = [ReadFile, WriteFile, EditFile, ListDirectory, RunCommand, SearchFiles, Glob].freeze
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Tools
5
+ # List files and directories at a given path, with trailing / for directories.
6
+ module ListDirectory
7
+ TOOL_NAME = "list_directory"
8
+
9
+ # Tool parameter definitions.
10
+ PARAMS = {
11
+ path: { type: "string", description: "The directory path to list. Defaults to current directory." }
12
+ }.freeze
13
+
14
+ # @return [Hash] OpenAI-compatible tool definition
15
+ def self.definition
16
+ Schema.build(name: TOOL_NAME, description: "List files and directories at the given path.", parameters: PARAMS, required: ["path"])
17
+ end
18
+
19
+ # @return [Hash] Anthropic-compatible tool definition
20
+ def self.anthropic_definition
21
+ Schema.build_anthropic(name: TOOL_NAME, description: "List files and directories at the given path.", parameters: PARAMS, required: ["path"])
22
+ end
23
+
24
+ # Execute the tool.
25
+ # @param path [String] directory path (defaults to ".")
26
+ # @return [String] sorted listing or error
27
+ def self.call(path: ".")
28
+ expanded = File.expand_path(path)
29
+ return "Error: Directory not found: #{path}" unless Dir.exist?(expanded)
30
+
31
+ entries = Dir.entries(expanded).sort - [".", ".."]
32
+
33
+ entries.map do |entry|
34
+ full_path = File.join(expanded, entry)
35
+ File.directory?(full_path) ? "#{entry}/" : entry
36
+ end.join("\n")
37
+ rescue => e
38
+ "Error listing directory: #{e.message}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Tools
5
+ # Read file contents with optional offset/limit for large files.
6
+ # Detects image and binary files and returns metadata instead of content.
7
+ module ReadFile
8
+ TOOL_NAME = "read_file"
9
+
10
+ # Extensions treated as viewable images.
11
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .ico .svg .tiff .tif].freeze
12
+ # Extensions treated as binary (not readable as text).
13
+ BINARY_EXTENSIONS = %w[.zip .tar .gz .bz2 .xz .7z .rar .exe .dll .so .dylib .o .a .class .jar .wasm .pdf .doc .docx .xls .xlsx .ppt .pptx .woff .woff2 .ttf .eot .otf .mp3 .mp4 .avi .mov .mkv .flac .ogg .wav].freeze
14
+
15
+ # Tool parameter definitions.
16
+ PARAMS = {
17
+ path: { type: "string", description: "The path to the file to read" },
18
+ offset: { type: "integer", description: "Line number to start reading from (1-indexed). Defaults to 1." },
19
+ limit: { type: "integer", description: "Maximum number of lines to read. Defaults to all lines." }
20
+ }.freeze
21
+
22
+ # @api private
23
+ def self.prepare_arguments(args)
24
+ args["offset"] = args["offset"].to_i if args["offset"]
25
+ args["limit"] = args["limit"].to_i if args["limit"]
26
+ args
27
+ end
28
+
29
+ # @return [Hash] OpenAI-compatible tool definition
30
+ def self.definition
31
+ Schema.build(name: TOOL_NAME, description: "Read the contents of a file. Supports offset/limit for reading portions of large files.", parameters: PARAMS, required: ["path"])
32
+ end
33
+
34
+ # @return [Hash] Anthropic-compatible tool definition
35
+ def self.anthropic_definition
36
+ Schema.build_anthropic(name: TOOL_NAME, description: "Read the contents of a file. Supports offset/limit for reading portions of large files.", parameters: PARAMS, required: ["path"])
37
+ end
38
+
39
+ # Execute the tool.
40
+ # @param path [String] file path
41
+ # @param offset [Integer, nil] starting line (1-indexed)
42
+ # @param limit [Integer, nil] max lines to read
43
+ # @return [String] file content or error message
44
+ def self.call(path:, offset: nil, limit: nil)
45
+ return "Error: No path provided" if path.nil? || path.strip.empty?
46
+
47
+ expanded = File.expand_path(path)
48
+ return "Error: File not found: #{path}" unless File.exist?(expanded)
49
+ return "Error: Not a file: #{path}" unless File.file?(expanded)
50
+
51
+ ext = File.extname(expanded).downcase
52
+ return describe_image(expanded, ext) if IMAGE_EXTENSIONS.include?(ext)
53
+ return describe_binary(expanded, ext) if BINARY_EXTENSIONS.include?(ext)
54
+
55
+ content = File.read(expanded)
56
+ lines = content.lines
57
+
58
+ if offset || limit
59
+ start_line = [(offset || 1) - 1, 0].max
60
+ end_line = limit ? start_line + limit : lines.length
61
+ end_line = [end_line, lines.length].min
62
+ total = lines.length
63
+
64
+ selected = lines[start_line...end_line]
65
+ numbered = selected.each_with_index.map do |line, i|
66
+ "#{start_line + i + 1}: #{line}"
67
+ end
68
+
69
+ "(lines #{start_line + 1}-#{end_line} of #{total})\n#{numbered.join}"
70
+ else
71
+ content
72
+ end
73
+ rescue => e
74
+ "Error reading file: #{e.message}"
75
+ end
76
+
77
+ # @api private
78
+ def self.describe_image(path, ext)
79
+ size = File.size(path)
80
+ size_str = size > 1_048_576 ? "#{(size / 1_048_576.0).round(1)}MB" : "#{(size / 1024.0).round(1)}KB"
81
+ "Image file: #{File.basename(path)} (#{ext}, #{size_str}). Image reading not yet supported."
82
+ end
83
+
84
+ # @api private
85
+ def self.describe_binary(path, ext)
86
+ size = File.size(path)
87
+ size_str = size > 1_048_576 ? "#{(size / 1_048_576.0).round(1)}MB" : "#{(size / 1024.0).round(1)}KB"
88
+ "Binary file: #{File.basename(path)} (#{ext}, #{size_str}). Cannot display binary content."
89
+ end
90
+ end
91
+ end
92
+ end