elelem 0.5.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5358e2fd855f1b8848a191c4db7160cc7eb296a73451d56f69da7e67b1b83313
4
- data.tar.gz: 728dcd83f11d131af93a99cc08b68c3141b57c7ee36d69fb038736220e628eb1
3
+ metadata.gz: 9f36b6e8749d5c12525548bc5cfd8b270c2bd5f7ada78855f7b4c55e23e3cb52
4
+ data.tar.gz: 8bf6ae1b937a7fbf5dc22e0b0b522e903803a6562644afa04cf2b00c8d28133b
5
5
  SHA512:
6
- metadata.gz: 2bcc2bb0271cb1188a68d390922b59a0c1007aaf8f8980c0981c26d60b75d6cb666c481702d72db92d61db4824402d88a9bbfffce0623b73ba133ca8557270df
7
- data.tar.gz: 1b36b16c23223f574942de9bf72ea46f34d5e090eae8523c53baeccbd64ce26239de21938f80adf673f76326e87c9344e011723cb53407ed5f3df7d9647a1c3c
6
+ metadata.gz: 1eccfe3793dc81e84c8c5a82b287732dccab7be793c350ad65e20466d3c29ea183ec1aca561969e2b8a78c5095668329c672ea650f5a97bb5aa52f1d8680379e
7
+ data.tar.gz: c24188e1de12fb63452d4a34cfa48de11795bc18b85e3b95b7f620959c9e2cf084b6f9dba8b492bdbea5f5cd5f27c07d6fa9657e2ea0b37564ddf1736baf1dba
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-01-14
4
+
5
+ ### Added
6
+ - ASCII spinner animation while waiting for LLM responses
7
+ - `Terminal#waiting` method with automatic cleanup on next output
8
+ - Decision-making principles in system prompt (prefer reversible actions, ask when uncertain)
9
+ - Mode enforcement tests
10
+
11
+ ### Changed
12
+ - Renamed internal `mode` concept to `permissions` for clarity (read/write/execute are permissions, plan/build/verify are modes)
13
+ - Refactored `Toolbox#run_tool` to accept `permissions:` parameter
14
+
15
+ ### Fixed
16
+ - **Security**: Mode restrictions now enforced at execution time, not just schema time
17
+ - Previously, LLMs could call tools outside their mode by guessing tool names
18
+ - Now `run_tool` validates the tool is allowed for the current permission set
19
+
20
+ ## [0.6.0] - 2026-01-12
21
+
22
+ ### Added
23
+ - `/env` slash command to capture environment variables for provider connections
24
+ - `/shell` slash command
25
+ - `/provider` and `/model` slash commands
26
+ - Tab completion for commands
27
+ - Help output for `/mode` and `/env` commands
28
+
29
+ ### Changed
30
+ - Renamed `bash` tool to `exec`
31
+ - Tuned system prompt
32
+ - Changed thinking prompt to ellipsis
33
+ - Removed username from system prompt
34
+ - Use pessimistic constraint on net-llm dependency
35
+ - Extracted Terminal class for IO abstraction (enables E2E testing)
36
+
37
+ ### Fixed
38
+ - Prevent infinite looping errors
39
+ - Provide function schema when tool is called with invalid arguments
40
+ - Tab completion for `pass` entries without requiring `show` subcommand
41
+ - Password store symlink support in tab completion
42
+
3
43
  ## [0.5.0] - 2025-01-07
4
44
 
5
45
  ### Added
data/README.md CHANGED
@@ -141,7 +141,7 @@ seven tools, each represented by a JSON schema that the LLM can call.
141
141
 
142
142
  | Tool | Purpose | Parameters |
143
143
  | ---- | ------- | ---------- |
144
- | `bash` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
144
+ | `exec` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
145
145
  | `eval` | Dynamically create new tools | `code` |
146
146
  | `grep` | Search Git‑tracked files | `query` |
147
147
  | `list` | List tracked files | `path` (optional) |
data/lib/elelem/agent.rb CHANGED
@@ -2,48 +2,33 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- attr_reader :conversation, :client, :toolbox
5
+ PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
6
+ ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
7
+ VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
8
+ COMMANDS = %w[/env /mode /provider /model /shell /clear /context /exit /help].freeze
9
+ MODES = %w[auto build plan verify].freeze
10
+ ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
6
11
 
