aicli 0.3.0 → 0.4.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 +4 -4
- data/README.md +23 -154
- data/lib/aicli/cli.rb +11 -32
- data/lib/aicli/commands/chat.rb +15 -37
- data/lib/aicli/commands/config.rb +29 -18
- data/lib/aicli/helpers/completion.rb +65 -103
- data/lib/aicli/helpers/config.rb +1 -5
- data/lib/aicli/helpers/constants.rb +1 -0
- data/lib/aicli/helpers/context.rb +1 -2
- data/lib/aicli/helpers/error.rb +1 -3
- data/lib/aicli/helpers/i18n.rb +6 -0
- data/lib/aicli/helpers/script_action_menu.rb +128 -0
- data/lib/aicli/helpers/script_editor.rb +65 -0
- data/lib/aicli/helpers/shell_exec.rb +14 -0
- data/lib/aicli/helpers/shell_history.rb +164 -27
- data/lib/aicli/helpers/theme.rb +40 -0
- data/lib/aicli/prompt.rb +77 -142
- data/lib/aicli/version.rb +1 -1
- data/lib/aicli.rb +4 -1
- data/locales/en.yml +11 -36
- data/locales/it.yml +14 -38
- metadata +19 -2
- data/lib/aicli/helpers/strip_regex_patterns.rb +0 -21
|
@@ -3,50 +3,28 @@
|
|
|
3
3
|
module AiCli
|
|
4
4
|
module Helpers
|
|
5
5
|
module Completion
|
|
6
|
-
EXPLAIN_IN_SECOND_REQUEST = true
|
|
7
|
-
# Opening fence must include a newline after optional language tag (e.g. ```zsh\n).
|
|
8
|
-
# Using \n? allowed ``` alone to match while streaming, leaking "zsh" into "zshfind".
|
|
9
6
|
SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n/i, /```[a-zA-Z]*/i, "\n"].freeze
|
|
10
7
|
|
|
11
8
|
module_function
|
|
12
9
|
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
read_script: stream_prompt(full_prompt(prompt), config: config, exclusions: SHELL_CODE_EXCLUSIONS),
|
|
16
|
-
read_info: ->(_writer) { '' }
|
|
17
|
-
}
|
|
10
|
+
def get_script(prompt:, config:)
|
|
11
|
+
ask(full_prompt(prompt), config, SHELL_CODE_EXCLUSIONS)
|
|
18
12
|
end
|
|
19
13
|
|
|
20
14
|
def get_explanation(script:, config:)
|
|
21
|
-
|
|
15
|
+
ask(explanation_prompt(script), config, [])
|
|
22
16
|
end
|
|
23
17
|
|
|
24
18
|
def get_revision(prompt:, code:, config:)
|
|
25
|
-
|
|
26
|
-
read_script: stream_prompt(
|
|
27
|
-
revision_prompt(prompt, code),
|
|
28
|
-
config: config,
|
|
29
|
-
exclusions: SHELL_CODE_EXCLUSIONS
|
|
30
|
-
)
|
|
31
|
-
}
|
|
19
|
+
ask(revision_prompt(prompt, code), config, SHELL_CODE_EXCLUSIONS)
|
|
32
20
|
end
|
|
33
21
|
|
|
34
22
|
def get_models(provider)
|
|
35
23
|
Llm.list_chat_models(provider)
|
|
36
24
|
end
|
|
37
25
|
|
|
38
|
-
# Streams a single-turn completion. Returns a callable that takes a writer.
|
|
39
|
-
def stream_prompt(prompt, config:, exclusions: [])
|
|
40
|
-
lambda do |writer|
|
|
41
|
-
stream_to_writer(prompt, config: config, exclusions: exclusions, writer: writer)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Multi-turn chat helper used by `ai chat`.
|
|
46
26
|
def start_chat(config)
|
|
47
|
-
|
|
48
|
-
chat.with_instructions(chat_system_prompt)
|
|
49
|
-
chat
|
|
27
|
+
Llm.build_chat(config).tap { |chat| chat.with_instructions(chat_system_prompt) }
|
|
50
28
|
end
|
|
51
29
|
|
|
52
30
|
def chat_system_prompt
|
|
@@ -57,46 +35,74 @@ module AiCli
|
|
|
57
35
|
Target shell: #{OsDetect.detect_shell}
|
|
58
36
|
|
|
59
37
|
Whenever you suggest a command the user can run, put the exact runnable command
|
|
60
|
-
alone inside a markdown fenced code block
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
ls -la
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Prefer a single command. You may briefly explain outside the fence.
|
|
67
|
-
Do not wrap non-runnable prose in fences.
|
|
38
|
+
alone inside a markdown fenced code block. Prefer a single command.
|
|
68
39
|
PROMPT
|
|
69
40
|
end
|
|
70
41
|
|
|
71
42
|
def extract_commands(text)
|
|
72
43
|
return [] if text.nil? || text.empty?
|
|
73
44
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.reject(&:empty?)
|
|
79
|
-
|
|
80
|
-
# Prefer the last fenced command if several were suggested.
|
|
81
|
-
blocks
|
|
45
|
+
text.scan(/```(?:[a-zA-Z0-9_+-]*)\s*\n?(.*?)```/m)
|
|
46
|
+
.flatten
|
|
47
|
+
.map { |block| block.strip.gsub(/\A`+|`+\z/, '') }
|
|
48
|
+
.reject(&:empty?)
|
|
82
49
|
end
|
|
83
50
|
|
|
84
51
|
def stream_chat_message(chat, message, writer:)
|
|
85
52
|
data = +''
|
|
86
53
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
next if content.empty?
|
|
54
|
+
chat.ask(message) do |chunk|
|
|
55
|
+
content = chunk.content.to_s
|
|
56
|
+
next if content.empty?
|
|
91
57
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
end
|
|
95
|
-
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
96
|
-
handle_ruby_llm_error(e)
|
|
58
|
+
data << content
|
|
59
|
+
writer.call(content)
|
|
97
60
|
end
|
|
98
61
|
|
|
99
62
|
data
|
|
63
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
64
|
+
handle_ruby_llm_error(e)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ask(prompt, config, exclusions)
|
|
68
|
+
ask_stream(prompt, config, exclusions) { |_chunk| }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ask_stream(prompt, config, exclusions, &writer)
|
|
72
|
+
chat = Llm.build_chat(config)
|
|
73
|
+
data = +''
|
|
74
|
+
data_start = exclusions.empty?
|
|
75
|
+
buffer = +''
|
|
76
|
+
prefix = exclusions.first
|
|
77
|
+
|
|
78
|
+
chat.ask(prompt) do |chunk|
|
|
79
|
+
content = chunk.content.to_s
|
|
80
|
+
next if content.empty?
|
|
81
|
+
|
|
82
|
+
unless data_start
|
|
83
|
+
buffer << content
|
|
84
|
+
next unless prefix.nil? || buffer.match?(prefix)
|
|
85
|
+
|
|
86
|
+
data_start = true
|
|
87
|
+
remainder = strip_exclusions(buffer, exclusions)
|
|
88
|
+
buffer = +''
|
|
89
|
+
next if remainder.empty?
|
|
90
|
+
|
|
91
|
+
data << remainder
|
|
92
|
+
writer.call(remainder)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
cleaned = strip_exclusions(content, exclusions)
|
|
97
|
+
next if cleaned.empty?
|
|
98
|
+
|
|
99
|
+
data << cleaned
|
|
100
|
+
writer.call(cleaned)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sanitize_script(data)
|
|
104
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
105
|
+
handle_ruby_llm_error(e)
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
def explanation_prompt(script)
|
|
@@ -107,12 +113,8 @@ module AiCli
|
|
|
107
113
|
PROMPT
|
|
108
114
|
end
|
|
109
115
|
|
|
110
|
-
def shell_details
|
|
111
|
-
"The target shell is #{OsDetect.detect_shell}"
|
|
112
|
-
end
|
|
113
|
-
|
|
114
116
|
def explain_script
|
|
115
|
-
'
|
|
117
|
+
'In one or two short sentences, explain what the script does. Plain text only.'
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
def generation_details
|
|
@@ -124,16 +126,13 @@ module AiCli
|
|
|
124
126
|
end
|
|
125
127
|
|
|
126
128
|
def full_prompt(prompt)
|
|
127
|
-
explain = EXPLAIN_IN_SECOND_REQUEST ? '' : explain_script
|
|
128
129
|
<<~PROMPT
|
|
129
130
|
Create a single line command that one can enter in a terminal and run, based on what is specified in the prompt.
|
|
130
131
|
|
|
131
|
-
#{
|
|
132
|
+
The target shell is #{OsDetect.detect_shell}
|
|
132
133
|
|
|
133
134
|
#{generation_details}
|
|
134
135
|
|
|
135
|
-
#{explain}
|
|
136
|
-
|
|
137
136
|
The prompt is: #{prompt}
|
|
138
137
|
PROMPT
|
|
139
138
|
end
|
|
@@ -150,43 +149,10 @@ module AiCli
|
|
|
150
149
|
PROMPT
|
|
151
150
|
end
|
|
152
151
|
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
data_start = exclusions.empty?
|
|
157
|
-
buffer = +''
|
|
158
|
-
excluded_prefix = exclusions.first
|
|
159
|
-
|
|
160
|
-
begin
|
|
161
|
-
chat.ask(prompt) do |chunk|
|
|
162
|
-
content = chunk.content.to_s
|
|
163
|
-
next if content.empty?
|
|
164
|
-
|
|
165
|
-
unless data_start
|
|
166
|
-
buffer << content
|
|
167
|
-
next unless excluded_prefix.nil? || buffer.match?(excluded_prefix)
|
|
168
|
-
|
|
169
|
-
data_start = true
|
|
170
|
-
remainder = StripRegexPatterns.call(buffer, exclusions)
|
|
171
|
-
buffer = +''
|
|
172
|
-
next if remainder.empty?
|
|
173
|
-
|
|
174
|
-
data << remainder
|
|
175
|
-
writer.call(remainder)
|
|
176
|
-
next
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
cleaned = StripRegexPatterns.call(content, exclusions)
|
|
180
|
-
next if cleaned.empty?
|
|
181
|
-
|
|
182
|
-
data << cleaned
|
|
183
|
-
writer.call(cleaned)
|
|
184
|
-
end
|
|
185
|
-
rescue RubyLLM::Error, RubyLLM::ConfigurationError => e
|
|
186
|
-
handle_ruby_llm_error(e)
|
|
152
|
+
def strip_exclusions(text, exclusions)
|
|
153
|
+
exclusions.reduce(text) do |current, pattern|
|
|
154
|
+
pattern.is_a?(Regexp) ? current.gsub(pattern, '') : current.gsub(pattern.to_s, '')
|
|
187
155
|
end
|
|
188
|
-
|
|
189
|
-
sanitize_script(data)
|
|
190
156
|
end
|
|
191
157
|
|
|
192
158
|
def sanitize_script(script)
|
|
@@ -229,13 +195,9 @@ module AiCli
|
|
|
229
195
|
MSG
|
|
230
196
|
end
|
|
231
197
|
|
|
232
|
-
raise KnownError,
|
|
233
|
-
Request to the LLM provider failed:
|
|
234
|
-
|
|
235
|
-
#{message}
|
|
236
|
-
MSG
|
|
198
|
+
raise KnownError, "Request to the LLM provider failed:\n\n#{message}"
|
|
237
199
|
end
|
|
238
|
-
private_class_method :
|
|
200
|
+
private_class_method :ask_stream, :handle_ruby_llm_error, :strip_exclusions, :sanitize_script
|
|
239
201
|
end
|
|
240
202
|
end
|
|
241
203
|
end
|
data/lib/aicli/helpers/config.rb
CHANGED
|
@@ -119,13 +119,9 @@ module AiCli
|
|
|
119
119
|
File.write(CONFIG_PATH, stringify_ini(config))
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
-
def has_own?(object, key)
|
|
123
|
-
object.key?(key)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
122
|
def show_config_ui
|
|
127
123
|
pastel = Pastel.new
|
|
128
|
-
prompt =
|
|
124
|
+
prompt = Helpers::Theme.prompt(interrupt: :exit)
|
|
129
125
|
|
|
130
126
|
loop do
|
|
131
127
|
config = get
|
|
@@ -7,7 +7,6 @@ require 'time'
|
|
|
7
7
|
module AiCli
|
|
8
8
|
module Helpers
|
|
9
9
|
module Context
|
|
10
|
-
# Keep the last N user/assistant messages across sessions.
|
|
11
10
|
MAX_MESSAGES = 40
|
|
12
11
|
CONTEXT_FILENAME = 'context'
|
|
13
12
|
|
|
@@ -45,7 +44,7 @@ module AiCli
|
|
|
45
44
|
}
|
|
46
45
|
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
47
46
|
rescue StandardError
|
|
48
|
-
|
|
47
|
+
nil
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def save_from_chat(chat)
|
data/lib/aicli/helpers/error.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'pastel'
|
|
4
|
-
|
|
5
3
|
module AiCli
|
|
6
4
|
module Helpers
|
|
7
5
|
class KnownError < StandardError; end
|
|
@@ -12,7 +10,7 @@ module AiCli
|
|
|
12
10
|
def handle_cli_error(error)
|
|
13
11
|
return if error.is_a?(KnownError)
|
|
14
12
|
|
|
15
|
-
pastel =
|
|
13
|
+
pastel = Theme.pastel
|
|
16
14
|
indent = ' ' * 4
|
|
17
15
|
|
|
18
16
|
if error.is_a?(StandardError) && error.backtrace
|
data/lib/aicli/helpers/i18n.rb
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AiCli
|
|
4
|
+
module Helpers
|
|
5
|
+
module ScriptActionMenu
|
|
6
|
+
MARKER = '‣'
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def select(question:, config:, silent_mode:, empty_script: false)
|
|
11
|
+
choices = build_choices(config, silent_mode, empty_script)
|
|
12
|
+
Menu.new(question, choices).ask
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build_choices(config, silent_mode, empty_script)
|
|
16
|
+
items = []
|
|
17
|
+
unless empty_script
|
|
18
|
+
items << choice('y', :yes, I18n.t('Yes'))
|
|
19
|
+
items << choice('m', :edit, I18n.t('Modify'))
|
|
20
|
+
unless silent_mode || config['SILENT_MODE']
|
|
21
|
+
items << choice('e', :explain, I18n.t('Explain'))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
items << choice('r', :revise, I18n.t('Revise'))
|
|
25
|
+
items << choice('c', :copy, I18n.t('Copy'))
|
|
26
|
+
items << choice('x', :cancel, I18n.t('Exit'))
|
|
27
|
+
items
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def choice(key, value, label)
|
|
31
|
+
{ key: key, value: value, label: shortcut_label(label, key) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shortcut_label(text, key)
|
|
35
|
+
idx = text.downcase.index(key.downcase)
|
|
36
|
+
return text unless idx
|
|
37
|
+
|
|
38
|
+
result = text.downcase
|
|
39
|
+
result[idx] = result[idx].upcase
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Menu
|
|
44
|
+
def initialize(question, choices)
|
|
45
|
+
@question = question
|
|
46
|
+
@choices = choices
|
|
47
|
+
@active = 0
|
|
48
|
+
@done = false
|
|
49
|
+
@result = nil
|
|
50
|
+
@prompt = Theme.prompt(interrupt: :exit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ask
|
|
54
|
+
@prompt.print(@prompt.hide)
|
|
55
|
+
@prompt.subscribe(self) do
|
|
56
|
+
until @done
|
|
57
|
+
view = render_view
|
|
58
|
+
lines = line_count(view)
|
|
59
|
+
@prompt.print(view)
|
|
60
|
+
@prompt.read_keypress
|
|
61
|
+
@prompt.print(@prompt.clear_lines(lines)) unless @done
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
@result
|
|
65
|
+
ensure
|
|
66
|
+
@prompt.print(@prompt.show)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def keyenter(*)
|
|
70
|
+
choose_active
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
alias keyreturn keyenter
|
|
74
|
+
alias keyspace keyenter
|
|
75
|
+
|
|
76
|
+
def keyup(*)
|
|
77
|
+
@active = (@active - 1) % @choices.size
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def keydown(*)
|
|
81
|
+
@active = (@active + 1) % @choices.size
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
alias keytab keydown
|
|
85
|
+
|
|
86
|
+
def keypress(event)
|
|
87
|
+
value = event.value.to_s
|
|
88
|
+
return if value.empty? || value.start_with?("\e")
|
|
89
|
+
|
|
90
|
+
char = value.downcase
|
|
91
|
+
idx = @choices.index { |choice| choice[:key] == char }
|
|
92
|
+
return unless idx
|
|
93
|
+
|
|
94
|
+
@active = idx
|
|
95
|
+
choose_active
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def choose_active
|
|
99
|
+
@result = @choices[@active][:value]
|
|
100
|
+
@done = true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_view
|
|
104
|
+
output = ["#{@question}\n"]
|
|
105
|
+
@choices.each_with_index do |choice, index|
|
|
106
|
+
line = if index == @active
|
|
107
|
+
"#{MARKER} #{choice[:label]}"
|
|
108
|
+
else
|
|
109
|
+
" #{choice[:label]}"
|
|
110
|
+
end
|
|
111
|
+
output << if index == @active
|
|
112
|
+
"#{Theme.accent(line)}\n"
|
|
113
|
+
else
|
|
114
|
+
"#{line}\n"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
output.join
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def line_count(view)
|
|
121
|
+
view.split($INPUT_RECORD_SEPARATOR, -1).sum do |line|
|
|
122
|
+
@prompt.count_screen_lines(line)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-prompt'
|
|
4
|
+
require 'tty-reader'
|
|
5
|
+
|
|
6
|
+
module AiCli
|
|
7
|
+
module Helpers
|
|
8
|
+
module ScriptEditor
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def ask(script)
|
|
12
|
+
text = script.to_s
|
|
13
|
+
return nil if text.strip.empty?
|
|
14
|
+
|
|
15
|
+
puts ''
|
|
16
|
+
puts I18n.t('Edit script')
|
|
17
|
+
puts Theme.dim(I18n.t('Edit with arrow keys, Enter to confirm, Ctrl+C to cancel'))
|
|
18
|
+
puts ''
|
|
19
|
+
|
|
20
|
+
if text.include?("\n")
|
|
21
|
+
ask_multiline(text)
|
|
22
|
+
else
|
|
23
|
+
ask_line(text)
|
|
24
|
+
end
|
|
25
|
+
rescue Interrupt, TTY::Reader::InputInterrupt
|
|
26
|
+
quit!
|
|
27
|
+
ensure
|
|
28
|
+
clear_reline_hook
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ask_line(script)
|
|
32
|
+
require 'reline'
|
|
33
|
+
|
|
34
|
+
Reline.pre_input_hook = proc do
|
|
35
|
+
Reline.insert_text(script.dup)
|
|
36
|
+
Reline.pre_input_hook = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
line = Reline.readline('$ ', true)
|
|
40
|
+
line&.strip
|
|
41
|
+
rescue Interrupt
|
|
42
|
+
quit!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ask_multiline(script)
|
|
46
|
+
prompt = Helpers::Theme.prompt(interrupt: :exit)
|
|
47
|
+
edited = prompt.editor(I18n.t('Edit script'), script, ext: 'sh')
|
|
48
|
+
edited&.strip
|
|
49
|
+
rescue TTY::Reader::InputInterrupt
|
|
50
|
+
quit!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def quit!
|
|
54
|
+
puts I18n.t('Goodbye!')
|
|
55
|
+
exit 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear_reline_hook
|
|
59
|
+
return unless defined?(Reline)
|
|
60
|
+
|
|
61
|
+
Reline.pre_input_hook = nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|