aicli 0.1.1 → 0.2.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38ddbde1455388bed7ee62cff84c0aaa5cbc7329b0210fb88aacdf9fe39a0e13
4
- data.tar.gz: 645ca62e82b191c88951293c8b04263428c7d5c3e4dfa7e6481db6ff59771a1c
3
+ metadata.gz: 9657d0162a9e2e70e856d1d6fd798880a300eb8c2dd8e284b2d3abe9e7964e9a
4
+ data.tar.gz: 73885b2b870ba4c450df673e7ee21f9eadd456106703b2023be0678b1b43aa78
5
5
  SHA512:
6
- metadata.gz: a36e4429798ec0261b2d65b39d038e6276d7680ed87d042d8b8a20b5ede788b5ba5010303103d36c87f25b69ebdcd520ed651f4668110d0ad3d341fb6d0852bf
7
- data.tar.gz: 68d9f6c8c3789eca4c5b9ff4e65e31d3598ab5f917985293dab89048c3651ba20ba7761fa46c06f04b06f247b0316adb785c0d0cb611955ae1db032a42f6a85c
6
+ metadata.gz: e395efe484bc84474cfc1e0d50600e07a1b836b6431dfda5f1786f7357289c7c113292df41ac7ff54bd11187f4a55f4ddb940b0be2af3bb235dc2dc82aa3466b
7
+ data.tar.gz: aa611ebff07502eef7886872083511cff09fb7e7613fd61294eb64c0403eb2012f083c5091c19f807d310ed842f4f48195ca7729cfc803bc4e924d4dddb04728
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## 0.2.6
2
+
3
+ - Ctrl+C clears the current input line instead of exiting chat/prompt
4
+
5
+ ## 0.2.5
6
+
7
+ - Fix chat `Run it? (y/n)` input (read from `/dev/tty`; only `y` executes)
8
+
9
+ ## 0.2.4
10
+
11
+ - Persist last 40 chat/prompt messages in `~/.aicli/context` and reload on next run
12
+ - Migrate flat `~/.aicli` config file to `~/.aicli/config`
13
+
14
+ ## 0.2.3
15
+
16
+ - In `ai chat`, detect suggested shell commands and ask to run them, then continue the conversation
17
+
18
+ ## 0.2.2
19
+
20
+ - Improve `ai update`: compare installed version with RubyGems, install latest explicitly, clearer status messages
21
+
22
+ ## 0.2.1
23
+
24
+ - Fix provider/model sync so Anthropic models are not sent to OpenAI
25
+ - Show active `provider / model` on each run
26
+ - Clearer error when a model is unavailable for the API key
27
+ - `config set MODEL=...` infers PROVIDER from the RubyLLM registry
28
+
29
+ ## 0.2.0
30
+
31
+ - Add Anthropic provider alongside OpenAI
32
+ - Route all LLM calls through RubyLLM
33
+ - Choose provider and model in `aicli config` (model list from RubyLLM registry)
34
+ - Config keys: `PROVIDER`, `ANTHROPIC_KEY` (plus existing `OPENAI_KEY` / `MODEL`)
35
+
36
+ ## 0.1.0
37
+
38
+ - Initial RubyGems release as `aicli`
39
+
1
40
  ## 1.0.12
2
41
 
3
42
  - Bug fixes
