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,45 +3,182 @@
3
3
  module AiCli
4
4
  module Helpers
5
5
  module ShellHistory
6
- module_function
6
+ SOURCE_MARKER = '# aicli shell history integration'
7
7
 
8
- def append(command)
9
- history_file = history_file_path
10
- return unless history_file
8
+ module_function
11
9
 
12
- last = last_command(history_file)
13
- return if last == command
10
+ # Queue the executed command for the shell wrapper to store in history
11
+ # instead of the original `ai ...` invocation.
12
+ def record_executed(command)
13
+ text = command.to_s.strip
14
+ return if text.empty?
14
15
 
15
- File.open(history_file, 'a') { |f| f.puts(command) }
16
+ Config.ensure_home!
17
+ File.write(pending_path, text)
16
18
  rescue StandardError
17
- # Ignore history write errors
19
+ nil
18
20
  end
19
21
 
20
- def history_file_path
21
- shell = File.basename(ENV['SHELL'] || '')
22
- home = Dir.home
22
+ def install_shell_integration!
23
+ Config.ensure_home!
24
+ target_dir = Config.home_dir
25
+
26
+ {
27
+ 'ai.zsh' => zsh_wrapper,
28
+ 'ai.bash' => bash_wrapper
29
+ }.each do |name, contents|
30
+ File.write(File.join(target_dir, name), contents)
31
+ end
32
+ end
33
+
34
+ def activate_shell_integration!
35
+ install_shell_integration!
36
+ rc_path = shell_rc_path
37
+ source_path = wrapper_source_path
38
+
39
+ if rc_path && File.exist?(rc_path)
40
+ append_source_to_rc!(rc_path, source_path)
41
+ { rc_path: rc_path, source_path: source_path, activated: true }
42
+ else
43
+ { rc_path: nil, source_path: source_path, activated: false }
44
+ end
45
+ end
46
+
47
+ def append_source_to_rc!(rc_path, source_path)
48
+ content = File.read(rc_path)
49
+ return if content.include?(SOURCE_MARKER) || content.include?(source_path)
50
+
51
+ block = <<~RC
52
+
53
+ #{SOURCE_MARKER}
54
+ [[ -f #{source_path} ]] && source #{source_path}
55
+ RC
56
+ File.write(rc_path, content.rstrip + block)
57
+ end
23
58
 
24
- case shell
25
- when 'bash', 'sh'
26
- File.join(home, '.bash_history')
59
+ def shell_rc_path
60
+ home = Dir.home
61
+ case current_shell
27
62
  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')
63
+ File.join(home, '.zshrc')
64
+ when 'bash'
65
+ File.join(home, '.bashrc')
35
66
  end
36
67
  end
37
68
 
38
- def last_command(history_file)
39
- return nil unless File.exist?(history_file)
69
+ def wrapper_source_path
70
+ name = current_shell == 'bash' ? 'ai.bash' : 'ai.zsh'
71
+ File.join(Config.home_dir, name)
72
+ end
40
73
 
41
- lines = File.read(history_file).strip.split("\n")
42
- lines.last
43
- rescue StandardError
44
- nil
74
+ def shell_integration_hint(activated: false, rc_path: nil, source_path: nil)
75
+ source_path ||= wrapper_source_path
76
+ if activated && rc_path
77
+ <<~HINT.strip
78
+ Added to #{rc_path}. Reload your shell:
79
+
80
+ source #{rc_path}
81
+
82
+ Or open a new terminal tab.
83
+ HINT
84
+ else
85
+ case current_shell
86
+ when 'zsh'
87
+ <<~HINT.strip
88
+ Add to ~/.zshrc for shell history integration:
89
+
90
+ source #{source_path}
91
+ HINT
92
+ when 'bash'
93
+ <<~HINT.strip
94
+ Add to ~/.bashrc for shell history integration:
95
+
96
+ source #{source_path}
97
+ HINT
98
+ else
99
+ ''
100
+ end
101
+ end
102
+ end
103
+
104
+ def pending_path
105
+ File.join(Config.home_dir, 'history.pending')
106
+ end
107
+
108
+ def current_shell
109
+ File.basename(ENV['SHELL'] || 'bash')
110
+ end
111
+
112
+ def zsh_wrapper
113
+ <<~'ZSH'
114
+ # aicli shell history integration for zsh
115
+ if [[ -z "$AICLI_ZSH_INTEGRATION" ]]; then
116
+ AICLI_ZSH_INTEGRATION=1
117
+
118
+ _aicli_zshaddhistory() {
119
+ if [[ -n "$AICLI_PENDING_HISTORY" ]]; then
120
+ print -s -- "$AICLI_PENDING_HISTORY"
121
+ unset AICLI_PENDING_HISTORY
122
+ return 1
123
+ fi
124
+ }
125
+
126
+ _aicli_precmd() {
127
+ if [[ -n "$AICLI_PENDING_HISTORY" ]]; then
128
+ local cmd="$AICLI_PENDING_HISTORY"
129
+ unset AICLI_PENDING_HISTORY
130
+ local last="${${(z)${(f)"$(fc -l -1 2>/dev/null)"}}[2]}"
131
+ if [[ "$last" == ai\ * || "$last" == aicli\ * ]]; then
132
+ fc -d -1 -I 2>/dev/null
133
+ fi
134
+ print -s -- "$cmd"
135
+ fi
136
+ }
137
+
138
+ ai() {
139
+ AICLI_SHELL_WRAPPER=1 command ai "$@"
140
+ local ret=$?
141
+ if [[ -f "$HOME/.aicli/history.pending" ]]; then
142
+ typeset -g AICLI_PENDING_HISTORY="${$(<"$HOME/.aicli/history.pending")}"
143
+ rm -f "$HOME/.aicli/history.pending"
144
+ fi
145
+ return $ret
146
+ }
147
+
148
+ zshaddhistory_functions+=(_aicli_zshaddhistory)
149
+ precmd_functions+=(_aicli_precmd)
150
+ fi
151
+ ZSH
152
+ end
153
+
154
+ def bash_wrapper
155
+ <<~'BASH'
156
+ # aicli shell history integration for bash
157
+ if [[ -z "$AICLI_BASH_INTEGRATION" ]]; then
158
+ AICLI_BASH_INTEGRATION=1
159
+
160
+ _aicli_bash_history() {
161
+ if [[ -n "$AICLI_PENDING_HISTORY" ]]; then
162
+ local cmd="$AICLI_PENDING_HISTORY"
163
+ unset AICLI_PENDING_HISTORY
164
+ history -d "$HISTCMD" 2>/dev/null
165
+ history -s "$cmd"
166
+ fi
167
+ }
168
+
169
+ ai() {
170
+ AICLI_SHELL_WRAPPER=1 command ai "$@"
171
+ local ret=$?
172
+ if [[ -f "$HOME/.aicli/history.pending" ]]; then
173
+ AICLI_PENDING_HISTORY=$(<"$HOME/.aicli/history.pending")
174
+ rm -f "$HOME/.aicli/history.pending"
175
+ fi
176
+ return $ret
177
+ }
178
+
179
+ PROMPT_COMMAND="_aicli_bash_history${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
180
+ fi
181
+ BASH
45
182
  end
46
183
  end
47
184
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'tty-prompt'
5
+
6
+ module AiCli
7
+ module Helpers
8
+ module Theme
9
+ module_function
10
+
11
+ def accent(text)
12
+ pastel.public_send(Constants::ACCENT_COLOR, text)
13
+ end
14
+
15
+ def bold(text)
16
+ pastel.bold(text)
17
+ end
18
+
19
+ def dim(text)
20
+ pastel.dim(text)
21
+ end
22
+
23
+ def status(config)
24
+ "#{Constants::PROJECT_NAME}, #{config['PROVIDER']} / #{config['MODEL']}"
25
+ end
26
+
27
+ def show_status(config)
28
+ puts accent(status(config))
29
+ end
30
+
31
+ def prompt(**options)
32
+ TTY::Prompt.new(active_color: Constants::ACCENT_COLOR, **options)
33
+ end
34
+
35
+ def pastel
36
+ @pastel ||= Pastel.new
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/aicli/prompt.rb CHANGED
@@ -1,102 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
3
  require 'clipboard'
5
- require 'pastel'
6
- require 'tty-prompt'
7
- require 'tty-spinner'
8
4
 
9
5
  module AiCli
10
6
  module Prompt
11
7
  module_function
12
8
 
13
9
  def run(use_prompt: nil, silent_mode: false)
14
- init_i18n
15
-
16
10
  config = Helpers::Config.get
17
- skip_explanation = silent_mode || config['SILENT_MODE']
18
-
19
- pastel = Pastel.new
20
- puts ''
21
- puts pastel.cyan(Helpers::Constants::PROJECT_NAME)
22
- puts pastel.dim("#{config['PROVIDER']} / #{config['MODEL']}")
23
-
24
- the_prompt = use_prompt.nil? || use_prompt.strip.empty? ? ask_prompt : use_prompt
11
+ prompt_text = use_prompt.to_s.strip.empty? ? ask_prompt : use_prompt
25
12
 
26
- spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('Loading...')}", format: :dots)
27
- spinner.auto_spin
28
-
29
- result = Helpers::Completion.get_script_and_info(
30
- prompt: the_prompt,
31
- config: config
32
- )
33
-
34
- spinner.success(Helpers::I18n.t('Your script') + ':')
35
- puts ''
36
- script = result[:read_script].call(->(chunk) { print chunk })
37
- puts ''
38
- puts ''
39
-
40
- explanation_text = ''
41
- unless skip_explanation
42
- spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('Getting explanation...')}", format: :dots)
43
- spinner.auto_spin
44
-
45
- info = result[:read_info].call(->(chunk) { print chunk })
46
- if info.nil? || info.empty?
47
- explanation = Helpers::Completion.get_explanation(
48
- script: script,
49
- config: config
50
- )
51
- spinner.success(Helpers::I18n.t('Explanation') + ':')
52
- puts ''
53
- explanation_text = explanation[:read_explanation].call(->(chunk) { print chunk })
54
- puts ''
55
- puts ''
56
- else
57
- explanation_text = info
58
- spinner.success(Helpers::I18n.t('Explanation') + ':')
59
- end
60
- end
61
-
62
- append_prompt_context(the_prompt, script, explanation_text)
63
- run_or_revise_flow(script, config, silent_mode)
13
+ Helpers::Theme.show_status(config)
14
+ script = Helpers::Completion.get_script(prompt: prompt_text, config: config)
15
+ save_context(prompt_text, script, '')
16
+ present_script_menu(script, config, silent_mode, leading_newline: true)
64
17
  end
