n2b 0.1.5 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +47 -3
- data/lib/n2b/base.rb +61 -0
- data/lib/n2b/cli.rb +190 -0
- data/lib/n2b/irb.rb +78 -0
- data/lib/n2b/llm/claude.rb +14 -3
- data/lib/n2b/llm/open_ai.rb +1 -1
- data/lib/n2b/version.rb +1 -1
- data/lib/n2b.rb +5 -244
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a2ca1c8cbf2140daf33550d891f390a389f2150f0c960228da34eb421cdd577d
|
4
|
+
data.tar.gz: eb67739e750eec2f0257469397b97f183ac96a2640ba8d8df67f2f7ba8f73b42
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1130910ebe431f6136d5ec1a3f268d898f897ead246176b8558b7742a77e21df788e2e127f8347fcd3d579f0694b7f3240f3a131ac58f65e634b1e8f1cee137c
|
7
|
+
data.tar.gz: 7bed17c961f2fe3d15b418aee78daf8e2a45e64b4e7298db9b183a73c32f03ab097316e819b6926480e5690cac5463b36aef031eeb39d664e4bc6a541617257e
|
data/README.md
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
# N2B: Natural Language to Bash Commands Converter
|
2
2
|
|
3
3
|
N2B (Natural to Bash) is a Ruby gem that converts natural language instructions into executable shell commands using the Claude AI or OpenAI API. It's designed to help users quickly generate shell commands without needing to remember exact syntax.
|
4
|
+
Also it has the n2r method which can help you with any Ruby or Rails related issues
|
4
5
|
|
5
6
|
## Features
|
6
7
|
|
8
|
+
### N2B
|
9
|
+
|
7
10
|
- Convert natural language to shell commands
|
8
11
|
- Support for multiple Claude AI models (Haiku, Sonnet, Sonnet 3.5)
|
9
12
|
- Support for OpenAI models
|
@@ -12,7 +15,13 @@ N2B (Natural to Bash) is a Ruby gem that converts natural language instructions
|
|
12
15
|
- Shell history integration
|
13
16
|
- Command history tracking for improved context
|
14
17
|
|
15
|
-
|
18
|
+
### N2R
|
19
|
+
- Convert natural language to ruby code or explain it
|
20
|
+
- analyze an exception and find the cause
|
21
|
+
- analyze existing ruby files
|
22
|
+
|
23
|
+
|
24
|
+
## Quick Example N2B
|
16
25
|
|
17
26
|
```
|
18
27
|
n2b init a new github repo called abc, add local files, transmit
|
@@ -31,6 +40,27 @@ git push -u origin main
|
|
31
40
|
Explanation:
|
32
41
|
These commands initialize a new Git repository, add a remote GitHub repository named 'abc', stage all local files, create an initial commit, and push the changes to GitHub. Replace 'yourusername' with your actual GitHub username. Note that you'll need to create the repository on GitHub first before running these commands. Also, ensure you have Git installed and configured with your GitHub credentials.
|
33
42
|
```
|
43
|
+
|
44
|
+
## Quick example n2r
|
45
|
+
|
46
|
+
```
|
47
|
+
irb
|
48
|
+
require 'n2b'
|
49
|
+
n2r 4544 # results in exception
|
50
|
+
n2r "what is the bug",exception:_
|
51
|
+
```
|
52
|
+
|
53
|
+
result
|
54
|
+
```
|
55
|
+
input_string.to_s.scan(/[\/\w.-]+\.rb(?=\s|:|$)/)
|
56
|
+
```
|
57
|
+
Explanation
|
58
|
+
The error `undefined method 'scan' for 7767:Integer` occurs because the method `scan` is being called on an integer instead of a string. To fix the issue, we need to ensure that `input_string` is a string before calling the `scan` method on it. Here's the corrected part of the code that converts `input_string` to a string before using `scan`:
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
input_string.to_s.scan(/[\/\w.-]+\.rb(?=\s|:|$)/)
|
62
|
+
```
|
63
|
+
|
34
64
|
------------------------
|
35
65
|
## Installation
|
36
66
|
|
@@ -43,8 +73,8 @@ Before using n2b, you need to configure it with your Claude API key and preferen
|
|
43
73
|
n2b -c
|
44
74
|
|
45
75
|
This will prompt you to enter:
|
46
|
-
- Your Claude API key
|
47
|
-
- Preferred
|
76
|
+
- Your Claude API or OpenAI key
|
77
|
+
- Preferred model (e.g. haiku, sonnet, or sonnet35)
|
48
78
|
- Privacy settings (whether to send shell history, past requests, current directory)
|
49
79
|
- Whether to append generated commands to your shell history
|
50
80
|
|
@@ -75,6 +105,11 @@ Examples:
|
|
75
105
|
|
76
106
|
```n2b -c ```
|
77
107
|
|
108
|
+
|
109
|
+
n2r in ruby or rails console
|
110
|
+
n2r "your question", files:['file1.rb', 'file2.rb'], exception: AnError
|
111
|
+
only question is mandatory
|
112
|
+
|
78
113
|
## How It Works
|
79
114
|
|
80
115
|
1. N2B takes your natural language input and sends it to the Claude AI API.
|
@@ -97,6 +132,15 @@ Always sent to llm
|
|
97
132
|
- shell type
|
98
133
|
- operating system
|
99
134
|
|
135
|
+
## Rails
|
136
|
+
|
137
|
+
in rails console use
|
138
|
+
|
139
|
+
```
|
140
|
+
include N2B::IRB
|
141
|
+
```
|
142
|
+
|
143
|
+
to get n2r
|
100
144
|
|
101
145
|
## Limitations
|
102
146
|
|
data/lib/n2b/base.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
module N2B
|
2
|
+
class Base
|
3
|
+
|
4
|
+
CONFIG_FILE = File.expand_path('~/.n2b/config.yml')
|
5
|
+
HISTORY_FILE = File.expand_path('~/.n2b/history')
|
6
|
+
|
7
|
+
def load_config
|
8
|
+
if File.exist?(CONFIG_FILE)
|
9
|
+
YAML.load_file(CONFIG_FILE)
|
10
|
+
else
|
11
|
+
{ }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_config( reconfigure: false)
|
16
|
+
config = load_config
|
17
|
+
api_key = ENV['CLAUDE_API_KEY'] || config['access_key']
|
18
|
+
model = config['model'] || 'sonnet35'
|
19
|
+
|
20
|
+
if api_key.nil? || api_key == '' || reconfigure
|
21
|
+
print "choose a language model to use (1:claude, 2:openai) #{ config['llm'] }: "
|
22
|
+
llm = $stdin.gets.chomp
|
23
|
+
llm = config['llm'] if llm.empty?
|
24
|
+
unless ['claude', 'openai','1','2'].include?(llm)
|
25
|
+
puts "Invalid language model. Choose from: claude, openai"
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
llm = 'claude' if llm == '1'
|
29
|
+
llm = 'openai' if llm == '2'
|
30
|
+
llm_class = llm == 'openai' ? N2M::Llm::OpenAi : N2M::Llm::Claude
|
31
|
+
|
32
|
+
print "Enter your #{llm} API key: #{ api_key.nil? || api_key.empty? ? '' : '(leave blank to keep the current key '+api_key[0..10]+'...)' }"
|
33
|
+
api_key = $stdin.gets.chomp
|
34
|
+
api_key = config['access_key'] if api_key.empty?
|
35
|
+
print "Choose a model (#{ llm_class::MODELS.keys }, #{ llm_class::MODELS.keys.first } default): "
|
36
|
+
model = $stdin.gets.chomp
|
37
|
+
model = llm_class::MODELS.keys.first if model.empty?
|
38
|
+
config['llm'] = llm
|
39
|
+
config['access_key'] = api_key
|
40
|
+
config['model'] = model
|
41
|
+
unless llm_class::MODELS.keys.include?(model)
|
42
|
+
puts "Invalid model. Choose from: #{llm_class::MODELS.keys.join(', ')}"
|
43
|
+
exit 1
|
44
|
+
end
|
45
|
+
puts "configure privacy settings directly in the config file #{CONFIG_FILE}"
|
46
|
+
config['privacy'] ||= {}
|
47
|
+
config['privacy']['send_shell_history'] = false
|
48
|
+
config['privacy']['send_llm_history'] = true
|
49
|
+
config['privacy']['send_current_directory'] =true
|
50
|
+
config['append_to_shell_history'] = false
|
51
|
+
puts "Current configuration: #{config['privacy']}"
|
52
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_FILE)) unless File.exist?(File.dirname(CONFIG_FILE))
|
53
|
+
File.open(CONFIG_FILE, 'w+') do |f|
|
54
|
+
f.write(config.to_yaml )
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
config
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
data/lib/n2b/cli.rb
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
module N2B
|
2
|
+
class CLI < Base
|
3
|
+
def self.run(args)
|
4
|
+
new(args).execute
|
5
|
+
end
|
6
|
+
|
7
|
+
def initialize(args)
|
8
|
+
@args = args
|
9
|
+
@options = parse_options
|
10
|
+
end
|
11
|
+
|
12
|
+
def execute
|
13
|
+
config = get_config(reconfigure: @options[:config])
|
14
|
+
input_text = @args.join(' ')
|
15
|
+
if input_text.empty?
|
16
|
+
puts "Enter your natural language command:"
|
17
|
+
input_text = $stdin.gets.chomp
|
18
|
+
end
|
19
|
+
|
20
|
+
bash_commands = call_llm(input_text, config)
|
21
|
+
|
22
|
+
puts "\nTranslated #{get_user_shell} Commands:"
|
23
|
+
puts "------------------------"
|
24
|
+
puts bash_commands['commands']
|
25
|
+
puts "------------------------"
|
26
|
+
if bash_commands['explanation']
|
27
|
+
puts "Explanation:"
|
28
|
+
puts bash_commands['explanation']
|
29
|
+
puts "------------------------"
|
30
|
+
end
|
31
|
+
|
32
|
+
if @options[:execute]
|
33
|
+
puts "Press Enter to execute these commands, or Ctrl+C to cancel."
|
34
|
+
$stdin.gets
|
35
|
+
system(bash_commands['commands'].join("\n"))
|
36
|
+
else
|
37
|
+
add_to_shell_history(bash_commands['commands'].join("\n")) if config['append_to_shell_history']
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
|
44
|
+
|
45
|
+
def append_to_llm_history_file(commands)
|
46
|
+
File.open(HISTORY_FILE, 'a') do |file|
|
47
|
+
file.puts(commands)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def read_llm_history_file
|
52
|
+
history = File.read(HISTORY_FILE) if File.exist?(HISTORY_FILE)
|
53
|
+
history ||= ''
|
54
|
+
# limit to 20 most recent commands
|
55
|
+
history.split("\n").last(20).join("\n")
|
56
|
+
end
|
57
|
+
|
58
|
+
def call_llm(prompt, config)
|
59
|
+
|
60
|
+
llm = config['llm'] == 'openai' ? N2M::Llm::OpenAi.new(config) : N2M::Llm::Claude.new(config)
|
61
|
+
|
62
|
+
content = <<-EOF
|
63
|
+
Translate the following natural language command to bash commands: #{prompt}\n\nProvide only the #{get_user_shell} commands for #{ get_user_os }. the commands should be separated by newlines.
|
64
|
+
#{' the user is in directory'+Dir.pwd if config['privacy']['send_current_directory']}.
|
65
|
+
#{' the user sent past requests to you and got these answers '+read_llm_history_file if config['privacy']['send_llm_history'] }
|
66
|
+
#{ "The user has this history for his shell. "+read_shell_history if config['privacy']['send_shell_history'] }
|
67
|
+
he is using #{File.basename(get_user_shell)} shell."
|
68
|
+
answer only with a valid json object with the key 'commands' and the value as a list of bash commands plus any additional information you want to provide in explanation.
|
69
|
+
{ "commands": [ "echo 'Hello, World!'" ], "explanation": "This command prints 'Hello, World!' to the terminal."}
|
70
|
+
EOF
|
71
|
+
|
72
|
+
|
73
|
+
answer = llm.make_request(content)
|
74
|
+
|
75
|
+
append_to_llm_history_file("#{prompt}\n#{answer}")
|
76
|
+
answer
|
77
|
+
end
|
78
|
+
|
79
|
+
def get_user_shell
|
80
|
+
ENV['SHELL'] || `getent passwd #{ENV['USER']}`.split(':')[6]
|
81
|
+
end
|
82
|
+
|
83
|
+
def get_user_os
|
84
|
+
case RbConfig::CONFIG['host_os']
|
85
|
+
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
86
|
+
:windows
|
87
|
+
when /darwin|mac os/
|
88
|
+
:macos
|
89
|
+
when /linux/
|
90
|
+
:linux
|
91
|
+
when /solaris|bsd/
|
92
|
+
:unix
|
93
|
+
else
|
94
|
+
:unknown
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def find_history_file(shell)
|
99
|
+
case shell
|
100
|
+
when 'zsh'
|
101
|
+
[
|
102
|
+
ENV['HISTFILE'],
|
103
|
+
File.expand_path('~/.zsh_history'),
|
104
|
+
File.expand_path('~/.zhistory')
|
105
|
+
].find { |f| f && File.exist?(f) }
|
106
|
+
when 'bash'
|
107
|
+
[
|
108
|
+
ENV['HISTFILE'],
|
109
|
+
File.expand_path('~/.bash_history')
|
110
|
+
].find { |f| f && File.exist?(f) }
|
111
|
+
else
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def read_shell_history()
|
117
|
+
shell = File.basename(get_user_shell)
|
118
|
+
history_file = find_history_file(shell)
|
119
|
+
return '' unless history_file
|
120
|
+
|
121
|
+
File.read(history_file)
|
122
|
+
end
|
123
|
+
|
124
|
+
def add_to_shell_history(commands)
|
125
|
+
shell = File.basename(get_user_shell)
|
126
|
+
history_file = find_history_file(shell)
|
127
|
+
|
128
|
+
unless history_file
|
129
|
+
puts "Could not find history file for #{shell}. Cannot add commands to history."
|
130
|
+
return
|
131
|
+
end
|
132
|
+
|
133
|
+
case shell
|
134
|
+
when 'zsh'
|
135
|
+
add_to_zsh_history(commands, history_file)
|
136
|
+
when 'bash'
|
137
|
+
add_to_bash_history(commands, history_file)
|
138
|
+
else
|
139
|
+
puts "Unsupported shell: #{shell}. Cannot add commands to history."
|
140
|
+
return
|
141
|
+
end
|
142
|
+
|
143
|
+
puts "Commands have been added to your #{shell} history file: #{history_file}"
|
144
|
+
puts "You may need to start a new shell session or reload your history to see the changes. #{ shell == 'zsh' ? 'For example, run `fc -R` in your zsh session.' : 'history -r for bash' }"
|
145
|
+
puts "Then you can access them using the up arrow key or Ctrl+R for reverse search."
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_to_zsh_history(commands, history_file)
|
149
|
+
File.open(history_file, 'a') do |file|
|
150
|
+
commands.each_line do |cmd|
|
151
|
+
timestamp = Time.now.to_i
|
152
|
+
file.puts(": #{timestamp}:0;#{cmd.strip}")
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def add_to_bash_history(commands, history_file)
|
158
|
+
File.open(history_file, 'a') do |file|
|
159
|
+
commands.each_line do |cmd|
|
160
|
+
file.puts(cmd.strip)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
system("history -r") # Attempt to reload history in current session
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
def parse_options
|
168
|
+
options = { execute: false, config: nil }
|
169
|
+
|
170
|
+
OptionParser.new do |opts|
|
171
|
+
opts.banner = "Usage: n2b [options] [natural language command]"
|
172
|
+
|
173
|
+
opts.on('-x', '--execute', 'Execute the commands after confirmation') do
|
174
|
+
options[:execute] = true
|
175
|
+
end
|
176
|
+
|
177
|
+
opts.on('-h', '--help', 'Print this help') do
|
178
|
+
puts opts
|
179
|
+
exit
|
180
|
+
end
|
181
|
+
|
182
|
+
opts.on('-c', '--config', 'Configure the API key and model') do
|
183
|
+
options[:config] = true
|
184
|
+
end
|
185
|
+
end.parse!(@args)
|
186
|
+
|
187
|
+
options
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
data/lib/n2b/irb.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
module N2B
|
2
|
+
module IRB
|
3
|
+
MAX_SOURCE_FILES = 4
|
4
|
+
def n2r(input_string='', files: [], exception: nil)
|
5
|
+
config = N2B::Base.new.get_config
|
6
|
+
llm = config['llm'] == 'openai' ? N2M::Llm::OpenAi.new(config) : N2M::Llm::Claude.new(config)
|
7
|
+
# detect if inside rails console
|
8
|
+
console = case
|
9
|
+
when defined?(Rails)
|
10
|
+
"You are in a Rails console"
|
11
|
+
when defined?(IRB)
|
12
|
+
"You are in an IRB console"
|
13
|
+
else
|
14
|
+
"You are in a standard Ruby console"
|
15
|
+
end
|
16
|
+
get_defined_classes = ObjectSpace.each_object(Class).to_a
|
17
|
+
get_gemfile = File.read('Gemfile') if File.exist?('Gemfile')
|
18
|
+
# scan the input for any files that the user has provided
|
19
|
+
# look for strings that end with .rb and get the path to the file
|
20
|
+
source_files = []
|
21
|
+
input_string.scan(/[\w\/.-]+\.rb(?=\s|:|$)/).each do |file|
|
22
|
+
full_path = File.expand_path(file) # Resolve the full path
|
23
|
+
source_files << full_path if File.exist?(full_path)
|
24
|
+
end
|
25
|
+
if exception
|
26
|
+
source_files += exception.backtrace.map do |line|
|
27
|
+
line.split(':').first
|
28
|
+
end
|
29
|
+
input_string << ' ' << exception.message
|
30
|
+
end
|
31
|
+
source_files = source_files.reverse.sort_by do |file|
|
32
|
+
# Check if the file path starts with the current directory path
|
33
|
+
if file.start_with?(Dir.pwd)
|
34
|
+
0 # Prioritize files in or below the current directory
|
35
|
+
else
|
36
|
+
1 # Keep other files in their original order
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
file_content = (files+source_files[0..MAX_SOURCE_FILES-1]).inject({}) do |h,file|
|
42
|
+
h[file] = File.read(file) if File.exist?(file)
|
43
|
+
h
|
44
|
+
end
|
45
|
+
content = <<~HEREDOC
|
46
|
+
you are a professional ruby programmer
|
47
|
+
#{ console}
|
48
|
+
The following classes are defined in this session:
|
49
|
+
#{ get_defined_classes}
|
50
|
+
#{ get_gemfile }
|
51
|
+
#{ @n2r_answers ? "user have made #{@n2r_answers} before" : "" }
|
52
|
+
your task is to give the user guidance on how perform a task he is asking for
|
53
|
+
if he pasts an error or backtrace, you can provide a solution to the problem.
|
54
|
+
if you need files you can ask the user to provide them request.
|
55
|
+
he can send them with n2r "his question" files: ['file1.rb', 'file2.rb']
|
56
|
+
if he sends files and you mention them in the response, provide the file name of the snippets you are referring to.
|
57
|
+
answer in a valid json object with the key 'code' with only the ruby code to be executed and a key 'explanation' with a markdown string with the explanation and the code.
|
58
|
+
{ "code": "puts 'Hello, World!'", "explanation": "### Explanation \n This command ´´´puts 'Hello, world!'´´´ prints 'Hello, World!' to the terminal.", files: ['file1.rb', 'file2.rb']}
|
59
|
+
#{input_string}
|
60
|
+
#{ "the user provided the following files: #{ file_content.collect{|k,v| "#{k}:#{v}" }.join("\n") }" if file_content }
|
61
|
+
}}
|
62
|
+
HEREDOC
|
63
|
+
@n2r_answers ||= []
|
64
|
+
@n2r_answer = llm.make_request(content)
|
65
|
+
@n2r_answers << { input: input_string, output: @n2r_answer }
|
66
|
+
@n2r_answer['code'].split("\n").each do |line|
|
67
|
+
puts line
|
68
|
+
end if @n2r_answer['code']
|
69
|
+
@n2r_answer['explanation'].split("\n").each do |line|
|
70
|
+
puts line
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Include the module in the main object if in console
|
78
|
+
include N2B::IRB if defined?(IRB)
|
data/lib/n2b/llm/claude.rb
CHANGED
@@ -37,11 +37,22 @@ module N2M
|
|
37
37
|
end
|
38
38
|
answer = JSON.parse(response.body)['content'].first['text']
|
39
39
|
begin
|
40
|
-
|
41
|
-
|
40
|
+
File.open('llm_response.json', 'w') do |f|
|
41
|
+
f.write(answer)
|
42
|
+
end
|
43
|
+
# remove everything before the first { and after the last }
|
44
|
+
|
45
|
+
answer = answer.sub(/.*?\{(.*)\}.*/m, '{\1}') unless answer.start_with?('{')
|
46
|
+
# gsub all \n with \\n that are inside "
|
47
|
+
#
|
48
|
+
answer.gsub!(/"([^"]*)"/) { |match| match.gsub(/\n/, "\\n") }
|
49
|
+
File.open('llm_response.json', 'w') do |f|
|
50
|
+
f.write(answer)
|
51
|
+
end
|
42
52
|
answer = JSON.parse(answer)
|
43
53
|
rescue JSON::ParserError
|
44
|
-
|
54
|
+
puts "Error parsing JSON: #{answer}"
|
55
|
+
answer = { 'explanation' => answer}
|
45
56
|
end
|
46
57
|
answer
|
47
58
|
end
|
data/lib/n2b/llm/open_ai.rb
CHANGED
@@ -6,7 +6,7 @@ module N2M
|
|
6
6
|
module Llm
|
7
7
|
class OpenAi
|
8
8
|
API_URI = URI.parse('https://api.openai.com/v1/chat/completions')
|
9
|
-
MODELS = { 'gpt-4o' => 'gpt-4o', 'gpt-35' => 'gpt-3.5-turbo-1106' }
|
9
|
+
MODELS = { 'gpt-4o' => 'gpt-4o','gpt-4o-mini'=>'gpt-4o-mini', 'gpt-35' => 'gpt-3.5-turbo-1106' }
|
10
10
|
|
11
11
|
def initialize(config)
|
12
12
|
@config = config
|
data/lib/n2b/version.rb
CHANGED
data/lib/n2b.rb
CHANGED
@@ -8,251 +8,12 @@ require 'fileutils'
|
|
8
8
|
require 'n2b/version'
|
9
9
|
require 'n2b/llm/claude'
|
10
10
|
require 'n2b/llm/open_ai'
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
CONFIG_FILE = File.expand_path('~/.n2b/config.yml')
|
15
|
-
HISTORY_FILE = File.expand_path('~/.n2b/history')
|
16
|
-
|
17
|
-
class CLI
|
18
|
-
def self.run(args)
|
19
|
-
new(args).execute
|
20
|
-
end
|
21
|
-
|
22
|
-
def initialize(args)
|
23
|
-
@args = args
|
24
|
-
@options = parse_options
|
25
|
-
end
|
26
|
-
|
27
|
-
def execute
|
28
|
-
config = get_config(reconfigure: @options[:config])
|
29
|
-
input_text = @args.join(' ')
|
30
|
-
if input_text.empty?
|
31
|
-
puts "Enter your natural language command:"
|
32
|
-
input_text = $stdin.gets.chomp
|
33
|
-
end
|
34
|
-
|
35
|
-
bash_commands = call_llm(input_text, config)
|
36
|
-
|
37
|
-
puts "\nTranslated #{get_user_shell} Commands:"
|
38
|
-
puts "------------------------"
|
39
|
-
puts bash_commands['commands']
|
40
|
-
puts "------------------------"
|
41
|
-
if bash_commands['explanation']
|
42
|
-
puts "Explanation:"
|
43
|
-
puts bash_commands['explanation']
|
44
|
-
puts "------------------------"
|
45
|
-
end
|
46
|
-
|
47
|
-
if @options[:execute]
|
48
|
-
puts "Press Enter to execute these commands, or Ctrl+C to cancel."
|
49
|
-
$stdin.gets
|
50
|
-
system(bash_commands['commands'].join("\n"))
|
51
|
-
else
|
52
|
-
add_to_shell_history(bash_commands['commands'].join("\n")) if config['append_to_shell_history']
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
private
|
11
|
+
require 'n2b/base'
|
12
|
+
require 'n2b/cli'
|
57
13
|
|
58
|
-
|
59
|
-
if File.exist?(CONFIG_FILE)
|
60
|
-
YAML.load_file(CONFIG_FILE)
|
61
|
-
else
|
62
|
-
{ }
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
def get_config( reconfigure: false)
|
67
|
-
config = load_config
|
68
|
-
api_key = ENV['CLAUDE_API_KEY'] || config['access_key']
|
69
|
-
model = config['model'] || 'sonnet35'
|
70
|
-
|
71
|
-
if api_key.nil? || api_key == '' || reconfigure
|
72
|
-
print "choose a language model to use (claude, openai): "
|
73
|
-
llm = $stdin.gets.chomp
|
74
|
-
unless ['claude', 'openai'].include?(llm)
|
75
|
-
puts "Invalid language model. Choose from: claude, openai"
|
76
|
-
exit 1
|
77
|
-
end
|
78
|
-
llm_class = llm == 'openai' ? N2M::Llm::OpenAi : N2M::Llm::Claude
|
14
|
+
require 'n2b/irb'
|
79
15
|
|
80
|
-
|
81
|
-
|
82
|
-
api_key = config['access_key'] if api_key.empty?
|
83
|
-
print "Choose a model (#{ llm_class::MODELS.keys }, #{ llm_class::MODELS.keys.first } default): "
|
84
|
-
model = $stdin.gets.chomp
|
85
|
-
model = llm_class::MODELS.keys.first if model.empty?
|
86
|
-
config['llm'] = llm
|
87
|
-
config['access_key'] = api_key
|
88
|
-
config['model'] = model
|
89
|
-
unless llm_class::MODELS.keys.include?(model)
|
90
|
-
puts "Invalid model. Choose from: #{llm_class::MODELS.keys.join(', ')}"
|
91
|
-
exit 1
|
92
|
-
end
|
93
|
-
|
94
|
-
config['privacy'] ||= {}
|
95
|
-
print "Do you want to send your shell history to #{llm}? (y/n): "
|
96
|
-
config['privacy']['send_shell_history'] = $stdin.gets.chomp == 'y'
|
97
|
-
print "Do you want to send your past requests and answers to #{llm}? (y/n): "
|
98
|
-
config['privacy']['send_llm_history'] = $stdin.gets.chomp == 'y'
|
99
|
-
print "Do you want to send your current directory to #{llm}? (y/n): "
|
100
|
-
config['privacy']['send_current_directory'] = $stdin.gets.chomp == 'y'
|
101
|
-
print "Do you want to append the commands to your shell history? (y/n): "
|
102
|
-
config['append_to_shell_history'] = $stdin.gets.chomp == 'y'
|
103
|
-
|
104
|
-
FileUtils.mkdir_p(File.dirname(CONFIG_FILE)) unless File.exist?(File.dirname(CONFIG_FILE))
|
105
|
-
File.open(CONFIG_FILE, 'w+') do |f|
|
106
|
-
f.write(config.to_yaml )
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
|
-
config
|
111
|
-
end
|
112
|
-
|
113
|
-
def append_to_llm_history_file(commands)
|
114
|
-
File.open(HISTORY_FILE, 'a') do |file|
|
115
|
-
file.puts(commands)
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
|
-
def read_llm_history_file
|
120
|
-
history = File.read(HISTORY_FILE) if File.exist?(HISTORY_FILE)
|
121
|
-
history ||= ''
|
122
|
-
# limit to 20 most recent commands
|
123
|
-
history.split("\n").last(20).join("\n")
|
124
|
-
end
|
125
|
-
|
126
|
-
def call_llm(prompt, config)
|
127
|
-
|
128
|
-
llm = config['llm'] == 'openai' ? N2M::Llm::OpenAi.new(config) : N2M::Llm::Claude.new(config)
|
129
|
-
|
130
|
-
content = <<-EOF
|
131
|
-
Translate the following natural language command to bash commands: #{prompt}\n\nProvide only the #{get_user_shell} commands for #{ get_user_os }. the commands should be separated by newlines.
|
132
|
-
#{' the user is in directory'+Dir.pwd if config['privacy']['send_current_directory']}.
|
133
|
-
#{' the user sent past requests to you and got these answers '+read_llm_history_file if config['privacy']['send_llm_history'] }
|
134
|
-
#{ "The user has this history for his shell. "+read_shell_history if config['privacy']['send_shell_history'] }
|
135
|
-
he is using #{File.basename(get_user_shell)} shell."
|
136
|
-
answer only with a valid json object with the key 'commands' and the value as a list of bash commands plus any additional information you want to provide in explanation.
|
137
|
-
{ "commands": [ "echo 'Hello, World!'" ], "explanation": "This command prints 'Hello, World!' to the terminal."}
|
138
|
-
EOF
|
139
|
-
|
140
|
-
|
141
|
-
answer = llm.make_request(content)
|
142
|
-
|
143
|
-
append_to_llm_history_file("#{prompt}\n#{answer}")
|
144
|
-
answer
|
145
|
-
end
|
146
|
-
|
147
|
-
def get_user_shell
|
148
|
-
ENV['SHELL'] || `getent passwd #{ENV['USER']}`.split(':')[6]
|
149
|
-
end
|
150
|
-
|
151
|
-
def get_user_os
|
152
|
-
case RbConfig::CONFIG['host_os']
|
153
|
-
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
154
|
-
:windows
|
155
|
-
when /darwin|mac os/
|
156
|
-
:macos
|
157
|
-
when /linux/
|
158
|
-
:linux
|
159
|
-
when /solaris|bsd/
|
160
|
-
:unix
|
161
|
-
else
|
162
|
-
:unknown
|
163
|
-
end
|
164
|
-
end
|
165
|
-
|
166
|
-
def find_history_file(shell)
|
167
|
-
case shell
|
168
|
-
when 'zsh'
|
169
|
-
[
|
170
|
-
ENV['HISTFILE'],
|
171
|
-
File.expand_path('~/.zsh_history'),
|
172
|
-
File.expand_path('~/.zhistory')
|
173
|
-
].find { |f| f && File.exist?(f) }
|
174
|
-
when 'bash'
|
175
|
-
[
|
176
|
-
ENV['HISTFILE'],
|
177
|
-
File.expand_path('~/.bash_history')
|
178
|
-
].find { |f| f && File.exist?(f) }
|
179
|
-
else
|
180
|
-
nil
|
181
|
-
end
|
182
|
-
end
|
183
|
-
|
184
|
-
def read_shell_history()
|
185
|
-
shell = File.basename(get_user_shell)
|
186
|
-
history_file = find_history_file(shell)
|
187
|
-
return '' unless history_file
|
188
|
-
|
189
|
-
File.read(history_file)
|
190
|
-
end
|
191
|
-
|
192
|
-
def add_to_shell_history(commands)
|
193
|
-
shell = File.basename(get_user_shell)
|
194
|
-
history_file = find_history_file(shell)
|
195
|
-
|
196
|
-
unless history_file
|
197
|
-
puts "Could not find history file for #{shell}. Cannot add commands to history."
|
198
|
-
return
|
199
|
-
end
|
200
|
-
|
201
|
-
case shell
|
202
|
-
when 'zsh'
|
203
|
-
add_to_zsh_history(commands, history_file)
|
204
|
-
when 'bash'
|
205
|
-
add_to_bash_history(commands, history_file)
|
206
|
-
else
|
207
|
-
puts "Unsupported shell: #{shell}. Cannot add commands to history."
|
208
|
-
return
|
209
|
-
end
|
210
|
-
|
211
|
-
puts "Commands have been added to your #{shell} history file: #{history_file}"
|
212
|
-
puts "You may need to start a new shell session or reload your history to see the changes. #{ shell == 'zsh' ? 'For example, run `fc -R` in your zsh session.' : 'history -r for bash' }"
|
213
|
-
puts "Then you can access them using the up arrow key or Ctrl+R for reverse search."
|
214
|
-
end
|
215
|
-
|
216
|
-
def add_to_zsh_history(commands, history_file)
|
217
|
-
File.open(history_file, 'a') do |file|
|
218
|
-
commands.each_line do |cmd|
|
219
|
-
timestamp = Time.now.to_i
|
220
|
-
file.puts(": #{timestamp}:0;#{cmd.strip}")
|
221
|
-
end
|
222
|
-
end
|
223
|
-
end
|
224
|
-
|
225
|
-
def add_to_bash_history(commands, history_file)
|
226
|
-
File.open(history_file, 'a') do |file|
|
227
|
-
commands.each_line do |cmd|
|
228
|
-
file.puts(cmd.strip)
|
229
|
-
end
|
230
|
-
end
|
231
|
-
system("history -r") # Attempt to reload history in current session
|
232
|
-
end
|
233
|
-
|
234
|
-
|
235
|
-
def parse_options
|
236
|
-
options = { execute: false, config: nil }
|
237
|
-
|
238
|
-
OptionParser.new do |opts|
|
239
|
-
opts.banner = "Usage: n2b [options] [natural language command]"
|
240
|
-
|
241
|
-
opts.on('-x', '--execute', 'Execute the commands after confirmation') do
|
242
|
-
options[:execute] = true
|
243
|
-
end
|
244
|
-
|
245
|
-
opts.on('-h', '--help', 'Print this help') do
|
246
|
-
puts opts
|
247
|
-
exit
|
248
|
-
end
|
249
|
-
|
250
|
-
opts.on('-c', '--config', 'Configure the API key and model') do
|
251
|
-
options[:config] = true
|
252
|
-
end
|
253
|
-
end.parse!(@args)
|
16
|
+
module N2B
|
17
|
+
class Error < StandardError; end
|
254
18
|
|
255
|
-
options
|
256
|
-
end
|
257
|
-
end
|
258
19
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: n2b
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Nothegger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-07-
|
11
|
+
date: 2024-07-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json
|
@@ -53,7 +53,8 @@ dependencies:
|
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '13.0'
|
55
55
|
description: A tool to convert natural language instructions to bash commands using
|
56
|
-
Claude API or OpenAI's GPT.
|
56
|
+
Claude API or OpenAI's GPT. also is q quick helper in the console to provide ruby
|
57
|
+
code snippets and explanations or debug exceptions.
|
57
58
|
email:
|
58
59
|
- stefan@kaproblem.com
|
59
60
|
executables:
|
@@ -64,6 +65,9 @@ files:
|
|
64
65
|
- README.md
|
65
66
|
- bin/n2b
|
66
67
|
- lib/n2b.rb
|
68
|
+
- lib/n2b/base.rb
|
69
|
+
- lib/n2b/cli.rb
|
70
|
+
- lib/n2b/irb.rb
|
67
71
|
- lib/n2b/llm/claude.rb
|
68
72
|
- lib/n2b/llm/open_ai.rb
|
69
73
|
- lib/n2b/version.rb
|
@@ -92,5 +96,5 @@ requirements: []
|
|
92
96
|
rubygems_version: 3.0.9
|
93
97
|
signing_key:
|
94
98
|
specification_version: 4
|
95
|
-
summary: Convert natural language to bash commands
|
99
|
+
summary: Convert natural language to bash commands or ruby code and help with debugging.
|
96
100
|
test_files: []
|