7
- def initialize(client, toolbox)
12
+ attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
13
+
14
+ def initialize(provider, model, toolbox, terminal: nil)
8
15
  @conversation = Conversation.new
9
- @client = client
16
+ @provider = provider
10
17
  @toolbox = toolbox
18
+ @client = build_client(provider, model)
19
+ @terminal = terminal || default_terminal
20
+ @permissions = Set.new([:read])
11
21
  end
12
22
 
13
23
  def repl
14
- mode = Set.new([:read])
15
-
16
24
  loop do
17
- input = ask?("User> ")
25
+ input = terminal.ask("User> ")
18
26
  break if input.nil?
19
27
  if input.start_with?("/")
20
- case input
21
- when "/mode auto"
22
- mode = Set[:read, :write, :execute]
23
- puts " → Mode: auto (all tools enabled)"
24
- when "/mode build"
25
- mode = Set[:read, :write]
26
- puts " → Mode: build (read + write)"
27
- when "/mode plan"
28
- mode = Set[:read]
29
- puts " → Mode: plan (read-only)"
30
- when "/mode verify"
31
- mode = Set[:read, :execute]
32
- puts " → Mode: verify (read + execute)"
33
- when "/mode"
34
- puts " Mode: #{mode.to_a.inspect}"
35
- puts " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
36
- when "/exit" then exit
37
- when "/clear"
38
- conversation.clear
39
- puts " → Conversation cleared"
40
- when "/context" then puts conversation.dump(mode)
41
- else
42
- puts help_banner
43
- end
28
+ handle_slash_command(input)
44
29
  else
45
30
  conversation.add(role: :user, content: input)
46
- result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
31
+ result = execute_turn(conversation.history_for(permissions))
47
32
  conversation.add(role: result[:role], content: result[:content])
48
33
  end
49
34
  end
@@ -51,13 +36,110 @@ module Elelem
51
36
 
52
37
  private
53
38
 
