elelem 0.10.0 → 0.12.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -195
  3. data/exe/elelem +7 -1
  4. data/lib/elelem/agent.rb +68 -62
  5. data/lib/elelem/cli.rb +116 -0
  6. data/lib/elelem/commands.rb +26 -10
  7. data/lib/elelem/config.rb +17 -0
  8. data/lib/elelem/conversation.rb +1 -0
  9. data/lib/elelem/input.rb +51 -0
  10. data/lib/elelem/null_input.rb +8 -0
  11. data/lib/elelem/null_output.rb +7 -0
  12. data/lib/elelem/null_tool.rb +18 -0
  13. data/lib/elelem/output.rb +55 -0
  14. data/lib/elelem/plugins.rb +20 -27
  15. data/lib/elelem/prompts/default.erb +3 -0
  16. data/lib/elelem/registration.rb +37 -0
  17. data/lib/elelem/registry.rb +68 -0
  18. data/lib/elelem/repl.rb +34 -0
  19. data/lib/elelem/result.rb +27 -0
  20. data/lib/elelem/schema.rb +20 -0
  21. data/lib/elelem/session.rb +17 -0
  22. data/lib/elelem/slash_command.rb +18 -0
  23. data/lib/elelem/stub_provider.rb +15 -0
  24. data/lib/elelem/system_prompt.rb +12 -135
  25. data/lib/elelem/tool.rb +7 -12
  26. data/lib/elelem/toolbox.rb +40 -31
  27. data/lib/elelem/transcript.rb +26 -0
  28. data/lib/elelem/version.rb +2 -2
  29. data/lib/elelem.rb +28 -113
  30. metadata +48 -155
  31. data/CHANGELOG.md +0 -309
  32. data/lib/elelem/mcp/oauth.rb +0 -217
  33. data/lib/elelem/mcp/token_storage.rb +0 -60
  34. data/lib/elelem/mcp.rb +0 -243
  35. data/lib/elelem/net/claude.rb +0 -202
  36. data/lib/elelem/net/ollama.rb +0 -81
  37. data/lib/elelem/net/openai.rb +0 -88
  38. data/lib/elelem/net.rb +0 -13
  39. data/lib/elelem/permissions.rb +0 -45
  40. data/lib/elelem/plugins/builtins.rb +0 -96
  41. data/lib/elelem/plugins/edit.rb +0 -15
  42. data/lib/elelem/plugins/eval.rb +0 -20
  43. data/lib/elelem/plugins/execute.rb +0 -18
  44. data/lib/elelem/plugins/git.rb +0 -20
  45. data/lib/elelem/plugins/glob.rb +0 -13
  46. data/lib/elelem/plugins/grep.rb +0 -21
  47. data/lib/elelem/plugins/list.rb +0 -14
  48. data/lib/elelem/plugins/mcp.rb +0 -20
  49. data/lib/elelem/plugins/permissions.json +0 -6
  50. data/lib/elelem/plugins/read.rb +0 -21
  51. data/lib/elelem/plugins/task.rb +0 -14
  52. data/lib/elelem/plugins/tools.rb +0 -13
  53. data/lib/elelem/plugins/verify.rb +0 -47
  54. data/lib/elelem/plugins/write.rb +0 -34
  55. data/lib/elelem/plugins/zz_confirm.rb +0 -9
  56. data/lib/elelem/terminal.rb +0 -103
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e95bbe823f4cf39a8a801bd32240caa47dcc3bdea6ce803f7c744f5f192f74c
4
- data.tar.gz: 685b6b52ff14408979bd61a0ea42e87d24602f398392ac845fcc893eceabccae
3
+ metadata.gz: d4696869850fc38fba45a65a38aca74f8663fe9b8562deb7a987cbc71058221a
4
+ data.tar.gz: 8ec379df1537243659cfee997c02fde40912be780ace54b14bb0ff181f5fad64
5
5
  SHA512:
