diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-14 09:56:52 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-14 09:56:52 +0200 |
| commit | 818ed50e2a54b40ccf7a7771bebe0312dc01a8b5 (patch) | |
| tree | 16bfb301c1192a1d301d50388eb33a52ecdc7e69 | |
| parent | 63607f415c45a8670cd2eb4d346e448dceb5422f (diff) | |
Add agent-backed file processing DSL
| -rw-r--r-- | .serena/.gitignore | 1 | ||||
| -rw-r--r-- | .serena/project.yml | 130 | ||||
| -rw-r--r-- | AGENTS.md | 22 | ||||
| -rw-r--r-- | CLAUDE.md | 4 | ||||
| -rw-r--r-- | examples/gem/Gemfile.lock | 2 | ||||
| -rw-r--r-- | examples/plain_ruby/Justfile | 12 | ||||
| -rw-r--r-- | examples/plain_ruby/README.md | 5 | ||||
| -rwxr-xr-x | examples/plain_ruby/agents.rb | 40 | ||||
| -rw-r--r-- | examples/plain_ruby/agents_example.txt | 1 | ||||
| -rwxr-xr-x | examples/plain_ruby/config.rb | 2 | ||||
| -rw-r--r-- | examples/rake/Gemfile.lock | 26 | ||||
| -rw-r--r-- | lib/dsl.rb | 30 | ||||
| -rw-r--r-- | lib/dslkeywords/agent.rb | 45 | ||||
| -rw-r--r-- | lib/dslkeywords/file.rb | 97 | ||||
| -rw-r--r-- | lib/dslkeywords/keyword.rb | 5 | ||||
| -rw-r--r-- | lib/dslkeywords/prompt.rb | 45 | ||||
| -rw-r--r-- | test/lib/dslkeywords/agent_test.rb | 312 | ||||
| -rw-r--r-- | test/support/mock_agent.rb | 26 |
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: [] @@ -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 +``` @@ -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 @@ -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 |