65
18
 
66
- def init_i18n
67
- config = Helpers::Config.get
68
- Helpers::I18n.set_language(config['LANGUAGE'])
69
- rescue StandardError
70
- Helpers::I18n.set_language('en')
71
- end
72
-
73
- def examples
74
- [
75
- Helpers::I18n.t('delete all log files'),
76
- Helpers::I18n.t('list js files'),
77
- Helpers::I18n.t('fetch me a random joke'),
78
- Helpers::I18n.t('list all commits')
79
- ]
80
- end
81
-
82
- def ask_prompt(initial = nil)
83
- prompt = TTY::Prompt.new(interrupt: :error)
19
+ def ask_prompt
20
+ prompt = Helpers::Theme.prompt(interrupt: :error)
84
21
  Helpers::Context.seed_prompt_history(prompt)
85
22
 
86
23
  loop do
87
- begin
88
- return prompt.ask(Helpers::I18n.t('What would you like me to do?')) do |q|
89
- q.required true
90
- q.default initial || Helpers::I18n.t('Say hello')
91
- q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
92
- end
93
- rescue TTY::Reader::InputInterrupt
94
- print "\r\e[2K"
24
+ return prompt.ask(Helpers::I18n.t('What would you like me to do?')) do |q|
25
+ q.required true
26
+ q.default Helpers::I18n.t('Say hello')
27
+ q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
95
28
  end
