20 KiB
Plan: Simplify Step Commands to Use REPL Format
Status: Complete
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:
- REPL format:
!step list(for humans typing in console) - 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
- Replace
!stepprefix withsteps(more ergonomic, no special character) - Remove JSON command parsing (unnecessary complexity)
- Add
--outputflag for response format control (textorjson) - Browser extension sends same command strings a human would type
- Single code path for all step command input
Progress Checklist
- Chunk 1: Update StepCommandParser -
stepsprefix,--outputflag, 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:
-
Add
OutputFormatenum and updateStepCommandbase 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
WasJsonInputproperty (replaced byOutputFormat). -
Update
IsStepCommand()- recognizestepsprefix, 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; } -
Update
Parse()- remove JSON branch:public StepCommand Parse(string input) { var trimmed = input?.Trim() ?? ""; return ParseReplCommand(trimmed); } -
Update
ParseReplCommand()- expectstepsas first token:if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase)) { throw new StepCommandException(StepCommandErrors.ParseError, "Invalid command format. Expected: steps <command> [args...]"); } -
Add
--outputflag 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.
-
Delete all JSON parsing methods:
ParseJsonCommand()ParseJsonListCommand()ParseJsonAddCommand()ParseJsonEditCommand()ParseJsonRemoveCommand()ParseJsonMoveCommand()ParseJsonExportCommand()ParseJsonPosition()ParseJsonDictionary()ParseJsonStringList()
-
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:
-
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 }; } -
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(); } -
Update error responses to also respect output format.
-
Remove
WasJsonInputchecks throughout the handler.
Estimated effort: Small
Chunk 3: Update Browser Extension - Build Command Strings
Files to modify:
browser-ext/content/content.js
Changes:
-
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 }; } } -
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'; } -
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, '\\"')}"`; } -
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- Deletesrc/Test/L0/Worker/Dap/StepCommands/StepCommandParserL0.cs- Modify
Changes:
-
Delete
StepCommandParserJsonL0.csentirely (JSON parsing tests no longer needed) -
Update
StepCommandParserL0.cs:a. Update
IsStepCommandtests:[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
!steptostepsin existing test cases:// Before: var cmd = _parser.Parse("!step list --verbose"); // After: var cmd = _parser.Parse("steps list --verbose");c. Add tests for
--outputflag:[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
stepsformat.
Estimated effort: Small
Chunk 6: Update Plan Documentation
Files to modify:
.opencode/plans/dap-step-manipulation.md
Changes:
-
Update command format documentation - change all
!stepreferences tosteps -
Document
--outputflag 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` -
Update Chunk 8 description - note that JSON API was replaced with
--outputflag -
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"}
}