compose 0.0.0.0 → 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +38 -0
- data/bin/compose +6 -0
- data/config/models.yml +20 -0
- data/config/prompts/ask.txt +12 -0
- data/config/prompts/task.txt +30 -0
- data/config/prompts/verify.txt +33 -0
- data/lib/compose/ai_client.rb +63 -0
- data/lib/compose/api_key_utils.rb +28 -0
- data/lib/compose/cli.rb +57 -0
- data/lib/compose/edit_processor.rb +155 -0
- data/lib/compose/edit_verifier.rb +51 -0
- data/lib/compose/file_processor.rb +101 -0
- data/lib/compose/model.rb +25 -0
- data/lib/compose/version.rb +5 -0
- data/lib/compose.rb +18 -0
- metadata +246 -44
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d36eebaf01309fb4c0762dcbc6665e48704c47efdcc53d705a959eca780cbc2f
|
4
|
+
data.tar.gz: d109de09f9b08947aa5b17a3f67ce4e050854cc123a69d4ae95c791586e0e719
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 657a0b56b8e25976c901be35c47d531947f524eef84e2696d9ebd0cdea8468b19ec1517b3eca803d49d4dac88e4a37b819695d3a8226bb323171fdeff8d38dd8
|
7
|
+
data.tar.gz: 72e63f67707c80f0287d5faab11e611e08f4910640e02be9c570738a11bfa939f4adba9c64a2ecd1cf9684c06fcdd5327f181fd1ebce40d2e1bde61ceee8c70e
|
data/README.md
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# Compose
|
2
|
+
|
3
|
+
Compose is a Ruby gem that provides AI-assisted code editing capabilities.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- Edit and create multiple files with a single prompt
|
8
|
+
- Ask questions about the codebase
|
9
|
+
- Uses multiple AI models (Claude and GPT)
|
10
|
+
- Verify diffs before applying changes
|
11
|
+
- Revert changes with `compose revert`
|
12
|
+
- Works with any codebase
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
```
|
17
|
+
$ gem install compose
|
18
|
+
```
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Run Compose with:
|
23
|
+
|
24
|
+
```
|
25
|
+
compose [FILES] [OPTIONS]
|
26
|
+
```
|
27
|
+
|
28
|
+
- `[FILES]`: Paths to individual files or folders you want to process
|
29
|
+
- `--model MODEL`: (Optional) AI model to use (e.g., 'claude-3-5-sonnet', 'gpt-4-turbo')
|
30
|
+
- `--include-imports`, `-a`: Include import statements when processing files
|
31
|
+
|
32
|
+
## Contributing
|
33
|
+
|
34
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/compose.
|
35
|
+
|
36
|
+
## License
|
37
|
+
|
38
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/bin/compose
ADDED
data/config/models.yml
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
models:
|
2
|
+
- name: claude-3-5-sonnet-20240620
|
3
|
+
provider: anthropic
|
4
|
+
nickname: sonnet35
|
5
|
+
output_cpm: 15
|
6
|
+
input_cpm: 3
|
7
|
+
output_length: 8092
|
8
|
+
context_window: 200000
|
9
|
+
verify_model: false
|
10
|
+
|
11
|
+
- name: gpt-4o-mini
|
12
|
+
provider: openai
|
13
|
+
nickname: 4omini
|
14
|
+
output_cpm: 0.6
|
15
|
+
input_cpm: 0.15
|
16
|
+
output_length: 16384
|
17
|
+
context_window: 128000
|
18
|
+
verify_model: true
|
19
|
+
|
20
|
+
preferred_verifier_model: 4omini
|
@@ -0,0 +1,30 @@
|
|
1
|
+
Task: {{TASK}}
|
2
|
+
|
3
|
+
Follow this spec and return ONLY VALID JSON to suggest additions or replacements in files to make this change in this codebase.
|
4
|
+
|
5
|
+
Facts:
|
6
|
+
1. You can provide a new filename to create a file.
|
7
|
+
2. Leave toLine empty for additions.
|
8
|
+
3. Make sure the code snippet in the edit is complete. Feel free to make multiple edits, avoid repeating existing code if you can.
|
9
|
+
4. Ensure the line numbers are accurate. Feel free to repeat existing code from previous or after lines to be sure.
|
10
|
+
|
11
|
+
```json
|
12
|
+
{
|
13
|
+
"explain": string; // explain what you want to do and why you're making this change.
|
14
|
+
"filename": string;
|
15
|
+
"change":
|
16
|
+
| {
|
17
|
+
"type": "addition";
|
18
|
+
"atLine": number;
|
19
|
+
}
|
20
|
+
| {
|
21
|
+
"type": "replacement";
|
22
|
+
"fromLineNumber": number;
|
23
|
+
"toLineNumber": number;
|
24
|
+
};
|
25
|
+
"code": string; // Code to insert or replace, make sure " are double escaped.
|
26
|
+
}
|
27
|
+
```
|
28
|
+
|
29
|
+
Respond with a valid JSON object with the key 'edits' which contains an array
|
30
|
+
of edit objects following the spec above.
|
@@ -0,0 +1,33 @@
|
|
1
|
+
Edits:
|
2
|
+
```json
|
3
|
+
{{EDIT}}
|
4
|
+
```
|
5
|
+
|
6
|
+
Above is an edit to the provided code.
|
7
|
+
Verify this change:
|
8
|
+
|
9
|
+
```json
|
10
|
+
{{CHANGE}}
|
11
|
+
```
|
12
|
+
|
13
|
+
and make sure it's needed and it's only replacing the correct lines, or adding the code to the correct place. Feel free to change additions to replacements or vice versa, or skip edits if they're not needed.
|
14
|
+
Return only the fixed change object following this JSON spec:
|
15
|
+
|
16
|
+
```json
|
17
|
+
{
|
18
|
+
"reason": string; // Explain why this change was made, write 'no change' if there's no need for a change
|
19
|
+
"type": "addition";
|
20
|
+
"atLine": number;
|
21
|
+
} | {
|
22
|
+
"reason": string;
|
23
|
+
"type": "replacement";
|
24
|
+
"fromLineNumber": number;
|
25
|
+
"toLineNumber": number;
|
26
|
+
} | {
|
27
|
+
"reason": string;
|
28
|
+
"type": "skip" // Means to skip this edit, no need to apply
|
29
|
+
};
|
30
|
+
```
|
31
|
+
|
32
|
+
Respond with a valid JSON object following the spec above. Do not surround the
|
33
|
+
response with backticks.
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'anthropic'
|
3
|
+
require 'ruby/openai'
|
4
|
+
require 'whirly'
|
5
|
+
require 'paint'
|
6
|
+
|
7
|
+
module Compose
|
8
|
+
class AIClient
|
9
|
+
def initialize(model)
|
10
|
+
@model = model
|
11
|
+
end
|
12
|
+
|
13
|
+
def chat(system_prompt, user_prompt, temperature: 0)
|
14
|
+
case @model[:provider]
|
15
|
+
when 'anthropic'
|
16
|
+
response = anthropic_chat(system_prompt, user_prompt, temperature: temperature)
|
17
|
+
response.dig('content', 0, 'text')
|
18
|
+
when 'openai'
|
19
|
+
response = openai_chat(system_prompt, user_prompt, temperature: temperature)
|
20
|
+
response.dig('choices', 0, 'message', 'content')
|
21
|
+
else
|
22
|
+
raise "Unsupported model: #{@model[:name]}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def anthropic_chat(system_prompt, user_prompt, temperature: 0)
|
29
|
+
Whirly.start spinner: 'dots', status: 'Received 0 chunks'
|
30
|
+
token_count = 0
|
31
|
+
client = Anthropic::Client.new(access_token: ENV['ANTHROPIC_API_KEY'])
|
32
|
+
response = client.messages(
|
33
|
+
parameters: {
|
34
|
+
model: @model[:name],
|
35
|
+
system: system_prompt,
|
36
|
+
messages: [{ role: 'user', content: user_prompt }],
|
37
|
+
temperature: temperature,
|
38
|
+
max_tokens: @model[:output_length],
|
39
|
+
stream: Proc.new { |chunk| token_count += 1; Whirly.status = "Received #{token_count} chunks" }
|
40
|
+
}
|
41
|
+
)
|
42
|
+
Whirly.stop
|
43
|
+
response
|
44
|
+
end
|
45
|
+
|
46
|
+
def openai_chat(system_prompt, user_prompt, temperature: 0)
|
47
|
+
Whirly.start spinner: 'dots', status: 'Verifying change'
|
48
|
+
client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
|
49
|
+
response = client.chat(
|
50
|
+
parameters: {
|
51
|
+
model: @model[:name],
|
52
|
+
messages: [
|
53
|
+
{ role: 'system', content: system_prompt },
|
54
|
+
{ role: 'user', content: user_prompt }
|
55
|
+
],
|
56
|
+
temperature: temperature
|
57
|
+
}
|
58
|
+
)
|
59
|
+
Whirly.stop
|
60
|
+
response
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dotenv/load'
|
4
|
+
|
5
|
+
module Compose
|
6
|
+
class ApiKeyUtils
|
7
|
+
def self.setup(model)
|
8
|
+
env_var = env_var_name(model[:provider])
|
9
|
+
return unless ENV[env_var].nil?
|
10
|
+
|
11
|
+
puts "\n#{env_var} is not set in your environment.".yellow
|
12
|
+
exit
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def self.env_var_name(provider)
|
18
|
+
case provider
|
19
|
+
when 'anthropic'
|
20
|
+
'ANTHROPIC_API_KEY'
|
21
|
+
when 'openai'
|
22
|
+
'OPENAI_API_KEY'
|
23
|
+
else
|
24
|
+
raise "Unsupported provider: #{provider}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/compose/cli.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
require 'colorize'
|
5
|
+
|
6
|
+
module Compose
|
7
|
+
class CLI < Thor
|
8
|
+
desc 'edit [FILES]', 'Edit files using AI assistance'
|
9
|
+
method_option :model, type: :string, default: 'sonnet35', desc: 'AI model to use'
|
10
|
+
method_option :include_imports, type: :boolean, default: true, aliases: '-a', desc: 'Include import statements when processing files'
|
11
|
+
def edit(*files)
|
12
|
+
puts 'Welcome to Compose!'.green
|
13
|
+
|
14
|
+
if files.empty?
|
15
|
+
puts 'Error: No files or folders to process'.red
|
16
|
+
exit(1)
|
17
|
+
end
|
18
|
+
|
19
|
+
code_model = Model.find(options[:model])
|
20
|
+
ApiKeyUtils.setup(code_model)
|
21
|
+
verifier_model = Model.preferred_verifier_model
|
22
|
+
ApiKeyUtils.setup(verifier_model)
|
23
|
+
|
24
|
+
file_processor = FileProcessor.new(files, include_imports: options[:include_imports], model: code_model)
|
25
|
+
puts "Loaded #{file_processor.files.count} file(s): #{file_processor.files.keys.map { |path| File.exist?(path) ? Pathname.new(path).relative_path_from(Pathname.pwd).to_s : path }.join(', ')}"
|
26
|
+
|
27
|
+
task = ask('What do you need me to do? (Type \'ask\' followed by your question to ask a question instead):')
|
28
|
+
|
29
|
+
if task.downcase.start_with?('ask ')
|
30
|
+
question = task[4..-1].strip
|
31
|
+
answer = file_processor.ask_files(question)
|
32
|
+
puts answer
|
33
|
+
else
|
34
|
+
edits = file_processor.edit_files(task)
|
35
|
+
|
36
|
+
edit_verifier = EditVerifier.new(edits, model: verifier_model)
|
37
|
+
verified_edits = edit_verifier.verify_edits
|
38
|
+
|
39
|
+
edit_processor = EditProcessor.new(verified_edits)
|
40
|
+
edit_processor.process_edits
|
41
|
+
end
|
42
|
+
|
43
|
+
puts 'Thank you for using Compose!'.green
|
44
|
+
end
|
45
|
+
|
46
|
+
desc 'version', 'Display the version of Compose'
|
47
|
+
def version
|
48
|
+
puts "Compose version #{Compose::VERSION}"
|
49
|
+
end
|
50
|
+
|
51
|
+
desc 'revert', 'Revert the last changes made by Compose'
|
52
|
+
def revert
|
53
|
+
edit_processor = EditProcessor.new
|
54
|
+
edit_processor.revert_last_changes
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'thor'
|
6
|
+
require 'colorize'
|
7
|
+
require 'diffy'
|
8
|
+
|
9
|
+
module Compose
|
10
|
+
class EditProcessor
|
11
|
+
include Thor::Shell
|
12
|
+
HISTORY_FILE = 'compose_history.json'
|
13
|
+
|
14
|
+
attr_reader :edits
|
15
|
+
|
16
|
+
def initialize(edits)
|
17
|
+
@edits = edits
|
18
|
+
@confirmed_edits = []
|
19
|
+
end
|
20
|
+
|
21
|
+
def process_edits
|
22
|
+
edits.each do |edit|
|
23
|
+
if confirm_edit(edit[:edit])
|
24
|
+
@confirmed_edits << edit[:edit]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
if @confirmed_edits.any?
|
29
|
+
if ask('Do you want to apply all confirmed edits? (y/n)') == 'y'
|
30
|
+
apply_confirmed_edits
|
31
|
+
else
|
32
|
+
puts 'All changes discarded.'
|
33
|
+
end
|
34
|
+
else
|
35
|
+
puts 'No edits were confirmed.'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# def revert_last_changes
|
40
|
+
# history_path = File.join(Dir.pwd, HISTORY_FILE)
|
41
|
+
# unless File.exist?(history_path)
|
42
|
+
# puts 'No history file found. Nothing to revert.'
|
43
|
+
# return
|
44
|
+
# end
|
45
|
+
|
46
|
+
# history = JSON.parse(File.read(history_path))
|
47
|
+
|
48
|
+
# if history.empty?
|
49
|
+
# puts 'No changes to revert.'
|
50
|
+
# return
|
51
|
+
# end
|
52
|
+
|
53
|
+
# puts 'Recent changes:'
|
54
|
+
# history.each_with_index do |file_history, index|
|
55
|
+
# puts "#{index + 1}. #{file_history['filename']} (#{file_history['edits'].length} edits)"
|
56
|
+
# end
|
57
|
+
|
58
|
+
# if ask('Do you want to revert all changes? (y/n)') == 'y'
|
59
|
+
# history.each do |file_history|
|
60
|
+
# if file_history['original_content'].empty?
|
61
|
+
# File.delete(file_history['filename']) if File.exist?(file_history['filename'])
|
62
|
+
# puts "Deleted file: #{file_history['filename']}"
|
63
|
+
# else
|
64
|
+
# File.write(file_history['filename'], file_history['original_content'])
|
65
|
+
# puts "Reverted changes in: #{file_history['filename']}"
|
66
|
+
# end
|
67
|
+
# end
|
68
|
+
|
69
|
+
# File.write(history_path, '[]')
|
70
|
+
# puts 'All changes have been reverted and history has been cleared.'
|
71
|
+
# else
|
72
|
+
# puts 'Revert operation cancelled.'
|
73
|
+
# end
|
74
|
+
# end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def confirm_edit(edit)
|
79
|
+
puts '=' * 50
|
80
|
+
puts "Proposed change for #{edit[:filename]}:".cyan
|
81
|
+
puts edit[:explain]
|
82
|
+
puts '=' * 50
|
83
|
+
|
84
|
+
old_content = File.exist?(edit[:filename]) ? File.readlines(edit[:filename]) : []
|
85
|
+
new_content = old_content.dup
|
86
|
+
|
87
|
+
case edit[:change][:type]
|
88
|
+
when 'addition'
|
89
|
+
new_content.insert(edit[:change][:atLine] - 1, edit[:code])
|
90
|
+
when 'replacement'
|
91
|
+
new_content[edit[:change][:fromLineNumber] - 1..edit[:change][:toLineNumber] - 1] = edit[:code].split("\n").map { |line| line + "\n" }
|
92
|
+
end
|
93
|
+
|
94
|
+
diff = Diffy::Diff.new(old_content.join, new_content.join, context: 3)
|
95
|
+
puts diff.to_s(:color)
|
96
|
+
puts '=' * 50
|
97
|
+
|
98
|
+
ask('Do you want to confirm this change? (y/n)') == 'y'
|
99
|
+
end
|
100
|
+
|
101
|
+
def apply_confirmed_edits
|
102
|
+
file_edits = @confirmed_edits.group_by { |edit| edit[:filename] }
|
103
|
+
|
104
|
+
file_edits.each do |filename, edits|
|
105
|
+
content = File.exist?(filename) ? File.readlines(filename) : []
|
106
|
+
line_offset = 0
|
107
|
+
|
108
|
+
edits.sort_by { |edit| edit[:change][:type] == 'addition' ? edit[:change][:atLine] : edit[:change][:fromLineNumber] }.each do |edit|
|
109
|
+
case edit[:change][:type]
|
110
|
+
when 'addition'
|
111
|
+
adjusted_line = edit[:change][:atLine] + line_offset
|
112
|
+
content.insert(adjusted_line - 1, edit[:code])
|
113
|
+
line_offset += edit[:code].count("\n") + 1
|
114
|
+
when 'replacement'
|
115
|
+
from_line = edit[:change][:fromLineNumber] + line_offset
|
116
|
+
to_line = edit[:change][:toLineNumber] + line_offset
|
117
|
+
old_lines = to_line - from_line + 1
|
118
|
+
new_lines = edit[:code].count("\n") + 1
|
119
|
+
content[from_line - 1..to_line - 1] = edit[:code].split("\n").map { |line| line + "\n" }
|
120
|
+
line_offset += new_lines - old_lines
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
FileUtils.mkdir_p(File.dirname(filename))
|
125
|
+
File.write(filename, content.join)
|
126
|
+
puts "Applied changes to #{filename}".green
|
127
|
+
end
|
128
|
+
|
129
|
+
save_edit_history(@confirmed_edits)
|
130
|
+
puts 'All changes have been applied and saved.'.green
|
131
|
+
end
|
132
|
+
|
133
|
+
def save_edit_history(edits)
|
134
|
+
history_path = File.join(Dir.pwd, HISTORY_FILE)
|
135
|
+
history = File.exist?(history_path) ? JSON.parse(File.read(history_path)) : []
|
136
|
+
|
137
|
+
edits.each do |edit|
|
138
|
+
file_history = history.find { |h| h['filename'] == edit[:filename] }
|
139
|
+
if file_history
|
140
|
+
file_history['edits'] << edit
|
141
|
+
else
|
142
|
+
original_content = File.exist?(edit[:filename]) ? File.read(edit[:filename]) : ''
|
143
|
+
history << {
|
144
|
+
'filename' => edit[:filename],
|
145
|
+
'original_content' => original_content,
|
146
|
+
'edits' => [edit]
|
147
|
+
}
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
File.write(history_path, JSON.pretty_generate(history))
|
152
|
+
puts 'Edit history has been updated.'.green
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Compose
|
4
|
+
class EditVerifier
|
5
|
+
attr_reader :ai_client, :edits
|
6
|
+
|
7
|
+
def initialize(edits, model:)
|
8
|
+
@ai_client = AIClient.new(model)
|
9
|
+
@edits = edits
|
10
|
+
end
|
11
|
+
|
12
|
+
def verify_edits
|
13
|
+
verified_edits = []
|
14
|
+
|
15
|
+
edits.each do |edit|
|
16
|
+
case edit[:type]
|
17
|
+
when 'edit'
|
18
|
+
verified_edit = verify_edit(edit[:edit])
|
19
|
+
verified_edits << { type: 'edit', edit: verified_edit } if verified_edit
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
verified_edits
|
24
|
+
end
|
25
|
+
|
26
|
+
def verify_edit(edit)
|
27
|
+
return edit unless File.exist?(edit[:filename])
|
28
|
+
|
29
|
+
prompt = File.read(File.expand_path("../../config/prompts/verify.txt", __dir__))
|
30
|
+
prompt.gsub!('{{EDIT}}', edit.to_json)
|
31
|
+
prompt.gsub!('{{CHANGE}}', edit[:change].to_json)
|
32
|
+
|
33
|
+
file_content = FileProcessor.load_file(File.expand_path(edit[:filename]))
|
34
|
+
system_prompt = "CODE:\n#{file_content}\n"
|
35
|
+
|
36
|
+
response = ai_client.chat(system_prompt, prompt)
|
37
|
+
|
38
|
+
begin
|
39
|
+
verified_change = JSON.parse(response)
|
40
|
+
verified_change = verified_change.transform_keys(&:to_sym)
|
41
|
+
return nil if verified_change[:type] == 'skip'
|
42
|
+
|
43
|
+
edit[:change] = verified_change
|
44
|
+
edit
|
45
|
+
rescue JSON::ParserError
|
46
|
+
puts "Failed to parse verification response. Using original edit."
|
47
|
+
edit
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'find'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module Compose
|
7
|
+
class FileProcessor
|
8
|
+
attr_reader :ai_client, :files, :tokens
|
9
|
+
|
10
|
+
def self.load_file(file_path, include_imports: true)
|
11
|
+
content = File.read(file_path)
|
12
|
+
processed_content = content.split("\n").map.with_index { |line, index| "L#{index + 1}: #{line}" }.join("\n")
|
13
|
+
|
14
|
+
unless include_imports
|
15
|
+
processed_content.gsub!(/L\d+:\s*require.*?\n/, '')
|
16
|
+
processed_content.gsub!(/L\d+:\s*import.*?\n/, '')
|
17
|
+
end
|
18
|
+
|
19
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(Dir.pwd)).to_s
|
20
|
+
"<#{relative_path}>\n#{processed_content}\n</#{relative_path}>"
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(input_files, include_imports: true, model:)
|
24
|
+
@ai_client = AIClient.new(model)
|
25
|
+
@files = {}
|
26
|
+
|
27
|
+
input_files.each do |input|
|
28
|
+
if File.directory?(input)
|
29
|
+
find_files_in_directory(input).each do |file|
|
30
|
+
@files[File.expand_path(file)] = nil
|
31
|
+
end
|
32
|
+
elsif File.file?(input)
|
33
|
+
@files[File.expand_path(input)] = nil
|
34
|
+
else
|
35
|
+
puts "Skipping invalid input: #{input}".yellow
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
@files.each do |file, _|
|
40
|
+
@files[file] = FileProcessor.load_file(file, include_imports: include_imports)
|
41
|
+
end
|
42
|
+
|
43
|
+
self
|
44
|
+
end
|
45
|
+
|
46
|
+
def content
|
47
|
+
@files.values.join("\n\n")
|
48
|
+
end
|
49
|
+
|
50
|
+
def ask_files(question)
|
51
|
+
prompt = File.read(File.expand_path("../../config/prompts/ask.txt", __dir__))
|
52
|
+
prompt.gsub!('{{QUESTION}}', question)
|
53
|
+
system_prompt = "CODE:\n#{content}\n"
|
54
|
+
|
55
|
+
ai_client.chat(system_prompt, prompt)
|
56
|
+
end
|
57
|
+
|
58
|
+
def edit_files(task)
|
59
|
+
prompt = File.read(File.expand_path("../../config/prompts/task.txt", __dir__))
|
60
|
+
prompt.gsub!('{{TASK}}', task)
|
61
|
+
system_prompt = "CODE:\n#{content}\n"
|
62
|
+
|
63
|
+
response = ai_client.chat(system_prompt, prompt)
|
64
|
+
|
65
|
+
begin
|
66
|
+
edits = JSON.parse(response)['edits']
|
67
|
+
edits.map do |edit|
|
68
|
+
edit = edit.transform_keys(&:to_sym)
|
69
|
+
edit[:change] = edit[:change].transform_keys(&:to_sym)
|
70
|
+
{
|
71
|
+
type: 'edit',
|
72
|
+
edit: edit
|
73
|
+
}
|
74
|
+
end
|
75
|
+
rescue JSON::ParserError => e
|
76
|
+
[{ type: 'error', error: "Failed to parse AI response: #{e.message}" }]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def find_files_in_directory(directory)
|
83
|
+
tracked_files = `git ls-files #{directory}`.split("\n")
|
84
|
+
tracked_files.select do |path|
|
85
|
+
File.file?(path) && text_file?(path)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def text_file?(path)
|
90
|
+
return false if File.zero?(path)
|
91
|
+
|
92
|
+
bytes = File.read(path, 1024) or return false
|
93
|
+
bytes.each_byte do |byte|
|
94
|
+
return false if [0, 1, 2, 3, 4, 5, 6, 11, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31].include?(byte)
|
95
|
+
end
|
96
|
+
true
|
97
|
+
rescue
|
98
|
+
false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Compose
|
4
|
+
class Model
|
5
|
+
def self.yaml
|
6
|
+
return @yaml if @yaml
|
7
|
+
|
8
|
+
yaml_content = File.read(File.expand_path("../../config/models.yml", __dir__))
|
9
|
+
@yaml = YAML.safe_load(yaml_content, symbolize_names: true)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.all
|
13
|
+
yaml[:models]
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.find(name)
|
17
|
+
all.find { |model| model[:name] == name || model[:nickname] == name }
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.preferred_verifier_model
|
21
|
+
preferred_nickname = yaml[:preferred_verifier_model]
|
22
|
+
all.find { |model| model[:nickname] == preferred_nickname }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/compose.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'compose/version'
|
4
|
+
require_relative 'compose/cli'
|
5
|
+
require_relative 'compose/api_key_utils'
|
6
|
+
require_relative 'compose/file_processor'
|
7
|
+
require_relative 'compose/edit_processor'
|
8
|
+
require_relative 'compose/ai_client'
|
9
|
+
require_relative 'compose/edit_verifier'
|
10
|
+
require_relative 'compose/model'
|
11
|
+
|
12
|
+
module Compose
|
13
|
+
class Error < StandardError; end
|
14
|
+
|
15
|
+
def self.run(args)
|
16
|
+
CLI.start(args)
|
17
|
+
end
|
18
|
+
end
|
metadata
CHANGED
@@ -1,56 +1,258 @@
|
|
1
|
-
--- !ruby/object:Gem::Specification
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
2
|
name: compose
|
3
|
-
version: !ruby/object:Gem::Version
|
4
|
-
|
5
|
-
version: 0.0.0.0
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.5
|
6
5
|
platform: ruby
|
7
|
-
authors:
|
8
|
-
-
|
9
|
-
autorequire:
|
6
|
+
authors:
|
7
|
+
- Dreaming Tulpa
|
8
|
+
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
11
|
+
date: 2024-09-11 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ruby-openai
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: anthropic
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: colorize
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: dotenv
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: faraday
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: json
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: thor
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: diffy
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: whirly
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :runtime
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: paint
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :runtime
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: fiddle
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :runtime
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: bundler
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - "~>"
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '2.0'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - "~>"
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '2.0'
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: rake
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - "~>"
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '13.0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - "~>"
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '13.0'
|
195
|
+
- !ruby/object:Gem::Dependency
|
196
|
+
name: byebug
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ">="
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '0'
|
202
|
+
type: :development
|
203
|
+
prerelease: false
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
205
|
+
requirements:
|
206
|
+
- - ">="
|
207
|
+
- !ruby/object:Gem::Version
|
208
|
+
version: '0'
|
209
|
+
description: compose is a Ruby gem that provides AI-assisted code editing capabilities.
|
210
|
+
email:
|
211
|
+
- hey@dreamingtulpa.com
|
212
|
+
executables:
|
213
|
+
- compose
|
21
214
|
extensions: []
|
22
|
-
|
23
215
|
extra_rdoc_files: []
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
216
|
+
files:
|
217
|
+
- README.md
|
218
|
+
- bin/compose
|
219
|
+
- config/models.yml
|
220
|
+
- config/prompts/ask.txt
|
221
|
+
- config/prompts/task.txt
|
222
|
+
- config/prompts/verify.txt
|
223
|
+
- lib/compose.rb
|
224
|
+
- lib/compose/ai_client.rb
|
225
|
+
- lib/compose/api_key_utils.rb
|
226
|
+
- lib/compose/cli.rb
|
227
|
+
- lib/compose/edit_processor.rb
|
228
|
+
- lib/compose/edit_verifier.rb
|
229
|
+
- lib/compose/file_processor.rb
|
230
|
+
- lib/compose/model.rb
|
231
|
+
- lib/compose/version.rb
|
232
|
+
homepage: https://github.com/dreamingtulpa/compose
|
233
|
+
licenses:
|
234
|
+
- MIT
|
235
|
+
metadata:
|
236
|
+
homepage_uri: https://github.com/dreamingtulpa/compose
|
237
|
+
source_code_uri: https://github.com/dreamingtulpa/compose
|
238
|
+
changelog_uri: https://github.com/dreamingtulpa/compose/blob/main/CHANGELOG.md
|
239
|
+
post_install_message:
|
32
240
|
rdoc_options: []
|
33
|
-
|
34
|
-
require_paths:
|
241
|
+
require_paths:
|
35
242
|
- lib
|
36
|
-
required_ruby_version: !ruby/object:Gem::Requirement
|
37
|
-
|
38
|
-
requirements:
|
243
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
244
|
+
requirements:
|
39
245
|
- - ">="
|
40
|
-
- !ruby/object:Gem::Version
|
41
|
-
version:
|
42
|
-
required_rubygems_version: !ruby/object:Gem::Requirement
|
43
|
-
|
44
|
-
requirements:
|
246
|
+
- !ruby/object:Gem::Version
|
247
|
+
version: 2.7.0
|
248
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
249
|
+
requirements:
|
45
250
|
- - ">="
|
46
|
-
- !ruby/object:Gem::Version
|
47
|
-
version:
|
251
|
+
- !ruby/object:Gem::Version
|
252
|
+
version: '0'
|
48
253
|
requirements: []
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
specification_version: 3
|
54
|
-
summary: Reserved gem name. Contact author if you want it, but there are no guarantees.
|
254
|
+
rubygems_version: 3.5.18
|
255
|
+
signing_key:
|
256
|
+
specification_version: 4
|
257
|
+
summary: A Ruby gem for AI-assisted code editing
|
55
258
|
test_files: []
|
56
|
-
|