Commit 5eb5c7e

mo khan <mo@mokhan.ca>
2026-09-07 16:28:36
feat: sync plugin content to current core API and add builtins.rb entry point
Filtered history brought in the plugins/ directory's full lifecycle, including files already deleted from core (confirm, mcp, ollama) or superseded APIs (Elelem::Plugins.register, agent.terminal). Sync the tree to the current 8 plugins (builtins, context, execute, provider, read, reload, stub, tools, write) and move Elelem.sh/truncate/ terminate/command_timeout here from core's lib/elelem.rb, since execute.rb was their only in-core caller. Claude-Session: https://claude.ai/code/session_01DUuj4amvRrPxDnHPHBkvz5
1 parent d81f443
lib/elelem/builtins/builtins.rb
@@ -1,14 +1,16 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:builtins) do |agent|
-  agent.commands.register("exit", description: "Exit elelem") { exit(0) }
+Elelem.configure do |config|
+  config.setup(:builtins) do |agent|
+    agent.commands.register("exit", description: "Exit elelem") { exit(0) }
 
-  agent.commands.register("clear", description: "Clear conversation history") do
-    agent.conversation.clear!
-    agent.terminal.say "  → context cleared"
-  end
+    agent.commands.register("clear", description: "Clear conversation history") do
+      agent.conversation.clear!
+      agent.output.say "  → context cleared"
+    end
 
-  agent.commands.register("help", description: "Show available commands") do
-    agent.terminal.say agent.commands.map { |name, description| "#{name.ljust(12)} #{description}" }.join("\n")
+    agent.commands.register("help", description: "Show available commands") do
+      agent.output.say agent.commands.map { |name, description| "#{name.ljust(12)} #{description}" }.join("\n")
+    end
   end
 end
lib/elelem/builtins/confirm.rb
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:confirm) do |agent|
-  permissions = Elelem::Permissions.new
-
-  agent.toolbox.before do |args, tool_name:|
-    permissions.check(tool_name, args, terminal: agent.terminal)
-  end
-end
lib/elelem/builtins/context.rb
@@ -1,29 +1,31 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:context) do |agent|
-  agent.commands.register("context", description: "Show conversation context") do |args|
-    messages = agent.context
+Elelem.configure do |config|
+  config.setup(:context) do |agent|
+    agent.commands.register("context", description: "Show conversation context") do |args|
+      messages = agent.context
 
-    case args
-    when nil, ""
-      messages.each_with_index do |msg, i|
-        role = msg[:role]
-        preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
-        preview += "..." if msg[:content].to_s.length > 60
-        agent.terminal.say "  #{i + 1}. #{role}: #{preview}"
-      end
-    when "json"
-      agent.terminal.say JSON.pretty_generate(messages)
-    when /^\d+$/
-      index = args.to_i - 1
-      if index >= 0 && index < messages.length
-        content = messages[index][:content].to_s
-        agent.terminal.say(agent.terminal.markdown(content))
+      case args
+      when nil, ""
+        messages.each_with_index do |msg, i|
+          role = msg[:role]
+          preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
+          preview += "..." if msg[:content].to_s.length > 60
+          agent.output.say "  #{i + 1}. #{role}: #{preview}"
+        end
+      when "json"
+        agent.output.say JSON.pretty_generate(messages)
+      when /^\d+$/
+        index = args.to_i - 1
+        if index >= 0 && index < messages.length
+          content = messages[index][:content].to_s
+          agent.output.say(content, as: :markdown)
+        else
+          agent.output.say "  Invalid index: #{args}"
+        end
       else
-        agent.terminal.say "  Invalid index: #{args}"
+        agent.output.say "  Usage: /context [json|<number>]"
       end
-    else
-      agent.terminal.say "  Usage: /context [json|<number>]"
     end
   end
 end
lib/elelem/builtins/execute.rb
@@ -1,18 +1,20 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:execute) do |agent|
-  agent.toolbox.add("execute",
-    description: "Run shell command (supports pipes and redirections)",
-    params: { command: { type: "string" } },
-    required: ["command"],
-    aliases: ["bash", "sh", "exec", "execute<|channel|>"]
-  ) do |a|
-    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
-  end
+Elelem.configure do |config|
+  config.setup(:execute) do |agent|
+    agent.toolbox.add("execute",
+      description: "Run shell command (supports pipes and redirections)",
+      params: { command: { type: "string" } },
+      required: ["command"],
+      aliases: ["bash", "sh", "exec", "execute<|channel|>"]
+    ) do |a|
+      Elelem.sh("bash", args: ["-c", a["command"]], timeout: Elelem.command_timeout) { |x| agent.output.print(x) }
+    end
 