6
- metadata.gz: cc605e03c30962a7f75d0846cde1bfb46d41c73ffbd07ee49f933fe51076ae08df7f9fb86b933d16864fd5aad7de9a29297d3b7da01337a9aa62d80aa9fa5940
7
- data.tar.gz: 5d2580db0a5e607c360bb1f691d2eabaec7a37af7ca4f705950fed4048a567be46077c04e5a289163473f679ec95b76b8b04e5c56d84a3bb4eb42be6cc1b4da6
6
+ metadata.gz: 52e225e4b206091bcd1838cce432ee2391c648fa4f995a722dfefc2820985bb405e5e3574a972a8e31be260695eb368f3f6f3dcfa32f8cbeeb76b5fab6b1a2f3
7
+ data.tar.gz: c508bfedce1daf081011046ca77a13872af1bb133d161edfb944d7e4019e92222b15dc9aeb4f98376328e099a62e5ec89f2b67d2f595ed6ea6a58281a0a44589
data/README.md CHANGED
@@ -1,77 +1,17 @@
1
1
  # Elelem
2
2
 
3
- Fast, correct, autonomous pick two.
3
+ Fast, correct, autonomous - pick two.
4
4
 
5
5
  ## Purpose
6
6
 
7
- Elelem is a minimal coding agent written in Ruby. It is designed to help
8
- you write, edit, and manage code and plain-text files from the command line
9
- by delegating work to an LLM. The agent exposes a simple text-based UI and a
10
- set of built-in tools that give the LLM access to the local file system.
7
+ Elelem is a minimal coding harness. The agent exposes a simple text-based UI.
11
8
 
12
9
  ## Design Principles
13
10
 
14
- * Unix philosophy simple, composable, minimal.
11
+ * Unix philosophy - simple, composable, minimal.
15
12
  * Convention over configuration.
16
13
  * No defensive checks or complexity beyond what is necessary.
17
14
  * Assumes a mature, responsible LLM that behaves like a capable engineer.
