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

651 lines
20 KiB
Markdown

# 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:**
```csharp
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:
```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 <command> [args...]");
}
```
5. **Add `--output` flag parsing** - create a helper method and call it in each Parse*Command method:
```csharp
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`:
```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<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:**
```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"}
}
```