Files
runner/.opencode/plans/dap-step-commands-simplification.md
Francesco Renzi 1bba60b475 simplify
2026-01-21 23:52:39 +00:00

20 KiB

Plan: Simplify Step Commands to Use REPL Format

Status: Ready for Implementation
Date: January 2026
Prerequisites: dap-step-manipulation.md (Chunks 1-9 completed)

Overview

Remove the JSON API for step commands and use a single REPL command format (steps <command>) for both human input and browser extension UI. Add --output flag for controlling response format.

Problem

Currently the step command system has two input formats:

  1. REPL format: !step list (for humans typing in console)
  2. JSON format: {"cmd":"step.list"} (for browser extension UI)

This causes issues:

  • The ! prefix is awkward for humans typing commands
  • The JSON API is unnecessary complexity (browser extension is just another DAP client)
  • Debugging is harder because UI sends different format than humans would type
  • Two code paths to maintain and test

Goals

  1. Replace !step prefix with steps (more ergonomic, no special character)
  2. Remove JSON command parsing (unnecessary complexity)
  3. Add --output flag for response format control (text or json)
  4. Browser extension sends same command strings a human would type
  5. Single code path for all step command input

Progress Checklist

  • Chunk 1: Update StepCommandParser - steps prefix, --output flag, remove JSON parsing
  • Chunk 2: Update StepCommandHandler - format responses based on OutputFormat
  • Chunk 3: Update Browser Extension - build REPL command strings
  • Chunk 4: Update REPL context detection in browser extension
  • Chunk 5: Update/remove tests
  • Chunk 6: Update plan documentation

Implementation Chunks

Chunk 1: Update StepCommandParser to Use steps Prefix

Files to modify:

  • src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs

Changes:

  1. Add OutputFormat enum and update StepCommand base class:

    public enum OutputFormat
    {
        Text,
        Json
    }
    
    public abstract class StepCommand
    {
        /// <summary>
        /// Output format for the command response.
        /// </summary>
        public OutputFormat Output { get; set; } = OutputFormat.Text;
    }
    

    Remove the WasJsonInput property (replaced by OutputFormat).

  2. Update IsStepCommand() - recognize steps prefix, remove JSON detection:

    public bool IsStepCommand(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
            return false;
    
        var trimmed = input.Trim();
    
        // Command format: steps ...
        if (trimmed.StartsWith("steps ", StringComparison.OrdinalIgnoreCase) ||
            trimmed.Equals("steps", StringComparison.OrdinalIgnoreCase))
            return true;
    
        return false;
    }
    
  3. Update Parse() - remove JSON branch:

    public StepCommand Parse(string input)
    {
        var trimmed = input?.Trim() ?? "";
        return ParseReplCommand(trimmed);
    }
    
  4. Update ParseReplCommand() - expect steps as first token:

    if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
    {
        throw new StepCommandException(StepCommandErrors.ParseError,
            "Invalid command format. Expected: steps <command> [args...]");
    }
    
  5. Add --output flag parsing - create a helper method and call it in each Parse*Command method:

    private OutputFormat ParseOutputFlag(List<string> tokens, ref int index)
    {
        // Look for --output, --output=json, --output=text, -o json, -o text
        for (int i = index; i < tokens.Count; i++)
        {
            var token = tokens[i].ToLower();
            if (token == "--output" || token == "-o")
            {
                if (i + 1 < tokens.Count)
                {
                    var format = tokens[i + 1].ToLower();
                    tokens.RemoveAt(i); // Remove flag
                    tokens.RemoveAt(i); // Remove value
                    return format == "json" ? OutputFormat.Json : OutputFormat.Text;
                }
            }
            else if (token.StartsWith("--output="))
            {
                var format = token.Substring("--output=".Length);
                tokens.RemoveAt(i);
                return format == "json" ? OutputFormat.Json : OutputFormat.Text;
            }
        }
        return OutputFormat.Text;
    }
    

    Apply to each command parser before processing other flags.

  6. Delete all JSON parsing methods:

    • ParseJsonCommand()
    • ParseJsonListCommand()
    • ParseJsonAddCommand()
    • ParseJsonEditCommand()
    • ParseJsonRemoveCommand()
    • ParseJsonMoveCommand()
    • ParseJsonExportCommand()
    • ParseJsonPosition()
    • ParseJsonDictionary()
    • ParseJsonStringList()
  7. Update error messages to reference steps <command> format.

Estimated effort: Small-medium


Chunk 2: Update StepCommandHandler Response Format