54
- def ask?(text)
55
- Reline.readline(text, true)&.strip
39
+ def default_terminal
40
+ Terminal.new(
41
+ commands: COMMANDS,
42
+ env_vars: ENV_VARS,
43
+ modes: MODES,
44
+ providers: PROVIDERS
45
+ )
46
+ end
47
+
48
+ def handle_slash_command(input)
49
+ case input
50
+ when "/mode auto"
51
+ permissions.replace([:read, :write, :execute])
52
+ terminal.say " → Mode: auto (all tools enabled)"
53
+ when "/mode build"
54
+ permissions.replace([:read, :write])
55
+ terminal.say " → Mode: build (read + write)"
56
+ when "/mode plan"
57
+ permissions.replace([:read])
58
+ terminal.say " → Mode: plan (read-only)"
59
+ when "/mode verify"
60
+ permissions.replace([:read, :execute])
61
+ terminal.say " → Mode: verify (read + execute)"
62
+ when "/mode"
63
+ terminal.say " Usage: /mode [auto|build|plan|verify]"
64
+ terminal.say ""
65
+ terminal.say " Provider: #{provider}/#{client.model}"
66
+ terminal.say " Permissions: #{permissions.to_a.inspect}"
67
+ terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
68
+ when "/exit" then exit
69
+ when "/clear"
70
+ conversation.clear
71
+ terminal.say " → Conversation cleared"
72
+ when "/context"
73
+ terminal.say conversation.dump(permissions)
74
+ when "/shell"
75
+ transcript = start_shell
76
+ conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
77
+ terminal.say " → Shell session captured"
78
+ when "/provider"
79
+ terminal.select("Provider?", PROVIDERS) do |selected_provider|
80
+ terminal.select("Model?", models_for(selected_provider)) do |m|
81
+ switch_client(selected_provider, m)
82
+ end
83
+ end
84
+ when "/model"
85
+ terminal.select("Model?", models_for(provider)) do |m|
86
+ switch_model(m)
87
+ end
88
+ when "/env"
89
+ terminal.say " Usage: /env VAR cmd..."
90
+ terminal.say ""
91
+ ENV_VARS.each do |var|
92
+ value = ENV[var]
93
+ if value
94
+ masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
95
+ terminal.say " #{var}=#{masked}"
96
+ else
97
+ terminal.say " #{var}=(not set)"
98
+ end
99
+ end
100
+ when %r{^/env\s+(\w+)\s+(.+)$}
101
+ var_name = $1
102
+ command = $2
103
+ result = Elelem.shell.execute("sh", args: ["-c", command])
104
+ if result["exit_status"].zero?
105
+ value = result["stdout"].lines.first&.strip
106
+ if value && !value.empty?
107
+ ENV[var_name] = value
108
+ terminal.say " → Set #{var_name}"
109
+ else
110
+ terminal.say " ⚠ Command produced no output"
111
+ end
112
+ else
113
+ terminal.say " ⚠ Command failed: #{result['stderr']}"
114
+ end
115
+ else
116
+ terminal.say help_banner
117
+ end
118
+ end
119
+
120
+ def strip_ansi(text)
121
+ text.gsub(/^Script started.*?\n/, '')
122
+ .gsub(/\nScript done.*$/, '')
123
+ .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
124
+ .gsub(/\e\[\?[0-9]+[hl]/, '')
125
+ .gsub(/[\b]/, '')
126
+ .gsub(/\r/, '')
127
+ end
128
+
129
+ def start_shell
130
+ Tempfile.create do |file|
131
+ system("script -q #{file.path}", chdir: Dir.pwd)
132
+ strip_ansi(File.read(file.path))
133
+ end
56
134
  end
57
135
 
58
136
  def help_banner
59
137
  <<~HELP
138
+ /env VAR cmd...
60
139
  /mode auto build plan verify
140
+ /provider
141
+ /model
142
+ /shell
61
143
  /clear
62
144
  /context
63
145
  /exit
@@ -65,6 +147,53 @@ module Elelem
65
147
  HELP
66
148
  end
67
149
 
150
+ def build_client(provider_name, model = nil)
151
+ model_opts = model ? { model: model } : {}
152
+
153
+ case provider_name
154
+ when "ollama" then Net::Llm::Ollama.new(**model_opts)
155
+ when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
156
+ when "openai" then Net::Llm::OpenAI.new(**model_opts)
157
+ when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
158
+ else
159
+ raise Error, "Unknown provider: #{provider_name}"
160
+ end
161
+ end
162
+
163
+ def models_for(provider_name)
164
+ case provider_name
165
+ when "ollama"
166
+ client_for_models = provider_name == provider ? client : build_client(provider_name)
167
+ client_for_models.tags["models"]&.map { |m| m["name"] } || []
168
+ when "openai"
169
+ client_for_models = provider_name == provider ? client : build_client(provider_name)
170
+ client_for_models.models["data"]&.map { |m| m["id"] } || []
171
+ when "anthropic"
172
+ ANTHROPIC_MODELS
173
+ when "vertex-ai"
174
+ VERTEX_MODELS
175
+ else
176
+ []
177
+ end
178
+ rescue KeyError => e
179
+ terminal.say " ⚠ Missing credentials: #{e.message}"
180
+ []
181
+ rescue => e
182
+ terminal.say " ⚠ Could not fetch models: #{e.message}"
183
+ []
184
+ end
185
+
186
+ def switch_client(new_provider, model)
187
+ @provider = new_provider
188
+ @client = build_client(new_provider, model)
189
+ terminal.say " → Switched to #{new_provider}/#{client.model}"
190
+ end
191
+
192
+ def switch_model(model)
193
+ @client = build_client(provider, model)
194
+ terminal.say " → Switched to #{provider}/#{client.model}"
195
+ end
196
+
68
197
  def format_tool_call_result(result)
69
198
  return if result.nil?
70
199
  return result["stdout"] if result["stdout"]
@@ -74,6 +203,17 @@ module Elelem
74
203
  ""
75
204
  end
76
205
 
206
+ def truncate_output(text, max_lines: 30)
207
+ return text if text.nil? || text.empty?
208
+
209
+ lines = text.to_s.lines
210
+ if lines.size > max_lines
211
+ lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
212
+ else
213
+ text
214
+ end
215
+ end
216
+
77
217
  def format_tool_calls_for_api(tool_calls)
78
218
  tool_calls.map do |tc|
79
219
  args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
@@ -89,41 +229,46 @@ module Elelem
89
229
  client.is_a?(Net::Llm::OpenAI)
90
230
  end
91
231
 
92
- def execute_turn(messages, tools:)
232
+ def execute_turn(messages)
233
+ tools = toolbox.tools_for(permissions)
93
234
  turn_context = []
235
+ errors = 0
94
236
 
95
237
  loop do
96
238
  content = ""
97
239
  tool_calls = []
98
240
 
99
- print "Thinking> "
100
- client.fetch(messages + turn_context, tools) do |chunk|
101
- case chunk[:type]
102
- when :delta
103
- print chunk[:thinking] if chunk[:thinking]
104
- content += chunk[:content] if chunk[:content]
105
- when :complete
106
- content = chunk[:content] if chunk[:content]
107
- tool_calls = chunk[:tool_calls] || []
241
+ terminal.waiting
242
+ begin
243
+ client.fetch(messages + turn_context, tools) do |chunk|
244
+ case chunk[:type]
245
+ when :delta
246
+ terminal.write chunk[:thinking] if chunk[:thinking]
247
+ content += chunk[:content] if chunk[:content]
248
+ when :complete
249
+ content = chunk[:content] if chunk[:content]
250
+ tool_calls = chunk[:tool_calls] || []
251
+ end
108
252
  end
253
+ rescue => e
254
+ terminal.say "\n ✗ API Error: #{e.message}"
255
+ return { role: "assistant", content: "[Error: #{e.message}]" }
109
256
  end
110
257
 
111
- puts "\nAssistant> #{content}" unless content.to_s.empty?
258
+ terminal.say "\nAssistant> #{content}" unless content.to_s.empty?
112
259
  api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
113
260
  turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
114
261
 
115
262
  if tool_calls.any?
116
263
  tool_calls.each do |call|
117
- name = call[:name]
118
- args = call[:arguments]
119
-
120
- puts "\nTool> #{name}(#{args})"
121
- result = toolbox.run_tool(name, args)
122
- puts format_tool_call_result(result)
264
+ name, args = call[:name], call[:arguments]
265
+ terminal.say "\nTool> #{name}(#{args})"
266
+ result = toolbox.run_tool(name, args, permissions: permissions)
267
+ terminal.say truncate_output(format_tool_call_result(result))
123
268
  turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
269
+ errors += 1 if result[:error]
124
270
  end
125
-
126
- tool_calls = []
271
+ return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
127
272
  next
128
273
  end
129
274
 
@@ -15,27 +15,13 @@ module Elelem
15
15
  type: :string,
16
16
  desc: "Model name (uses provider default if not specified)"
17
17
  def chat(*)
18
- client = build_client
19
- say "Agent (#{options[:provider]}/#{client.model})", :green
20
- agent = Agent.new(client, Toolbox.new)
18
+ provider = options[:provider]
19
+ model = options[:model]
20
+ say "Agent (#{provider})", :green
21
+ agent = Agent.new(provider, model, Toolbox.new)
21
22
  agent.repl
22
23
  end
23
24
 
24
- private
25
-
26
- def build_client
27
- model_opts = options[:model] ? { model: options[:model] } : {}
28
-
29
- case options[:provider]
30
- when "ollama" then Net::Llm::Ollama.new(**model_opts)
31
- when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
32
- when "openai" then Net::Llm::OpenAI.new(**model_opts)
33
- when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
34
- else
35
- raise Error, "Unknown provider: #{options[:provider]}. Use: #{PROVIDERS.join(', ')}"
36
- end
37
- end
38
-
39
25
  desc "files", "Generate CXML of the files"
40
26
  def files
41
27
  puts '<documents>'
@@ -8,9 +8,9 @@ module Elelem
8
8
  @items = items
9
9
  end
10
10
 
11
- def history_for(mode)
11
+ def history_for(permissions)
12
12
  history = @items.dup
13
- history[0] = { role: "system", content: system_prompt_for(mode) }
13
+ history[0] = { role: "system", content: system_prompt_for(permissions) }
14
14
  history
15
15
  end
16
16
 
@@ -30,8 +30,8 @@ module Elelem
30
30
  @items = default_context
31
31
  end
32
32
 
33
- def dump(mode)
34
- JSON.pretty_generate(history_for(mode))
33
+ def dump(permissions)
34
+ JSON.pretty_generate(history_for(permissions))
35
35
  end
36
36
 
37
37
  private
@@ -40,10 +40,10 @@ module Elelem
40
40
  [{ role: "system", content: prompt }]
41
41
  end
42
42
 
43
- def system_prompt_for(mode)
43
+ def system_prompt_for(permissions)
44
44
  base = system_prompt
45
45
 
46
- case mode.sort
46
+ case permissions.sort
47
47
  when [:read]
48
48
  "#{base}\n\nYou may read files on the system."
49
49
  when [:write]
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class GitContext
5
+ MAX_DIFF_LINES = 100
6
+
7
+ def initialize(shell = Elelem.shell)
8
+ @shell = shell
9
+ end
10
+
11
+ def to_s
12
+ return "" unless git_repo?
13
+
14
+ parts = []
15
+ parts << "Branch: #{branch}" if branch
16
+ parts << status_section if status.any?
17
+ parts << diff_section if staged_diff.any? || unstaged_diff.any?
18
+ parts << recent_commits_section if recent_commits.any?
19
+ parts.join("\n\n")
20
+ end
21
+
22
+ private
23
+
24
+ def git_repo?
25
+ @shell.execute("git", args: ["rev-parse", "--git-dir"])["exit_status"].zero?
26
+ end
27
+
28
+ def branch
29
+ @branch ||= @shell.execute("git", args: ["branch", "--show-current"])["stdout"].strip.then { |b| b.empty? ? nil : b }
30
+ end
31
+
32
+ def status
33
+ @status ||= @shell.execute("git", args: ["status", "--porcelain"])["stdout"].lines.map(&:chomp)
34
+ end
35
+
36
+ def staged_diff
37
+ @staged_diff ||= @shell.execute("git", args: ["diff", "--cached", "--stat"])["stdout"].lines
38
+ end
39
+
40
+ def unstaged_diff
41
+ @unstaged_diff ||= @shell.execute("git", args: ["diff", "--stat"])["stdout"].lines
42
+ end
43
+
44
+ def recent_commits
45
+ @recent_commits ||= @shell.execute("git", args: ["log", "--oneline", "-5"])["stdout"].lines.map(&:strip)
46
+ end
47
+
48
+ def status_section
49
+ modified = status.select { |l| l[0] == "M" || l[1] == "M" }.map { |l| l[3..] }
50
+ added = status.select { |l| l[0] == "A" || l.start_with?("??") }.map { |l| l[3..] }
51
+ deleted = status.select { |l| l[0] == "D" || l[1] == "D" }.map { |l| l[3..] }
52
+
53
+ lines = []
54
+ lines << "Modified: #{modified.join(', ')}" if modified.any?
55
+ lines << "Added: #{added.join(', ')}" if added.any?
56
+ lines << "Deleted: #{deleted.join(', ')}" if deleted.any?
57
+ lines.any? ? "Working tree:\n#{lines.join("\n")}" : nil
58
+ end
59
+
60
+ def diff_section
61
+ lines = []
62
+ lines << "Staged:\n#{truncate(staged_diff)}" if staged_diff.any?
63
+ lines << "Unstaged:\n#{truncate(unstaged_diff)}" if unstaged_diff.any?
64
+ lines.join("\n\n")
65
+ end
66
+
67
+ def recent_commits_section
68
+ "Recent commits:\n#{recent_commits.join("\n")}"
69
+ end
70
+
71
+ def truncate(lines)
72
+ if lines.size > MAX_DIFF_LINES
73
+ lines.first(MAX_DIFF_LINES).join + "\n... (#{lines.size - MAX_DIFF_LINES} more lines)"
74
+ else
75
+ lines.join
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,15 +1,16 @@
1
- You are a reasoning coding and system agent.
1
+ 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.
2
+
3
+ ## Principles
4
+
5
+ - Act, don't explain. Execute the task.
6
+ - Read before write. Understand existing code first.
7
+ - Small focused changes. One thing at a time.
8
+ - Verify your work. Run tests, check output.
2
9
 
3
10
  ## System
4
11
 
5
- Operating System: <%= `uname -a` %>
6
- USER: <%= ENV['USER'] %>
7
- HOME: <%= ENV['HOME'] %>
8
- SHELL: <%= ENV['SHELL'] %>
9
- PATH: <%= ENV['PATH'] %>
10
- PWD: <%= ENV['PWD'] %>
11
- LANG: <%= ENV['LANG'] %>
12
- EDITOR: <%= ENV['EDITOR'] %>
13
- LOGNAME: <%= ENV['LOGNAME'] %>
14
- TERM: <%= ENV['TERM'] %>
15
- MAIL: <%= ENV['MAIL'] %>
12
+ <%= `uname -s`.strip %> · <%= ENV['PWD'] %>
13
+
14
+ ## Git State
15
+
16
+ <%= Elelem::GitContext.new.to_s %>
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Terminal
5
+ def initialize(commands: [], modes: [], providers: [], env_vars: [])
6
+ @commands = commands
7
+ @modes = modes
8
+ @providers = providers
9
+ @env_vars = env_vars
10
+ @spinner_thread = nil
11
+ setup_completion
12
+ end
13
+
14
+ def ask(prompt)
15
+ Reline.readline(prompt, true)&.strip
16
+ end
17
+
18
+ def say(message)
19
+ stop_spinner
20
+ $stdout.puts message
21
+ end
22
+
23
+ def write(message)
24
+ stop_spinner
25
+ $stdout.print message
26
+ end
27
+
28
+ def waiting
29
+ @spinner_thread = Thread.new do
30
+ frames = %w[| / - \\]
31
+ i = 0
32
+ loop do
33
+ $stdout.print "\r#{frames[i % frames.length]} "
34
+ $stdout.flush
35
+ i += 1
36
+ sleep 0.1
37
+ end
38
+ end
39
+ end
40
+
41
+ def select(question, options, &block)
42
+ CLI::UI::Prompt.ask(question) do |handler|
43
+ options.each do |option|
44
+ handler.option(option) { |selected| block.call(selected) }
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def stop_spinner
52
+ return unless @spinner_thread
53
+
54
+ @spinner_thread.kill
55
+ @spinner_thread = nil
56
+ $stdout.print "\r \r"
57
+ end
58
+
59
+ def setup_completion
60
+ Reline.autocompletion = true
61
+ Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
62
+ end
63
+
64
+ def complete(target, preposing)
65
+ line = "#{preposing}#{target}"
66
+
67
+ if line.start_with?('/') && !preposing.include?(' ')
68
+ return @commands.select { |c| c.start_with?(line) }
69
+ end
70
+
71
+ case preposing.strip
72
+ when '/mode'
73
+ @modes.select { |m| m.start_with?(target) }
74
+ when '/provider'
75
+ @providers.select { |p| p.start_with?(target) }
76
+ when '/env'
77
+ @env_vars.select { |v| v.start_with?(target) }
78
+ when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
79
+ subcommands = %w[show ls insert generate edit rm]
80
+ matches = subcommands.select { |c| c.start_with?(target) }
81
+ matches.any? ? matches : complete_pass_entries(target)
82
+ when %r{^/env\s+\w+$}
83
+ complete_commands(target)
84
+ else
85
+ complete_files(target)
86
+ end
87
+ end
88
+
89
+ def complete_commands(target)
90
+ result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
91
+ result["stdout"].lines.map(&:strip).first(20)
92
+ end
93
+
94
+ def complete_files(target)
95
+ result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
96
+ result["stdout"].lines.map(&:strip).first(20)
97
+ end
98
+
99
+ def complete_pass_entries(target)
100
+ store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
101
+ result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
102
+ result["stdout"].lines.map { |l|
103
+ l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
104
+ }.select { |e| e.start_with?(target) }.first(20)
105
+ end
106
+ end
107
+ end
data/lib/elelem/tool.rb CHANGED
@@ -12,7 +12,9 @@ module Elelem
12
12
 
13
13
  def call(args)
14
14
  unless valid?(args)
15
- return { error: "Invalid args for #{@name}", received: args.keys, expected: @schema.dig(:function, :parameters, :required) }
15
+ actual = args.keys
16
+ expected = @schema.dig(:function, :parameters)
17
+ return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
16
18
  end
17
19
 
18
20
  @block.call(args)
@@ -9,7 +9,7 @@ module Elelem
9
9
  full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
10
10
  end
11
11
 
12
- 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|
12
+ 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|
13
13
  Elelem.shell.execute(
14
14
  args["cmd"],
15
15
  args: args["args"] || [],
@@ -38,19 +38,21 @@ module Elelem
38
38
  end
39
39
 
40
40
  TOOL_ALIASES = {
41
- "exec" => "bash",
42
- "execute" => "bash",
41
+ "bash" => "exec",
42
+ "execute" => "exec",
43
43
  "open" => "read",
44
44
  "search" => "grep",
45
+ "sh" => "exec",
45
46
  }
46
47
 
47
48
  attr_reader :tools
48
49
 
49
50
  def initialize
50
51
  @tools_by_name = {}
52
+ @tool_permissions = {}
51
53
  @tools = { read: [], write: [], execute: [] }
52
54
  add_tool(eval_tool(binding), :execute)
53
- add_tool(BASH_TOOL, :execute)
55
+ add_tool(EXEC_TOOL, :execute)
54
56
  add_tool(GREP_TOOL, :read)
55
57
  add_tool(LIST_TOOL, :read)
56
58
  add_tool(PATCH_TOOL, :write)
@@ -58,22 +60,31 @@ module Elelem
58
60
  add_tool(WRITE_TOOL, :write)
59
61
  end
60
62
 
61
- def add_tool(tool, mode)
62
- @tools[mode] << tool
63
+ def add_tool(tool, permission)
64
+ @tools[permission] << tool
63
65
  @tools_by_name[tool.name] = tool
66
+ @tool_permissions[tool.name] = permission
64
67
  end
65
68
 
66
69
  def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
67
70
  add_tool(Tool.build(name, description, properties, required, &block), mode)
68
71
  end
69
72
 
70
- def tools_for(modes)
71
- Array(modes).map { |mode| tools[mode].map(&:to_h) }.flatten
73
+ def tools_for(permissions)
74
+ Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
72
75
  end
73
76
 
74
- def run_tool(name, args)
77
+ def run_tool(name, args, permissions: [])
75
78
  resolved_name = TOOL_ALIASES.fetch(name, name)
76
- @tools_by_name[resolved_name]&.call(args) || { error: "Unknown tool", name: name, args: args }
79
+ tool = @tools_by_name[resolved_name]
80
+ return { error: "Unknown tool", name: name, args: args } unless tool
81
+
82
+ tool_permission = @tool_permissions[resolved_name]
83
+ unless Array(permissions).include?(tool_permission)
84
+ return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
85
+ end
86
+
87
+ tool.call(args)
77
88
  rescue => error
78
89
  { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
79
90
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.5.0"
5
- end
4
+ VERSION = "0.7.0"
5
+ end
data/lib/elelem.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cli/ui"
3
4
  require "erb"
4
5
  require "fileutils"
5
6
  require "json"
@@ -16,6 +17,8 @@ require "timeout"
16
17
  require_relative "elelem/agent"
17
18
  require_relative "elelem/application"
18
19
  require_relative "elelem/conversation"
20
+ require_relative "elelem/git_context"
21
+ require_relative "elelem/terminal"
19
22
  require_relative "elelem/tool"
20
23
  require_relative "elelem/toolbox"
21
24
  require_relative "elelem/version"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elelem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cli-ui
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: erb
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -200,7 +214,9 @@ files:
200
214
  - lib/elelem/agent.rb
201
215
  - lib/elelem/application.rb
202
216
  - lib/elelem/conversation.rb
217
+ - lib/elelem/git_context.rb
203
218
  - lib/elelem/system_prompt.erb
219
+ - lib/elelem/terminal.rb
204
220
  - lib/elelem/tool.rb
205
221
  - lib/elelem/toolbox.rb
206
222
  - lib/elelem/version.rb