29
+ rescue TTY::Reader::InputInterrupt
30
+ print "\r\e[2K"
31
+ end
32
+ end
33
+
34
+ def ask_revision
35
+ Helpers::Theme.prompt(interrupt: :exit).ask(
36
+ Helpers::I18n.t('What would you like me to change in this script?')
37
+ ) do |q|
38
+ q.required true
39
+ q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
96
40
  end
97
41
  end
98
42
 
99
- def append_prompt_context(user_prompt, script, explanation)
43
+ def save_context(user_prompt, script, explanation = '')
100
44
  assistant = +"```\n#{script}\n```"
101
45
  assistant << "\n\n#{explanation}" unless explanation.to_s.strip.empty?
102
46
 
@@ -106,43 +50,65 @@ module AiCli
106
50
  Helpers::Context.save_messages(messages)
107
51
  end
108
52
 
109
- def ask_revision
110
- prompt = TTY::Prompt.new(interrupt: :exit)
111
- prompt.ask(Helpers::I18n.t('What would you like me to change in this script?')) do |q|
112
- q.required true
113
- q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
114
- end
53
+ def append_explanation_to_context(explanation)
54
+ text = explanation.to_s.strip
55
+ return if text.empty?
56
+
57
+ messages = Helpers::Context.load_messages
58
+ return if messages.empty?
59
+
60
+ last = messages.last
61
+ return unless last['role'] == 'assistant' && !last['content'].include?(text)
62
+
63
+ last['content'] = "#{last['content']}\n\n#{text}"
64
+ Helpers::Context.save_messages(messages)
65
+ end
66
+
67
+ def display_script(script, leading_newline: false)
68
+ text = script.to_s.strip
69
+ return if text.empty?
70
+
71
+ puts '' if leading_newline
72
+ puts Helpers::Theme.bold(text)
115
73
  end