Files to modify:

  • src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs

Changes:

  1. Update each command handler to format response based on command.Output:

    For ListCommand:

    if (command.Output == OutputFormat.Json)
    {
        return new StepCommandResult
        {
            Success = true,
            Message = JsonConvert.SerializeObject(new { Success = true, Result = steps }),
            Result = steps
        };
    }
    else
    {
        return new StepCommandResult
        {
            Success = true,
            Message = FormatStepListAsText(steps),
            Result = steps
        };
    }
    
  2. Add text formatting helpers:

    private string FormatStepListAsText(IReadOnlyList<StepInfo> steps)
    {
        var sb = new StringBuilder();
        sb.AppendLine("Steps:");
        foreach (var step in steps)
        {
            var statusIcon = step.Status switch
            {
                StepStatus.Completed => "✓",
                StepStatus.Current => "▶",
                _ => " "
            };
            var changeBadge = step.Change.HasValue ? $"[{step.Change}]" : "";
            sb.AppendLine($"  {statusIcon} {step.Index}. {step.Name,-30} {changeBadge,-12} {step.Type,-5} {step.TypeDetail}");
        }
        sb.AppendLine();
        sb.AppendLine("Legend: ✓ = completed, ▶ = current, [ADDED] = new, [MODIFIED] = edited");
        return sb.ToString();
    }
    
  3. Update error responses to also respect output format.

  4. Remove WasJsonInput checks throughout the handler.

Estimated effort: Small


Chunk 3: Update Browser Extension - Build Command Strings

Files to modify:

  • browser-ext/content/content.js

