aicli 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.
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'thread'
7
+
8
+ module AiCli
9
+ module Helpers
10
+ module Completion
11
+ EXPLAIN_IN_SECOND_REQUEST = true
12
+ SHELL_CODE_EXCLUSIONS = [/```[a-zA-Z]*\n/i, /```[a-zA-Z]*/i, "\n"].freeze
13
+
14
+ module_function
15
+
16
+ def get_script_and_info(prompt:, key:, api_endpoint:, model: nil)
17
+ stream = generate_completion(
18
+ prompt: full_prompt(prompt),
19
+ key: key,
20
+ model: model,
21
+ api_endpoint: api_endpoint
22
+ )
23
+ enumerator = stream.each
24
+
25
+ {
26
+ read_script: read_data(enumerator, *SHELL_CODE_EXCLUSIONS),
27
+ read_info: read_data(enumerator, *SHELL_CODE_EXCLUSIONS)
28
+ }
29
+ end
30
+
31
+ def get_explanation(script:, key:, api_endpoint:, model: nil)
32
+ stream = generate_completion(
33
+ prompt: explanation_prompt(script),
34
+ key: key,
35
+ model: model,
36
+ api_endpoint: api_endpoint
37
+ )
38
+ { read_explanation: read_data(stream.each) }
39
+ end
40
+
41
+ def get_revision(prompt:, code:, key:, api_endpoint:, model: nil)
42
+ stream = generate_completion(
43
+ prompt: revision_prompt(prompt, code),
44
+ key: key,
45
+ model: model,
46
+ api_endpoint: api_endpoint
47
+ )
48
+ { read_script: read_data(stream.each, *SHELL_CODE_EXCLUSIONS) }
49
+ end
50
+
51
+ def get_models(key, api_endpoint)
52
+ uri = URI.join(ensure_trailing_slash(api_endpoint), 'models')
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = uri.scheme == 'https'
55
+
56
+ request = Net::HTTP::Get.new(uri)
57
+ request['Authorization'] = "Bearer #{key}"
58
+ request['Content-Type'] = 'application/json'
59
+
60
+ response = http.request(request)
61
+ raise KnownError, "Failed to list models: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
+
63
+ data = JSON.parse(response.body)
64
+ data.fetch('data', []).select { |m| m['object'] == 'model' }
65
+ end
66
+
67
+ # Returns a live Enumerator of SSE "data: ..." lines
68
+ def generate_completion(prompt:, key:, api_endpoint:, model: nil, number: 1)
69
+ messages = if prompt.is_a?(Array)
70
+ prompt
71
+ else
72
+ [{ 'role' => 'user', 'content' => prompt }]
73
+ end
74
+
75
+ uri = URI.join(ensure_trailing_slash(api_endpoint), 'chat/completions')
76
+ body = {
77
+ model: model || 'gpt-4o-mini',
78
+ messages: messages,
79
+ n: [number, 10].min,
80
+ stream: true
81
+ }
82
+
83
+ queue = Queue.new
84
+ error_box = []
85
+
86
+ Thread.new do
87
+ http = Net::HTTP.new(uri.host, uri.port)
88
+ http.use_ssl = uri.scheme == 'https'
89
+ http.read_timeout = 120
90
+
91
+ request = Net::HTTP::Post.new(uri)
92
+ request['Authorization'] = "Bearer #{key}"
93
+ request['Content-Type'] = 'application/json'
94
+ request['Accept'] = 'text/event-stream'
95
+ request.body = JSON.generate(body)
96
+
97
+ http.request(request) do |response|
98
+ unless response.is_a?(Net::HTTPSuccess)
99
+ error_box << [:api, response.code.to_i, response.body]
100
+ queue << :done
101
+ next
102
+ end
103
+
104
+ buffer = +''
105
+ response.read_body do |chunk|
106
+ buffer << chunk
107
+ while (eol = buffer.index("\n"))
108
+ line = buffer.slice!(0..eol).strip
109
+ queue << line if line.start_with?('data: ')
110
+ end
111
+ end
112
+ end
113
+ queue << :done
114
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
115
+ error_box << [:network, uri.host, e.message]
116
+ queue << :done
117
+ rescue StandardError => e
118
+ error_box << [:other, e]
119
+ queue << :done
120
+ end
121
+
122
+ Enumerator.new do |yielder|
123
+ loop do
124
+ item = queue.pop
125
+ break if item == :done
126
+
127
+ yielder << item
128
+ end
129
+
130
+ if (err = error_box.first)
131
+ case err[0]
132
+ when :api
133
+ handle_api_error(err[1], err[2])
134
+ when :network
135
+ raise KnownError,
136
+ "Error connecting to #{err[1]}. Are you connected to the internet? (#{err[2]})"
137
+ when :other
138
+ raise err[1]
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def read_data(enumerator, *excluded)
145
+ lambda do |writer|
146
+ data = +''
147
+ data_start = false
148
+ buffer = +''
149
+ excluded_prefix = excluded.first
150
+
151
+ loop do
152
+ chunk = enumerator.next
153
+ payloads = chunk.to_s.split("\n\n")
154
+
155
+ payloads.each do |payload|
156
+ return data if payload.include?('[DONE]')
157
+
158
+ next unless payload.start_with?('data:')
159
+
160
+ content = parse_content(payload)
161
+
162
+ unless data_start
163
+ buffer << content
164
+ if excluded_prefix.nil? || buffer.match?(excluded_prefix)
165
+ data_start = true
166
+ buffer = +''
167
+ break if excluded_prefix
168
+ end
169
+ end
170
+
171
+ next unless data_start && !content.empty?
172
+
173
+ cleaned = StripRegexPatterns.call(content, excluded)
174
+ data << cleaned
175
+ writer.call(cleaned)
176
+ end
177
+ rescue StopIteration
178
+ break
179
+ end
180
+
181
+ data
182
+ end
183
+ end
184
+
185
+ def parse_content(payload)
186
+ raw = payload.sub(/^data:\s*/, '')
187
+ delta = JSON.parse(raw.strip)
188
+ delta.dig('choices', 0, 'delta', 'content') || ''
189
+ rescue JSON::ParserError => e
190
+ "Error with JSON.parse and #{payload}.\n#{e}"
191
+ end
192
+
193
+ def handle_api_error(status, body)
194
+ message_string = begin
195
+ JSON.pretty_generate(JSON.parse(body))
196
+ rescue StandardError
197
+ body.to_s
198
+ end
199
+
200
+ if status == 429
201
+ raise KnownError, <<~MSG
202
+ Request to OpenAI failed with status 429. This is due to incorrect billing setup or excessive quota usage. Please follow this guide to fix it: https://help.openai.com/en/articles/6891831-error-code-429-you-exceeded-your-current-quota-please-check-your-plan-and-billing-details
203
+
204
+ You can activate billing here: https://platform.openai.com/account/billing/overview . Make sure to add a payment method if not under an active grant from OpenAI.
205
+
206
+ Full message from OpenAI:
207
+
208
+ #{message_string}
209
+ MSG
210
+ end
211
+
212
+ raise KnownError, <<~MSG
213
+ Request to OpenAI failed with status #{status}:
214
+
215
+ #{message_string}
216
+ MSG
217
+ end
218
+
219
+ def explanation_prompt(script)
220
+ <<~PROMPT
221
+ #{explain_script} Please reply in #{I18n.current_language_name}
222
+
223
+ The script: #{script}
224
+ PROMPT
225
+ end
226
+
227
+ def shell_details
228
+ "The target shell is #{OsDetect.detect_shell}"
229
+ end
230
+
231
+ def explain_script
232
+ 'Please provide a clear, concise description of the script, using minimal words. Outline the steps in a list format.'
233
+ end
234
+
235
+ def generation_details
236
+ <<~DETAILS.chomp
237
+ Only reply with the single line command surrounded by three backticks. It must be able to be directly run in the target shell. Do not include any other text.
238
+
239
+ Make sure the command runs on #{OsDetect.operating_system_name} operating system.
240
+ DETAILS
241
+ end
242
+
243
+ def full_prompt(prompt)
244
+ explain = EXPLAIN_IN_SECOND_REQUEST ? '' : explain_script
245
+ <<~PROMPT
246
+ Create a single line command that one can enter in a terminal and run, based on what is specified in the prompt.
247
+
248
+ #{shell_details}
249
+
250
+ #{generation_details}
251
+
252
+ #{explain}
253
+
254
+ The prompt is: #{prompt}
255
+ PROMPT
256
+ end
257
+
258
+ def revision_prompt(prompt, code)
259
+ <<~PROMPT
260
+ Update the following script based on what is asked in the following prompt.
261
+
262
+ The script: #{code}
263
+
264
+ The prompt: #{prompt}
265
+
266
+ #{generation_details}
267
+ PROMPT
268
+ end
269
+
270
+ def ensure_trailing_slash(endpoint)
271
+ endpoint.end_with?('/') ? endpoint : "#{endpoint}/"
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pastel'
5
+ require 'tty-prompt'
6
+
7
+ module AiCli
8
+ module Helpers
9
+ module Config
10
+ CONFIG_PATH = File.join(Dir.home, '.aicli')
11
+ LEGACY_CONFIG_PATH = File.join(Dir.home, '.ai-shell')
12
+
13
+ CONFIG_PARSERS = {
14
+ 'OPENAI_KEY' => lambda { |key|
15
+ if key.nil? || key.empty?
16
+ raise KnownError,
17
+ "Please set your OpenAI API key via `#{Constants::COMMAND_NAME} config set OPENAI_KEY=<your token>`"
18
+ end
19
+ key
20
+ },
21
+ 'MODEL' => lambda { |model|
22
+ model.nil? || model.empty? ? 'gpt-4o-mini' : model
23
+ },
24
+ 'SILENT_MODE' => lambda { |mode|
25
+ mode.to_s.downcase == 'true'
26
+ },
27
+ 'OPENAI_API_ENDPOINT' => lambda { |api_endpoint|
28
+ api_endpoint.nil? || api_endpoint.empty? ? 'https://api.openai.com/v1' : api_endpoint
29
+ },
30
+ 'LANGUAGE' => lambda { |language|
31
+ language.nil? || language.empty? ? 'en' : language
32
+ }
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ def get(cli_config = nil)
38
+ config = read_config_file
39
+ parsed = {}
40
+
41
+ CONFIG_PARSERS.each do |key, parser|
42
+ value = cli_config&.dig(key) || config[key]
43
+ parsed[key] = parser.call(value)
44
+ end
45
+
46
+ parsed
47
+ end
48
+
49
+ def set(key_values)
50
+ config = read_config_file
51
+
52
+ key_values.each do |key, value|
53
+ unless CONFIG_PARSERS.key?(key)
54
+ raise KnownError, "#{I18n.t('Invalid config property')}: #{key}"
55
+ end
56
+
57
+ parsed = CONFIG_PARSERS[key].call(value)
58
+ config[key] = parsed.to_s
59
+ end
60
+
61
+ File.write(CONFIG_PATH, stringify_ini(config))
62
+ end
63
+
64
+ def has_own?(object, key)
65
+ object.key?(key)
66
+ end
67
+
68
+ def show_config_ui
69
+ pastel = Pastel.new
70
+ prompt = TTY::Prompt.new(interrupt: :exit)
71
+
72
+ loop do
73
+ config = get
74
+ choice = prompt.select("#{I18n.t('Set config')}:") do |menu|
75
+ menu.choice "#{I18n.t('OpenAI Key')} (#{display_hint(config, 'OPENAI_KEY') { |v| "sk-...#{v[-3..]}" }})",
76
+ 'OPENAI_KEY'
77
+ menu.choice "#{I18n.t('OpenAI API Endpoint')} (#{display_hint(config, 'OPENAI_API_ENDPOINT')})",
78
+ 'OPENAI_API_ENDPOINT'
79
+ menu.choice "#{I18n.t('Silent Mode')} (#{display_hint(config, 'SILENT_MODE')})",
80
+ 'SILENT_MODE'
81
+ menu.choice "#{I18n.t('Model')} (#{display_hint(config, 'MODEL')})",
82
+ 'MODEL'
83
+ menu.choice "#{I18n.t('Language')} (#{display_hint(config, 'LANGUAGE')})",
84
+ 'LANGUAGE'
85
+ menu.choice I18n.t('Cancel'), 'cancel'
86
+ end
87
+
88
+ case choice
89
+ when 'OPENAI_KEY'
90
+ key = prompt.ask(I18n.t('Enter your OpenAI API key')) do |q|
91
+ q.required true
92
+ end
93
+ set([['OPENAI_KEY', key]])
94
+ when 'OPENAI_API_ENDPOINT'
95
+ api_endpoint = prompt.ask(I18n.t('Enter your OpenAI API Endpoint'))
96
+ set([['OPENAI_API_ENDPOINT', api_endpoint]]) if api_endpoint
97
+ when 'SILENT_MODE'
98
+ silent = prompt.yes?(I18n.t('Enable silent mode?'))
99
+ set([['SILENT_MODE', silent ? 'true' : 'false']])
100
+ when 'MODEL'
101
+ cfg = get
102
+ models = Completion.get_models(cfg['OPENAI_KEY'], cfg['OPENAI_API_ENDPOINT'])
103
+ model = prompt.select('Pick a model.') do |menu|
104
+ models.each { |m| menu.choice m['id'], m['id'] }
105
+ end
106
+ set([['MODEL', model]])
107
+ when 'LANGUAGE'
108
+ language = prompt.select(I18n.t('Enter the language you want to use')) do |menu|
109
+ I18n.languages.each { |k, v| menu.choice v, k }
110
+ end
111
+ set([['LANGUAGE', language]])
112
+ I18n.set_language(language)
113
+ when 'cancel'
114
+ break
115
+ end
116
+ end
117
+ rescue KnownError, StandardError => e
118
+ puts "\n#{pastel.red('✖')} #{e.message}"
119
+ Error.handle_cli_error(e)
120
+ exit 1
121
+ end
122
+
123
+ def read_config_file
124
+ path = if File.exist?(CONFIG_PATH)
125
+ CONFIG_PATH
126
+ elsif File.exist?(LEGACY_CONFIG_PATH)
127
+ LEGACY_CONFIG_PATH
128
+ end
129
+ return {} unless path
130
+
131
+ parse_ini(File.read(path))
132
+ end
133
+
134
+ def parse_ini(content)
135
+ result = {}
136
+ content.each_line do |line|
137
+ line = line.strip
138
+ next if line.empty? || line.start_with?('#', ';')
139
+ next unless line.include?('=')
140
+
141
+ key, value = line.split('=', 2)
142
+ result[key.strip] = value.strip.gsub(/\A["']|["']\z/, '')
143
+ end
144
+ result
145
+ end
146
+
147
+ def stringify_ini(config)
148
+ "#{config.map { |k, v| "#{k}=#{v}" }.join("\n")}\n"
149
+ end
150
+
151
+ def display_hint(config, key)
152
+ if config.key?(key) && !config[key].nil?
153
+ value = config[key]
154
+ block_given? ? yield(value) : value.to_s
155
+ else
156
+ I18n.t('(not set)')
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AiCli
4
+ module Helpers
5
+ module Constants
6
+ COMMAND_NAME = 'aicli'
7
+ PROJECT_NAME = 'aicli'
8
+ REPO_URL = 'https://github.com/magnum/aicli'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module AiCli
6
+ module Helpers
7
+ class KnownError < StandardError; end
8
+
9
+ module Error
10
+ module_function
11
+
12
+ def handle_cli_error(error)
13
+ return if error.is_a?(KnownError)
14
+
15
+ pastel = Pastel.new
16
+ indent = ' ' * 4
17
+
18
+ if error.is_a?(StandardError) && error.backtrace
19
+ puts pastel.dim(error.backtrace.join("\n"))
20
+ end
21
+
22
+ puts "\n#{indent}#{pastel.dim("aicli v#{AiCli::VERSION}")}"
23
+ puts "\n#{indent}#{I18n.t('Please open a Bug report with the information above')}:"
24
+ puts "#{indent}https://github.com/magnum/aicli/issues/new"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module AiCli
6
+ module Helpers
7
+ module I18n
8
+ LANGUAGES = {
9
+ 'en' => 'English',
10
+ 'zh-Hans' => '简体中文',
11
+ 'zh-Hant' => '繁體中文',
12
+ 'es' => 'Español',
13
+ 'jp' => '日本語',
14
+ 'ko' => '한국어',
15
+ 'fr' => 'Français',
16
+ 'de' => 'Deutsch',
17
+ 'ru' => 'Русский',
18
+ 'uk' => 'Українська',
19
+ 'vi' => 'Tiếng Việt',
20
+ 'ar' => 'العربية',
21
+ 'pt' => 'Português',
22
+ 'id' => 'Indonesia',
23
+ 'it' => 'Italiano',
24
+ 'tr' => 'Türkçe'
25
+ }.freeze
26
+
27
+ class << self
28
+ attr_reader :current_lang
29
+
30
+ def set_language(lang)
31
+ @current_lang = lang.nil? || lang.empty? ? 'en' : lang
32
+ load_translations
33
+ end
34
+
35
+ def t(key)
36
+ return key if @current_lang.nil? || @current_lang == 'en'
37
+
38
+ @translations[key] || key
39
+ end
40
+
41
+ def current_language_name
42
+ LANGUAGES[@current_lang] || LANGUAGES['en']
43
+ end
44
+
45
+ def languages
46
+ LANGUAGES
47
+ end
48
+
49
+ private
50
+
51
+ def load_translations
52
+ return if @current_lang == 'en'
53
+
54
+ locale_path = File.expand_path(
55
+ "../../../locales/#{@current_lang}.yml",
56
+ __dir__
57
+ )
58
+
59
+ @translations = if File.exist?(locale_path)
60
+ YAML.safe_load(File.read(locale_path), permitted_classes: [String]) || {}
61
+ else
62
+ {}
63
+ end
64
+ end
65
+ end
66
+
67
+ set_language('en')
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'rbconfig'
5
+
6
+ module AiCli
7
+ module Helpers
8
+ module OsDetect
9
+ module_function
10
+
11
+ def detect_shell
12
+ return 'powershell' if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/i)
13
+
14
+ shell = ENV['SHELL'] || Etc.getpwuid.shell || 'bash'
15
+ File.basename(shell)
16
+ rescue StandardError => e
17
+ raise "#{I18n.t('Shell detection failed unexpectedly')}: #{e.message}"
18
+ end
19
+
20
+ def operating_system_name
21
+ case RbConfig::CONFIG['host_os']
22
+ when /mswin|mingw|cygwin/i then 'Windows'
23
+ when /darwin/i then 'macOS'
24
+ when /linux/i then 'Linux'
25
+ when /bsd/i then 'BSD'
26
+ else RbConfig::CONFIG['host_os']
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AiCli
4
+ module Helpers
5
+ module ShellHistory
6
+ module_function
7
+
8
+ def append(command)
9
+ history_file = history_file_path
10
+ return unless history_file
11
+
12
+ last = last_command(history_file)
13
+ return if last == command
14
+
15
+ File.open(history_file, 'a') { |f| f.puts(command) }
16
+ rescue StandardError
17
+ # Ignore history write errors
18
+ end
19
+
20
+ def history_file_path
21
+ shell = File.basename(ENV['SHELL'] || '')
22
+ home = Dir.home
23
+
24
+ case shell
25
+ when 'bash', 'sh'
26
+ File.join(home, '.bash_history')
27
+ when 'zsh'
28
+ File.join(home, '.zsh_history')
29
+ when 'fish'
30
+ File.join(home, '.local', 'share', 'fish', 'fish_history')
31
+ when 'ksh'
32
+ File.join(home, '.ksh_history')
33
+ when 'tcsh'
34
+ File.join(home, '.history')
35
+ end
36
+ end
37
+
38
+ def last_command(history_file)
39
+ return nil unless File.exist?(history_file)
40
+
41
+ lines = File.read(history_file).strip.split("\n")
42
+ lines.last
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AiCli
4
+ module Helpers
5
+ module StripRegexPatterns
6
+ module_function
7
+
8
+ def call(input_string, pattern_list)
9
+ pattern_list.reduce(input_string) do |current, pattern|
10
+ next current if pattern.nil?
11
+
12
+ if pattern.is_a?(Regexp)
13
+ current.gsub(pattern, '')
14
+ else
15
+ current.gsub(pattern.to_s, '')
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end