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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +151 -0
- data/LICENSE +22 -0
- data/README.md +176 -0
- data/bin/ai +6 -0
- data/bin/aicli +6 -0
- data/lib/aicli/cli.rb +104 -0
- data/lib/aicli/commands/chat.rb +60 -0
- data/lib/aicli/commands/config.rb +49 -0
- data/lib/aicli/commands/update.rb +21 -0
- data/lib/aicli/helpers/completion.rb +275 -0
- data/lib/aicli/helpers/config.rb +161 -0
- data/lib/aicli/helpers/constants.rb +11 -0
- data/lib/aicli/helpers/error.rb +28 -0
- data/lib/aicli/helpers/i18n.rb +70 -0
- data/lib/aicli/helpers/os_detect.rb +31 -0
- data/lib/aicli/helpers/shell_history.rb +48 -0
- data/lib/aicli/helpers/strip_regex_patterns.rb +21 -0
- data/lib/aicli/prompt.rb +192 -0
- data/lib/aicli/version.rb +5 -0
- data/lib/aicli.rb +19 -0
- data/locales/ar.yml +60 -0
- data/locales/de.yml +63 -0
- data/locales/en.yml +61 -0
- data/locales/es.yml +62 -0
- data/locales/fr.yml +62 -0
- data/locales/id.yml +62 -0
- data/locales/it.yml +60 -0
- data/locales/jp.yml +58 -0
- data/locales/ko.yml +58 -0
- data/locales/pt.yml +62 -0
- data/locales/ru.yml +62 -0
- data/locales/tr.yml +60 -0
- data/locales/uk.yml +61 -0
- data/locales/vi.yml +63 -0
- data/locales/zh-Hans.yml +58 -0
- data/locales/zh-Hant.yml +58 -0
- metadata +154 -0
|
@@ -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,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
|