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,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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # Current version of the Crimson gem.
5
+ VERSION = "0.1.0"
6
+ 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.