116
74
 
117
- def run_script(script)
118
- puts "#{Helpers::I18n.t('Running')}: #{script}"
75
+ def display_explanation(text)
76
+ stripped = text.to_s.strip
77
+ return if stripped.empty?
78
+
119
79
  puts ''
120
- system(ENV['SHELL'] || 'bash', '-c', script)
121
- Helpers::ShellHistory.append(script)
80
+ puts Helpers::Theme.dim(stripped)
122
81
  end
123
82
 
124
- def run_or_revise_flow(script, config, silent_mode)
125
- prompt = TTY::Prompt.new(interrupt: :exit)
126
- empty_script = script.strip.empty?
83
+ def present_script_menu(script, config, silent_mode, leading_newline: false, show_script: true)
84
+ display_script(script, leading_newline: leading_newline) if show_script
85
+ run_action_menu(script, config, silent_mode)
86
+ end
127
87
 
128
- choices = []
129
- unless empty_script
130
- choices << { name: "✅ #{Helpers::I18n.t('Yes')}", value: :yes }
131
- choices << { name: "📝 #{Helpers::I18n.t('Edit')}", value: :edit }
132
- end
133
- choices << { name: "🔁 #{Helpers::I18n.t('Revise')}", value: :revise }
134
- choices << { name: "📋 #{Helpers::I18n.t('Copy')}", value: :copy }
135
- choices << { name: "❌ #{Helpers::I18n.t('Cancel')}", value: :cancel }
88
+ def run_action_menu(script, config, silent_mode)
89
+ empty = script.strip.empty?
90
+ message = empty ? Helpers::I18n.t('Revise this script?') : Helpers::I18n.t('Run this script?')
136
91
 