-  agent.toolbox.after("execute") do |args, result|
-    next if result[:exit_status] == 0
+    agent.toolbox.after("execute") do |args, result|
+      next if result[:exit_status] == 0
 
-    agent.terminal.say agent.toolbox.header("execute", args, state: "x")
+      agent.output.say agent.toolbox.header("execute", args, state: "x")
+    end
   end
 end
lib/elelem/builtins/mcp.rb
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Plugins.register(:mcp) do |agent|
-  mcp = Elelem::MCP.new
-  at_exit { mcp.close }
-
-  Thread.new do
-    mcp.tools.each do |name, tool|
-      agent.toolbox.add(
-        name,
-        description: tool[:description],
-        params: tool[:params],
-        required: tool[:required],
-        &tool[:fn]
-      )
-    end
-  rescue => e
-    warn "MCP failed: #{e.message}"
-  end
-end
lib/elelem/builtins/ollama.rb
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-Elelem::Providers.register(:ollama) do
-  Elelem::Net::Ollama.new(
-    model: ENV.fetch("OLLAMA_MODEL", "gpt-oss:latest"),
-    host: ENV.fetch("OLLAMA_HOST", "localhost:11434")
-  )
-end
lib/elelem/builtins/provider.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem.configure do |config|
+  config.setup(:provider) do |agent|
+    agent.commands.register("provider", description: "Switch provider", completions: -> { Elelem::Config.names }) do |name|
+      if name.nil? || name.empty?
+        agent.output.say "  → available: #{Elelem::Config.names.join(", ")}"
+      else
+        agent.client = Elelem::Config.build(name)
+        agent.output.say "  → switched to #{name}"
+      end
+    end
+  end
+end
lib/elelem/builtins/read.rb
@@ -1,21 +1,23 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:read) do |agent|
-  agent.toolbox.add("read",
-    description: "Read file",
-    params: { path: { type: "string" } },
-    required: ["path"],
-    aliases: ["open"]
-  ) do |a|
-    path = Pathname.new(a["path"]).expand_path
-    path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
-  end
+Elelem.configure do |config|
+  config.setup(:read) do |agent|
+    agent.toolbox.add("read",
+      description: "Read file",
+      params: { path: { type: "string" } },
+      required: ["path"],
+      aliases: ["open"]
+    ) do |a|
+      path = Pathname.new(a["path"]).expand_path
+      path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
+    end
 
-  agent.toolbox.after("read") do |_, result|
-    if result[:error]
-      agent.terminal.say "  ! #{result[:error]}"
-    else
-      agent.terminal.display_file(result[:path], fallback: result[:content])
+    agent.toolbox.after("read") do |_, result|
+      if result[:error]
+        agent.output.say "  ! #{result[:error]}"
+      else
+        agent.output.display_file(result[:path], fallback: result[:content])
+      end
     end
   end
 end
lib/elelem/builtins/reload.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Elelem.configure do |config|
+  config.setup(:reload) do |agent|
+    agent.commands.register("reload", description: "Reload plugin code from disk") do
+      Elelem::Config.reload(agent)
+    end
+  end
+end
lib/elelem/builtins/stub.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class StubProvider
+  def fetch(*)
+    raise "no provider installed. Run: gem install elelem-llama (or elelem-ollama, elelem-openai, elelem-anthropic)"
+  end
+end
+
+Elelem.configure { |config| config.provider(:stub) { StubProvider.new } }
lib/elelem/builtins/tools.rb
@@ -1,35 +1,27 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:tools) do |agent|
-  completions = -> { agent.toolbox.tools.keys }
+Elelem.configure do |config|
+  config.setup(:tools) do |agent|
+    completions = -> { agent.toolbox.tools.keys }
 
-  agent.commands.register("tools", description: "List available tools", completions: completions) do |arg|
-    if arg && !arg.empty?
-      tool = agent.toolbox.tools[arg]
-      unless tool
-        agent.terminal.say "Unknown tool: #{arg}"
-        next
-      end
+    agent.commands.register("tools", description: "List available tools", completions: completions) do |arg|
+      if arg && !arg.empty?
+        tool = agent.toolbox.tools[arg]
+        unless tool
+          agent.output.say "Unknown tool: #{arg}"
+          next
+        end
 
