cowrite 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/MIT-LICENSE +20 -0
- data/bin/cowrite +11 -0
- data/lib/cowrite/cli.rb +142 -0
- data/lib/cowrite/version.rb +4 -0
- data/lib/cowrite.rb +115 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 92022a9db5707f4dae9c95807e6eb39b813056ea3b39c252d3f65aa67d62acf5
|
|
4
|
+
data.tar.gz: 5b18e63cc172da86b5dba8d098510a00bb7c888fd2560f3f6f38a007f77c2aa5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8b7560a50a193d66dd3208786deeb95638a67517c9cd630377ac8b3653f262f112f3b93e8ffcf820ef1b604661725bf2f0208c3891daa16abe2078f9f3374f37
|
|
7
|
+
data.tar.gz: dcb4ee5bbb99036cdd506425102c905d7dc22ecedeed3125b02e08d700b7d35dc766247ac4827d30b01372bde42afec7a7004c76ebf44682e5d509c6f30eeb1e
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (C) 2013 Michael Grosser <michael@grosser.it>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/bin/cowrite
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# enable local usage from cloned repo
|
|
5
|
+
root = File.expand_path('..', __dir__)
|
|
6
|
+
$LOAD_PATH << "#{root}/lib" if File.exist?("#{root}/Gemfile")
|
|
7
|
+
|
|
8
|
+
require "cowrite"
|
|
9
|
+
require "cowrite/cli"
|
|
10
|
+
|
|
11
|
+
Cowrite::CLI.new.run(ARGV)
|
data/lib/cowrite/cli.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "parallel"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
class Cowrite
|
|
8
|
+
class CLI
|
|
9
|
+
QUESTION_COLOR = :blue
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
|
|
14
|
+
# get config first so we fail fast
|
|
15
|
+
api_key = ENV.fetch("COWRITE_API_KEY")
|
|
16
|
+
url = ENV.fetch("COWRITE_URL", "https://api.openai.com")
|
|
17
|
+
model = ENV.fetch("MODEL", "gpt-4o")
|
|
18
|
+
@cowrite = Cowrite.new(url:, api_key:, model:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(argv)
|
|
22
|
+
abort "Use only first argument for prompt" if argv.size != 1 # TODO: remove
|
|
23
|
+
prompt = ARGV[0]
|
|
24
|
+
|
|
25
|
+
files = find_files prompt
|
|
26
|
+
|
|
27
|
+
# prompting on main thread so we can go 1-by-1
|
|
28
|
+
finish = lambda do |file, i, diff|
|
|
29
|
+
# ask user if diff is fine (TODO: add a "no" option and re-prompt somehow)
|
|
30
|
+
prompt "Diff for #{file}:\n#{color_diff(diff)}Apply diff to #{file}?", ["yes"]
|
|
31
|
+
|
|
32
|
+
with_content_in_file("#{diff.strip}\n") do |path|
|
|
33
|
+
# apply diff (force sus changes, do not make backups)
|
|
34
|
+
cmd = "patch -f --posix #{file} < #{path}"
|
|
35
|
+
out = `#{cmd}`
|
|
36
|
+
return if $?.success?
|
|
37
|
+
|
|
38
|
+
# give the user a chance to copy the tempfile or modify it
|
|
39
|
+
warn "Patch failed:\n#{cmd}\n#{out}"
|
|
40
|
+
prompt "Continue ?", ["yes"] unless i + 1 == files.size
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# TODO: --parallel instead of env
|
|
45
|
+
# produce diffs in parallel since it is slow
|
|
46
|
+
Parallel.each files, finish:, threads: Integer(ENV["PARALLEL"] || "10"), progress: true do |file|
|
|
47
|
+
@cowrite.diff file, prompt
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def with_content_in_file(content)
|
|
54
|
+
Tempfile.create("cowrite-diff") do |f|
|
|
55
|
+
f.write content
|
|
56
|
+
f.close
|
|
57
|
+
yield f.path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_files(prompt)
|
|
62
|
+
files = @cowrite.files prompt
|
|
63
|
+
files.each_with_index { |f, i| warn "#{i}: #{f}" } # needs to match choose_files logic
|
|
64
|
+
answer = prompt("Accept #{files.size} files to iterate ?", ["yes", "choose"])
|
|
65
|
+
answer == "choose" ? choose_files(files) : files
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# let user select for index of given files or select whatever files they want via path
|
|
69
|
+
def choose_files(files)
|
|
70
|
+
answer = prompt_freeform("Enter index or path of files command separated")
|
|
71
|
+
chosen = answer.split(/\s?,\s?/)
|
|
72
|
+
abort "No files selected" if chosen.empty?
|
|
73
|
+
chosen =
|
|
74
|
+
files.each_with_index.filter_map { |f, i| f if chosen.include?(i.to_s) } + # index
|
|
75
|
+
chosen.grep_v(/^\d+$/) # path
|
|
76
|
+
missing = chosen.reject { |f| File.exist?(f) }
|
|
77
|
+
abort "Files #{missing} do not exist" if missing.any?
|
|
78
|
+
chosen
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# prompt user with questions and answers until they pick one of them
|
|
82
|
+
# - supports replying with the first letter of the answer
|
|
83
|
+
# - supports enter for yes
|
|
84
|
+
def prompt(question, answers)
|
|
85
|
+
colored_answers = answers.map { |a| color(:underline, a[0]) + a[1...] }.join("/")
|
|
86
|
+
loop do
|
|
87
|
+
read = prompt_freeform "#{color_last_line(QUESTION_COLOR, question)} [#{colored_answers}]", color: :none
|
|
88
|
+
read = "yes" if read == "" && answers.include?("yes")
|
|
89
|
+
return read if answers.include?(read)
|
|
90
|
+
if (ai = answers.map { |a| a[0] }.index(read))
|
|
91
|
+
return answers[ai]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def prompt_freeform(question, color: QUESTION_COLOR)
|
|
97
|
+
warn color(color, question)
|
|
98
|
+
$stdin.gets.strip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def color_last_line(color, text)
|
|
102
|
+
modify_lines(text) { |l, i, size| i + 1 == size ? color(color, l) : l }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# color lines with +/- but not the header with ---/+++
|
|
106
|
+
def color_diff(diff)
|
|
107
|
+
modify_lines(diff) do |l, _, _|
|
|
108
|
+
if l =~ /^-[^-]/
|
|
109
|
+
color(:bg_light_green, l)
|
|
110
|
+
elsif l =~ /^\+[^+]/
|
|
111
|
+
color(:bg_light_red, l)
|
|
112
|
+
else
|
|
113
|
+
l
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def modify_lines(text)
|
|
119
|
+
lines = text.split("\n", -1)
|
|
120
|
+
size = lines.size
|
|
121
|
+
lines = lines.each_with_index.map { |l, i| yield l, i, size }
|
|
122
|
+
lines.join("\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def color(color, text)
|
|
126
|
+
return text if color == :none
|
|
127
|
+
code =
|
|
128
|
+
case color
|
|
129
|
+
when :underline then 4
|
|
130
|
+
when :blue then 34
|
|
131
|
+
when :bg_light_red then 101
|
|
132
|
+
when :bg_light_green then 102
|
|
133
|
+
else raise ArgumentError
|
|
134
|
+
end
|
|
135
|
+
"\e[#{code}m#{text}\e[0m"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_shell_colors(string)
|
|
139
|
+
string.gsub(/\e\[(\d+)(;\d+)*m/, "")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/cowrite.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class Cowrite
|
|
8
|
+
def initialize(url:, api_key:, model:)
|
|
9
|
+
@url = url
|
|
10
|
+
@api_key = api_key
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def files(prompt)
|
|
15
|
+
# TODO: ask llm which folders to search when >1k files
|
|
16
|
+
files = `git ls-files`
|
|
17
|
+
abort files unless $?.success?
|
|
18
|
+
|
|
19
|
+
prompt = <<~MSG
|
|
20
|
+
Your task is to find a subset of files from a given list of file names.
|
|
21
|
+
Only reply with the subset of files, newline separated, nothing else.
|
|
22
|
+
|
|
23
|
+
Given this list of files: #{files.split("\n").inspect}
|
|
24
|
+
|
|
25
|
+
Which subset of files would be useful for this LLM prompt:
|
|
26
|
+
```
|
|
27
|
+
#{prompt}
|
|
28
|
+
```
|
|
29
|
+
MSG
|
|
30
|
+
puts "prompt:#{prompt}" if ENV["DEBUG"]
|
|
31
|
+
answer = send_to_openai(prompt)
|
|
32
|
+
puts "answer:\n#{answer}" if ENV["DEBUG"]
|
|
33
|
+
without_quotes(answer).split("\n").map(&:strip) # llms like to add extra spaces
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def diff(file, prompt)
|
|
37
|
+
# - tried "patch format" but that is often invalid
|
|
38
|
+
# - tied "full fixed content" but that is always missing the fix
|
|
39
|
+
# - need "ONLY" or it adds comments
|
|
40
|
+
prompt = <<~MSG
|
|
41
|
+
Solve this prompt:
|
|
42
|
+
```
|
|
43
|
+
#{prompt}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
By changing the content of the file #{file}:
|
|
47
|
+
```
|
|
48
|
+
#{File.read file}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Reply with ONLY the change in diff format.
|
|
52
|
+
MSG
|
|
53
|
+
puts "prompt:#{prompt}" if ENV["DEBUG"]
|
|
54
|
+
answer = send_to_openai(prompt)
|
|
55
|
+
puts "answer:\n#{answer}" if ENV["DEBUG"]
|
|
56
|
+
without_quotes(answer)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# remove ```foo<content>``` wrapping
|
|
62
|
+
def without_quotes(answer)
|
|
63
|
+
answer.strip.sub(/\A```\S*\n(.*)```\z/m, "\\1")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# def lines_from_file(file_path, line_number)
|
|
67
|
+
# start_line = [line_number - @context, 1].max
|
|
68
|
+
# end_line = line_number + @context
|
|
69
|
+
#
|
|
70
|
+
# lines = File.read(file_path).split("\n", -1)
|
|
71
|
+
# context = lines.each_with_index.map { |l, i| "line #{(i + 1).to_s.rjust(5, " ")}:#{l}" }
|
|
72
|
+
# [lines[line_number - 1], context[start_line - 1..end_line - 1]]
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# def replace_line_in_file(file_path, line_number, new_line)
|
|
76
|
+
# lines = File.read(file_path).split("\n", -1)
|
|
77
|
+
# lines[line_number - 1] = new_line
|
|
78
|
+
# File.write(file_path, lines.join("\n"))
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
# def append_line_to_file(path, answer)
|
|
82
|
+
# File.open(path, "a") do |f|
|
|
83
|
+
# f.puts(answer)
|
|
84
|
+
# end
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# def remove_line_in_file(path, line_number)
|
|
88
|
+
# lines = File.read(path).split("\n", -1)
|
|
89
|
+
# lines.delete_at(line_number - 1)
|
|
90
|
+
# File.write(path, lines.join("\n"))
|
|
91
|
+
# end
|
|
92
|
+
|
|
93
|
+
def send_to_openai(prompt)
|
|
94
|
+
uri = URI.parse("#{@url}/v1/chat/completions")
|
|
95
|
+
request = Net::HTTP::Post.new(uri)
|
|
96
|
+
request.content_type = "application/json"
|
|
97
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
98
|
+
|
|
99
|
+
request.body = JSON.dump(
|
|
100
|
+
{
|
|
101
|
+
model: @model,
|
|
102
|
+
messages: [{ role: "user", content: prompt }],
|
|
103
|
+
max_completion_tokens: 10_000,
|
|
104
|
+
temperature: 0.3
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
109
|
+
http.request(request)
|
|
110
|
+
end
|
|
111
|
+
raise "Invalid http response #{response.code}:\n#{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
112
|
+
|
|
113
|
+
JSON.parse(response.body)["choices"][0]["message"]["content"]
|
|
114
|
+
end
|
|
115
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cowrite
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michael Grosser
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2024-10-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: parallel
|
|
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: ruby-progressbar
|
|
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
|
+
description:
|
|
42
|
+
email: michael@grosser.it
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- MIT-LICENSE
|
|
48
|
+
- bin/cowrite
|
|
49
|
+
- lib/cowrite.rb
|
|
50
|
+
- lib/cowrite/cli.rb
|
|
51
|
+
- lib/cowrite/version.rb
|
|
52
|
+
homepage: https://github.com/grosser/cowrite
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata: {}
|
|
56
|
+
post_install_message:
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.1.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.4.10
|
|
72
|
+
signing_key:
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: Create changes for a local repository with chatgpt / openai / local llm
|
|
75
|
+
test_files: []
|