137
- message = empty_script ? Helpers::I18n.t('Revise this script?') : Helpers::I18n.t('Run this script?')
138
- answer = prompt.select(message, choices)
92
+ puts ''
93
+ answer = Helpers::ScriptActionMenu.select(
94
+ question: message,
95
+ config: config,
96
+ silent_mode: silent_mode,
97
+ empty_script: empty
98
+ )
139
99
 
140
100
  case answer
141
101
  when :yes
142
- run_script(script)
102
+ puts ''
103
+ Helpers::ShellExec.run(script)
143
104
  when :edit
144
- new_script = prompt.ask(Helpers::I18n.t('you can edit script here'), default: script)
145
- run_script(new_script) if new_script && !new_script.empty?
105
+ edited = Helpers::ScriptEditor.ask(script)
106
+ present_script_menu(edited, config, silent_mode, show_script: false) if edited && !edited.empty?
107
+ when :explain
108
+ text = Helpers::Completion.get_explanation(script: script, config: config)
109
+ display_explanation(text)
110
+ append_explanation_to_context(text)
111
+ present_script_menu(script, config, silent_mode, leading_newline: !text.to_s.strip.empty?)
146
112
  when :revise
147
113
  revision_flow(script, config, silent_mode)
148
114
  when :copy
@@ -154,45 +120,14 @@ module AiCli
154
120
  end
155
121
  end
156
122
 
157
- def revision_flow(current_script, config, silent_mode)
123
+ def revision_flow(script, config, silent_mode)
158
124
  revision = ask_revision
159
-
160
- spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('Loading...')}", format: :dots)
161
- spinner.auto_spin
162
-
163
- result = Helpers::Completion.get_revision(
164
- prompt: revision,
165
- code: current_script,
166
- config: config
167
- )
168
-
169
- spinner.success(Helpers::I18n.t('Your new script') + ':')
170
- puts ''
171
- script = result[:read_script].call(->(chunk) { print chunk })
172
- puts ''
173
- puts ''
174
-
175
- unless silent_mode
176
- info_spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('Getting explanation...')}", format: :dots)
177
- info_spinner.auto_spin
178
-
179
- explanation = Helpers::Completion.get_explanation(
180
- script: script,
181
- config: config
182
- )
183
-
184
- info_spinner.success(Helpers::I18n.t('Explanation') + ':')
185
- puts ''
186
- explanation[:read_explanation].call(->(chunk) { print chunk })
187
- puts ''
188
- puts ''
189
- end
190
-
191
- run_or_revise_flow(script, config, silent_mode)
125
+ revised = Helpers::Completion.get_revision(prompt: revision, code: script, config: config)
126
+ present_script_menu(revised, config, silent_mode, leading_newline: true)
192
127
  end
193
128
 
194
- private_class_method :init_i18n, :examples, :ask_prompt, :ask_revision,
195
- :run_script, :run_or_revise_flow, :revision_flow,
196
- :append_prompt_context
129
+ private_class_method :ask_prompt, :ask_revision, :save_context, :append_explanation_to_context,
130
+ :display_script, :display_explanation, :present_script_menu,
131
+ :run_action_menu, :revision_flow
197
132
  end
198
133
  end
data/lib/aicli/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AiCli
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/aicli.rb CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  require_relative 'aicli/version'
4
4
  require_relative 'aicli/helpers/constants'
5
+ require_relative 'aicli/helpers/theme'
6
+ require_relative 'aicli/helpers/script_action_menu'
5
7
  require_relative 'aicli/helpers/error'
6
8
  require_relative 'aicli/helpers/i18n'
7
9
  require_relative 'aicli/helpers/os_detect'
8
- require_relative 'aicli/helpers/strip_regex_patterns'
9
10
  require_relative 'aicli/helpers/shell_history'
11
+ require_relative 'aicli/helpers/shell_exec'
12
+ require_relative 'aicli/helpers/script_editor'
10
13
  require_relative 'aicli/helpers/llm'
11
14
  require_relative 'aicli/helpers/completion'
12
15
  require_relative 'aicli/helpers/config'
data/locales/en.yml CHANGED
@@ -1,11 +1,7 @@
1
1
  ---