-      lines = ["## #{tool.name}", "", tool.description, "", "### Parameters", ""]
-      tool.params.each do |name, spec|
-        req = tool.required.include?(name.to_s) ? ", required" : ""
-        desc = spec[:description] ? " - #{spec[:description]}" : ""
-        lines << "- `#{name}` (#{spec[:type]}#{req})#{desc}"
-      end
-      lines << "" << "*aliases: #{tool.aliases.join(", ")}*" if tool.aliases.any?
+        agent.output.say tool.to_markdown, as: :markdown
+      else
+        rows = agent.toolbox.tools.each_value.map(&:summary)
 
-      agent.terminal.say agent.terminal.markdown(lines.join("\n"))
-    else
-      rows = agent.toolbox.tools.each_value.map do |tool|
-        "| #{tool.name} | #{tool.description.lines.first.chomp} |"
-      end
+        md = String.new("| Tool | Description |\n")
+        md << "|------|-------------|\n"
+        md << rows.join("\n")
 
-      md = String.new("| Tool | Description |\n")
-      md << "|------|-------------|\n"
-      md << rows.join("\n")
-
-      agent.terminal.say agent.terminal.markdown(md)
+        agent.output.say md, as: :markdown
+      end
     end
   end
 end
lib/elelem/builtins/write.rb
@@ -1,34 +1,36 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:write) do |agent|
-  agent.toolbox.add("write",
-    description: "Write file",
-    params: { path: { type: "string" }, content: { type: "string" } },
-    required: ["path", "content"],
-    aliases: ["write<|channel|>"]
-  ) do |a|
-    path = Pathname.new(a["path"]).expand_path
-    FileUtils.mkdir_p(path.dirname)
-    { bytes: path.write(a["content"]), path: a["path"] }
-  end
+Elelem.configure do |config|
+  config.setup(:write) do |agent|
+    agent.toolbox.add("write",
+      description: "Write file",
+      params: { path: { type: "string" }, content: { type: "string" } },
+      required: ["path", "content"],
+      aliases: ["write<|channel|>"]
+    ) do |a|
+      path = Pathname.new(a["path"]).expand_path
+      FileUtils.mkdir_p(path.dirname)
+      { bytes: path.write(a["content"]), path: a["path"] }
+    end
 
-  agent.toolbox.before("write") do |args|
-    path = Pathname.new(args["path"]).expand_path
-    next unless path.exist? && $stdin.tty?
+    agent.toolbox.before("write") do |args|
+      path = Pathname.new(args["path"]).expand_path
+      next unless path.exist? && $stdin.tty?
 
-    Tempfile.create(["elelem", File.extname(path)]) do |t|
-      t.write(args["content"])
-      t.flush
-      system("diff", "--color=always", "-u", path.to_s, t.path)
+      Tempfile.create(["elelem", File.extname(path)]) do |t|
+        t.write(args["content"])
+        t.flush
+        system("diff", "--color=always", "-u", path.to_s, t.path)
+      end
     end
-  end
 
-  agent.toolbox.after("write") do |_, result|
-    if result[:error]
-      agent.terminal.say "  ! #{result[:error]}"
-    else
-      agent.terminal.display_file(result[:path], fallback: "  -> #{result[:path]}")
-      agent.toolbox.run("verify", { "path" => result[:path] })
+    agent.toolbox.after("write") do |_, result|
+      if result[:error]
+        agent.output.say "  ! #{result[:error]}"
+      else
+        agent.output.display_file(result[:path], fallback: "  -> #{result[:path]}")
+        agent.toolbox.run("verify", { "path" => result[:path] }) if agent.toolbox.tools.key?("verify")
+      end
     end
   end
 end
