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.
@@ -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 get_script_and_info(prompt:, config:)
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
- { read_explanation: stream_prompt(explanation_prompt(script), config: config) }
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
- chat = Llm.build_chat(config)
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, for example:
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
- blocks = text.scan(/```(?:[a-zA-Z0-9_+-]*)\s*\n?(.*?)```/m)
75
- .flatten
76
- .map { |block| block.strip.gsub(/\A`+|`+\z/, '') }
77
- .map(&:strip)
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
- begin
88
- chat.ask(message) do |chunk|
89
- content = chunk.content.to_s
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
- data << content
93
- writer.call(content)
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
- 'Please provide a clear, concise description of the script, using minimal words. Outline the steps in a list format.'
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
- #{shell_details}
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 stream_to_writer(prompt, config:, exclusions:, writer:)
154
- chat = Llm.build_chat(config)
155
- data = +''
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, <<~MSG
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 :stream_to_writer, :handle_ruby_llm_error, :sanitize_script
200
+ private_class_method :ask_stream, :handle_ruby_llm_error, :strip_exclusions, :sanitize_script
239
201
  end
240
202
  end
241
203
  end
@@ -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 = TTY::Prompt.new(interrupt: :exit)
124
+ prompt = Helpers::Theme.prompt(interrupt: :exit)
129
125
 
130
126
  loop do
131
127
  config = get
@@ -6,6 +6,7 @@ module AiCli
6
6
  COMMAND_NAME = 'ai'
7
7
  PROJECT_NAME = 'aicli'
8
8
  REPO_URL = 'https://github.com/magnum/aicli'
9
+ ACCENT_COLOR = :cyan
9
10
  end
10
11
  end
11
12
  end
@@ -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
- # Ignore persistence failures; chat should still work.
47
+ nil
49
48
  end
50
49
 
51
50
  def save_from_chat(chat)
@@ -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 = Pastel.new
13
+ pastel = Theme.pastel
16
14
  indent = ' ' * 4
17
15
 
18
16
  if error.is_a?(StandardError) && error.backtrace
@@ -46,6 +46,12 @@ module AiCli
46
46
  LANGUAGES
47
47
  end
48
48
 
49
+ def load_from_config!
50
+ set_language(Config.get['LANGUAGE'])
51
+ rescue StandardError
52
+ set_language('en')
53
+ end
54
+
49
55
  private
50
56
 
51
57
  def load_translations
@@ -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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AiCli
4
+ module Helpers
5
+ module ShellExec
6
+ module_function
7
+
8
+ def run(command)
9
+ system(ENV['SHELL'] || 'bash', '-c', command)
10
+ ShellHistory.record_executed(command)
11
+ end
12
+ end
13
+ end
14
+ end