18
- * Optimised for my personal workflow and preferences.
19
- * Efficient and minimal like *aider* – https://aider.chat/.
20
- * UX similar to Claude Code – https://docs.claude.com/en/docs/claude-code/overview.
21
-
22
- ## System Assumptions
23
-
24
- * Linux host with Alacritty, tmux, Bash, Vim.
25
- * Runs inside a Git repository.
26
- * Git is available and functional.
27
-
28
- ## Dependencies
29
-
30
- Elelem relies on several external tools. Install the ones you need:
31
-
32
- | Tool | Purpose | Install |
33
- |------|---------|---------|
34
- | [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
35
- | [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
36
- | [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
37
- | [git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
38
- | [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
39
- | [jq](https://jqlang.github.io/jq/) | JSON processing | `brew install jq` / `apt install jq` |
40
- | [ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
41
- | [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
42
-
43
- **Required:** Git, Ollama (or another LLM provider)
44
-
45
- **Recommended:** glow, jq, ctags, ripgrep, fd
46
-
47
- **Optional:** ast-grep (for structural code search)
48
-
49
- ## Scope
50
-
51
- Only plain-text and source-code files are supported. No binary handling,
52
- sandboxing, or permission checks are performed - the LLM has full access.
53
-
54
- ## Configuration
55
-
56
- Prefer convention over configuration. Add environment variables only after
57
- repeated use proves their usefulness.
58
-
59
- ## UI Expectations
60
-
61
- Keyboard-driven, minimal TUI. No mouse support or complex widgets.
62
-
63
- ## Coding Standards for the LLM
64
-
65
- * No extra error handling unless essential.
66
- * Keep methods short, single-purpose.
67
- * Descriptive, conventional names.
68
- * Use Ruby standard library where possible.
69
-
70
- ## Helpful Links
71
-
72
- * https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
73
- * https://www.anthropic.com/engineering/writing-tools-for-agents
74
- * https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
75
15
 
76
16
  ## Installation
77
17
 
@@ -89,144 +29,17 @@ Start an interactive chat session:
89
29
  elelem chat
90
30
  ```
91
31
 
92
- ### Options
32
+ ## Tools
93
33
 
94
- * `--provider` LLM provider: `ollama`, `anthropic`, `openai`, or `vertex-ai` (default: `ollama`).
95
- * `--model` – Override the default model for the selected provider.
34
+ Use the plugin API to add tools.
96
35
 
97
- ### Examples
36
+ ## Contributing
98
37
 
99
38
  ```bash
100
- # Default (Ollama)
101
- elelem chat
102
-
103
- # Anthropic Claude
104
- ANTHROPIC_API_KEY=sk-... elelem chat --provider anthropic
105
-
106
- # OpenAI
107
- OPENAI_API_KEY=sk-... elelem chat --provider openai
108
-
109
- # VertexAI (uses gcloud ADC)
110
- elelem chat --provider vertex-ai --model claude-sonnet-4@20250514
111
- ```
112
-
113
- ### Provider Configuration
114
-
115
- Each provider reads its configuration from environment variables:
116
-
117
- | Provider | Environment Variables |
118
- |-------------|---------------------------------------------------|
119
- | ollama | `OLLAMA_HOST` (default: localhost:11434) |
120
- | anthropic | `ANTHROPIC_API_KEY` |
121
- | openai | `OPENAI_API_KEY`, `OPENAI_BASE_URL` |
122
- | vertex-ai | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
123
-
124
- ## Features
125
-
126
- * **Interactive REPL** – clean, streaming chat.
127
- * **Toolbox** – file I/O and shell execution.
128
- * **Streaming Responses** – output appears in real time.
129
- * **Conversation History** – persists across turns; can be cleared.
130
- * **Context Dump** – `/context` shows the current conversation state.
131
-
132
- ## Tools
133
-
134
- Built-in tools available to the LLM:
135
-
136
- | Tool | Purpose | Parameters |
137
- | --------- | -------------------------- | ------------------------- |
138
- | `read` | Read file contents | `path` |
139
- | `write` | Write file | `path`, `content` |
140
- | `edit` | Replace text in file | `path`, `old`, `new` |
141
- | `execute` | Run shell command | `command` |
142
- | `eval` | Execute Ruby code | `ruby` |
143
- | `glob` | Find files by pattern | `pattern`, `path` |
144
- | `grep` | Search file contents | `pattern`, `path`, `glob` |
145
- | `list` | List directory | `path`, `recursive` |
146
- | `git` | Run git command | `command`, `args` |
147
- | `task` | Delegate to sub-agent | `prompt` |
148
- | `verify` | Check syntax and run tests | `path` |
149
-
150
- Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`; `ls` → `list`
151
-
152
- ## Plugins
153
-
154
- Plugins extend elelem with custom tools and commands. They are loaded from:
155
- - `lib/elelem/plugins/` (built-in)
156
- - `~/.elelem/plugins/` (user global)
157
- - `.elelem/plugins/` (project local)
158
-
159
- ### Writing a Plugin
160
-
161
- ```ruby
162
- # ~/.elelem/plugins/hello.rb
163
- Elelem::Plugins.register(:hello) do |agent|
164
- # Add a tool
165
- agent.toolbox.add("hello",
166
- description: "Say hello",
167
- params: { name: { type: "string" } },
168
- required: ["name"]
169
- ) do |args|
170
- { message: "Hello, #{args["name"]}!" }
171
- end
172
-
173
- # Add a command
174
- agent.commands.register("greet", description: "Greet the user") do
175
- agent.terminal.say "Hello!"
176
- end
177
-
178
- # Add hooks
179
- agent.toolbox.before("execute") { |args| puts "Running: #{args["command"]}" }
180
- agent.toolbox.after("execute") { |args, result| puts "Exit: #{result[:exit_status]}" }
181
-
182
- # Global hook (runs for all tools)
183
- agent.toolbox.before { |args, tool_name:| puts "Calling #{tool_name}" }
184
- end
185
- ```
186
-
187
- ### Plugin API
188
-
189
- Plugins receive an `agent` object with access to:
190
- - `agent.toolbox` - add tools, register hooks
191
- - `agent.terminal` - output to the user (`say`, `ask`, `markdown`)
192
- - `agent.commands` - register slash commands
193
- - `agent.conversation` - access message history
194
- - `agent.client` - the LLM client
195
- - `agent.fork(system_prompt:)` - create a sub-agent
196
-
197
- ## MCP Configuration
198
-
199
- Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
200
-
201
- ```json
202
- {
203
- "mcpServers": {
204
- "gitlab": {
205
- "command": "npx",
206
- "args": ["-y", "@anthropics/gitlab-mcp"],
207
- "env": {
208
- "GITLAB_TOKEN": "${GITLAB_TOKEN}"
209
- }
210
- },
211
- "remote": {
212
- "type": "http",
213
- "url": "https://mcp.example.com/sse"
214
- }
215
- }
216
- }
39
+ $ git clone https://git.mokhan.ca/elelem/elelem.git
217
40
  ```
218
41
 
219
- HTTP servers support OAuth authentication automatically.
220
-
221
- ## Known Limitations
222
-
223
- * Assumes the current directory is a Git repository.
224
- * No sandboxing – the LLM can run arbitrary commands.
225
- * Error handling is minimal; exceptions are returned as an `error` field.
226
-
227
- ## Contributing
228
-
229
- Send me an email. For instructions see https://git-send-email.io/.
42
+ Send me a patch via email. For instructions see https://git-send-email.io/.
230
43
 
231
44
  ## License
232
45
 
data/exe/elelem CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env -S ruby -W0
2
2
  # frozen_string_literal: true
3
3
 
4
- require "elelem"
4
+ require "elelem/cli"
5
+
6
+ begin
7
+ require "elelem/builtins"
8
+ rescue LoadError
9
+ warn "elelem: gem install elelem-builtins for the default tools and commands"
10
+ end
5
11
 
6
12
  Signal.trap("INT") { exit 1 }
7
13
 
data/lib/elelem/agent.rb CHANGED
@@ -2,94 +2,100 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- attr_reader :conversation, :client, :toolbox, :terminal, :commands
6
- attr_writer :terminal, :toolbox, :commands
5
+ attr_reader :conversation, :toolbox, :output
6
+ attr_accessor :provider
7
7
 
8
- def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
9
- @client = client
8
+ def initialize(provider, toolbox: Toolbox.new, output: NullOutput.new, system_prompt: nil)
9
+ @provider = provider
10
10
  @toolbox = toolbox
11
- @commands = commands || Commands.new
12
- @terminal = terminal
13
- @conversation = Conversation.new
14
- @system_prompt = system_prompt
15
- end
16
-
17
- def repl
18
- terminal.say "elelem v#{VERSION}"
19
- loop do
20
- input = terminal.ask("> ")
21
- break if input.nil?
22
- next if input.empty?
23
- input.start_with?("/") ? command(input) : turn(input)
24
- end
25
- end
26
-
27
- def command(input)
28
- parts = input.delete_prefix("/").split(" ", 2)
29
- name, args = parts[0], parts[1]
30
- commands.run(name, args) || terminal.say(commands.names.join(" "))
11
+ @output = output
12
+ @conversation = Transcript.new(system_prompt: system_prompt)
31
13
  end
32
14
 
33
15
  def context
34
- @conversation.to_a(system_prompt: system_prompt)
16
+ @conversation.to_a
35
17
  end
36
18
 
37
- def fork(system_prompt:)
38
- Agent.new(client, toolbox: toolbox, terminal: terminal, system_prompt: system_prompt)
19
+ def turn(prompt)
20
+ @conversation.add(role: "user", content: prompt)
21
+ content = run_until_done([])
22
+ @conversation.add(role: "assistant", content: content)
23
+ content
39
24
  end
40
25
 
41
- def turn(input)
42
- @conversation.add(role: "user", content: input)
43
- ctx = []
44
- content = nil
26
+ private
45
27
 
28
+ def run_until_done(ctx)
46
29
  loop do
47
- terminal.waiting
48
- content, tool_calls = fetch_response(ctx)
49
- terminal.say(terminal.markdown(content))
50
- break if tool_calls.empty?
51
-
52
- ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
53
- tool_calls.each do |tool_call|
54
- ctx << { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
55
- end
30
+ content, tool_calls = fetch_and_say(ctx)
31
+ return content if tool_calls.empty?
32
+
33
+ ctx.concat(tool_turn(content, tool_calls))
56
34
  end
35
+ end
57
36
 
58
- @conversation.add(role: "assistant", content: content)
59
- content
37
+ def fetch_and_say(ctx)
38
+ output.waiting
39
+ content, tool_calls = fetch_response(ctx)
40
+ output.say(content, as: :markdown)
41
+ [content, tool_calls]
60
42
  end
61
43
 
62
- private
44
+ def tool_turn(content, tool_calls)
45
+ results = tool_calls.map { |tool_call| tool_result(tool_call) }
46
+ [{ role: "assistant", content: content, tool_calls: tool_calls }.compact, *results]
47
+ end
48
+
49
+ def tool_result(tool_call)
50
+ { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
51
+ end
63
52
 
64
53
  def process(tool_call)
65
- name, args = tool_call[:name], tool_call[:arguments]
66
- terminal.say toolbox.header(name, args)
67
- toolbox.run(name.to_s, args)
54
+ name = tool_call[:name]
55
+ args = tool_call[:arguments]
56
+ output.doing(toolbox.tool_for(name).name, args)
57
+ Elelem.logger.debug("agent: #{name}(#{args.inspect})")
58
+ log_result(name, toolbox.run(name.to_s, args))
59
+ end
60
+
61
+ def log_result(name, result)
62
+ Elelem.logger.debug("agent: #{name} -> #{result.inspect}")
63
+ result
68
64
  end
69
65
 
70
66
  def fetch_response(ctx)
67
+ collect_response(ctx)
68
+ rescue StandardError, ScriptError => e
69
+ fetch_error(e)
70
+ end
71
+
72
+ def collect_response(ctx)
71
73
  content = String.new
72
74
  tool_calls = []
75
+ provider.fetch(messages(ctx), toolbox.to_a) { |event| handle_event(event, content, tool_calls) }
76
+ [content, tool_calls]
77
+ end
73
78
 
74
- client.fetch(@conversation.to_a(system_prompt: system_prompt) + ctx, toolbox.to_a) do |event|
75
- case event[:type]
76
- when "saying"
77
- content << event[:text].to_s
78
- when "thinking"
79
- terminal.print(terminal.think(event[:text]))
80
- when "tool_call"
81
- tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
82
- end
83
- end
79
+ def messages(ctx)
80
+ @conversation.to_a(ctx)
81
+ end
84
82
 
85
- [content, tool_calls]
86
- rescue => e
87
- terminal.say "\n ✗ #{e.message}"
88
- ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
83
+ def fetch_error(error)
84
+ warn_and_say(error)
85
+ ["Error: #{error.message}", []]
86
+ end
87
+
88
+ def handle_event(event, content, tool_calls)
89
+ case event[:type]
90
+ when "saying" then content << event[:text].to_s
91
+ when "thinking" then output.thinking(event[:text])
92
+ when "doing" then tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
93
+ end
89
94
  end
90
95
 
91
- def system_prompt
92
- @system_prompt || SystemPrompt.new.render
96
+ def warn_and_say(error)
97
+ Elelem.logger.warn("agent: #{error.message}")
98
+ output.say(error.message, as: :error)
93
99
  end
94
100
  end
95
101
  end
data/lib/elelem/cli.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "elelem"
4
+ require "io/console"
5
+ require "optparse"
6
+ require "reline"
7
+
8
+ module Elelem
9
+ class CLI
10
+ COMMANDS = %w[chat ask files help].freeze
11
+ USAGE = <<~TEXT
12
+ \nCommands:
13
+ chat Interactive REPL (default)
14
+ ask <prompt> One-shot query (reads stdin if piped)
15
+ files Output files as XML (no options)
16
+ help Show this help
17
+ \nOptions:
18
+ TEXT
19
+ private_constant :USAGE
20
+
21
+ def initialize(args, provider: "stub")
22
+ @provider = provider
23
+ @args = parse(args)
24
+ end
25
+
26
+ def run
27
+ command = @args.shift || "chat"
28
+ abort "Unknown command: #{command}" unless COMMANDS.include?(command)
29
+ send(command)
30
+ end
31
+
32
+ private
33
+
34
+ def parse(args)
35
+ @parser = OptionParser.new { |o| describe_options(o) }
36
+ @parser.parse!(args)
37
+ end
38
+
39
+ def describe_options(parser)
40
+ describe_usage(parser)
41
+ parser.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
42
+ parser.on("-h", "--help", "Show this help") { print_help_and_exit(parser) }
43
+ end
44
+
45
+ def print_help_and_exit(parser)
46
+ puts parser
47
+ exit
48
+ end
49
+
50
+ def describe_usage(parser)
51
+ parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]"
52
+ parser.separator USAGE
53
+ end
54
+
55
+ def help
56
+ puts @parser
57
+ end
58
+
59
+ def chat
60
+ Elelem.start(provider: @provider)
61
+ rescue StandardError, ScriptError => e
62
+ warn_and_abort(e)
63
+ end
64
+
65
+ def ask
66
+ abort "Usage: elelem-chat ask <prompt>" if @args.empty?
67
+
68
+ reply = Elelem.ask(prompt_for_ask, provider: @provider, output: output_for_ask)
69
+ print_reply(reply, piped: piped?)
70
+ rescue StandardError, ScriptError => e
71
+ warn_and_abort(e)
72
+ end
73
+
74
+ def piped?
75
+ !$stdout.tty?
76
+ end
77
+
78
+ def output_for_ask
79
+ Elelem::Output.new(stream: piped? ? $stderr : $stdout)
80
+ end
81
+
82
+ def prompt_for_ask
83
+ prompt = @args.join(" ")
84
+ $stdin.stat.pipe? ? "#{prompt}\n\n```\n#{$stdin.read}\n```" : prompt
85
+ end
86
+
87
+ def print_reply(reply, piped:)
88
+ abort "elelem: no reply" if reply.to_s.strip.empty?
89
+ Elelem::Output.new.say(reply, as: :markdown) if piped
90
+ end
91
+
92
+ def warn_and_abort(error)
93
+ Elelem.logger.warn("cli: #{error.message}\n#{error.backtrace.join("\n")}")
94
+ abort "elelem: #{error.message}"
95
+ end
96
+
97
+ def files
98
+ puts "<documents>"
99
+ candidate_files.each_with_index { |line, i| print_document(line, i) }
100
+ puts "</documents>"
101
+ end
102
+
103
+ def candidate_files
104
+ $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
105
+ end
106
+
107
+ def print_document(line, index)
108
+ path = line.strip
109
+ return if path.empty? || !File.file?(path)
110
+
111
+ content = File.read(path)
112
+ puts %(<document index="#{index + 1}"><source>#{path}</source>) +
113
+ %(<document_content><![CDATA[#{content}]]></document_content></document>)
114
+ end
115
+ end
116
+ end
@@ -2,19 +2,33 @@
2
2
 
3
3
  module Elelem
4
4
  class Commands
5
- def initialize
6
- @registry = {}
5
+ include Enumerable
6
+
7
+ def initialize(registry = {})
8
+ @registry = registry
9
+ end
10
+
11
+ def clear!
12
+ @registry.clear
7
13
  end
8
14
 
9
- def register(name, description: "", &handler)
10
- @registry[name] = { description: description, handler: handler }
15
+ def register(name, description: "", completions: nil, &handler)
16
+ @registry[name] = SlashCommand.new(name, description, completions, &handler)
17
+ end
18
+
19
+ def completions_for(name, partial = "")
20
+ cmd = command_for(name)
21
+ return [] unless cmd&.completions
22
+
23
+ options = cmd.completions.respond_to?(:call) ? cmd.completions.call : cmd.completions
24
+ options.select { |o| o.start_with?(partial) }
11
25
  end
12
26
 
13
27
  def run(name, args = nil)
14
- entry = @registry[name]
15
- return false unless entry
28
+ command = command_for(name)
29
+ return false unless command
16
30
 
17
- entry[:handler].arity == 0 ? entry[:handler].call : entry[:handler].call(args)
31
+ command.call(args)
18
32
  true
19
33
  end
20
34
 
@@ -23,11 +37,13 @@ module Elelem
23
37
  end
24
38
 
25
39
  def each
26
- @registry.each { |name, entry| yield "/#{name}", entry[:description] }
40
+ @registry.each { |name, command| yield "/#{name}", command.description }
27
41
  end
28
42
 
29
- def include?(name)
30
- @registry.key?(name)
43
+ private
44
+
45
+ def command_for(name)
46
+ @registry[name]
31
47
  end
32
48
  end
33
49
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Config
5
+ extend SingleForwardable
6
+
7
+ def_single_delegators :default, :build_provider, :build_session, :apply, :reload, :names
8
+
9
+ def self.default
10
+ @default ||= Registry.new
11
+ end
12
+ end
13
+
14
+ def self.configure(&)
15
+ Config.default.configure(&)
16
+ end
17
+ end
@@ -10,6 +10,7 @@ module Elelem
10
10
 
11
11
  def add(role:, content:, **extra)
12
12
  raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
13
+
13
14
  @messages << { role: role, content: content, **extra }.compact
14
15
  end
15
16
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Elelem
6
+ class Input
7
+ def initialize(commands: [])
8
+ @commands = commands
9
+ Reline.autocompletion = true
10
+ Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
11
+ end
12
+
13
+ def ask(prompt)
14
+ Reline.readline(prompt, true)&.strip
15
+ end
16
+
17
+ def interactive?
18
+ $stdin.tty?
19
+ end
20
+
21
+ private
22
+
23
+ def complete(target, preposing)
24
+ line = "#{preposing}#{target}"
25
+ return command_names.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
26
+ return complete_command_args_for(preposing, target) if preposing.start_with?("/") && preposing.include?(" ")
27
+
28
+ complete_files(target)
29
+ end
30
+
31
+ def complete_command_args_for(preposing, target)
32
+ cmd_name = preposing.delete_prefix("/").split(" ", 2).first
33
+ complete_command_args(cmd_name, target)
34
+ end
35
+
36
+ def command_names
37
+ @commands.respond_to?(:names) ? @commands.names : @commands
38
+ end
39
+
40
+ def complete_command_args(cmd_name, partial)
41
+ return [] unless @commands.respond_to?(:completions_for)
42
+
43
+ @commands.completions_for(cmd_name, partial)
44
+ end
45
+
46
+ def complete_files(target)
47
+ output, = Open3.capture2("bash", "-c", "compgen -f #{Shellwords.escape(target)}")
48
+ output.lines.map(&:strip).first(20)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class NullInput
5
+ def ask(_prompt) = nil
6
+ def interactive? = false
7
+ end
8
+ end