lib/elelem/builtins.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "elelem"
+require "fileutils"
+require "json"
+require "open3"
+require "pathname"
+require "stringio"
+require "tempfile"
+
+require_relative "builtins/version"
+
+module Elelem
+  def self.sh(cmd, args: [], cwd: Dir.pwd, env: {}, timeout: nil)
+    output = StringIO.new
+    options = { chdir: cwd }
+    options[:pgroup] = true if timeout
+
+    Open3.popen2e(env, cmd, *args, **options) do |stdin, out, wait_thr|
+      stdin.close
+      timed_out = false
+      watchdog = timeout && Thread.new do
+        sleep(timeout)
+        timed_out = true
+        terminate(wait_thr.pid)
+      end
+
+      out.each_line do |line|
+        yield line if block_given?
+        output.write(line)
+      end
+      timed_out ? watchdog&.join : watchdog&.kill
+
+      status = wait_thr.value
+      note = timed_out ? "\n[command timed out after #{timeout}s]" : ""
+      { exit_status: status.exitstatus || (timed_out ? 124 : 1), content: truncate(output.string) + note }
+    end
+  end
+
+  def self.truncate(content, limit: 10_000)
+    return content if content.length <= limit
+
+    half = limit / 2
+    elided = content.length - limit
+    "#{content[0, half]}\n[#{elided} characters elided]\n#{content[-half, half]}"
+  end
+
+  def self.terminate(pid)
+    Process.kill("TERM", -pid)
+    sleep 0.5
+    Process.kill("KILL", -pid)
+  rescue Errno::ESRCH
+    nil
+  end
+
+  def self.command_timeout
+    value = ENV["ELELEM_CMD_TIMEOUT"]
+    value && !value.empty? ? Integer(value) : nil
+  end
+end
+
+require_relative "builtins/stub"
+require_relative "builtins/provider"
+require_relative "builtins/reload"
+require_relative "builtins/builtins"
+require_relative "builtins/context"
+require_relative "builtins/tools"
+require_relative "builtins/read"
+require_relative "builtins/write"
+require_relative "builtins/execute"
elelem-builtins.gemspec
@@ -29,4 +29,7 @@ Gem::Specification.new do |spec|
   spec.add_dependency "elelem", "~> 0.11"
   spec.add_dependency "fileutils", "~> 1.8"
   spec.add_dependency "json", "~> 2.21"
+  spec.add_dependency "open3", "~> 0.2"
+  spec.add_dependency "stringio", "~> 3.1"
+  spec.add_dependency "tempfile", "~> 0.3"
 end
Gemfile.lock
@@ -0,0 +1,136 @@
+PATH
+  remote: .
+  specs:
+    elelem-builtins (0.1.0)
+      elelem (~> 0.11)
+      fileutils (~> 1.8)
+      json (~> 2.21)
+      open3 (~> 0.2)
+      stringio (~> 3.1)
+      tempfile (~> 0.3)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    bigdecimal (4.1.2)
+    diff-lcs (1.6.2)
+    elelem (0.11.0)
+      erb (~> 6.0)
+      fileutils (~> 1.8)
+      io-console (~> 0.9)
+      json (~> 2.21)
+      json_schemer (~> 2.5)
+      logger (~> 1.7)
+      open3 (~> 0.2)
+      optparse (~> 0.8)
+      pathname (~> 0.5)
+      reline (~> 0.7)
+      shellwords (~> 0.2)
+      stringio (~> 3.2)
+      tempfile (~> 0.3)
+      uri (~> 1.0)
+    erb (6.0.7)
+    fileutils (1.8.0)
+    hana (1.3.7)
+    io-console (0.9.2)
+    irb (1.18.0)
+      pp (>= 0.6.0)
+      prism (>= 1.3.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (2.21.2)
+    json_schemer (2.5.0)
+      bigdecimal
+      hana (~> 1.3)
+      regexp_parser (~> 2.0)
+      simpleidn (~> 0.2)
+    logger (1.7.0)
+    open3 (0.2.1)
+    optparse (0.8.1)
+    pathname (0.5.0)
+    pp (0.6.4)
+      prettyprint
+    prettyprint (0.2.0)
+    prism (1.9.0)
+    rake (13.4.2)
+    rbs (4.2.0)
+      logger
+      prism (>= 1.6.0)
+      tsort
+    rdoc (8.0.0)
+      erb
+      prism (>= 1.6.0)
+      rbs (>= 4.0.0)
+      tsort
+    regexp_parser (2.12.0)
+    reline (0.7.0)
+      io-console (~> 0.5)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    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.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
+    shellwords (0.2.2)
+    simpleidn (0.3.0)
+    stringio (3.2.0)
+    tempfile (0.3.1)
+    tsort (0.2.0)
+    uri (1.1.1)
+
+PLATFORMS
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  elelem-builtins!
+  irb
+  rake (~> 13.0)
+  rspec (~> 3.0)
+
+CHECKSUMS
+  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+  bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+  elelem (0.11.0)
+  elelem-builtins (0.1.0)
+  erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+  fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+  hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
+  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+  json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
+  optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+  pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
+  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+  rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
+  reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
+  shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
+  simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
+  stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+  tempfile (0.3.1) sha256=0bb53ab646744e505eb3102147e22ae130a626a15563e882428c1ec973fed76a
+  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+
+BUNDLED WITH
+  4.0.20