2
- Starting new conversation: Starting new conversation
3
2
  Goodbye!: Goodbye!
4
- send a message ('exit' to quit): send a message ('exit' to quit)
5
3
  Please enter a prompt.: Please enter a prompt.
6
4
  THINKING...: THINKING...
7
- Please set your OpenAI API key via `ai config set OPENAI_KEY=<your token>`: Please
8
- set your OpenAI API key via `ai config set OPENAI_KEY=<your token>`
9
5
  Set config: Set config
10
6
  Enter your OpenAI API key: Enter your OpenAI API key
11
7
  Enter your Anthropic API key: Enter your Anthropic API key
@@ -14,7 +10,6 @@ Provider: Provider
14
10
  Pick a provider: Pick a provider
15
11
  OpenAI Key: OpenAI Key
16
12
  Anthropic Key: Anthropic Key
17
- Please enter a key: Please enter a key
18
13
  OpenAI API Endpoint: OpenAI API Endpoint
19
14
  Enter your OpenAI API Endpoint: Enter your OpenAI API Endpoint
20
15
  Silent Mode: Silent Mode
@@ -22,50 +17,30 @@ Enable silent mode?: Enable silent mode?
22
17
  Model: Model
23
18
  Pick a model.: Pick a model.
24
19
  No models found for this provider.: No models found for this provider.
25
- Enter the model you want to use: Enter the model you want to use
26
20
  Language: Language
27
21
  Enter the language you want to use: Enter the language you want to use
28
22
  What would you like me to do?: What would you like me to do?
29
- delete all log files: delete all log files
30
- list js files: list js files
31
- fetch me a random joke: fetch me a random joke
32
- list all commits: list all commits
33
23
  Say hello: Say hello
34
- you can edit script here: you can edit script here
35
- What would you like me to change in this script?: What would you like me to change
36
- in this script?
37
- e.g.: e.g.
38
- e.g. change the folder name: e.g. change the folder name
39
- Your script: Your script
40
- Loading...: Loading...
41
- Getting explanation...: Getting explanation...
42
- Explanation: Explanation
24
+ Edit script: Edit script
25
+ Edit with arrow keys, Enter to confirm, Ctrl+C to cancel: Edit with arrow keys, Enter to confirm, Ctrl+C to cancel
26
+ What would you like me to change in this script?: What would you like me to change in this script?
43
27
  Run this script?: Run this script?
44
- Run it?: Run it?
45
28
  Revise this script?: Revise this script?
46
- 'Yes': 'Yes'
47
- Lets go!: Lets go!
48
- Edit: Edit
49
- Make some adjustments before running: Make some adjustments before running
29
+ Yes: Yes
30
+ Modify: Modify
31
+ Exit: Exit
32
+ Explain: Explain
50
33
  Revise: Revise
51
- Give feedback via prompt and get a new result: Give feedback via prompt and get a
52
- new result
53
34
  Copy: Copy
54
- Copy the generated script to your clipboard: Copy the generated script to your clipboard
55
35
  Cancel: Cancel
56
- Exit the program: Exit the program
57
36
  Running: Running
58
37
  Copied to clipboard!: Copied to clipboard!
59
- Your new script: Your new script
60
38
  Invalid config property: Invalid config property
39
+ Invalid mode: Invalid mode
61
40
  Shell detection failed unexpectedly: Shell detection failed unexpectedly
62
- Invalid model: Invalid model
63
41
  Error: Error
64
42
  Missing required parameter: Missing required parameter
65
- Please open a Bug report with the information above: Please open a Bug report with
66
- the information above
67
- Prompt to run: Prompt to run
43
+ Please open a Bug report with the information above: Please open a Bug report with the information above
68
44
  You: You
69
- Ask for a shell command. Type exit to quit.: Ask for a shell command. Type exit to quit.
70
- Restored context: Restored context
71
- messages: messages
45
+ Shell integration installed.: Shell integration installed.
46
+ Run it?: Run it?