pear-programmer 0.1.1
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/bin/pear-on +2 -0
- data/lib/cli/actions.rb +224 -0
- data/lib/cli/configuration.rb +73 -0
- data/lib/cli/display.rb +83 -0
- data/lib/cli/version.rb +5 -0
- data/lib/cli.rb +59 -0
- data/lib/pairprogrammer/api/client.rb +73 -0
- data/lib/pairprogrammer/api/coder.rb +62 -0
- data/lib/pairprogrammer/api/planner.rb +48 -0
- data/lib/pairprogrammer/api/system.rb +22 -0
- data/lib/pairprogrammer/command.rb +158 -0
- data/lib/pairprogrammer/configuration.rb +43 -0
- data/lib/pairprogrammer.rb +4 -0
- data/lib/spec/cli/actions_spec.rb +29 -0
- data/lib/spec/spec_helper.rb +30 -0
- metadata +144 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5ff1857eefc3d9eb63642ff07a42536cec791067bbcaa27884586f37d8e3d1bb
|
4
|
+
data.tar.gz: 9639755ec441aa4ef3dca87d75dc71c742002386e275a4c406ba827269baf00e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 65b97022ce92f9504703082db00c511eb94d8936e8cd1ce8b75c55c2e80aa70a55168cda8afed36458213ded9689e35f5ac66c841d845730f6ff6fc9fc9240f1
|
7
|
+
data.tar.gz: 9c59045e0bc0a77e6a5190fa5836720b9a5bd8ec7e330c7a84676015933aab9e362bb8ad9a3b84cfc5a14e225e75e305eb587884fa994e487e73b9789e72e9fd
|
data/bin/pear-on
ADDED
data/lib/cli/actions.rb
ADDED
@@ -0,0 +1,224 @@
|
|
1
|
+
require_relative "../pairprogrammer"
|
2
|
+
require_relative "display"
|
3
|
+
require_relative "version"
|
4
|
+
# need to require file
|
5
|
+
|
6
|
+
# TODO VALIDATIONS
|
7
|
+
module Cli
|
8
|
+
class Actions
|
9
|
+
def self.init
|
10
|
+
Cli::Display.info_message("Welcome to Pear Programmer!")
|
11
|
+
confirmation = Cli::Display.confirmation("Are you in the root directory of your project?")
|
12
|
+
if !confirmation
|
13
|
+
Cli::Display.error_message("Please change directory into the root of your project and try again.")
|
14
|
+
return
|
15
|
+
end
|
16
|
+
|
17
|
+
Cli::Display.info_message("creating #{Cli::Configuration::FILE_NAME} in #{Dir.pwd}")
|
18
|
+
Cli::Display.info_message("Context is any information about your project that you want the large language model to know about. A few sentences should suffice.")
|
19
|
+
Cli::Display.info_message("Include information about your framework (ie Ruby on Rails), your database, how you handle assets, authentication, etc.")
|
20
|
+
Cli::Display.info_message("The more detailed you are the better the LLM will perform.")
|
21
|
+
context = Cli::Display.get_input("context: ")
|
22
|
+
|
23
|
+
Cli::Display.info_message("If you haven't already, sign up for an API key at https://pairprogrammer.io")
|
24
|
+
Cli::Display.info_message("To skip for now and update later press enter")
|
25
|
+
api_key = Cli::Display.get_input("api_key: ")
|
26
|
+
|
27
|
+
Cli::Display.info_message("If you are running a python app, which python binary are you using? (python, python2, python3)")
|
28
|
+
Cli::Display.info_message("If you are not using python, press enter to skip")
|
29
|
+
python_command = Cli::Display.select("python command: (choose none if you are not using python)", {"python" => "python", "python2" => "python2", "python3" => "python3", "none" => ""})
|
30
|
+
|
31
|
+
Cli::Configuration.create(context, api_key, python_command)
|
32
|
+
Cli::Display.success_message("successfully created #{Cli::Configuration::FILE_NAME} - you can update this file at any time")
|
33
|
+
Cli::Display.info_message("Please add it to your .gitignore file")
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.check_cli_version
|
37
|
+
versions = PairProgrammer::Api::System.versions
|
38
|
+
if versions["cli"] != Cli::Version::VERSION
|
39
|
+
Cli::Display.info_message("A new version of the CLI is available, installing update")
|
40
|
+
gem = PairProgrammer::Configuration.development? ? "pear-programmer-0.1.gem" : "pear-programmer"
|
41
|
+
Cli::Display.info_message("Running gem update #{gem}")
|
42
|
+
system("gem update #{gem}")
|
43
|
+
Cli::Display.success_message("Update complete")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.report_exception(command, e)
|
48
|
+
version = Cli::Version::VERSION
|
49
|
+
PairProgrammer::Api::System.client_exception(command, e, version)
|
50
|
+
Cli::Display.error_message("An error occurred")
|
51
|
+
Cli::Display.error_message(e.message)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.help
|
55
|
+
Cli::Display.info_message "Available Commands:"
|
56
|
+
Cli::Display.info_message " init - initialize pear-programmer from the root of your project"
|
57
|
+
Cli::Display.info_message " help"
|
58
|
+
Cli::Display.info_message " coding (new|start|list)"
|
59
|
+
|
60
|
+
Cli::Display.info_message "Usage examples:"
|
61
|
+
Cli::Display.info_message " pairprogrammer init"
|
62
|
+
Cli::Display.info_message " pairprogrammer help"
|
63
|
+
Cli::Display.info_message " pairprogrammer coding new"
|
64
|
+
Cli::Display.info_message " pairprogrammer coding start --id CODER_ID"
|
65
|
+
Cli::Display.info_message " pairprogrammer coding list"
|
66
|
+
end
|
67
|
+
|
68
|
+
# CODER
|
69
|
+
def self.create_coder(options)
|
70
|
+
config = Cli::Configuration.new
|
71
|
+
context = Cli::Display.get_input("context (press enter to use default): ")
|
72
|
+
if context.empty?
|
73
|
+
Cli::Display.info_message("using default context")
|
74
|
+
Cli::Display.info_message(config.default_context)
|
75
|
+
context = config.default_context
|
76
|
+
end
|
77
|
+
requirements = Cli::Display.get_input("requirements: ")
|
78
|
+
tasks = []
|
79
|
+
while true do
|
80
|
+
task = Cli::Display.get_input("task (press enter to complete): ")
|
81
|
+
if task.empty?
|
82
|
+
break
|
83
|
+
else
|
84
|
+
tasks << task
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
id = PairProgrammer::Api::Coder.create(tasks, context, requirements)
|
89
|
+
puts "Created coding assistant #{id}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.run_coder(options)
|
93
|
+
config = Cli::Configuration.new
|
94
|
+
auto = !!options[:auto]
|
95
|
+
|
96
|
+
if options[:id]
|
97
|
+
id = options[:id]
|
98
|
+
else
|
99
|
+
coders = PairProgrammer::Api::Coder.list
|
100
|
+
id = Cli::Display.select("Select your coding assistant:", coders.inject({}) { |hash, coder| hash[coder["requirements"]] = coder["id"]; hash })
|
101
|
+
end
|
102
|
+
|
103
|
+
while true do
|
104
|
+
# RETRY when Net::ReadTimeout
|
105
|
+
spinner = Cli::Display.spinner
|
106
|
+
spinner.auto_spin
|
107
|
+
begin
|
108
|
+
response = PairProgrammer::Api::Coder.run(id)
|
109
|
+
rescue Net::ReadTimeout
|
110
|
+
Cli::Display.error_message("connection timed out but coder is still running. reconnecting...")
|
111
|
+
next
|
112
|
+
ensure
|
113
|
+
spinner.stop()
|
114
|
+
end
|
115
|
+
|
116
|
+
if response["running"]
|
117
|
+
Cli::Display.info_message("coder is still running, will try again in 20 seconds")
|
118
|
+
sleep(20)
|
119
|
+
next
|
120
|
+
elsif response["reached_max_length"]
|
121
|
+
Cli::Display.error_message("conversation has reached its context length due to limitations with LLMs")
|
122
|
+
Cli::Display.error_message("please create a new coder, this coder will no longer be able to run")
|
123
|
+
return
|
124
|
+
elsif response["error"]
|
125
|
+
Cli::Display.error_message("there was an error processing the assistant's response")
|
126
|
+
Cli::Display.info_message("retrying...")
|
127
|
+
next
|
128
|
+
end
|
129
|
+
|
130
|
+
if response["available_tokens"] && response["available_tokens"] < 500
|
131
|
+
Cli::Display.info_message("conversation is getting long and approaching context length limit")
|
132
|
+
Cli::Display.info_message("this conversation has #{response["available_tokens"]} tokens left")
|
133
|
+
end
|
134
|
+
|
135
|
+
system_message = response["system_message"]
|
136
|
+
|
137
|
+
response_required = true
|
138
|
+
# TODO if there is explanation but no command then response is required
|
139
|
+
if system_message["explanation"] && !system_message["explanation"].empty?
|
140
|
+
Cli::Display.message("assistant", system_message["explanation"])
|
141
|
+
end
|
142
|
+
|
143
|
+
if system_message["command"]
|
144
|
+
skip_command = false
|
145
|
+
command_display = PairProgrammer::Command.display_command(system_message["command"], system_message["arguments"])
|
146
|
+
Cli::Display.info_message(command_display) if command_display
|
147
|
+
response_required = false
|
148
|
+
# command overwrites
|
149
|
+
if system_message["command"] == "comment"
|
150
|
+
Cli::Display.message("assistant", system_message["arguments"]["comment"])
|
151
|
+
response_required = true
|
152
|
+
elsif system_message["command"] == "ask_question"
|
153
|
+
Cli::Display.message("assistant", system_message["arguments"]["question"])
|
154
|
+
response_required = true
|
155
|
+
else
|
156
|
+
if system_message["command"] == "write_file"
|
157
|
+
# this fails if file doesn't exist
|
158
|
+
file_path = PairProgrammer::Configuration.absolute_path(system_message["arguments"]["file_path"])
|
159
|
+
begin
|
160
|
+
original_content = File.read(file_path)
|
161
|
+
rescue Errno::ENOENT
|
162
|
+
Cli::Display.error_message("there was an error running the command, notifying assistant of error.")
|
163
|
+
PairProgrammer::Api::Coder.append_exception(id, e)
|
164
|
+
Cli::Display.info_message("retrying...")
|
165
|
+
next
|
166
|
+
end
|
167
|
+
|
168
|
+
Cli::Display.dispaly_diff(original_content, system_message["arguments"]["content"])
|
169
|
+
|
170
|
+
confirmation = Cli::Display.confirmation("Accept changes?")
|
171
|
+
if !confirmation
|
172
|
+
Cli::Display.info_message("changes rejected")
|
173
|
+
# PairProgrammer::Api::Coder.append_output(id, "COMMAND REJECTED")
|
174
|
+
skip_command = true
|
175
|
+
response_required = true
|
176
|
+
else
|
177
|
+
Cli::Display.info_message("changes accepted")
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
if !skip_command
|
182
|
+
output = nil
|
183
|
+
begin
|
184
|
+
output = PairProgrammer::Command.run(system_message["command"], system_message["arguments"])
|
185
|
+
rescue => e
|
186
|
+
Cli::Display.error_message("there was an error running the command, notifying assistant of error.")
|
187
|
+
PairProgrammer::Api::Coder.append_exception(id, e)
|
188
|
+
Cli::Display.info_message("retrying...")
|
189
|
+
next
|
190
|
+
end
|
191
|
+
PairProgrammer::Api::Coder.append_output(id, output)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
while true do
|
197
|
+
# user does not have to respond if auto is true
|
198
|
+
if !response_required && auto
|
199
|
+
break
|
200
|
+
end
|
201
|
+
|
202
|
+
display = response_required ? "required" : "optional"
|
203
|
+
message = Cli::Display.get_input("response (#{display}): ")
|
204
|
+
if message.empty?
|
205
|
+
if response_required
|
206
|
+
Cli::Display.error_message("response required")
|
207
|
+
next
|
208
|
+
else
|
209
|
+
break
|
210
|
+
end
|
211
|
+
else
|
212
|
+
PairProgrammer::Api::Coder.add_user_message(id, message)
|
213
|
+
response_required = false
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def self.list_coders(options)
|
220
|
+
coders = PairProgrammer::Api::Coder.list
|
221
|
+
Cli::Display.table(coders, ["id", "context", "requirements", "tasks", "created_at"])
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Cli
|
4
|
+
class Configuration
|
5
|
+
FILE_NAME = ".pear-programmer.yml"
|
6
|
+
|
7
|
+
def self.create(context, api_key, python_command)
|
8
|
+
if !["python", "python2", "python3", ""].include?(python_command)
|
9
|
+
raise "Invalid python command, please choose python, python2, or python3"
|
10
|
+
end
|
11
|
+
|
12
|
+
# the version of the configuration file, not cli version
|
13
|
+
config = {
|
14
|
+
"version" => 1.0,
|
15
|
+
"project_settings" => {
|
16
|
+
"context" => context,
|
17
|
+
},
|
18
|
+
"auth" => {
|
19
|
+
"api_key" => api_key,
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
if !python_command.empty?
|
24
|
+
config["commands"] = {
|
25
|
+
"python" => python_command
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
File.open(File.join(Dir.pwd, FILE_NAME), "w") do |file|
|
30
|
+
file.write(config.to_yaml)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
attr_accessor :root
|
35
|
+
def initialize
|
36
|
+
@root = Dir.pwd
|
37
|
+
@configuration_file_path = File.join(@root, FILE_NAME)
|
38
|
+
if !File.exists?(@configuration_file_path)
|
39
|
+
raise "Pear Programmer configuration file does not exist, please run 'pear-on init' or switch to working directory"
|
40
|
+
end
|
41
|
+
@configuration_file = YAML.load_file(@configuration_file_path)
|
42
|
+
|
43
|
+
# validations
|
44
|
+
if @configuration_file["auth"]&.[]("api_key").nil? || @configuration_file["auth"]["api_key"].empty?
|
45
|
+
raise "Pear Programmer api key is missing. Please add your api key to #{@configuration_file_path}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def python_command
|
50
|
+
command = @configuration_file&.[]("commands")&.[]("python")
|
51
|
+
if command.nil? || command.empty?
|
52
|
+
nil
|
53
|
+
else
|
54
|
+
command
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def api_key
|
59
|
+
@configuration_file["auth"]["api_key"]
|
60
|
+
end
|
61
|
+
|
62
|
+
def default_context
|
63
|
+
@configuration_file["project_settings"]["context"]
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def update_file(config)
|
69
|
+
File.open(@configuration_file_path, "w") { |file| file.write(config.to_yaml) }
|
70
|
+
@configuration_file = config
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/lib/cli/display.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'colorize'
|
2
|
+
require 'tty-spinner'
|
3
|
+
require 'diffy'
|
4
|
+
require 'terminal-table'
|
5
|
+
require 'tty-prompt'
|
6
|
+
|
7
|
+
module Cli
|
8
|
+
class Display
|
9
|
+
def self.message(role, content)
|
10
|
+
if role == "user"
|
11
|
+
puts("you".colorize(background: :green) + ": " + content)
|
12
|
+
else
|
13
|
+
puts("assistant".colorize(background: :yellow) + ": " + content)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.table(rows, headers)
|
18
|
+
rows = rows.map(&:values)
|
19
|
+
table = Terminal::Table.new(
|
20
|
+
headings: headers,
|
21
|
+
rows: rows
|
22
|
+
)
|
23
|
+
|
24
|
+
puts table
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.select(title, options)
|
28
|
+
prompt = TTY::Prompt.new
|
29
|
+
prompt.select(title.colorize(mode: :bold), options, per_page: 100, columnize: 2)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.confirmation(title)
|
33
|
+
while true do
|
34
|
+
puts(title.colorize(mode: :bold))
|
35
|
+
print("y/N: ".colorize(mode: :bold))
|
36
|
+
response = STDIN.gets.chomp.downcase
|
37
|
+
|
38
|
+
if response.empty?
|
39
|
+
puts("Response required".colorize(:red))
|
40
|
+
elsif response == "y"
|
41
|
+
return true
|
42
|
+
elsif response == "n"
|
43
|
+
return false
|
44
|
+
else
|
45
|
+
puts("Invalid response. Must be one letter, case insenstive".colorize(:red))
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.get_input(input)
|
51
|
+
print(input.colorize(mode: :bold))
|
52
|
+
STDIN.gets.chomp
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.success_message(message)
|
56
|
+
puts(message.colorize(:green))
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.error_message(message)
|
60
|
+
puts(message.colorize(:red))
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.info_message(message)
|
64
|
+
puts(message.colorize(color: :light_black))
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.spinner(title="running")
|
68
|
+
TTY::Spinner.new("[:spinner] #{title}", format: :spin_2)
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.dispaly_diff(original_content, new_content)
|
72
|
+
Diffy::Diff.new(original_content, new_content, source: 'strings').each do |line|
|
73
|
+
if line.start_with?("+")
|
74
|
+
puts line.colorize(:green)
|
75
|
+
elsif line.start_with?("-")
|
76
|
+
puts line.colorize(:red)
|
77
|
+
else
|
78
|
+
puts line
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
data/lib/cli/version.rb
ADDED
data/lib/cli.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
require_relative 'cli/actions'
|
2
|
+
require_relative 'pairprogrammer/configuration'
|
3
|
+
require_relative 'cli/configuration'
|
4
|
+
require 'optionparser'
|
5
|
+
require_relative 'cli/display'
|
6
|
+
|
7
|
+
|
8
|
+
command = ARGV[0]
|
9
|
+
|
10
|
+
if command == "init"
|
11
|
+
Cli::Actions.init
|
12
|
+
return
|
13
|
+
elsif command == "help"
|
14
|
+
Cli::Actions.help
|
15
|
+
return
|
16
|
+
end
|
17
|
+
|
18
|
+
config = Cli::Configuration.new
|
19
|
+
PairProgrammer::Configuration.root = config.root
|
20
|
+
PairProgrammer::Configuration.api_key = config.api_key
|
21
|
+
|
22
|
+
begin
|
23
|
+
if config.python_command
|
24
|
+
PairProgrammer::Configuration.python_command = config.python_command
|
25
|
+
end
|
26
|
+
|
27
|
+
Cli::Actions.check_cli_version
|
28
|
+
|
29
|
+
options = {}
|
30
|
+
case command
|
31
|
+
when 'coding'
|
32
|
+
subcommand = ARGV[1]
|
33
|
+
case subcommand
|
34
|
+
when "new"
|
35
|
+
Cli::Actions.create_coder(options)
|
36
|
+
when "start"
|
37
|
+
OptionParser.new do |opts|
|
38
|
+
opts.banner = "Usage: coder run [options]"
|
39
|
+
|
40
|
+
opts.on('-a', '--auto', 'Specify auto') do
|
41
|
+
options[:auto] = true
|
42
|
+
end
|
43
|
+
end.parse!
|
44
|
+
|
45
|
+
Cli::Actions.run_coder(options)
|
46
|
+
when "list"
|
47
|
+
Cli::Actions.list_coders(options)
|
48
|
+
else
|
49
|
+
Cli::Display.error_message "Invalid coding command"
|
50
|
+
Cli::Actions.help
|
51
|
+
end
|
52
|
+
else
|
53
|
+
Cli::Display.error_message "Invalid command"
|
54
|
+
Cli::Actions.help
|
55
|
+
end
|
56
|
+
rescue => e
|
57
|
+
Cli::Actions.report_exception(ARGV.join(" "), e)
|
58
|
+
end
|
59
|
+
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
require 'json'
|
4
|
+
require_relative '../configuration'
|
5
|
+
|
6
|
+
module PairProgrammer
|
7
|
+
module Api
|
8
|
+
class Client
|
9
|
+
def initialize
|
10
|
+
@api_key = PairProgrammer::Configuration.api_key
|
11
|
+
# TODO api key is not always required, ie checking version
|
12
|
+
raise "Missing api key" if @api_key.nil?
|
13
|
+
|
14
|
+
@domain = PairProgrammer::Configuration.development? ? 'http://localhost:8000' : 'https://www.pearprogrammer.dev'
|
15
|
+
end
|
16
|
+
|
17
|
+
def get(endpoint, query_params={})
|
18
|
+
encoded_params = URI.encode_www_form(query_params)
|
19
|
+
uri = URI.parse(@domain + endpoint + "?" + encoded_params)
|
20
|
+
|
21
|
+
# Create a new instance of Net::HTTP for the specified URI
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
23
|
+
|
24
|
+
# Use SSL if the URI scheme is HTTPS
|
25
|
+
http.use_ssl = true if uri.scheme == 'https'
|
26
|
+
|
27
|
+
# Set the open_timeout and read_timeout values (in seconds)
|
28
|
+
# http.open_timeout = 5 # Timeout for opening the connection
|
29
|
+
http.read_timeout = 605 # matches openai timeout
|
30
|
+
|
31
|
+
# Create a GET request
|
32
|
+
request = Net::HTTP::Get.new(uri)
|
33
|
+
request['Pairprogrammer-Api-Key'] = @api_key
|
34
|
+
|
35
|
+
# Make the HTTP GET request and handle the response
|
36
|
+
response = http.request(request)
|
37
|
+
handle_response(response)
|
38
|
+
end
|
39
|
+
|
40
|
+
def post(endpoint, body)
|
41
|
+
uri = URI.parse(@domain + endpoint)
|
42
|
+
request = Net::HTTP::Post.new(uri)
|
43
|
+
request.body = body.to_json
|
44
|
+
request['Content-Type'] = 'application/json'
|
45
|
+
request['Pairprogrammer-Api-Key'] = @api_key
|
46
|
+
|
47
|
+
# Create a new instance of Net::HTTP for the specified URI
|
48
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
49
|
+
|
50
|
+
# Use SSL if the URI scheme is HTTPS
|
51
|
+
http.use_ssl = true if uri.scheme == 'https'
|
52
|
+
|
53
|
+
# Set the open_timeout and read_timeout values (in seconds)
|
54
|
+
# http.open_timeout = 5 # Timeout for opening the connection
|
55
|
+
http.read_timeout = 300 # Timeout for reading the response
|
56
|
+
|
57
|
+
# Make the HTTP POST request and handle the response
|
58
|
+
response = http.request(request)
|
59
|
+
handle_response(response)
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def handle_response(response)
|
65
|
+
if response.code.to_i.between?(200, 299)
|
66
|
+
JSON.parse(response.body) if !response.body.empty?
|
67
|
+
else
|
68
|
+
raise "Error processing your request #{response.body}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require_relative 'client'
|
2
|
+
|
3
|
+
module PairProgrammer
|
4
|
+
module Api
|
5
|
+
class Coder
|
6
|
+
def self.create(tasks, context, requirements)
|
7
|
+
body = {
|
8
|
+
tasks: tasks,
|
9
|
+
context: context,
|
10
|
+
requirements: requirements,
|
11
|
+
}
|
12
|
+
response = Client.new.post('/api/v1/coder', body)
|
13
|
+
response["id"]
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.create_from_planner(planner_id)
|
17
|
+
body = {
|
18
|
+
from_planner: true,
|
19
|
+
planner_id: planner_id
|
20
|
+
}
|
21
|
+
response = Client.new.post('/api/v1/coder', body)
|
22
|
+
response["id"]
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.run(id)
|
26
|
+
body = {
|
27
|
+
id: id
|
28
|
+
}
|
29
|
+
Client.new.post('/api/v1/coder/run', body)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.append_output(id, output)
|
33
|
+
body = {
|
34
|
+
id: id,
|
35
|
+
output: output
|
36
|
+
}
|
37
|
+
Client.new.post('/api/v1/coder/append_output', body)
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.list
|
41
|
+
Client.new.get('/api/v1/coder/list', {})
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.add_user_message(id, message)
|
45
|
+
body = {
|
46
|
+
id: id,
|
47
|
+
message: message
|
48
|
+
}
|
49
|
+
Client.new.post('/api/v1/coder/user_message', body)
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.append_exception(id, e)
|
53
|
+
body = {
|
54
|
+
exception: e.class.to_s,
|
55
|
+
exception_message: e.message,
|
56
|
+
id: id
|
57
|
+
}
|
58
|
+
Client.new.post('/api/v1/coder/append_exception', body)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
|
2
|
+
module PairProgrammer
|
3
|
+
module Api
|
4
|
+
class Planner
|
5
|
+
def self.create(context, requirements)
|
6
|
+
body = {
|
7
|
+
context: context,
|
8
|
+
requirements: requirements,
|
9
|
+
}
|
10
|
+
response = Client.new.post('/api/v1/planner', body)
|
11
|
+
response["id"]
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.messages(id)
|
15
|
+
query = {
|
16
|
+
id: id
|
17
|
+
}
|
18
|
+
Client.new.get('/api/v1/planner/get_messages', query)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.run(id)
|
22
|
+
body = {
|
23
|
+
id: id
|
24
|
+
}
|
25
|
+
Client.new.post('/api/v1/planner/run', body)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.list
|
29
|
+
Client.new.get('/api/v1/planner/list', {})
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.respond(id, content)
|
33
|
+
body = {
|
34
|
+
id: id,
|
35
|
+
content: content
|
36
|
+
}
|
37
|
+
Client.new.post('/api/v1/planner/respond', body)
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.generate_tasks(id)
|
41
|
+
body = {
|
42
|
+
id: id
|
43
|
+
}
|
44
|
+
Client.new.post('/api/v1/planner/generate_tasks', body)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative 'client'
|
2
|
+
|
3
|
+
module PairProgrammer
|
4
|
+
module Api
|
5
|
+
class System
|
6
|
+
def self.versions
|
7
|
+
Client.new.get('/api/v1/versions', {})
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.client_exception(command, exception, version)
|
11
|
+
body = {
|
12
|
+
command: command,
|
13
|
+
exception: exception.class.to_s,
|
14
|
+
message: exception.message,
|
15
|
+
backtrace: exception.backtrace,
|
16
|
+
version: version
|
17
|
+
}
|
18
|
+
Client.new.post('/api/v1/client_exception', body)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require_relative 'configuration'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'open3'
|
4
|
+
|
5
|
+
module PairProgrammer
|
6
|
+
class Command
|
7
|
+
def self.display_command(command, arguments)
|
8
|
+
case command
|
9
|
+
when "yarn"
|
10
|
+
"running `yarn #{arguments["command"]}`"
|
11
|
+
when "mv"
|
12
|
+
puts "moving #{arguments["source"]} to #{arguments["destination"]}"
|
13
|
+
when "python"
|
14
|
+
"running `python #{arguments["command"]}`"
|
15
|
+
when "ls"
|
16
|
+
"listing files and directories for #{arguments["directory_path"]}"
|
17
|
+
when "bundle"
|
18
|
+
puts "running `bundle #{arguments["command"]}`"
|
19
|
+
when "rails"
|
20
|
+
"running `rails #{arguments["command"]}`"
|
21
|
+
when "comment"
|
22
|
+
nil
|
23
|
+
when "write_file"
|
24
|
+
puts "updating #{arguments["file_path"]}"
|
25
|
+
when "create_directory"
|
26
|
+
puts "creating directory #{arguments["directory_path"]}"
|
27
|
+
when "delete_file"
|
28
|
+
puts "deleting #{arguments["file_path"]}"
|
29
|
+
when "view_changes"
|
30
|
+
# TODO
|
31
|
+
when "delete_lines"
|
32
|
+
puts "deleting lines #{line_numbers} from #{arguments["file_path"]}"
|
33
|
+
when "rspec"
|
34
|
+
puts "running `rspec #{arguments["file_path"]}`"
|
35
|
+
when "ask_question"
|
36
|
+
nil # will ask question
|
37
|
+
when "read_file"
|
38
|
+
puts "reading #{arguments["file_path"]}"
|
39
|
+
when "update_file"
|
40
|
+
puts "updating #{arguments["file_path"]}"
|
41
|
+
when "create_file"
|
42
|
+
puts "creating #{arguments["file_path"]}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# default
|
47
|
+
@@default_commands = {
|
48
|
+
python: "python3",
|
49
|
+
}
|
50
|
+
|
51
|
+
def self.python_command
|
52
|
+
PairProgrammer::Configuration.python_command || @@default_commands[:python]
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.run_shell(command)
|
56
|
+
output = ""
|
57
|
+
Open3.popen2e(command) do |stdin, stdout_err, wait_thr|
|
58
|
+
while line = stdout_err.gets
|
59
|
+
output += line
|
60
|
+
end
|
61
|
+
end
|
62
|
+
output
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.run(command, arguments)
|
66
|
+
case command
|
67
|
+
when "update_file"
|
68
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
69
|
+
file_content = File.readlines(file_path)
|
70
|
+
file_content.insert(arguments["line_number"], arguments["content"])
|
71
|
+
File.open(file_path, 'w') do |file|
|
72
|
+
file_content.each { |line| file.puts(line) }
|
73
|
+
end
|
74
|
+
when "yarn"
|
75
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && yarn #{arguments["command"]}"
|
76
|
+
when "mv"
|
77
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && mv #{arguments["source"]} #{arguments["destination"]}"
|
78
|
+
when "python"
|
79
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && #{python_command} manage.py #{arguments["command"]}"
|
80
|
+
when "ls"
|
81
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && ls #{arguments["directory_path"]}"
|
82
|
+
when "bundle"
|
83
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && bundle #{arguments["command"]}"
|
84
|
+
when "rails"
|
85
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && rails #{arguments["command"]}"
|
86
|
+
when "comment"
|
87
|
+
"comment received"
|
88
|
+
when "write_file"
|
89
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
90
|
+
File.open(file_path, "w") do |file|
|
91
|
+
file.puts(arguments["content"])
|
92
|
+
end
|
93
|
+
when "create_directory"
|
94
|
+
directory_path = PairProgrammer::Configuration.absolute_path(arguments["directory_path"])
|
95
|
+
FileUtils.mkdir_p(directory_path)
|
96
|
+
when "delete_file"
|
97
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
98
|
+
File.delete(file_path)
|
99
|
+
when "view_changes"
|
100
|
+
# TODO
|
101
|
+
when "delete_lines"
|
102
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
103
|
+
line_numbers = arguments["line_numbers"]
|
104
|
+
|
105
|
+
# Read the contents of the file into memory
|
106
|
+
lines = File.readlines(file_path)
|
107
|
+
|
108
|
+
# Delete the specified lines from the contents
|
109
|
+
lines.delete_if.with_index { |line, index| line_numbers.include?(index) }
|
110
|
+
|
111
|
+
# Write the modified contents back to the file
|
112
|
+
File.write(file_path, lines.join)
|
113
|
+
when "rspec"
|
114
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
115
|
+
run_shell "cd #{PairProgrammer::Configuration.root} && rspec #{file_path}}"
|
116
|
+
when "ask_question"
|
117
|
+
puts arguments["question"]
|
118
|
+
STDIN.gets.chomp
|
119
|
+
when "read_file"
|
120
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
121
|
+
if File.exists?(file_path)
|
122
|
+
File.read(file_path)
|
123
|
+
else
|
124
|
+
"file does not exist"
|
125
|
+
end
|
126
|
+
when "create_file"
|
127
|
+
# TODO return response if file is already created
|
128
|
+
file_path = PairProgrammer::Configuration.absolute_path(arguments["file_path"])
|
129
|
+
FileUtils.touch(file_path)
|
130
|
+
else
|
131
|
+
raise "Invalid command: #{command}"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# TODO if a process hangs, ie it asks for user input, need to kill it or let the user interact with it
|
136
|
+
def self.run_shell(command)
|
137
|
+
logs = ""
|
138
|
+
Open3.popen2e(command) do |stdin, stdout_err, wait_thr|
|
139
|
+
logs = stdout_err.read
|
140
|
+
end
|
141
|
+
logs
|
142
|
+
end
|
143
|
+
|
144
|
+
|
145
|
+
def self.insert_content_at_line(file_path, content, line_number)
|
146
|
+
# Read the file's content into an array
|
147
|
+
file_content = File.readlines(file_path)
|
148
|
+
|
149
|
+
# Insert the new content at the desired line number
|
150
|
+
file_content.insert(line_number, content)
|
151
|
+
|
152
|
+
# Write the modified content back to the file
|
153
|
+
File.open(file_path, 'w') do |file|
|
154
|
+
file_content.each { |line| file.puts(line) }
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module PairProgrammer
|
2
|
+
class Configuration
|
3
|
+
|
4
|
+
@@api_key = nil
|
5
|
+
|
6
|
+
def self.root
|
7
|
+
@@root || ENV["PEAR_PROGRAMMER_ROOT"]
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.api_key
|
11
|
+
@@api_key || ENV["PEAR_PROGRAMMER_API_KEY"]
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.api_key=(api_key)
|
15
|
+
@@api_key = api_key
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.root=(root)
|
19
|
+
@@root = root
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.absolute_path(relative_path)
|
23
|
+
File.join(root, relative_path)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.python_command
|
27
|
+
@@python_command
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.python_command=(python_command)
|
31
|
+
available_commands = ["python", "python2", "python3"]
|
32
|
+
if available_commands.include?(python_command)
|
33
|
+
@@python_command = python_command
|
34
|
+
else
|
35
|
+
raise "Invalid python command - #{python_command} - command must be one of #{available_commands}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.development?
|
40
|
+
ENV["PEAR_PROGRAMMER_ENV"] == "development"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require 'cli/actions'
|
3
|
+
require 'cli/display'
|
4
|
+
require 'cli/configuration'
|
5
|
+
|
6
|
+
RSpec.describe Cli::Actions do
|
7
|
+
describe '.run_coder' do
|
8
|
+
let(:mock_api_response) {
|
9
|
+
{ 'running' => false, 'system_message' => { 'explanation' => 'This is an explanation', 'command' => 'comment', 'arguments' => { 'comment' => 'This is a comment.' } } }
|
10
|
+
}
|
11
|
+
|
12
|
+
let(:coder_list_response) {
|
13
|
+
[{ "requirements": "test", "id": "abcde" }]
|
14
|
+
}
|
15
|
+
|
16
|
+
xit 'calls the coder API, displays messages, and shows a comment' do
|
17
|
+
config = instance_double('Cli::Configuration')
|
18
|
+
allow(Cli::Configuration).to receive(:new).and_return(config)
|
19
|
+
|
20
|
+
allow(PairProgrammer::Api::Coder).to receive(:list).and_return(coder_list_response)
|
21
|
+
allow(PairProgrammer::Api::Coder).to receive(:run).and_return(mock_api_response)
|
22
|
+
|
23
|
+
expect(Cli::Display).to receive(:info_message).with('This is an explanation').ordered
|
24
|
+
expect(Cli::Display).to receive(:message).with('assistant', 'This is a comment.').ordered
|
25
|
+
|
26
|
+
Cli::Actions.run_coder({})
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# This file was generated by the `rspec --init` command. Conventionally, all
|
2
|
+
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
3
|
+
# Require this file using `require "spec_helper"` to ensure that it is only
|
4
|
+
# loaded once.
|
5
|
+
#
|
6
|
+
# See http://rspec.info/documentation/ for more information about RSpec
|
7
|
+
|
8
|
+
require 'webmock/rspec'
|
9
|
+
|
10
|
+
RSpec.configure do |config|
|
11
|
+
# Enable flags like --only-failures and --next-failure
|
12
|
+
config.example_status_persistence_file_path = "spec/examples.txt"
|
13
|
+
|
14
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
15
|
+
config.disable_monkey_patching!
|
16
|
+
|
17
|
+
config.expect_with :rspec do |c|
|
18
|
+
c.syntax = :expect
|
19
|
+
end
|
20
|
+
|
21
|
+
ENV["PEAR_PROGRAMMER_ENV"] = "development"
|
22
|
+
|
23
|
+
config.before(:each) do
|
24
|
+
stub_request(:any, /localhost/)
|
25
|
+
.to_return { |_request|
|
26
|
+
{status: 200, body: {response: 'stubbed response'}.to_json, headers: {'Content-Type' => 'application/json'}}
|
27
|
+
}
|
28
|
+
allow(PairProgrammer::Configuration).to receive(:api_key).and_return('fake_api_key')
|
29
|
+
end
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pear-programmer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sam Edelstein
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-06-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: open3
|
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: colorize
|
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: tty-spinner
|
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: terminal-table
|
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: diffy
|
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: tty-prompt
|
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
|
+
description: A Ruby command line interface gem for integrating with the coding assistant
|
98
|
+
API for various tasks
|
99
|
+
email:
|
100
|
+
- your.email@example.com
|
101
|
+
executables:
|
102
|
+
- pear-on
|
103
|
+
extensions: []
|
104
|
+
extra_rdoc_files: []
|
105
|
+
files:
|
106
|
+
- bin/pear-on
|
107
|
+
- lib/cli.rb
|
108
|
+
- lib/cli/actions.rb
|
109
|
+
- lib/cli/configuration.rb
|
110
|
+
- lib/cli/display.rb
|
111
|
+
- lib/cli/version.rb
|
112
|
+
- lib/pairprogrammer.rb
|
113
|
+
- lib/pairprogrammer/api/client.rb
|
114
|
+
- lib/pairprogrammer/api/coder.rb
|
115
|
+
- lib/pairprogrammer/api/planner.rb
|
116
|
+
- lib/pairprogrammer/api/system.rb
|
117
|
+
- lib/pairprogrammer/command.rb
|
118
|
+
- lib/pairprogrammer/configuration.rb
|
119
|
+
- lib/spec/cli/actions_spec.rb
|
120
|
+
- lib/spec/spec_helper.rb
|
121
|
+
homepage: https://github.com/username/pairprogrammer
|
122
|
+
licenses:
|
123
|
+
- MIT
|
124
|
+
metadata: {}
|
125
|
+
post_install_message:
|
126
|
+
rdoc_options: []
|
127
|
+
require_paths:
|
128
|
+
- lib
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
requirements: []
|
140
|
+
rubygems_version: 3.3.7
|
141
|
+
signing_key:
|
142
|
+
specification_version: 4
|
143
|
+
summary: Ruby CLI for interacting with coding assistant API
|
144
|
+
test_files: []
|