Changes:

  1. Replace sendStepCommand() implementation:

    /**
     * Send step command via REPL format
     */
    async function sendStepCommand(action, options = {}) {
      const expression = buildStepCommand(action, options);
      try {
        const response = await sendDapRequest('evaluate', {
          expression,
          frameId: currentFrameId,
          context: 'repl',
        });
    
        if (response.result) {
          try {
            return JSON.parse(response.result);
          } catch (e) {
            // Response might be plain text for non-JSON output
            return { Success: true, Message: response.result };
          }
        }
        return { Success: false, Error: 'NO_RESPONSE', Message: 'No response from server' };
      } catch (error) {
        return { Success: false, Error: 'REQUEST_FAILED', Message: error.message };
      }
    }
    
  2. Add buildStepCommand() function:

    /**
     * Build REPL command string from action and options
     */
    function buildStepCommand(action, options) {
      let cmd;
      switch (action) {
        case 'step.list':
          cmd = options.verbose ? 'steps list --verbose' : 'steps list';
          break;
        case 'step.add':
          cmd = buildAddStepCommand(options);
          break;
        case 'step.edit':
          cmd = buildEditStepCommand(options);
          break;
        case 'step.remove':
          cmd = `steps remove ${options.index}`;
          break;
        case 'step.move':
          cmd = buildMoveStepCommand(options);
          break;
        case 'step.export':
          cmd = buildExportCommand(options);
          break;
        default:
          throw new Error(`Unknown step command: ${action}`);
      }
      // Always request JSON output for programmatic use
      return cmd + ' --output json';
    }
    
  3. Add command builder helpers:

    function buildAddStepCommand(options) {
      let cmd = 'steps add';
    
      if (options.type === 'run') {
        cmd += ` run ${quoteString(options.script)}`;
        if (options.shell) cmd += ` --shell ${options.shell}`;
        if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`;
      } else if (options.type === 'uses') {
        cmd += ` uses ${options.action}`;
        if (options.with) {
          for (const [key, value] of Object.entries(options.with)) {
            cmd += ` --with ${key}=${value}`;
          }
        }
      }
    
      if (options.name) cmd += ` --name ${quoteString(options.name)}`;
      if (options.if) cmd += ` --if ${quoteString(options.if)}`;
      if (options.env) {
        for (const [key, value] of Object.entries(options.env)) {
          cmd += ` --env ${key}=${value}`;
        }
      }
      if (options.continueOnError) cmd += ' --continue-on-error';
      if (options.timeout) cmd += ` --timeout ${options.timeout}`;
    
      // Position
      if (options.position) {
        if (options.position.after) cmd += ` --after ${options.position.after}`;
        else if (options.position.before) cmd += ` --before ${options.position.before}`;
        else if (options.position.at) cmd += ` --at ${options.position.at}`;
        else if (options.position.first) cmd += ' --first';
        // --last is default, no need to specify
      }
    
      return cmd;
    }
    
    function buildEditStepCommand(options) {
      let cmd = `steps edit ${options.index}`;
      if (options.name) cmd += ` --name ${quoteString(options.name)}`;
      if (options.script) cmd += ` --script ${quoteString(options.script)}`;
      if (options.if) cmd += ` --if ${quoteString(options.if)}`;
      if (options.shell) cmd += ` --shell ${options.shell}`;
      if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`;
      return cmd;
    }
    
    function buildMoveStepCommand(options) {
      let cmd = `steps move ${options.from}`;
      const pos = options.position;
      if (pos.after) cmd += ` --after ${pos.after}`;
      else if (pos.before) cmd += ` --before ${pos.before}`;
      else if (pos.at) cmd += ` --to ${pos.at}`;
      else if (pos.first) cmd += ' --first';
      else if (pos.last) cmd += ' --last';
      return cmd;
    }
    
    function buildExportCommand(options) {
      let cmd = 'steps export';
      if (options.changesOnly) cmd += ' --changes-only';
      if (options.withComments) cmd += ' --with-comments';
      return cmd;
    }
    
    /**
     * Quote a string for use in command, escaping as needed
     */
    function quoteString(str) {
      // Escape backslashes and quotes, wrap in quotes
      return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
    }
    
  4. Update loadSteps():

    async function loadSteps() {
      try {
        const response = await sendDapRequest('evaluate', {
          expression: 'steps list --output json',
          frameId: currentFrameId,
          context: 'repl',
        });
        // ... rest of parsing logic unchanged
      }
    }
    

Estimated effort: Medium


Chunk 4: Update REPL Context Detection

Files to modify:

  • browser-ext/content/content.js

Changes:

Update handleReplKeydown() to set context to 'repl' for steps commands:

async function handleReplKeydown(e) {
  const input = e.target;

  if (e.key === 'Enter') {
    const command = input.value.trim();
    if (!command) return;

    replHistory.push(command);
    replHistoryIndex = replHistory.length;
    input.value = '';

    // Show command
    appendOutput(`> ${command}`, 'input');

    // Send to DAP
    try {
      const response = await sendDapRequest('evaluate', {
        expression: command,
        frameId: currentFrameId,
        // Use 'repl' context for shell commands (!) and step commands
        context: (command.startsWith('!') || command.startsWith('steps')) ? 'repl' : 'watch',
      });
      // ... rest unchanged
    }
  }
  // ... arrow key handling unchanged
}

Estimated effort: Trivial


Chunk 5: Update/Remove Tests

Files to modify:

  • src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs - Delete
  • src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs - Modify

Changes:

  1. Delete StepCommandParserJsonL0.cs entirely (JSON parsing tests no longer needed)

  2. Update StepCommandParserL0.cs:

    a. Update IsStepCommand tests:

    [Fact]
    public void IsStepCommand_DetectsStepsPrefix()
    {
        Assert.True(_parser.IsStepCommand("steps list"));
        Assert.True(_parser.IsStepCommand("steps add run \"test\""));
        Assert.True(_parser.IsStepCommand("STEPS LIST")); // case insensitive
        Assert.True(_parser.IsStepCommand("  steps list  ")); // whitespace
    }
    
    [Fact]
    public void IsStepCommand_RejectsInvalid()
    {
        Assert.False(_parser.IsStepCommand("step list")); // missing 's'
        Assert.False(_parser.IsStepCommand("!step list")); // old format
        Assert.False(_parser.IsStepCommand("stepslist")); // no space
        Assert.False(_parser.IsStepCommand(""));
        Assert.False(_parser.IsStepCommand(null));
    }
    

    b. Change all !step to steps in existing test cases:

    // Before:
    var cmd = _parser.Parse("!step list --verbose");
    
    // After:
    var cmd = _parser.Parse("steps list --verbose");
    

    c. Add tests for --output flag:

    [Fact]
    public void Parse_ListCommand_WithOutputJson()
    {
        var cmd = _parser.Parse("steps list --output json") as ListCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Json, cmd.Output);
    }
    
    [Fact]
    public void Parse_ListCommand_WithOutputText()
    {
        var cmd = _parser.Parse("steps list --output text") as ListCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Text, cmd.Output);
    }
    
    [Fact]
    public void Parse_ListCommand_DefaultOutputIsText()
    {
        var cmd = _parser.Parse("steps list") as ListCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Text, cmd.Output);
    }
    
    [Fact]
    public void Parse_AddCommand_WithOutputFlag()
    {
        var cmd = _parser.Parse("steps add run \"echo test\" --output json") as AddRunCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Json, cmd.Output);
        Assert.Equal("echo test", cmd.Script);
    }
    
    [Fact]
    public void Parse_OutputFlag_ShortForm()
    {
        var cmd = _parser.Parse("steps list -o json") as ListCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Json, cmd.Output);
    }
    
    [Fact]
    public void Parse_OutputFlag_EqualsForm()
    {
        var cmd = _parser.Parse("steps list --output=json") as ListCommand;
        Assert.NotNull(cmd);
        Assert.Equal(OutputFormat.Json, cmd.Output);
    }
    

    d. Update error message expectations to reference steps format.