data/README.md CHANGED
@@ -38,17 +38,26 @@ Ruby port by [Antonio Molinari](https://github.com/magnum), inspired by the orig
38
38
  gem build aicli.gemspec && gem install ./aicli-*.gem
39
39
  ```
40
40
 
41
- 2. Retrieve your API key from [OpenAI](https://platform.openai.com/account/api-keys)
41
+ 2. Choose a provider and set the matching API key.
42
42
 
43
- > Note: If you haven't already, you'll have to create an account and set up billing.
44
-
45
- 3. Set the key so aicli can use it:
43
+ **OpenAI** (default) key from [OpenAI](https://platform.openai.com/account/api-keys):
46
44
 
47
45
  ```sh
46
+ aicli config set PROVIDER=openai
48
47
  aicli config set OPENAI_KEY=<your token>
49
48
  ```
50
49
 
51
- This will create a `.aicli` file in your home directory.
50
+ **Anthropic** key from [Anthropic](https://console.anthropic.com/settings/keys):
51
+
52
+ ```sh
53
+ aicli config set PROVIDER=anthropic
54
+ aicli config set ANTHROPIC_KEY=<your token>
55
+ ```
56
+
57
+ Or use the interactive UI (`aicli config`) to pick provider, key, and model.
58
+ Model lists come from the [RubyLLM](https://rubyllm.com/models/) registry.
59
+
60
+ Config lives in `~/.aicli/config`. Chat/prompt history is stored in `~/.aicli/context` (last 40 messages) and reloaded on the next run.
52
61
 
53
62
  ## Usage
54
63
 
@@ -80,7 +89,7 @@ aicli 'what is my ip address'
80
89
  aicli chat
81
90
  ```
82
91
 
83
- Engage in a conversation with the AI directly through the CLI.
92
+ Ask for shell commands in a multi-turn conversation. When the assistant suggests a command in a code fence, you are asked whether to run it; after it runs (or you decline), chat continues. Press `Ctrl+d` to quit.
84
93
 
85
94
  ### Silent mode (skip explanations)
86
95
 
@@ -94,7 +103,20 @@ Or save the preference:
94
103
  aicli config set SILENT_MODE=true
95
104
  ```
96
105
 
97
- ### Custom API endpoint
106
+ ### Provider and model
107
+
108
+ ```sh
109
+ aicli config set PROVIDER=openai # or anthropic
110
+ aicli config set MODEL=gpt-4o-mini # provider-specific model id
111
+ ```
112
+
113
+ Defaults: `gpt-4o-mini` (OpenAI), `claude-sonnet-4-6` (Anthropic).
114
+
115
+ LLM calls go through [RubyLLM](https://rubyllm.com/), so chat streaming and model metadata stay provider-agnostic.
116
+
117
+ ### Custom OpenAI API endpoint
118
+
119
+ Useful for OpenAI-compatible proxies (ignored for Anthropic):
98
120
 
99
121
  ```sh
100
122
  aicli config set OPENAI_API_ENDPOINT=<your proxy endpoint>
@@ -148,20 +170,32 @@ aicli update
148
170
 
149
171
  ## Development
150
172
 
173
+ Run the local checkout (not the installed gem) with Bundler from this directory:
174
+
151
175
  ```sh
152
176
  bundle install
153
- bundle exec bin/aicli --help
154
- bundle exec bin/aicli config set OPENAI_KEY=<your token>
155
- bundle exec bin/aicli list all log files
177
+
178
+ bundle exec bin/ai --help
179
+ bundle exec bin/ai config
180
+ bundle exec bin/ai chat
181
+ bundle exec bin/ai list all log files
156
182
  ```
157
183
 
158
- ## Common Issues
184
+ `bin/aicli` is equivalent to `bin/ai`. `bundle exec` loads dependencies from the Gemfile and code from `lib/`.
159
185
 
160
- ### 429 error
186
+ Example config for local testing:
187
+
188
+ ```sh
189
+ bundle exec bin/ai config set PROVIDER=openai
190
+ bundle exec bin/ai config set OPENAI_KEY=<your token>
191
+ # or: PROVIDER=anthropic + ANTHROPIC_KEY=<your token>
192
+ ```
193
+
194
+ ## Common Issues
161
195
 
162
- This is due to incorrect billing setup or excessive quota usage. Please follow [this guide](https://help.openai.com/en/articles/6891831-error-code-429-you-exceeded-your-current-quota-please-check-your-plan-and-billing-details) to fix it.
196
+ ### Rate limit / quota errors
163
197
 
164
- You can activate billing at [this link](https://platform.openai.com/account/billing/overview).
198
+ Usually billing or quota on the configured provider (OpenAI or Anthropic). Check that provider’s console billing page and that the matching API key is set in `~/.aicli/config`.
165
199
 
166
200
  ## Motivation
167
201
 
data/lib/aicli/cli.rb CHANGED
@@ -86,9 +86,9 @@ module AiCli
86
86
  Commands::Update.run
87
87
  end
88
88
 
89
- map %w[--version -v] => :__print_version
89
+ map %w[--version -v] => :version
90
90
  desc 'version', 'Print version'
91
- def __print_version
91
+ def version
92
92
  puts AiCli::VERSION
93
93
  end
94
94
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
3
4
  require 'pastel'
4
5
  require 'tty-prompt'
5
6
  require 'tty-spinner'
@@ -11,50 +12,110 @@ module AiCli
11
12
 
12
13
  def run
13
14
  config = Helpers::Config.get
14
- key = config['OPENAI_KEY']
15
- model = config['MODEL']
16
- api_endpoint = config['OPENAI_API_ENDPOINT']
17
- chat_history = []
15
+ chat = Helpers::Completion.start_chat(config)
16
+ restored = Helpers::Context.load_messages
17
+ Helpers::Context.apply_to_chat(chat) if restored.any?
18
18
 
19
19
  pastel = Pastel.new
20
- prompt = TTY::Prompt.new(interrupt: :exit)
20
+ prompt = TTY::Prompt.new(interrupt: :error)
21
+ Helpers::Context.seed_prompt_history(prompt)
21
22
 
22
23
  puts ''
23
- puts " #{Helpers::I18n.t('Starting new conversation')}"
24
+ puts "#{config['PROVIDER']} / #{config['MODEL']} #{pastel.dim('Ctrl+d to quit')}"
24
25
 
25
- loop do
26
- user_prompt = prompt.ask(pastel.cyan("#{Helpers::I18n.t('You')}:")) do |q|
27
- q.required true
28
- q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
29
- end
26
+ catch(:chat_quit) do
27
+ prompt.on(:keyctrl_d) { throw :chat_quit }
28
+
29
+ loop do
30
+ begin
31
+ user_prompt = prompt.ask(pastel.cyan("#{Helpers::I18n.t('You')}:")) do |q|
32
+ q.required true
33
+ q.validate(/.+/, Helpers::I18n.t('Please enter a prompt.'))
34
+ end
35
+ rescue TTY::Reader::InputInterrupt
36
+ # Ctrl+C clears the current input line and re-prompts.
37
+ print "\r\e[2K"
38
+ next
39
+ end
40
+
41
+ if user_prompt.nil? || user_prompt.strip.downcase == 'exit'
42
+ throw :chat_quit
43
+ end
44
+
45
+ spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('THINKING...')}", format: :dots)
46
+ spinner.auto_spin
47
+ started = false
48
+
49
+ response = Helpers::Completion.stream_chat_message(
50
+ chat,
51
+ user_prompt,
52
+ writer: lambda { |chunk|
53
+ unless started
54
+ spinner.success(pastel.green('aicli:'))
55
+ puts ''
56
+ started = true
57
+ end
58
+ print chunk
59
+ }
60
+ )
30
61
 
31
- if user_prompt.nil? || user_prompt == 'exit'
32
- puts "└ #{Helpers::I18n.t('Goodbye!')}"
33
- exit 0
62
+ unless started
63
+ spinner.success(pastel.green('aicli:'))
64
+ puts ''
65
+ end
66
+ puts ''
67
+ puts ''
68
+
69
+ Helpers::Context.save_from_chat(chat)
70
+ offer_to_run_commands(response, pastel)
34
71
  end
72
+ end
35
73
 
36
- spinner = TTY::Spinner.new("[:spinner] #{Helpers::I18n.t('THINKING...')}", format: :dots)
37
- spinner.auto_spin
74
+ Helpers::Context.save_from_chat(chat)
75
+ puts ''
76
+ puts Helpers::I18n.t('Goodbye!')
77
+ end
38
78
 
39
- chat_history << { 'role' => 'user', 'content' => user_prompt }
79
+ def offer_to_run_commands(response, pastel)
80
+ commands = Helpers::Completion.extract_commands(response)
81
+ return if commands.empty?
40
82
 
41
- stream_lines = Helpers::Completion.generate_completion(
42
- prompt: chat_history,
43
- key: key,
44
- model: model,
45
- api_endpoint: api_endpoint
46
- )
47
- enumerator = stream_lines.each
48
- read_response = Helpers::Completion.read_data(enumerator)
83
+ commands.each do |command|
84
+ puts pastel.dim(command)
85
+ next unless ask_to_run?
49
86
 
50
- spinner.success(pastel.green('aicli:'))
51
- puts ''
52
- full_response = read_response.call(->(chunk) { print chunk })
53
- chat_history << { 'role' => 'assistant', 'content' => full_response }
54
- puts ''
87
+ run_command(command)
55
88
  puts ''
56
89
  end
57
90
  end
91
+
92
+ # Only explicit `y` runs the command. Enter, Esc, n, Ctrl-C → skip.
93
+ def ask_to_run?
94
+ tty = File.open('/dev/tty', 'r+')
95
+ tty.print "#{Helpers::I18n.t('Run it?')} (y/n) "
96
+ tty.flush
97
+ char = tty.getch
98
+ tty.puts
99
+ char.to_s.downcase == 'y'
100
+ rescue Interrupt
101
+ begin
102
+ tty&.puts
103
+ rescue StandardError
104
+ puts
105
+ end
106
+ false
107
+ ensure
108
+ tty&.close
109
+ end
110
+
111
+ def run_command(command)
112
+ puts "#{Helpers::I18n.t('Running')}: #{command}"
113
+ puts ''
114
+ system(ENV['SHELL'] || 'bash', '-c', command)
115
+ Helpers::ShellHistory.append(command)
116
+ end
117
+
118
+ private_class_method :offer_to_run_commands, :ask_to_run?, :run_command
58
119
  end
59
120
  end
60
121
  end
@@ -1,21 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
3
6
  require 'pastel'
4
7
 
5
8
  module AiCli
6
9
  module Commands
7
10
  module Update
11
+ RUBYGEMS_GEM_URI = 'https://rubygems.org/api/v1/gems/aicli.json'
12
+
8
13
  module_function
9
14
 
10
15
  def run
11
16
  pastel = Pastel.new
17
+ current = Gem::Version.new(AiCli::VERSION)
18
+
19
+ puts ''
20
+ puts pastel.dim("Current version: #{current}")
21
+
22
+ latest_str = fetch_latest_version
23
+ latest = Gem::Version.new(latest_str)
24
+ puts pastel.dim("Latest on RubyGems: #{latest}")
12
25
  puts ''
13
- command = 'gem update aicli'
14
- puts pastel.dim("#{Helpers::I18n.t('Running')}: #{command}")
26
+
27
+ if latest < current
28
+ puts pastel.yellow(
29
+ "Installed #{current} is newer than RubyGems #{latest}. Skipping update."
30
+ )
31
+ puts pastel.dim('Publish a new version, then run `ai update` again.')
32
+ return
33
+ end
34
+
35
+ if latest == current
36
+ puts pastel.green("Already up to date (#{current}).")
37
+ return
38
+ end
39
+
40
+ command = ['gem', 'install', 'aicli', '-v', latest_str]
41
+ puts pastel.dim("#{Helpers::I18n.t('Running')}: #{command.join(' ')}")
15
42
  puts ''
16
- system(command)
43
+
44
+ ok = system(*command)
17
45
  puts ''
46
+
47
+ unless ok
48
+ raise Helpers::KnownError,
49
+ 'Failed to update aicli. Try manually: gem install aicli'
50
+ end
51
+
52
+ puts pastel.green("Updated aicli to #{latest}.")
53
+ puts pastel.dim('Open a new shell if `ai version` still shows the old number.')
54
+ rescue Helpers::KnownError => e
55
+ puts "\n#{pastel.red('✖')} #{e.message}"
56
+ exit 1
57
+ rescue StandardError => e
58
+ puts "\n#{pastel.red('✖')} Could not check RubyGems for updates: #{e.message}"
59
+ Helpers::Error.handle_cli_error(e)
60
+ exit 1
61
+ end
62
+
63
+ def fetch_latest_version
64
+ uri = URI(RUBYGEMS_GEM_URI)
65
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
66
+ http.open_timeout = 10
67
+ http.read_timeout = 10
68
+ http.get(uri.request_uri)
69
+ end
70
+
71
+ unless response.is_a?(Net::HTTPSuccess)
72
+ raise "RubyGems returned HTTP #{response.code}"
73
+ end
74
+
75
+ version = JSON.parse(response.body)['version'].to_s
76
+ raise 'RubyGems response missing version' if version.empty?
77
+
78
+ version
18
79
  end
80
+ private_class_method :fetch_latest_version
19
81
  end
20
82
  end
21
83
  end