summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.serena/.gitignore1
-rw-r--r--.serena/project.yml130
-rw-r--r--AGENTS.md22
-rw-r--r--CLAUDE.md4
-rw-r--r--examples/gem/Gemfile.lock2
-rw-r--r--examples/plain_ruby/Justfile12
-rw-r--r--examples/plain_ruby/README.md5
-rwxr-xr-xexamples/plain_ruby/agents.rb40
-rw-r--r--examples/plain_ruby/agents_example.txt1
-rwxr-xr-xexamples/plain_ruby/config.rb2
-rw-r--r--examples/rake/Gemfile.lock26
-rw-r--r--lib/dsl.rb30
-rw-r--r--lib/dslkeywords/agent.rb45
-rw-r--r--lib/dslkeywords/file.rb97
-rw-r--r--lib/dslkeywords/keyword.rb5
-rw-r--r--lib/dslkeywords/prompt.rb45
-rw-r--r--test/lib/dslkeywords/agent_test.rb312
-rw-r--r--test/support/mock_agent.rb26
18 files changed, 791 insertions, 14 deletions
diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 0000000..14d86ad
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1 @@
+/cache
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 0000000..5b72e88
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,130 @@
+# the name by which the project can be referenced within Serena
+project_name: "rcm"
+
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# java julia kotlin lua markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- ruby
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+# * `activate_project`: Activates a project by name.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_lines`: Deletes a range of lines within a file.
+# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
+# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
+# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
+# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+# * `initial_instructions`: Gets the initial instructions for the current project.
+# Should only be used in settings where the system prompt cannot be set,
+# e.g. in clients you have no control over, like Claude Desktop.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_at_line`: Inserts content at a given line in a file.
+# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+# * `list_memories`: Lists memories in Serena's project-specific memory store.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
+# * `remove_project`: Removes a project from the Serena configuration.
+# * `replace_lines`: Replaces a range of lines within a file with new content.
+# * `replace_symbol_body`: Replaces the full definition of a symbol.
+# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
+# * `switch_modes`: Activates modes by providing a list of their names
+# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
+# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
+# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
+# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
diff --git a/AGENTS.md b/AGENTS.md
index 3c6cea7..df19b63 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
-# CLAUDE.md
+# AGENTS.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+This file provides guidance to coding agents working with code in this repository.
## RCM (Ruby Configuration Management)
@@ -18,6 +18,9 @@ rake test
# Run a specific test file
rake test TEST=test/lib/dslkeywords/file_test.rb
+# Run RuboCop on the files you changed
+rubocop lib/dsl.rb lib/dslkeywords/file.rb test/lib/dslkeywords/file_test.rb
+
# Execute a configuration task in the playground
cd playground
rake wireguard -- --debug # with debug output
@@ -31,7 +34,7 @@ rake wireguard -- --dry # dry run mode
1. **DSL Entry Point** (`lib/dsl.rb`):
- Provides the `configure` and `configure_from_scratch` methods
- Manages resource scheduling and evaluation
- - Tracks resource objects to prevent duplicates
+ - Registers all DSL objects generically and tracks them by object id to prevent duplicates
2. **Base Classes**:
- `Keyword` (`lib/dslkeywords/keyword.rb`): Base class for all DSL keywords
@@ -57,12 +60,23 @@ rake wireguard -- --dry # dry run mode
- **Backup System**: File operations create backups in `.rcmbackup/` directory
- **Chained DSL**: Natural language syntax like `given { hostname is :earth }`
+## Project Conventions
+
+- **Use generic DSL registration**: Register new DSL objects through `RCM::DSL#register`. Avoid parallel registries or object-specific `register_*` helpers when the generic path already fits.
+- **Use `register_keyword` for resource-style DSL keywords**: File-system keywords should follow the shared `register_keyword` flow so object creation, `dsl=` wiring, registration, and scheduling stay consistent.
+- **Lookup by object id**: Resolve named DSL objects with `RCM::DSL#object!` and `Keyword.id_for(...)`. Duplicate detection and lookup are id-based, not hash-based by ad hoc names.
+- **Keep normalization in the keyword class**: If a DSL keyword accepts names, normalize them in the keyword class itself so registration and lookup use the same representation. Agent and prompt names may contain spaces.
+- **Keep RuboCop clean on touched files**: Run RuboCop on edited files and keep disables narrow, justified, and local. Remove stale disable directives when they are no longer needed.
+- **Run tests after behavior changes**: At minimum run `rake test`. If you change examples, execute the relevant example commands from their own directories so relative paths behave as documented.
+- **Prefer documented execution paths**: Validate examples with the commands shown in the example README or Justfile unless you are explicitly fixing the docs themselves.
+
### Testing
Tests use Minitest and are located in `test/`. Test files follow the pattern `*_test.rb` and typically:
- Create temporary files/directories with `.rcmtmp` suffix
- Clean up after themselves using `Minitest.after_run`
- Test individual DSL keywords and their functionality
+- Prefer realistic DSL names in tests, including names with spaces where that behavior matters
### Usage Example
@@ -80,4 +94,4 @@ configure do
line '192.168.1.101 foo'
end
end
-``` \ No newline at end of file
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index 43c994c..430e3db 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1,3 @@
-@AGENTS.md
+# CLAUDE.md
+
+See `AGENTS.md` for the canonical repository instructions.
diff --git a/examples/gem/Gemfile.lock b/examples/gem/Gemfile.lock
index f426524..8bc6670 100644
--- a/examples/gem/Gemfile.lock
+++ b/examples/gem/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: ../..
specs:
- rcm (0.1.1)
+ rcm (0.1.2)
erb
toml (~> 0.3)
diff --git a/examples/plain_ruby/Justfile b/examples/plain_ruby/Justfile
index c758519..a4528ce 100644
--- a/examples/plain_ruby/Justfile
+++ b/examples/plain_ruby/Justfile
@@ -9,3 +9,15 @@ dry:
# Verbose output
debug:
ruby config.rb --debug
+
+# Apply the agent-backed example
+agents:
+ ruby agents.rb
+
+# Dry run the agent-backed example
+agents-dry:
+ ruby agents.rb --dry
+
+# Verbose output for the agent-backed example
+agents-debug:
+ ruby agents.rb --debug
diff --git a/examples/plain_ruby/README.md b/examples/plain_ruby/README.md
index 7208317..464d537 100644
--- a/examples/plain_ruby/README.md
+++ b/examples/plain_ruby/README.md
@@ -13,6 +13,10 @@ ruby config.rb --debug
# Apply configuration
ruby config.rb
+
+# Agent-backed file processing example
+ruby agents.rb --dry
+ruby agents.rb
```
## What it does
@@ -20,3 +24,4 @@ ruby config.rb
- Creates `/tmp/example/hello.txt` with static content (parent directory created automatically)
- Ensures the line `127.0.0.1 localhost` is present in `/tmp/example/hosts.txt`
- Creates `/tmp/example/greeting.txt` from an inline ERB template
+- `agents.rb` drafts `/tmp/example/notes.txt` and then runs it through `hexai` with a prompt that fixes English grammar and clarity
diff --git a/examples/plain_ruby/agents.rb b/examples/plain_ruby/agents.rb
new file mode 100755
index 0000000..e04da98
--- /dev/null
+++ b/examples/plain_ruby/agents.rb
@@ -0,0 +1,40 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Example: Plain Ruby script using agent-backed file processing.
+#
+# Run with:
+# ruby agents.rb --dry # dry run, no changes made
+# ruby agents.rb --debug # verbose output
+# ruby agents.rb # apply configuration
+#
+# Requires rcm to be installed as a gem, or adjust the path below:
+# require_relative '../../lib/dsl'
+begin
+ require 'rcm'
+rescue LoadError
+ require_relative '../../lib/dsl'
+end
+
+configure do
+ agent hexai do
+ 'hexai PROMPT'
+ end
+
+ prompt fix english do
+ 'Correct English spellings and grammar. Improve clarity of the text. Dont introduce any new text or headers'
+ end
+
+ # Draft a rough note, then let hexai polish the language in place.
+ file example notes draft do
+ path 'agents_example.txt'
+ manage directory
+ 'this are a short note with bad english and unclear wording.'
+ end
+
+ file example notes polished do
+ path 'agents_example.txt'
+ requires file example notes draft
+ agent hexai fix english
+ end
+end
diff --git a/examples/plain_ruby/agents_example.txt b/examples/plain_ruby/agents_example.txt
new file mode 100644
index 0000000..1285e52
--- /dev/null
+++ b/examples/plain_ruby/agents_example.txt
@@ -0,0 +1 @@
+This is a short note with poor English and unclear wording. \ No newline at end of file
diff --git a/examples/plain_ruby/config.rb b/examples/plain_ruby/config.rb
index c730c05..93061c2 100755
--- a/examples/plain_ruby/config.rb
+++ b/examples/plain_ruby/config.rb
@@ -1,4 +1,6 @@
#!/usr/bin/env ruby
+# frozen_string_literal: true
+
# Example: Plain Ruby script — no Rake, no bundler required.
#
# Run with:
diff --git a/examples/rake/Gemfile.lock b/examples/rake/Gemfile.lock
new file mode 100644
index 0000000..57fd030
--- /dev/null
+++ b/examples/rake/Gemfile.lock
@@ -0,0 +1,26 @@
+PATH
+ remote: ../..
+ specs:
+ rcm (0.1.2)
+ erb
+ toml (~> 0.3)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ erb (6.0.2)
+ parslet (2.0.0)
+ rake (13.3.1)
+ toml (0.3.0)
+ parslet (>= 1.8.0, < 3.0.0)
+
+PLATFORMS
+ ruby
+ x86_64-linux
+
+DEPENDENCIES
+ rake
+ rcm!
+
+BUNDLED WITH
+ 2.6.9
diff --git a/lib/dsl.rb b/lib/dsl.rb
index e3f6ee2..57bbef3 100644
--- a/lib/dsl.rb
+++ b/lib/dsl.rb
@@ -1,8 +1,13 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/ClassVars
require_relative 'config'
require_relative 'options'
require_relative 'log'
require_relative 'chained'
+require_relative 'dslkeywords/agent'
+require_relative 'dslkeywords/prompt'
require_relative 'dslkeywords/file'
require_relative 'dslkeywords/symlink'
require_relative 'dslkeywords/touch'
@@ -29,6 +34,9 @@ module RCM
include Chained
class DuplicateResource < StandardError; end
+ class DuplicateDefinition < StandardError; end
+ class NoSuchAgentDefinition < StandardError; end
+ class NoSuchPromptDefinition < StandardError; end
def initialize(reset)
DSL.reset! if reset
@@ -41,10 +49,20 @@ module RCM
def to_s = @id
def evaluate! = @scheduled.each(&:evaluate!)
- def <<(obj)
- raise DuplicateResource, "#{obj.id} already declared!" if @@objs.key?(obj.id)
+ def <<(obj) = register(obj)
+
+ def register(obj, schedule: obj.is_a?(Resource), duplicate_error: DuplicateResource)
+ raise duplicate_error, "#{obj.id} already declared!" if @@objs.key?(obj.id)
- @scheduled << @@objs[obj.id] = obj
+ @@objs[obj.id] = obj
+ @scheduled << obj if schedule
+ obj
+ end
+
+ def object!(klass, name, error_class:, kind:)
+ @@objs.fetch(klass.id_for(name)) do
+ raise error_class, "No such #{kind} '#{name}'"
+ end
end
private
@@ -63,13 +81,15 @@ module RCM
return unless @conds_met
obj = klass.new(path)
+ obj.dsl = self if obj.respond_to?(:dsl=)
yield obj
- self << obj
- obj
+ register(obj)
end
end
end
+# rubocop:enable Style/ClassVars
+
def configure(reset: false, &block)
# Parse ARGV and load config.toml each time configure is called so that
# scripts and test suites that call configure multiple times always
diff --git a/lib/dslkeywords/agent.rb b/lib/dslkeywords/agent.rb
new file mode 100644
index 0000000..6632835
--- /dev/null
+++ b/lib/dslkeywords/agent.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require_relative 'keyword'
+
+module RCM
+ # Stores a named shell command template for agent-backed file processing.
+ class AgentDefinition < Keyword
+ attr_reader :name
+
+ class InvalidName < StandardError; end
+
+ def self.id_for(name) = super(normalize_name(name))
+
+ def self.normalize_name(name)
+ normalized = name.to_s.strip.gsub(/\s+/, ' ')
+ raise InvalidName, 'Agent name must not be empty' if normalized.empty?
+
+ normalized
+ end
+
+ def initialize(name)
+ @name = self.class.normalize_name(name)
+ super(@name)
+ end
+
+ def command(text = nil)
+ return @command if text.nil?
+
+ @command = text.to_s
+ end
+ end
+
+ # Adds the `agent` definition keyword to the top-level DSL.
+ class DSL
+ def agent(name = nil, &block)
+ return name if name.nil?
+ return unless @conds_met
+
+ definition = AgentDefinition.new(name)
+ definition.dsl = self
+ definition.command(definition.instance_eval(&block)) if block
+ register(definition, schedule: false, duplicate_error: DuplicateDefinition)
+ end
+ end
+end
diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb
index 8e1c772..81f5f29 100644
--- a/lib/dslkeywords/file.rb
+++ b/lib/dslkeywords/file.rb
@@ -1,6 +1,11 @@
+# frozen_string_literal: true
+
require 'digest'
require 'erb'
require 'fileutils'
+require 'open3'
+require 'shellwords'
+require 'tempfile'
require_relative 'resource'
require_relative '../chained'
@@ -97,7 +102,7 @@ module RCM
def set_mode!(stat, file_path = path)
return if @mode.nil?
- current_mode = stat.mode.to_s(8).split('')[-4..-1].join.to_i(8)
+ current_mode = stat.mode.to_s(8).split('')[-4..].join.to_i(8)
return if current_mode == @mode
do? "Changing mode of #{file_path} to #{@mode}" do
@@ -105,6 +110,7 @@ module RCM
end
end
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def set_owner!(stat, file_path = path)
return if @owner.nil? && @group.nil?
@@ -117,6 +123,7 @@ module RCM
FileUtils.chown(@owner, @group, file_path)
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
# Intermediate base for resources that carry file content: regular files
@@ -140,7 +147,29 @@ module RCM
# Manages regular files: write content, ensure/remove individual lines,
# delete. Writes via a temp file so the final rename is atomic.
+ # rubocop:disable Metrics/ClassLength
class File < BaseFile
+ class AgentCommandFailed < StandardError; end
+ class InvalidAgentSpec < StandardError; end
+ class MissingAgentInput < StandardError; end
+
+ attr_reader :agent_name, :prompt_name
+
+ def agent(spec = nil, prompt_name = nil)
+ agent_name = normalize_agent_reference(spec)
+ prompt_name = normalize_agent_reference(prompt_name)
+ agent_name, prompt_name = agent_name.split(/\s+/, 2) if prompt_name.nil? && agent_name&.include?(' ')
+
+ if agent_name.nil? || prompt_name.nil?
+ raise InvalidAgentSpec, 'Expected exactly one agent name and one prompt name'
+ end
+
+ @agent_name = agent_name
+ @prompt_name = prompt_name
+ end
+
+ def agent_processing? = !@agent_name.nil?
+
def line(line) = @ensure_line = line
def evaluate!
@@ -148,6 +177,7 @@ module RCM
return evaluate_ensure_line! unless @ensure_line.nil?
return evaluate_absent! if %i[absent purged].include?(@is)
+ return evaluate_agent_processing! if agent_processing?
create_parent_directory! if @manage_directory
@@ -181,6 +211,28 @@ module RCM
end
end
+ def normalize_agent_reference(name)
+ normalized = name&.to_s&.strip
+ return if normalized.nil? || normalized.empty?
+
+ normalized.gsub(/\s+/, ' ')
+ end
+
+ def evaluate_agent_processing!
+ raise MissingAgentInput, "File #{@file_path} does not exist for agent processing" unless ::File.file?(@file_path)
+
+ if option :dry
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
+ return
+ end
+
+ input = ::File.read(@file_path)
+ output = run_agent!(input)
+ create_parent_directory! unless ::File.directory?(::File.dirname(@file_path))
+ write!(output)
+ end
+
+ # rubocop:disable Metrics/MethodLength
def write!(text)
# In dry-run mode skip all filesystem access and just report what would
# happen — the parent directory may not exist yet so we cannot write the
@@ -206,11 +258,52 @@ module RCM
::File.rename(tmp_path, @file_path)
::File.delete(tmp_path) if ::File.file?(tmp_path)
end
+ # rubocop:enable Metrics/MethodLength
+
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ def run_agent!(input)
+ agent_definition = dsl.object!(AgentDefinition, @agent_name,
+ error_class: DSL::NoSuchAgentDefinition, kind: 'agent')
+ prompt_definition = dsl.object!(PromptDefinition, @prompt_name,
+ error_class: DSL::NoSuchPromptDefinition, kind: 'prompt')
+
+ Tempfile.create(['rcm-agent-input', '.txt']) do |tmp|
+ tmp.write(input)
+ tmp.flush
+ tmp.close
+
+ command = render_agent_command(agent_definition.command.to_s, prompt_definition.text.to_s, tmp.path)
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}"
+ stdout, stderr, status = Open3.capture3(command, stdin_data: input)
+ return stdout if status.success?
+
+ message = stderr.to_s.strip
+ message = 'no stderr output' if message.empty?
+ raise AgentCommandFailed,
+ "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}): #{message}"
+ end
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+
+ def render_agent_command(template, prompt_text, input_path)
+ command = template.dup
+ command.gsub!(/\bINPUT\b/, Shellwords.escape(input_path))
+ command.gsub!(/\bPROMPT\b/, Shellwords.escape(prompt_text))
+ command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(@file_path))
+ command
+ end
end
+ # rubocop:enable Metrics/ClassLength
+ # Adds the `file` resource keyword to the DSL.
class DSL
def file(file_path = nil, &block)
- register_keyword(File, :file, file_path) { |f| f.content(f.instance_eval(&block)) }
+ register_keyword(File, :file, file_path) do |f|
+ next unless block
+
+ result = f.instance_eval(&block)
+ f.content(result) unless f.agent_processing? || result.nil?
+ end
end
end
end
diff --git a/lib/dslkeywords/keyword.rb b/lib/dslkeywords/keyword.rb
index 1cbda9f..59ef5bf 100644
--- a/lib/dslkeywords/keyword.rb
+++ b/lib/dslkeywords/keyword.rb
@@ -1,4 +1,4 @@
-require 'set'
+# frozen_string_literal: true
require_relative '../options'
require_relative '../log'
@@ -6,11 +6,14 @@ require_relative '../log'
module RCM
# The base class of all DSL key words
class Keyword
+ attr_accessor :dsl
attr_reader :id
include Options
include Log
+ def self.id_for(name) = "#{to_s.sub('RCM::', '').downcase}('#{name}')"
+
def initialize(name) = @id = "#{self.class.to_s.sub('RCM::', '').downcase}('#{name}')"
def to_s = @id
diff --git a/lib/dslkeywords/prompt.rb b/lib/dslkeywords/prompt.rb
new file mode 100644
index 0000000..9e599ef
--- /dev/null
+++ b/lib/dslkeywords/prompt.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require_relative 'keyword'
+
+module RCM
+ # Stores a named prompt body for agent-backed file processing.
+ class PromptDefinition < Keyword
+ attr_reader :name
+
+ class InvalidName < StandardError; end
+
+ def self.id_for(name) = super(normalize_name(name))
+
+ def self.normalize_name(name)
+ normalized = name.to_s.strip.gsub(/\s+/, ' ')
+ raise InvalidName, 'Prompt name must not be empty' if normalized.empty?
+
+ normalized
+ end
+
+ def initialize(name)
+ @name = self.class.normalize_name(name)
+ super(@name)
+ end
+
+ def text(value = nil)
+ return @text if value.nil?
+
+ @text = value.to_s
+ end
+ end
+
+ # Adds the `prompt` definition keyword to the top-level DSL.
+ class DSL
+ def prompt(name = nil, &block)
+ return name if name.nil?
+ return unless @conds_met
+
+ definition = PromptDefinition.new(name)
+ definition.dsl = self
+ definition.text(definition.instance_eval(&block)) if block
+ register(definition, schedule: false, duplicate_error: DuplicateDefinition)
+ end
+ end
+end
diff --git a/test/lib/dslkeywords/agent_test.rb b/test/lib/dslkeywords/agent_test.rb
new file mode 100644
index 0000000..5eb5bc5
--- /dev/null
+++ b/test/lib/dslkeywords/agent_test.rb
@@ -0,0 +1,312 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/ClassLength, Metrics/MethodLength
+require 'minitest/autorun'
+require 'fileutils'
+require 'rbconfig'
+require 'shellwords'
+require 'tmpdir'
+
+require_relative '../../../lib/dsl'
+
+class RCMAgentTest < Minitest::Test
+ MOCK_AGENT = File.expand_path('../../support/mock_agent.rb', __dir__).freeze
+
+ def setup
+ @dir_path = Dir.mktmpdir('.agent_test.rcmtmp.')
+ @original_argv = ARGV.dup
+ end
+
+ def teardown
+ ARGV.replace(@original_argv) if @original_argv
+ FileUtils.rm_rf(@dir_path) if @dir_path
+ end
+
+ def path(name)
+ File.join(@dir_path, name)
+ end
+
+ def mock_agent_command(mode, *args)
+ parts = [RbConfig.ruby, MOCK_AGENT, mode.to_s]
+ args.each do |arg|
+ parts << if %w[INPUT PROMPT FILE_PATH].include?(arg)
+ arg
+ else
+ Shellwords.escape(arg.to_s)
+ end
+ end
+
+ [Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), *parts].join(' ')
+ end
+
+ def test_duplicate_agent_definition
+ assert_raises(RCM::DSL::DuplicateDefinition) do
+ configure_from_scratch do
+ agent mock do
+ 'ruby -e "print STDIN.read"'
+ end
+
+ agent mock do
+ 'ruby -e "print STDIN.read"'
+ end
+ end
+ end
+ end
+
+ def test_duplicate_prompt_definition
+ assert_raises(RCM::DSL::DuplicateDefinition) do
+ configure_from_scratch do
+ prompt 'fix english' do
+ 'Fix grammar'
+ end
+
+ prompt 'fix english' do
+ 'Fix spelling'
+ end
+ end
+ end
+ end
+
+ def test_agent_processes_file_using_stdin_and_names_with_spaces
+ file_path = path('process.txt')
+ command = mock_agent_command(:upcase_prompt, 'PROMPT')
+ File.write(file_path, 'hello world')
+
+ configure_from_scratch do
+ agent mock do
+ command
+ end
+
+ prompt 'fix english' do
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent mock, 'fix english'
+ end
+ end
+
+ assert_equal 'HELLO WORLD|Fix grammar', File.read(file_path)
+ end
+
+ def test_agent_processes_file_using_prompt_name_with_spaces
+ file_path = path('process-spaced-prompt.txt')
+ command = mock_agent_command(:upcase_prompt, 'PROMPT')
+ File.write(file_path, 'hello world')
+
+ configure_from_scratch do
+ agent mock do
+ command
+ end
+
+ prompt fix english do
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent mock fix english
+ end
+ end
+
+ assert_equal 'HELLO WORLD|Fix grammar', File.read(file_path)
+ end
+
+ def test_agent_can_use_input_placeholder
+ file_path = path('input.txt')
+ command = mock_agent_command(:reverse_input, 'INPUT')
+ File.write(file_path, 'abc123')
+
+ configure_from_scratch do
+ agent 'reverse via file' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'reverse via file', 'no op'
+ end
+ end
+
+ assert_equal '321cba', File.read(file_path)
+ end
+
+ def test_agent_can_use_file_path_placeholder
+ file_path = path('placeholder.txt')
+ command = mock_agent_command(:basename, 'FILE_PATH')
+ File.write(file_path, 'ignored')
+
+ configure_from_scratch do
+ agent 'show file name' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'show file name', 'no op'
+ end
+ end
+
+ assert_equal 'placeholder.txt', File.read(file_path)
+ end
+
+ def test_agent_processing_skips_backup_when_output_is_unchanged
+ file_path = path('unchanged.txt')
+ command = mock_agent_command(:pass_through)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'pass through' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'pass through', 'no op'
+ end
+ end
+
+ backup_dir = File.join(File.dirname(file_path), '.rcmbackup')
+ assert_empty Dir.glob(File.join(backup_dir, 'unchanged.txt.*'))
+ assert_equal 'same content', File.read(file_path)
+ end
+
+ def test_agent_processing_creates_backup_when_output_changes
+ file_path = path('backup.txt')
+ command = mock_agent_command(:upcase)
+ File.write(file_path, 'hello')
+
+ configure_from_scratch do
+ agent 'make loud' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'make loud', 'no op'
+ end
+ end
+
+ backup_dir = File.join(@dir_path, '.rcmbackup')
+ assert_equal 'HELLO', File.read(file_path)
+ assert_equal 1, Dir.glob(File.join(backup_dir, 'backup.txt.*')).count
+ end
+
+ def test_unknown_agent_raises
+ file_path = path('unknown-agent.txt')
+ File.write(file_path, 'hello')
+
+ assert_raises(RCM::DSL::NoSuchAgentDefinition) do
+ configure_from_scratch do
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'missing agent', 'no op'
+ end
+ end
+ end
+ end
+
+ def test_unknown_prompt_raises
+ file_path = path('unknown-prompt.txt')
+ command = mock_agent_command(:pass_through)
+ File.write(file_path, 'hello')
+
+ assert_raises(RCM::DSL::NoSuchPromptDefinition) do
+ configure_from_scratch do
+ agent mock do
+ command
+ end
+
+ file file_path do
+ agent mock, 'missing prompt'
+ end
+ end
+ end
+ end
+
+ def test_missing_agent_input_raises
+ file_path = path('missing.txt')
+ command = mock_agent_command(:pass_through)
+ refute File.exist?(file_path)
+
+ assert_raises(RCM::File::MissingAgentInput) do
+ configure_from_scratch do
+ agent mock do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent mock, 'no op'
+ end
+ end
+ end
+ end
+
+ def test_dry_run_does_not_execute_agent
+ file_path = path('dry-run.txt')
+ command = mock_agent_command(:fail, 'boom', '7')
+ File.write(file_path, 'keep me')
+ ARGV.replace(['--dry'])
+
+ configure_from_scratch do
+ agent 'should not run' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'should not run', 'no op'
+ end
+ end
+
+ assert_equal 'keep me', File.read(file_path)
+ end
+
+ def test_non_zero_exit_raises
+ file_path = path('broken.txt')
+ command = mock_agent_command(:fail, 'boom', '7')
+ File.write(file_path, 'hello')
+
+ error = assert_raises(RCM::File::AgentCommandFailed) do
+ configure_from_scratch do
+ agent 'broken agent' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'broken agent', 'no op'
+ end
+ end
+ end
+
+ assert_match('exit 7', error.message)
+ assert_match('boom', error.message)
+ end
+end
+
+# rubocop:enable Metrics/ClassLength, Metrics/MethodLength
diff --git a/test/support/mock_agent.rb b/test/support/mock_agent.rb
new file mode 100644
index 0000000..b86de4f
--- /dev/null
+++ b/test/support/mock_agent.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+mode = ARGV.shift.to_s
+stdin = $stdin.read
+
+case mode
+when 'upcase_prompt'
+ prompt = ARGV.fetch(0, '')
+ print "#{stdin.upcase}|#{prompt}"
+when 'reverse_input'
+ input_path = ARGV.fetch(0)
+ print File.read(input_path).reverse
+when 'basename'
+ file_path = ARGV.fetch(0)
+ print File.basename(file_path)
+when 'pass_through'
+ print stdin
+when 'upcase'
+ print stdin.upcase
+when 'fail'
+ warn ARGV.fetch(0, 'boom')
+ exit Integer(ARGV.fetch(1, '7'))
+else
+ warn "unknown mode: #{mode}"
+ exit 2
+end