Comparing changes
v0.5.0
→
v0.6.0
19 commits
17 files changed
Commits
Changed files (17)
lib/elelem/agent.rb
@@ -2,45 +2,36 @@
module Elelem
class Agent
- attr_reader :conversation, :client, :toolbox
+ PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
+ ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
+ VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
+ COMMANDS = %w[/env /mode /provider /model /shell /clear /context /exit /help].freeze
+ MODES = %w[auto build plan verify].freeze
+ ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
- def initialize(client, toolbox)
+ attr_reader :conversation, :client, :toolbox, :provider, :terminal
+
+ def initialize(provider, model, toolbox, terminal: nil)
@conversation = Conversation.new
- @client = client
+ @provider = provider
@toolbox = toolbox
+ @client = build_client(provider, model)
+ @terminal = terminal || Terminal.new(
+ commands: COMMANDS,
+ modes: MODES,
+ providers: PROVIDERS,
+ env_vars: ENV_VARS
+ )
end
def repl
mode = Set.new([:read])
loop do
- input = ask?("User> ")
+ input = terminal.ask("User> ")
break if input.nil?
if input.start_with?("/")
- case input
- when "/mode auto"
- mode = Set[:read, :write, :execute]
- puts " → Mode: auto (all tools enabled)"
- when "/mode build"
- mode = Set[:read, :write]
- puts " → Mode: build (read + write)"
- when "/mode plan"
- mode = Set[:read]
- puts " → Mode: plan (read-only)"
- when "/mode verify"
- mode = Set[:read, :execute]
- puts " → Mode: verify (read + execute)"
- when "/mode"
- puts " Mode: #{mode.to_a.inspect}"
- puts " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
- when "/exit" then exit
- when "/clear"
- conversation.clear
- puts " → Conversation cleared"
- when "/context" then puts conversation.dump(mode)
- else
- puts help_banner
- end
+ handle_command(input, mode)
else
conversation.add(role: :user, content: input)
result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
@@ -51,13 +42,111 @@ module Elelem
private
- def ask?(text)
- Reline.readline(text, true)&.strip
+ def handle_command(input, mode)
+ case input
+ when "/mode auto"
+ mode.replace([:read, :write, :execute])
+ terminal.say " → Mode: auto (all tools enabled)"
+ when "/mode build"
+ mode.replace([:read, :write])
+ terminal.say " → Mode: build (read + write)"
+ when "/mode plan"
+ mode.replace([:read])
+ terminal.say " → Mode: plan (read-only)"
+ when "/mode verify"
+ mode.replace([:read, :execute])
+ terminal.say " → Mode: verify (read + execute)"
+ when "/mode"
+ terminal.say " Usage: /mode [auto|build|plan|verify]"
+ terminal.say ""
+ terminal.say " Provider: #{provider}/#{client.model}"
+ terminal.say " Mode: #{mode.to_a.inspect}"
+ terminal.say " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
+ when "/exit" then exit
+ when "/clear"
+ conversation.clear
+ terminal.say " → Conversation cleared"
+ when "/context"
+ terminal.say conversation.dump(mode)
+ when "/shell"
+ transcript = start_shell
+ conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
+ terminal.say " → Shell session captured"
+ when "/provider"
+ terminal.select("Provider?", PROVIDERS) do |selected_provider|
+ models = models_for(selected_provider)
+ if models.empty?
+ terminal.say " ✗ No models available for #{selected_provider}"
+ else
+ terminal.select("Model?", models) do |m|
+ switch_client(selected_provider, m)
+ end
+ end
+ end
+ when "/model"
+ models = models_for(provider)
+ if models.empty?
+ terminal.say " ✗ No models available for #{provider}"
+ else
+ terminal.select("Model?", models) do |m|
+ switch_model(m)
+ end
+ end
+ when "/env"
+ terminal.say " Usage: /env VAR cmd..."
+ terminal.say ""
+ ENV_VARS.each do |var|
+ value = ENV[var]
+ if value
+ masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
+ terminal.say " #{var}=#{masked}"
+ else
+ terminal.say " #{var}=(not set)"
+ end
+ end
+ when %r{^/env\s+(\w+)\s+(.+)$}
+ var_name = $1
+ command = $2
+ result = Elelem.shell.execute("sh", args: ["-c", command])
+ if result["exit_status"].zero?
+ value = result["stdout"].lines.first&.strip
+ if value && !value.empty?
+ ENV[var_name] = value
+ terminal.say " → Set #{var_name}"
+ else
+ terminal.say " ⚠ Command produced no output"
+ end
+ else
+ terminal.say " ⚠ Command failed: #{result['stderr']}"
+ end
+ else
+ terminal.say help_banner
+ end
+ end
+
+ def strip_ansi(text)
+ text.gsub(/^Script started.*?\n/, '')
+ .gsub(/\nScript done.*$/, '')
+ .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
+ .gsub(/\e\[\?[0-9]+[hl]/, '')
+ .gsub(/[\b]/, '')
+ .gsub(/\r/, '')
+ end
+
+ def start_shell
+ Tempfile.create do |file|
+ system("script -q #{file.path}", chdir: Dir.pwd)
+ strip_ansi(File.read(file.path))
+ end
end
def help_banner
<<~HELP
+ /env VAR cmd...
/mode auto build plan verify
+ /provider
+ /model
+ /shell
/clear
/context
/exit
@@ -65,6 +154,53 @@ module Elelem
HELP
end
+ def build_client(provider_name, model = nil)
+ model_opts = model ? { model: model } : {}
+
+ case provider_name
+ when "ollama" then Net::Llm::Ollama.new(**model_opts)
+ when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
+ when "openai" then Net::Llm::OpenAI.new(**model_opts)
+ when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
+ else
+ raise Error, "Unknown provider: #{provider_name}"
+ end
+ end
+
+ def models_for(provider_name)
+ case provider_name
+ when "ollama"
+ client_for_models = provider_name == provider ? client : build_client(provider_name)
+ client_for_models.tags["models"]&.map { |m| m["name"] } || []
+ when "openai"
+ client_for_models = provider_name == provider ? client : build_client(provider_name)
+ client_for_models.models["data"]&.map { |m| m["id"] } || []
+ when "anthropic"
+ ANTHROPIC_MODELS
+ when "vertex-ai"
+ VERTEX_MODELS
+ else
+ []
+ end
+ rescue KeyError => e
+ terminal.say " ⚠ Missing credentials: #{e.message}"
+ []
+ rescue => e
+ terminal.say " ⚠ Could not fetch models: #{e.message}"
+ []
+ end
+
+ def switch_client(new_provider, model)
+ @provider = new_provider
+ @client = build_client(new_provider, model)
+ terminal.say " → Switched to #{new_provider}/#{client.model}"
+ end
+
+ def switch_model(model)
+ @client = build_client(provider, model)
+ terminal.say " → Switched to #{provider}/#{client.model}"
+ end
+
def format_tool_call_result(result)
return if result.nil?
return result["stdout"] if result["stdout"]
@@ -74,6 +210,17 @@ module Elelem
""
end
+ def truncate_output(text, max_lines: 30)
+ return text if text.nil? || text.empty?
+
+ lines = text.to_s.lines
+ if lines.size > max_lines
+ lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
+ else
+ text
+ end
+ end
+
def format_tool_calls_for_api(tool_calls)
tool_calls.map do |tc|
args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
@@ -91,39 +238,43 @@ module Elelem
def execute_turn(messages, tools:)
turn_context = []
+ errors = 0
loop do
content = ""
tool_calls = []
- print "Thinking> "
- client.fetch(messages + turn_context, tools) do |chunk|
- case chunk[:type]
- when :delta
- print chunk[:thinking] if chunk[:thinking]
- content += chunk[:content] if chunk[:content]
- when :complete
- content = chunk[:content] if chunk[:content]
- tool_calls = chunk[:tool_calls] || []
+ terminal.write "Thinking... "
+ begin
+ client.fetch(messages + turn_context, tools) do |chunk|
+ case chunk[:type]
+ when :delta
+ terminal.write chunk[:thinking] if chunk[:thinking]
+ content += chunk[:content] if chunk[:content]
+ when :complete
+ content = chunk[:content] if chunk[:content]
+ tool_calls = chunk[:tool_calls] || []
+ end
end
+ rescue => e
+ terminal.say "\n ✗ API Error: #{e.message}"
+ return { role: "assistant", content: "[Error: #{e.message}]" }
end
- puts "\nAssistant> #{content}" unless content.to_s.empty?
+ terminal.say "\nAssistant> #{content}" unless content.to_s.empty?
api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
if tool_calls.any?
tool_calls.each do |call|
- name = call[:name]
- args = call[:arguments]
-
- puts "\nTool> #{name}(#{args})"
+ name, args = call[:name], call[:arguments]
+ terminal.say "\nTool> #{name}(#{args})"
result = toolbox.run_tool(name, args)
- puts format_tool_call_result(result)
+ terminal.say truncate_output(format_tool_call_result(result))
turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
+ errors += 1 if result[:error]
end
-
- tool_calls = []
+ return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
next
end
lib/elelem/application.rb
@@ -15,27 +15,13 @@ module Elelem
type: :string,
desc: "Model name (uses provider default if not specified)"
def chat(*)
- client = build_client
- say "Agent (#{options[:provider]}/#{client.model})", :green
- agent = Agent.new(client, Toolbox.new)
+ provider = options[:provider]
+ model = options[:model]
+ say "Agent (#{provider})", :green
+ agent = Agent.new(provider, model, Toolbox.new)
agent.repl
end
- private
-
- def build_client
- model_opts = options[:model] ? { model: options[:model] } : {}
-
- case options[:provider]
- when "ollama" then Net::Llm::Ollama.new(**model_opts)
- when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
- when "openai" then Net::Llm::OpenAI.new(**model_opts)
- when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
- else
- raise Error, "Unknown provider: #{options[:provider]}. Use: #{PROVIDERS.join(', ')}"
- end
- end
-
desc "files", "Generate CXML of the files"
def files
puts '<documents>'
lib/elelem/system_prompt.erb
@@ -1,15 +1,12 @@
-You are a reasoning coding and system agent.
+You are a trusted terminal agent. You act on behalf of the user - executing tasks directly through bash, files, and git. Be capable, be direct, be done.
+
+## Principles
+
+- Act, don't explain. Execute the task.
+- Read before write. Understand existing code first.
+- Small focused changes. One thing at a time.
+- Verify your work. Run tests, check output.
## System
-Operating System: <%= `uname -a` %>
-USER: <%= ENV['USER'] %>
-HOME: <%= ENV['HOME'] %>
-SHELL: <%= ENV['SHELL'] %>
-PATH: <%= ENV['PATH'] %>
-PWD: <%= ENV['PWD'] %>
-LANG: <%= ENV['LANG'] %>
-EDITOR: <%= ENV['EDITOR'] %>
-LOGNAME: <%= ENV['LOGNAME'] %>
-TERM: <%= ENV['TERM'] %>
-MAIL: <%= ENV['MAIL'] %>
+<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
lib/elelem/terminal.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Terminal
+ def initialize(commands: [], modes: [], providers: [], env_vars: [])
+ @commands = commands
+ @modes = modes
+ @providers = providers
+ @env_vars = env_vars
+ setup_completion
+ end
+
+ def ask(prompt)
+ Reline.readline(prompt, true)&.strip
+ end
+
+ def say(message)
+ $stdout.puts message
+ end
+
+ def write(message)
+ $stdout.print message
+ end
+
+ def select(question, options, &block)
+ CLI::UI::Prompt.ask(question) do |handler|
+ options.each do |option|
+ handler.option(option) { |selected| block.call(selected) }
+ end
+ end
+ end
+
+ private
+
+ def setup_completion
+ Reline.autocompletion = true
+ Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
+ end
+
+ def complete(target, preposing)
+ line = "#{preposing}#{target}"
+
+ if line.start_with?('/') && !preposing.include?(' ')
+ return @commands.select { |c| c.start_with?(line) }
+ end
+
+ case preposing.strip
+ when '/mode'
+ @modes.select { |m| m.start_with?(target) }
+ when '/provider'
+ @providers.select { |p| p.start_with?(target) }
+ when '/env'
+ @env_vars.select { |v| v.start_with?(target) }
+ when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
+ subcommands = %w[show ls insert generate edit rm]
+ matches = subcommands.select { |c| c.start_with?(target) }
+ matches.any? ? matches : complete_pass_entries(target)
+ when %r{^/env\s+\w+$}
+ complete_commands(target)
+ else
+ complete_files(target)
+ end
+ end
+
+ def complete_commands(target)
+ result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
+ result["stdout"].lines.map(&:strip).first(20)
+ end
+
+ def complete_files(target)
+ result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
+ result["stdout"].lines.map(&:strip).first(20)
+ end
+
+ def complete_pass_entries(target)
+ store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
+ result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
+ result["stdout"].lines.map { |l|
+ l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
+ }.select { |e| e.start_with?(target) }.first(20)
+ end
+ end
+end
lib/elelem/tool.rb
@@ -12,7 +12,9 @@ module Elelem
def call(args)
unless valid?(args)
- return { error: "Invalid args for #{@name}", received: args.keys, expected: @schema.dig(:function, :parameters, :required) }
+ actual = args.keys
+ expected = @schema.dig(:function, :parameters)
+ return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
end
@block.call(args)
lib/elelem/toolbox.rb
@@ -9,7 +9,7 @@ module Elelem
full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
end
- BASH_TOOL = Tool.build("bash", "Run shell commands. For git: bash({\"cmd\": \"git\", \"args\": [\"log\", \"--oneline\"]}). Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
+ EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
Elelem.shell.execute(
args["cmd"],
args: args["args"] || [],
@@ -38,10 +38,11 @@ module Elelem
end
TOOL_ALIASES = {
- "exec" => "bash",
- "execute" => "bash",
+ "bash" => "exec",
+ "execute" => "exec",
"open" => "read",
"search" => "grep",
+ "sh" => "exec",
}
attr_reader :tools
@@ -50,7 +51,7 @@ module Elelem
@tools_by_name = {}
@tools = { read: [], write: [], execute: [] }
add_tool(eval_tool(binding), :execute)
- add_tool(BASH_TOOL, :execute)
+ add_tool(EXEC_TOOL, :execute)
add_tool(GREP_TOOL, :read)
add_tool(LIST_TOOL, :read)
add_tool(PATCH_TOOL, :write)
lib/elelem/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Elelem
- VERSION = "0.5.0"
-end
+ VERSION = "0.6.0"
+end
\ No newline at end of file
lib/elelem.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+require "cli/ui"
require "erb"
require "fileutils"
require "json"
@@ -16,6 +17,7 @@ require "timeout"
require_relative "elelem/agent"
require_relative "elelem/application"
require_relative "elelem/conversation"
+require_relative "elelem/terminal"
require_relative "elelem/tool"
require_relative "elelem/toolbox"
require_relative "elelem/version"
spec/elelem/agent_e2e_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Agent do
+ let(:toolbox) { Elelem::Toolbox.new }
+ let(:fake_client) { instance_double(Net::Llm::Ollama, model: "test-model") }
+
+ before do
+ allow(Net::Llm::Ollama).to receive(:new).and_return(fake_client)
+ end
+
+ describe "slash commands" do
+ describe "/mode" do
+ it "shows help when called without arguments" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/mode", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" Usage: /mode [auto|build|plan|verify]")
+ end
+
+ it "switches to auto mode" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/mode auto", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Mode: auto (all tools enabled)")
+ end
+
+ it "switches to build mode" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/mode build", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Mode: build (read + write)")
+ end
+
+ it "switches to plan mode" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/mode plan", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Mode: plan (read-only)")
+ end
+
+ it "switches to verify mode" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/mode verify", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Mode: verify (read + execute)")
+ end
+ end
+
+ describe "/clear" do
+ it "clears the conversation" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/clear", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+ agent.conversation.add(role: :user, content: "hello")
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Conversation cleared")
+ end
+ end
+
+ describe "/env" do
+ it "shows help and env vars when called without arguments" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/env", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" Usage: /env VAR cmd...")
+ expect(terminal.output.any? { |line| line.include?("ANTHROPIC_API_KEY") }).to be true
+ end
+
+ it "sets environment variable from command output" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/env TEST_VAR echo hello", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output).to include(" → Set TEST_VAR")
+ expect(ENV["TEST_VAR"]).to eq("hello")
+ end
+ end
+
+ describe "/help" do
+ it "shows help banner" do
+ terminal = Elelem::FakeTerminal.new(inputs: ["/help", nil])
+ agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+ agent.repl
+
+ expect(terminal.output.join).to include("/env VAR cmd...")
+ expect(terminal.output.join).to include("/mode auto build plan verify")
+ end
+ end
+ end
+end
spec/elelem/agent_spec.rb
@@ -1,8 +1,15 @@
# frozen_string_literal: true
RSpec.describe Elelem::Agent do
- let(:mock_client) { double("client") }
- let(:agent) { described_class.new(mock_client, Elelem::Toolbox.new) }
+ let(:mock_client) { double("client", model: "test-model") }
+ let(:agent) do
+ agent = described_class.allocate
+ agent.instance_variable_set(:@conversation, Elelem::Conversation.new)
+ agent.instance_variable_set(:@provider, "ollama")
+ agent.instance_variable_set(:@toolbox, Elelem::Toolbox.new)
+ agent.instance_variable_set(:@client, mock_client)
+ agent
+ end
describe "#initialize" do
it "creates a new conversation" do
spec/elelem/toolbox_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Elelem::Toolbox do
tool_names = tools.map { |t| t.dig(:function, :name) }
expect(tool_names).to include("grep", "list", "read")
- expect(tool_names).not_to include("write", "patch", "bash")
+ expect(tool_names).not_to include("write", "patch", "exec")
end
it "returns write tools for write mode" do
@@ -19,7 +19,7 @@ RSpec.describe Elelem::Toolbox do
tool_names = tools.map { |t| t.dig(:function, :name) }
expect(tool_names).to include("patch", "write")
- expect(tool_names).not_to include("grep", "bash")
+ expect(tool_names).not_to include("grep", "exec")
end
it "returns execute tools for execute mode" do
@@ -27,7 +27,7 @@ RSpec.describe Elelem::Toolbox do
tools = subject.tools_for(mode)
tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("bash")
+ expect(tool_names).to include("exec")
expect(tool_names).not_to include("grep", "write")
end
@@ -36,7 +36,7 @@ RSpec.describe Elelem::Toolbox do
tools = subject.tools_for(mode)
tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("grep", "list", "read", "patch", "write", "bash")
+ expect(tool_names).to include("grep", "list", "read", "patch", "write", "exec")
end
it "returns combined tools for build mode" do
@@ -45,7 +45,7 @@ RSpec.describe Elelem::Toolbox do
tool_names = tools.map { |t| t.dig(:function, :name) }
expect(tool_names).to include("grep", "read", "write", "patch")
- expect(tool_names).not_to include("bash")
+ expect(tool_names).not_to include("exec")
end
end
spec/support/fake_terminal.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Elelem
+ class FakeTerminal
+ attr_reader :output, :selections
+
+ def initialize(inputs: [], selections: {})
+ @inputs = inputs
+ @selections = selections
+ @output = []
+ end
+
+ def ask(_prompt)
+ @inputs.shift
+ end
+
+ def say(message)
+ @output << message
+ end
+
+ def write(message)
+ @output << message
+ end
+
+ def select(question, _options, &block)
+ selected = @selections[question]
+ block.call(selected) if selected
+ end
+ end
+end
spec/spec_helper.rb
@@ -2,6 +2,8 @@
require_relative "../lib/elelem"
+Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f }
+
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = ".rspec_status"
CHANGELOG.md
@@ -1,5 +1,28 @@
## [Unreleased]
+## [0.6.0] - 2026-01-12
+
+### Added
+- `/env` slash command to capture environment variables for provider connections
+- `/shell` slash command
+- `/provider` and `/model` slash commands
+- Tab completion for commands
+- Help output for `/mode` and `/env` commands
+
+### Changed
+- Renamed `bash` tool to `exec`
+- Tuned system prompt
+- Changed thinking prompt to ellipsis
+- Removed username from system prompt
+- Use pessimistic constraint on net-llm dependency
+- Extracted Terminal class for IO abstraction (enables E2E testing)
+
+### Fixed
+- Prevent infinite looping errors
+- Provide function schema when tool is called with invalid arguments
+- Tab completion for `pass` entries without requiring `show` subcommand
+- Password store symlink support in tab completion
+
## [0.5.0] - 2025-01-07
### Added
elelem.gemspec
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
"lib/elelem/application.rb",
"lib/elelem/conversation.rb",
"lib/elelem/system_prompt.erb",
+ "lib/elelem/terminal.rb",
"lib/elelem/tool.rb",
"lib/elelem/toolbox.rb",
"lib/elelem/version.rb",
@@ -38,6 +39,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
+ spec.add_dependency "cli-ui", "~> 2.0"
spec.add_dependency "erb", "~> 6.0"
spec.add_dependency "fileutils", "~> 1.0"
spec.add_dependency "json", "~> 2.0"
Gemfile.lock
@@ -1,7 +1,8 @@
PATH
remote: .
specs:
- elelem (0.5.0)
+ elelem (0.6.0)
+ cli-ui (~> 2.0)
erb (~> 6.0)
fileutils (~> 1.0)
json (~> 2.0)
@@ -18,23 +19,24 @@ PATH
GEM
remote: https://rubygems.org/
specs:
- addressable (2.8.7)
- public_suffix (>= 2.0.2, < 7.0)
+ addressable (2.8.8)
+ public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
- bigdecimal (3.2.2)
- date (3.4.1)
+ bigdecimal (4.0.1)
+ cli-ui (2.7.0)
+ date (3.5.1)
diff-lcs (1.6.2)
erb (6.0.1)
fileutils (1.8.0)
- io-console (0.8.1)
- irb (1.15.2)
+ io-console (0.8.2)
+ irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json (2.13.2)
- json-schema (6.0.0)
+ json (2.18.0)
+ json-schema (6.1.0)
addressable (~> 2.8)
- bigdecimal (~> 3.1)
+ bigdecimal (>= 3.1, < 5)
logger (1.7.0)
net-hippie (1.4.0)
base64 (~> 0.1)
@@ -42,46 +44,48 @@ GEM
logger (~> 1.0)
net-http (~> 0.6)
openssl (~> 3.0)
- net-http (0.6.0)
- uri
+ net-http (0.9.1)
+ uri (>= 0.11.1)
net-llm (0.5.0)
json (~> 2.0)
net-hippie (~> 1.0)
uri (~> 1.0)
open3 (0.2.1)
- openssl (3.3.1)
+ openssl (3.3.2)
pathname (0.4.0)
- pp (0.6.2)
+ pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- psych (5.2.6)
+ psych (5.3.1)
date
stringio
- public_suffix (6.0.2)
- rake (13.3.0)
- rdoc (6.14.2)
+ public_suffix (7.0.2)
+ rake (13.3.1)
+ rdoc (7.0.3)
erb
psych (>= 4.0.0)
- reline (0.6.2)
+ tsort
+ reline (0.6.3)
io-console (~> 0.5)
- rspec (3.13.1)
+ rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
- rspec-core (3.13.5)
+ rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-mocks (3.13.5)
+ rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-support (3.13.4)
+ rspec-support (3.13.6)
set (1.1.2)
- stringio (3.1.7)
- thor (1.3.2)
- timeout (0.4.3)
- uri (1.0.3)
+ stringio (3.2.0)
+ thor (1.5.0)
+ timeout (0.6.0)
+ tsort (0.2.0)
+ uri (1.1.1)
PLATFORMS
ruby
README.md
@@ -141,7 +141,7 @@ seven tools, each represented by a JSON schema that the LLM can call.
| Tool | Purpose | Parameters |
| ---- | ------- | ---------- |
-| `bash` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
+| `exec` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
| `eval` | Dynamically create new tools | `code` |
| `grep` | Search Git‑tracked files | `query` |
| `list` | List tracked files | `path` (optional) |