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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/exe/crimson +207 -0
- data/lib/crimson/agent/event_emitter.rb +56 -0
- data/lib/crimson/agent/events.rb +43 -0
- data/lib/crimson/agent/steering.rb +91 -0
- data/lib/crimson/agent/tool_executor.rb +114 -0
- data/lib/crimson/agent.rb +564 -0
- data/lib/crimson/client/anthropic_adapter.rb +206 -0
- data/lib/crimson/client/base.rb +25 -0
- data/lib/crimson/client/factory.rb +27 -0
- data/lib/crimson/client/openai_adapter.rb +188 -0
- data/lib/crimson/compactor.rb +129 -0
- data/lib/crimson/config.rb +95 -0
- data/lib/crimson/cost_tracker.rb +62 -0
- data/lib/crimson/formatter.rb +93 -0
- data/lib/crimson/message.rb +177 -0
- data/lib/crimson/output_handler.rb +252 -0
- data/lib/crimson/project_context.rb +184 -0
- data/lib/crimson/providers.rb +49 -0
- data/lib/crimson/repl.rb +310 -0
- data/lib/crimson/retry_handler.rb +104 -0
- data/lib/crimson/session_entry.rb +145 -0
- data/lib/crimson/session_manager.rb +219 -0
- data/lib/crimson/setup.rb +134 -0
- data/lib/crimson/skill_router.rb +165 -0
- data/lib/crimson/token_counter.rb +84 -0
- data/lib/crimson/tool_registry.rb +112 -0
- data/lib/crimson/tools/diff_util.rb +44 -0
- data/lib/crimson/tools/edit_file.rb +145 -0
- data/lib/crimson/tools/file_mutation_queue.rb +30 -0
- data/lib/crimson/tools/glob.rb +49 -0
- data/lib/crimson/tools/index.rb +20 -0
- data/lib/crimson/tools/list_directory.rb +42 -0
- data/lib/crimson/tools/read_file.rb +92 -0
- data/lib/crimson/tools/run_command.rb +138 -0
- data/lib/crimson/tools/schema.rb +60 -0
- data/lib/crimson/tools/search_files.rb +107 -0
- data/lib/crimson/tools/truncator.rb +94 -0
- data/lib/crimson/tools/write_file.rb +53 -0
- data/lib/crimson/trust_manager.rb +102 -0
- data/lib/crimson/version.rb +6 -0
- data/lib/crimson.rb +55 -0
- data/skills/coding.md +49 -0
- data/skills/debugging.md +32 -0
- data/skills/git.md +37 -0
- data/skills/planning.md +56 -0
- data/skills/refactoring.md +37 -0
- data/skills/research.md +37 -0
- data/skills/review.md +37 -0
- data/skills/security.md +42 -0
- data/skills/testing.md +37 -0
- data/skills/writing.md +43 -0
- metadata +294 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
module Tools
|
|
8
|
+
# Execute shell commands with timeout, streaming output, and abort support.
|
|
9
|
+
module RunCommand
|
|
10
|
+
TOOL_NAME = "run_command"
|
|
11
|
+
# This tool must run sequentially (not parallel).
|
|
12
|
+
EXECUTION_MODE = :sequential
|
|
13
|
+
|
|
14
|
+
# Tool parameter definitions.
|
|
15
|
+
PARAMS = {
|
|
16
|
+
command: { type: "string", description: "The shell command to execute" },
|
|
17
|
+
timeout: { type: "integer", description: "Timeout in seconds (default: 30)" }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
@update_callback = nil
|
|
21
|
+
@callback_mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Register a callback for streaming execution updates.
|
|
25
|
+
# @param callback [Proc, nil]
|
|
26
|
+
def on_update=(callback)
|
|
27
|
+
@callback_mutex.synchronize { @update_callback = callback }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Proc, nil] current update callback
|
|
31
|
+
def on_update
|
|
32
|
+
@callback_mutex.synchronize { @update_callback }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
37
|
+
def self.definition
|
|
38
|
+
Schema.build(name: TOOL_NAME, description: "Execute a shell command and return stdout and stderr.", parameters: PARAMS, required: ["command"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
42
|
+
def self.anthropic_definition
|
|
43
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Execute a shell command and return stdout and stderr.", parameters: PARAMS, required: ["command"])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Execute a command without abort signal support.
|
|
47
|
+
# @param command [String] shell command
|
|
48
|
+
# @param timeout [Integer] timeout in seconds
|
|
49
|
+
# @return [String] command output or error
|
|
50
|
+
def self.call(command:, timeout: 30)
|
|
51
|
+
call_with_signal(command: command, timeout: timeout, signal: nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Execute a command with abort signal support.
|
|
55
|
+
# @param command [String] shell command
|
|
56
|
+
# @param timeout [Integer] timeout in seconds
|
|
57
|
+
# @param signal [AbortSignal, nil]
|
|
58
|
+
# @return [String] command output or error
|
|
59
|
+
def self.call_with_signal(command:, timeout: 30, signal: nil)
|
|
60
|
+
return "Error: No command provided" if command.nil? || command.strip.empty?
|
|
61
|
+
|
|
62
|
+
stdout = String.new
|
|
63
|
+
stderr = String.new
|
|
64
|
+
status = nil
|
|
65
|
+
start_time = Time.now
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
Timeout.timeout(timeout) do
|
|
69
|
+
Open3.popen3(command) do |stdin, out, err, wait_thr|
|
|
70
|
+
stdin.close
|
|
71
|
+
|
|
72
|
+
abort_thread = if signal
|
|
73
|
+
Thread.new do
|
|
74
|
+
sleep 0.1 until signal.aborted? || !wait_thr.status
|
|
75
|
+
if signal.aborted? && wait_thr.pid
|
|
76
|
+
begin
|
|
77
|
+
Process.kill("TERM", wait_thr.pid)
|
|
78
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
readers = [out, err]
|
|
85
|
+
while readers.any?
|
|
86
|
+
ready = IO.select(readers, nil, nil, 0.1)
|
|
87
|
+
next unless ready
|
|
88
|
+
|
|
89
|
+
ready[0].each do |io|
|
|
90
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
91
|
+
if chunk == :wait_readable || chunk.nil?
|
|
92
|
+
readers.delete(io) if io.eof?
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
if io == out
|
|
96
|
+
stdout << chunk
|
|
97
|
+
else
|
|
98
|
+
stderr << chunk
|
|
99
|
+
end
|
|
100
|
+
elapsed = Time.now - start_time
|
|
101
|
+
cb = on_update
|
|
102
|
+
cb&.call(command, elapsed, stdout.length + stderr.length)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
status = wait_thr.value
|
|
107
|
+
abort_thread&.kill
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
output = String.new
|
|
112
|
+
output << stdout if !stdout.empty?
|
|
113
|
+
output << stderr if !stderr.empty?
|
|
114
|
+
|
|
115
|
+
output = strip_ansi_codes(output)
|
|
116
|
+
output = String.new("(no output)") if output.strip.empty?
|
|
117
|
+
if status.success?
|
|
118
|
+
# No exit code line needed for success
|
|
119
|
+
elsif status.exitstatus
|
|
120
|
+
output << "\n(exit code: #{status.exitstatus})"
|
|
121
|
+
else
|
|
122
|
+
output << "\n(process killed)"
|
|
123
|
+
end
|
|
124
|
+
output
|
|
125
|
+
rescue Timeout::Error
|
|
126
|
+
"Error: Command timed out after #{timeout} seconds"
|
|
127
|
+
rescue => e
|
|
128
|
+
"Error executing command: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def self.strip_ansi_codes(text)
|
|
134
|
+
text.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# Helper for building OpenAI and Anthropic tool definition schemas.
|
|
6
|
+
module Schema
|
|
7
|
+
# Build an OpenAI-compatible tool definition.
|
|
8
|
+
# @param name [String]
|
|
9
|
+
# @param description [String]
|
|
10
|
+
# @param parameters [Hash] JSON Schema properties
|
|
11
|
+
# @param required [Array<String>]
|
|
12
|
+
# @return [Hash]
|
|
13
|
+
def self.build(name:, description:, parameters:, required:)
|
|
14
|
+
{
|
|
15
|
+
type: "function",
|
|
16
|
+
function: {
|
|
17
|
+
name: name,
|
|
18
|
+
description: description,
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: parameters,
|
|
22
|
+
required: required
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Build an Anthropic-compatible tool definition.
|
|
29
|
+
# @param name [String]
|
|
30
|
+
# @param description [String]
|
|
31
|
+
# @param parameters [Hash] JSON Schema properties
|
|
32
|
+
# @param required [Array<String>]
|
|
33
|
+
# @return [Hash]
|
|
34
|
+
def self.build_anthropic(name:, description:, parameters:, required:)
|
|
35
|
+
{
|
|
36
|
+
name: name,
|
|
37
|
+
description: description,
|
|
38
|
+
input_schema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: parameters,
|
|
41
|
+
required: required
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build both OpenAI and Anthropic definitions at once.
|
|
47
|
+
# @param name [String]
|
|
48
|
+
# @param description [String]
|
|
49
|
+
# @param parameters [Hash]
|
|
50
|
+
# @param required [Array<String>]
|
|
51
|
+
# @return [Hash] with keys :openai and :anthropic
|
|
52
|
+
def self.definitions_for(name:, description:, parameters:, required:)
|
|
53
|
+
{
|
|
54
|
+
openai: build(name: name, description: description, parameters: parameters, required: required),
|
|
55
|
+
anthropic: build_anthropic(name: name, description: description, parameters: parameters, required: required)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
module Tools
|
|
7
|
+
# Search files for regex patterns using ripgrep (preferred) or grep fallback.
|
|
8
|
+
module SearchFiles
|
|
9
|
+
TOOL_NAME = "search_files"
|
|
10
|
+
|
|
11
|
+
# Tool parameter definitions.
|
|
12
|
+
PARAMS = {
|
|
13
|
+
pattern: { type: "string", description: "The regex pattern to search for" },
|
|
14
|
+
path: { type: "string", description: "The directory to search in. Defaults to current directory." },
|
|
15
|
+
file_pattern: { type: "string", description: "Glob pattern to filter files (e.g. '*.rb'). Defaults to all files." },
|
|
16
|
+
context_lines: { type: "integer", description: "Number of context lines to show around each match (default: 0)" }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Whether ripgrep is available on this system.
|
|
20
|
+
RG_AVAILABLE = system("which rg > /dev/null 2>&1")
|
|
21
|
+
|
|
22
|
+
# @api private
|
|
23
|
+
def self.prepare_arguments(args)
|
|
24
|
+
args["context_lines"] = args["context_lines"].to_i if args["context_lines"]
|
|
25
|
+
args
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
29
|
+
def self.definition
|
|
30
|
+
Schema.build(name: TOOL_NAME, description: "Search for a regex pattern in files. Returns matching file paths, line numbers, and context.", parameters: PARAMS, required: ["pattern"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
34
|
+
def self.anthropic_definition
|
|
35
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Search for a regex pattern in files. Returns matching file paths, line numbers, and context.", parameters: PARAMS, required: ["pattern"])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Execute the tool.
|
|
39
|
+
# @param pattern [String] regex pattern
|
|
40
|
+
# @param path [String] directory to search (default ".")
|
|
41
|
+
# @param file_pattern [String, nil] glob to filter files
|
|
42
|
+
# @param context_lines [Integer] lines of context around matches
|
|
43
|
+
# @return [String] search results or error
|
|
44
|
+
def self.call(pattern:, path: ".", file_pattern: nil, context_lines: 0)
|
|
45
|
+
return "Error: No pattern provided" if pattern.nil? || pattern.strip.empty?
|
|
46
|
+
|
|
47
|
+
expanded = File.expand_path(path)
|
|
48
|
+
context = [context_lines, 5].min
|
|
49
|
+
|
|
50
|
+
if RG_AVAILABLE
|
|
51
|
+
search_with_rg(pattern, expanded, file_pattern, context)
|
|
52
|
+
else
|
|
53
|
+
search_with_grep(pattern, expanded, file_pattern, context)
|
|
54
|
+
end
|
|
55
|
+
rescue => e
|
|
56
|
+
"Error searching files: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
def search_with_rg(pattern, path, file_pattern, context)
|
|
64
|
+
cmd = ["rg", "--no-heading", "--line-number", "--color=never"]
|
|
65
|
+
cmd << "-C" << context.to_s if context > 0
|
|
66
|
+
cmd += ["--glob", "!{.git,node_modules,vendor,.bundle,tmp,log}"]
|
|
67
|
+
cmd += ["--glob", file_pattern] if file_pattern
|
|
68
|
+
cmd << "--max-count" << "500"
|
|
69
|
+
cmd << pattern << path
|
|
70
|
+
|
|
71
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
72
|
+
|
|
73
|
+
return "No matches found." if status.exitstatus == 1
|
|
74
|
+
return "Error: #{stderr}" unless status.success? || status.exitstatus == 2
|
|
75
|
+
|
|
76
|
+
truncate_output(stdout)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
def search_with_grep(pattern, path, file_pattern, context)
|
|
81
|
+
cmd = ["grep", "-rn", "--color=never", "-E"]
|
|
82
|
+
cmd << "-C" << context.to_s if context > 0
|
|
83
|
+
cmd += ["--exclude-dir=.git", "--exclude-dir=node_modules", "--exclude-dir=vendor"]
|
|
84
|
+
cmd += ["--exclude-dir=.bundle", "--exclude-dir=tmp", "--exclude-dir=log"]
|
|
85
|
+
cmd += ["--include=#{file_pattern}"] if file_pattern
|
|
86
|
+
cmd << "-m" << "500" if context == 0
|
|
87
|
+
cmd << pattern << path
|
|
88
|
+
|
|
89
|
+
stdout, _stderr, status = Open3.capture3(*cmd)
|
|
90
|
+
|
|
91
|
+
return "No matches found." if status.exitstatus == 1
|
|
92
|
+
truncate_output(stdout)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @api private
|
|
96
|
+
def truncate_output(output)
|
|
97
|
+
lines = output.lines
|
|
98
|
+
if lines.length > 200
|
|
99
|
+
"#{lines.first(200).join}\n... (truncated, #{lines.length - 200} more lines)"
|
|
100
|
+
else
|
|
101
|
+
output
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
module Tools
|
|
7
|
+
# Truncates large text output by line count, line length, and byte size.
|
|
8
|
+
# Saves full output to a temp file when truncation occurs.
|
|
9
|
+
module Truncator
|
|
10
|
+
# Default maximum output size in bytes.
|
|
11
|
+
DEFAULT_MAX_BYTES = 100_000
|
|
12
|
+
# Default maximum number of lines.
|
|
13
|
+
DEFAULT_MAX_LINES = 2000
|
|
14
|
+
# Default maximum length per line.
|
|
15
|
+
DEFAULT_MAX_LINE_LENGTH = 2000
|
|
16
|
+
|
|
17
|
+
# Truncation result struct.
|
|
18
|
+
# @!attribute [r] text
|
|
19
|
+
# @return [String] truncated text
|
|
20
|
+
# @!attribute [r] was_truncated
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
# @!attribute [r] full_output_path
|
|
23
|
+
# @return [String, nil] path to full output temp file
|
|
24
|
+
# @!attribute [r] original_size
|
|
25
|
+
# @return [Integer] original byte size
|
|
26
|
+
Result = Struct.new(:text, :was_truncated, :full_output_path, :original_size, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# Truncate text by line length, line count, and byte size limits.
|
|
29
|
+
# @param text [String, nil]
|
|
30
|
+
# @param max_bytes [Integer]
|
|
31
|
+
# @param max_lines [Integer]
|
|
32
|
+
# @param max_line_length [Integer]
|
|
33
|
+
# @return [Result]
|
|
34
|
+
def self.truncate(text, max_bytes: DEFAULT_MAX_BYTES, max_lines: DEFAULT_MAX_LINES, max_line_length: DEFAULT_MAX_LINE_LENGTH)
|
|
35
|
+
return Result.new(text: text, was_truncated: false, full_output_path: nil, original_size: 0) if text.nil? || text.empty?
|
|
36
|
+
|
|
37
|
+
original_size = text.bytesize
|
|
38
|
+
truncated = false
|
|
39
|
+
full_output_path = nil
|
|
40
|
+
|
|
41
|
+
lines = text.lines
|
|
42
|
+
if lines.any? { |l| l.chomp.length > max_line_length }
|
|
43
|
+
lines = lines.map do |line|
|
|
44
|
+
if line.chomp.length > max_line_length
|
|
45
|
+
line.chomp[0...max_line_length] + "... (line truncated)\n"
|
|
46
|
+
else
|
|
47
|
+
line
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
truncated = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if lines.length > max_lines
|
|
54
|
+
kept = lines.first(max_lines)
|
|
55
|
+
remaining = lines.length - max_lines
|
|
56
|
+
kept << "\n... (#{remaining} more lines, output truncated)\n"
|
|
57
|
+
lines = kept
|
|
58
|
+
truncated = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = lines.join
|
|
62
|
+
|
|
63
|
+
if result.bytesize > max_bytes
|
|
64
|
+
byte_limit = max_bytes - 100
|
|
65
|
+
result = result.byteslice(0, byte_limit) + "\n... (output truncated by size)\n"
|
|
66
|
+
truncated = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if truncated && original_size > max_bytes
|
|
70
|
+
full_output_path = save_full_output(text)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Result.new(
|
|
74
|
+
text: result,
|
|
75
|
+
was_truncated: truncated,
|
|
76
|
+
full_output_path: full_output_path,
|
|
77
|
+
original_size: original_size
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Save full text to a temp file and return its path.
|
|
82
|
+
# @api private
|
|
83
|
+
def self.save_full_output(text)
|
|
84
|
+
file = Tempfile.new(["crimson-output-", ".log"])
|
|
85
|
+
file.binmode
|
|
86
|
+
file.write(text)
|
|
87
|
+
file.close
|
|
88
|
+
file.path
|
|
89
|
+
rescue => e
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
module Tools
|
|
7
|
+
# Write content to a file, creating parent directories and showing a diff.
|
|
8
|
+
module WriteFile
|
|
9
|
+
TOOL_NAME = "write_file"
|
|
10
|
+
|
|
11
|
+
# Tool parameter definitions.
|
|
12
|
+
PARAMS = {
|
|
13
|
+
path: { type: "string", description: "The path to the file to write" },
|
|
14
|
+
content: { type: "string", description: "The content to write" }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
MUTATION_QUEUE = FileMutationQueue.new
|
|
18
|
+
|
|
19
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
20
|
+
def self.definition
|
|
21
|
+
Schema.build(name: TOOL_NAME, description: "Write content to a file. Creates the file and parent directories if needed.", parameters: PARAMS, required: %w[path content])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
25
|
+
def self.anthropic_definition
|
|
26
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Write content to a file. Creates the file and parent directories if needed.", parameters: PARAMS, required: %w[path content])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execute the tool.
|
|
30
|
+
# @param path [String] file path
|
|
31
|
+
# @param content [String] content to write
|
|
32
|
+
# @return [String] result message with diff or error
|
|
33
|
+
def self.call(path:, content:)
|
|
34
|
+
return "Error: No path provided" if path.nil? || path.strip.empty?
|
|
35
|
+
|
|
36
|
+
expanded = File.expand_path(path)
|
|
37
|
+
|
|
38
|
+
MUTATION_QUEUE.with_file(expanded) do
|
|
39
|
+
dir = File.dirname(expanded)
|
|
40
|
+
old_content = File.exist?(expanded) ? File.read(expanded) : nil
|
|
41
|
+
|
|
42
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
43
|
+
File.write(expanded, content)
|
|
44
|
+
|
|
45
|
+
diff = DiffUtil.format_diff(old_content || "", content, path)
|
|
46
|
+
"Successfully wrote to #{path}\n#{diff}"
|
|
47
|
+
end
|
|
48
|
+
rescue => e
|
|
49
|
+
"Error writing file: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
# Manages directory trust state for loading project context files.
|
|
8
|
+
# Persists trust decisions to a JSON file and prompts users for untrusted directories.
|
|
9
|
+
class TrustManager
|
|
10
|
+
# File names considered as project context files.
|
|
11
|
+
CONTEXT_FILE_NAMES = %w[
|
|
12
|
+
AGENTS.md AGENTS.MD
|
|
13
|
+
CLAUDE.md CLAUDE.MD
|
|
14
|
+
GEMINI.md GEMINI.MD
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# @param trust_file [String, nil] path to the trust JSON file
|
|
18
|
+
def initialize(trust_file: nil)
|
|
19
|
+
@trust_file = trust_file || File.join(Crimson::CONFIG_DIR, "trust.json")
|
|
20
|
+
@trust_data = load_trust_data
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check whether a directory (or an ancestor) is trusted.
|
|
24
|
+
# @param cwd [String] directory path
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def trusted?(cwd)
|
|
27
|
+
entry = find_nearest_trust(File.expand_path(cwd))
|
|
28
|
+
entry == true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Prompt the user to trust a directory that has context files.
|
|
32
|
+
# @param cwd [String] directory path
|
|
33
|
+
# @return [Boolean] whether the directory is now trusted
|
|
34
|
+
def prompt_trust(cwd)
|
|
35
|
+
expanded = File.expand_path(cwd)
|
|
36
|
+
return true unless has_context_files?(expanded)
|
|
37
|
+
return true if trusted?(expanded)
|
|
38
|
+
|
|
39
|
+
prompt = TTY::Prompt.new
|
|
40
|
+
puts
|
|
41
|
+
choice = prompt.select("Trust this project?\n#{expanded}\n\nThis allows loading AGENTS.md and project settings.", [
|
|
42
|
+
{ name: "Trust", value: :trust },
|
|
43
|
+
{ name: "Trust parent folder (#{File.dirname(expanded)})", value: :trust_parent },
|
|
44
|
+
{ name: "Trust (this session only)", value: :session_only },
|
|
45
|
+
{ name: "Don't trust", value: :deny }
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
case choice
|
|
49
|
+
when :trust
|
|
50
|
+
save_trust(expanded, true)
|
|
51
|
+
true
|
|
52
|
+
when :trust_parent
|
|
53
|
+
save_trust(File.dirname(expanded), true)
|
|
54
|
+
save_trust(expanded, nil)
|
|
55
|
+
true
|
|
56
|
+
when :session_only
|
|
57
|
+
true
|
|
58
|
+
when :deny
|
|
59
|
+
save_trust(expanded, false)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check whether a directory contains any project context files.
|
|
65
|
+
# @param dir [String] directory path
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def has_context_files?(dir)
|
|
68
|
+
CONTEXT_FILE_NAMES.any? { |name| File.exist?(File.join(dir, name)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def load_trust_data
|
|
75
|
+
return {} unless File.exist?(@trust_file)
|
|
76
|
+
JSON.parse(File.read(@trust_file))
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @api private
|
|
82
|
+
def save_trust(path, decision)
|
|
83
|
+
@trust_data[File.expand_path(path)] = decision
|
|
84
|
+
FileUtils.mkdir_p(File.dirname(@trust_file))
|
|
85
|
+
File.write(@trust_file, JSON.pretty_generate(@trust_data))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Walk up the directory tree looking for a trust decision.
|
|
89
|
+
# @api private
|
|
90
|
+
def find_nearest_trust(dir)
|
|
91
|
+
loop do
|
|
92
|
+
value = @trust_data[dir]
|
|
93
|
+
return value if value == true || value == false
|
|
94
|
+
|
|
95
|
+
parent = File.dirname(dir)
|
|
96
|
+
break if parent == dir
|
|
97
|
+
dir = parent
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/crimson.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "crimson/version"
|
|
4
|
+
require_relative "crimson/config"
|
|
5
|
+
require_relative "crimson/providers"
|
|
6
|
+
require_relative "crimson/message"
|
|
7
|
+
require_relative "crimson/tools/index"
|
|
8
|
+
require_relative "crimson/tool_registry"
|
|
9
|
+
require_relative "crimson/skill_router"
|
|
10
|
+
require_relative "crimson/formatter"
|
|
11
|
+
require_relative "crimson/client/base"
|
|
12
|
+
require_relative "crimson/client/factory"
|
|
13
|
+
require_relative "crimson/agent/event_emitter"
|
|
14
|
+
require_relative "crimson/agent/events"
|
|
15
|
+
require_relative "crimson/agent/steering"
|
|
16
|
+
require_relative "crimson/agent/tool_executor"
|
|
17
|
+
require_relative "crimson/agent"
|
|
18
|
+
require_relative "crimson/output_handler"
|
|
19
|
+
require_relative "crimson/repl"
|
|
20
|
+
require_relative "crimson/setup"
|
|
21
|
+
require_relative "crimson/project_context"
|
|
22
|
+
require_relative "crimson/session_entry"
|
|
23
|
+
require_relative "crimson/session_manager"
|
|
24
|
+
require_relative "crimson/cost_tracker"
|
|
25
|
+
require_relative "crimson/compactor"
|
|
26
|
+
require_relative "crimson/retry_handler"
|
|
27
|
+
require_relative "crimson/token_counter"
|
|
28
|
+
require_relative "crimson/trust_manager"
|
|
29
|
+
|
|
30
|
+
module Crimson
|
|
31
|
+
# Base error class for Crimson-specific errors.
|
|
32
|
+
class Error < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Directory for Crimson configuration files.
|
|
35
|
+
CONFIG_DIR = File.join(Dir.home, ".crimson")
|
|
36
|
+
# Path to the JSON configuration file.
|
|
37
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
|
|
38
|
+
# Directory for user skill markdown files.
|
|
39
|
+
SKILLS_DIR = File.join(CONFIG_DIR, "skills")
|
|
40
|
+
|
|
41
|
+
# @return [Config] the global configuration (loaded once, cached)
|
|
42
|
+
def self.config
|
|
43
|
+
@config ||= Crimson::Config.load
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] path to the config directory
|
|
47
|
+
def self.config_dir
|
|
48
|
+
CONFIG_DIR
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether the config file exists
|
|
52
|
+
def self.configured?
|
|
53
|
+
File.exist?(CONFIG_FILE)
|
|
54
|
+
end
|
|
55
|
+
end
|
data/skills/coding.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
domain: base
|
|
3
|
+
triggers: []
|
|
4
|
+
priority: 0
|
|
5
|
+
auto_inject: false
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are Crimson, a minimal coding agent. You help users with software engineering tasks.
|
|
9
|
+
|
|
10
|
+
## Core Principles
|
|
11
|
+
|
|
12
|
+
- Be concise and direct. Avoid unnecessary explanations.
|
|
13
|
+
- Only read files when the user explicitly asks you to or when you need to edit them.
|
|
14
|
+
- Do not explore the codebase unless asked.
|
|
15
|
+
- When making changes, prefer editing existing files over creating new ones.
|
|
16
|
+
- Always verify your changes work by suggesting the user run tests or lint commands.
|
|
17
|
+
|
|
18
|
+
## Available Tools
|
|
19
|
+
|
|
20
|
+
You have access to the following tools:
|
|
21
|
+
|
|
22
|
+
- `read_file` - Read the contents of a file
|
|
23
|
+
- `write_file` - Write content to a file (creates or overwrites)
|
|
24
|
+
- `edit_file` - Edit a file with targeted string replacement
|
|
25
|
+
- `list_directory` - List files and directories
|
|
26
|
+
- `run_command` - Execute a shell command
|
|
27
|
+
- `search_files` - Search for patterns in files using grep
|
|
28
|
+
- `glob` - Find files by pattern
|
|
29
|
+
|
|
30
|
+
## Workflow
|
|
31
|
+
|
|
32
|
+
1. Answer simple questions directly without reading files.
|
|
33
|
+
2. Only read files when you need to edit them or when the user asks.
|
|
34
|
+
3. Make targeted, minimal changes.
|
|
35
|
+
4. Verify changes by running relevant commands (tests, linters, etc.).
|
|
36
|
+
|
|
37
|
+
## Code Style
|
|
38
|
+
|
|
39
|
+
- Match the existing code style in the file you are editing.
|
|
40
|
+
- Use the same indentation, naming conventions, and import patterns.
|
|
41
|
+
- Do not introduce new dependencies unless the user asks.
|
|
42
|
+
- Prefer standard library over external gems/packages when possible.
|
|
43
|
+
|
|
44
|
+
## Guidelines
|
|
45
|
+
|
|
46
|
+
- Never commit changes unless explicitly asked.
|
|
47
|
+
- Never expose or log secrets, API keys, or tokens.
|
|
48
|
+
- Work within the current working directory unless specified otherwise.
|
|
49
|
+
- If you are unsure about something, ask the user for clarification.
|