diff --git a/.opencode/plans/dap-step-commands-simplification.md b/.opencode/plans/dap-step-commands-simplification.md new file mode 100644 index 000000000..b412856f8 --- /dev/null +++ b/.opencode/plans/dap-step-commands-simplification.md @@ -0,0 +1,650 @@ +# 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 `) 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:** + ```csharp + public enum OutputFormat + { + Text, + Json + } + + public abstract class StepCommand + { + /// + /// Output format for the command response. + /// + public OutputFormat Output { get; set; } = OutputFormat.Text; + } + ``` + + Remove the `WasJsonInput` property (replaced by `OutputFormat`). + +2. **Update `IsStepCommand()`** - recognize `steps` prefix, remove JSON detection: + ```csharp + 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: + ```csharp + public StepCommand Parse(string input) + { + var trimmed = input?.Trim() ?? ""; + return ParseReplCommand(trimmed); + } + ``` + +4. **Update `ParseReplCommand()`** - expect `steps` as first token: + ```csharp + if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase)) + { + throw new StepCommandException(StepCommandErrors.ParseError, + "Invalid command format. Expected: steps [args...]"); + } + ``` + +5. **Add `--output` flag parsing** - create a helper method and call it in each Parse*Command method: + ```csharp + private OutputFormat ParseOutputFlag(List 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 ` 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`: + ```csharp + 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:** + ```csharp + private string FormatStepListAsText(IReadOnlyList 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:** + + ```javascript + /** + * 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:** + + ```javascript + /** + * 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:** + + ```javascript + 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()`:** + ```javascript + 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: + +```javascript +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: + ```csharp + [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: + ```csharp + // Before: + var cmd = _parser.Parse("!step list --verbose"); + + // After: + var cmd = _parser.Parse("steps list --verbose"); + ``` + + c. Add tests for `--output` flag: + ```csharp + [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`:** +```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`:** +```json +{ + "Success": true, + "Message": "Step added at position 6", + "Result": {"index": 6, "name": "Greeting", "type": "run", "typeDetail": "echo hello", "status": "pending", "change": "ADDED"} +} +```