Estimated effort: Small


Chunk 6: Update Plan Documentation

Files to modify:

  • .opencode/plans/dap-step-manipulation.md

Changes:

  1. Update command format documentation - change all !step references to steps

  2. Document --output flag in command reference:

    ### Output Format
    
    All commands support the `--output` flag to control response format:
    - `--output text` (default) - Human-readable text output
    - `--output json` - JSON output for programmatic use
    - Short form: `-o json`, `-o text`
    - Equals form: `--output=json`, `--output=text`
    
  3. Update Chunk 8 description - note that JSON API was replaced with --output flag

  4. Update command reference table:

    | Command | Purpose | Example |
    |---------|---------|---------|
    | `steps list` | Show all steps | `steps list --verbose` |
    | `steps add` | Add new step | `steps add run "npm test" --after 3` |
    | `steps edit` | Modify step | `steps edit 4 --script "npm run test:ci"` |
    | `steps remove` | Delete step | `steps remove 5` |
    | `steps move` | Reorder step | `steps move 5 --after 2` |
    | `steps export` | Generate YAML | `steps export --with-comments` |
    

Estimated effort: Trivial


File Summary

File Action Chunk Description
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs Modify 1 Change prefix to steps, add --output flag, remove JSON parsing
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs Modify 2 Format responses based on OutputFormat
browser-ext/content/content.js Modify 3, 4 Build REPL command strings, update context detection
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserJsonL0.cs Delete 5 No longer needed
src/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs Modify 5 Update for steps prefix, add --output tests
.opencode/plans/dap-step-manipulation.md Modify 6 Update documentation

Command Reference (After Changes)

Human Usage (text output, default)

Action Command
List steps steps list
List verbose steps list --verbose
Add run step steps add run "echo hello"
Add run with options steps add run "npm test" --name "Run tests" --shell bash
Add uses step steps add uses actions/checkout@v4
Add uses with inputs steps add uses actions/setup-node@v4 --with node-version=20
Edit step steps edit 4 --name "New name" --script "new script"
Remove step steps remove 5
Move step steps move 5 --after 2
Export steps export
Export with options steps export --changes-only --with-comments

Browser Extension (JSON output)

The browser extension appends --output json to all commands:

Action Command Sent
List steps steps list --output json
Add step steps add uses actions/checkout@v4 --output json
Remove step steps remove 5 --output json

Output Format Examples

steps list (text, default):

Steps:
  ✓ 1. Checkout                          uses  actions/checkout@v4
  ✓ 2. Setup Node                        uses  actions/setup-node@v4
  ▶ 3. Install deps                      run   npm ci
    4. Run tests           [MODIFIED]    run   npm test
    5. Build               [ADDED]       run   npm run build

Legend: ✓ = completed, ▶ = current, [ADDED] = new, [MODIFIED] = edited

steps list --output json:

{
  "Success": true,
  "Result": [
    {"index": 1, "name": "Checkout", "type": "uses", "typeDetail": "actions/checkout@v4", "status": "completed"},
    {"index": 2, "name": "Setup Node", "type": "uses", "typeDetail": "actions/setup-node@v4", "status": "completed"},
    {"index": 3, "name": "Install deps", "type": "run", "typeDetail": "npm ci", "status": "current"},
    {"index": 4, "name": "Run tests", "type": "run", "typeDetail": "npm test", "status": "pending", "change": "MODIFIED"},
    {"index": 5, "name": "Build", "type": "run", "typeDetail": "npm run build", "status": "pending", "change": "ADDED"}
  ]
}

steps add run "echo hello" --name "Greeting" (text):

Step added at position 6: Greeting

steps add run "echo hello" --name "Greeting" --output json:

{
  "Success": true,
  "Message": "Step added at position 6",
  "Result": {"index": 6, "name": "Greeting", "type": "run", "typeDetail": "echo hello", "status": "pending", "change": "ADDED"}
}