Files
runner/.opencode/plans/dap-step-commands-refinements.md
Francesco Renzi 8fbe9aa963 Add step command refinements: --here, --id, and help commands
- Add --here position option to insert steps before the current step
- Add --id option to specify custom step IDs for expression references
- Add --help flag support for all step commands with detailed usage info
- Update browser extension UI with ID field and improved position dropdown
2026-01-22 10:36:56 +00:00

24 KiB

Step Commands Refinements: --here, --id, and Help

Status: Draft
Author: GitHub Actions Team
Date: January 2026
Prerequisites: dap-step-manipulation.md (completed)

Progress Checklist

  • Chunk 1: --here Position Option
  • Chunk 2: --id Option for Step Identification
  • Chunk 3: Help Commands (--help)
  • Chunk 4: Browser Extension UI Updates

Overview

This plan addresses three refinements to the step manipulation commands based on user feedback:

  1. --here position option: Insert a step before the current step (the one you're paused at), so it runs immediately when stepping forward
  2. --id option: Allow users to specify a custom step ID for later reference (e.g., steps.<id>.outputs)
  3. Help commands: Add --help flag support to all step commands for discoverability

Problem Statement

Issue 1: "First pending position" inserts in the wrong place

When paused before a step (e.g., checkout at position 1), using --first inserts the new step after the current step, not before it:

Before (paused at step 1):
▶ 1. Checkout

After "steps add run 'echo hello' --first":
▶ 1. Checkout
  2. hello [ADDED]    <-- Wrong! Should be before Checkout

Root cause: PositionType.First returns index 0 of the JobSteps queue, which contains steps after the current step. The current step is held separately in _currentStep.

Expected behavior: User wants to insert a step that will run immediately when they continue, i.e., before the current step.

Issue 2: No way to specify step ID

Dynamically added steps get auto-generated IDs like _dynamic_<guid>, making them impossible to reference in expressions like steps.<id>.outputs.foo.

Issue 3: Command options are hard to remember

With growing options (--name, --shell, --after, --before, --at, --first, --last, etc.), users need a way to quickly see available options without consulting documentation.


Chunk 1: --here Position Option

Goal: Add a new position option that inserts a step before the current step (the one paused at a breakpoint).

Design

Flag Meaning
--here Insert before the current step, so it becomes the next step to run

Behavior:

  • Only valid when paused at a breakpoint
  • Returns error if not paused: "Can only use --here when paused at a breakpoint"
  • Inserts the new step such that it will execute immediately when the user continues/steps forward

Example:

Before (paused at step 1):
▶ 1. Checkout
  2. Build
  3. Test

After "steps add run 'echo hello' --here":
▶ 1. hello [ADDED]    <-- New step runs next
  2. Checkout
  3. Build
  4. Test

Files to Modify

File Changes
StepCommandParser.cs Add Here to PositionType enum; add StepPosition.Here() factory; parse --here flag in add/move commands
StepManipulator.cs Handle PositionType.Here in CalculateInsertIndex() and CalculateMoveTargetIndex()

Implementation Details

StepCommandParser.cs:

// Add to PositionType enum
public enum PositionType
{
    At,
    After,
    Before,
    First,
    Last,
    Here  // NEW: Insert before current step (requires paused state)
}

// Add factory method to StepPosition
public static StepPosition Here() => new StepPosition { Type = PositionType.Here };

// Update ToString()
PositionType.Here => "here",

// In ParseReplAddRunCommand and ParseReplAddUsesCommand, add case:
case "--here":
    cmd.Position = StepPosition.Here();
    break;

// Same for ParseReplMoveCommand

StepManipulator.cs:

// In CalculateInsertIndex():
case PositionType.Here:
{
    // "Here" means before the current step
    // Since current step is held separately (not in JobSteps queue),
    // we need to:
    // 1. Verify we're paused (have a current step)
    // 2. Insert at position 0 of pending AND move current step after it
    
    if (_currentStep == null)
    {
        throw new StepCommandException(StepCommandErrors.InvalidPosition,
            "Can only use --here when paused at a breakpoint.");
    }
    
    // The new step goes at index 0, and we need to re-queue the current step
    // Actually, we need a different approach - see "Special handling" below
}

Special handling for --here:

The current architecture has _currentStep held separately from JobSteps. To insert "before" the current step, we need to:

  1. Insert the new step at position 0 of JobSteps
  2. Move _currentStep back into JobSteps at position 1
  3. Set the new step as _currentStep

Alternative (simpler): Modify InsertStep to handle Here specially:

public int InsertStep(IStep step, StepPosition position)
{
    // Special case: --here inserts before current step
    if (position.Type == PositionType.Here)
    {
        if (_currentStep == null)
        {
            throw new StepCommandException(StepCommandErrors.InvalidPosition,
                "Can only use --here when paused at a breakpoint.");
        }
        
        // Re-queue current step at the front
        var pending = _jobContext.JobSteps.ToList();
        pending.Insert(0, _currentStep);
        
        // Insert new step before it (at position 0)
        pending.Insert(0, step);
        
        // Clear and re-queue
        _jobContext.JobSteps.Clear();
        foreach (var s in pending)
            _jobContext.JobSteps.Enqueue(s);
        
        // New step becomes current
        _currentStep = step;
        
        // Track change and return index
        var newIndex = _completedSteps.Count + 1;
        // ... track change ...
        return newIndex;
    }
    
    // ... existing logic for other position types ...
}

Testing

  • steps add run "echo test" --here when paused at step 1 inserts at position 1
  • New step becomes the current step (shows as in list)
  • Original current step moves to position 2
  • Stepping forward runs the new step first
  • --here when not paused returns appropriate error
  • steps move 3 --here moves step 3 to before current step

Chunk 2: --id Option for Step Identification

Goal: Allow users to specify a custom ID for dynamically added steps.

Design

Flag Meaning
--id <identifier> Set the step's ID (used in steps.<id>.outputs, etc.)

Validation:

  • ID must be a non-empty string
  • No format restrictions (matches YAML behavior - users can use any string)

Duplicate handling:

  • If a step with the same ID already exists, return error: "Step with ID '' already exists"

Default behavior (unchanged):

  • If --id is not provided, auto-generate _dynamic_<guid> as before

Files to Modify

File Changes
StepCommandParser.cs Add Id property to AddRunCommand and AddUsesCommand; parse --id flag
StepFactory.cs Add id parameter to CreateRunStep() and CreateUsesStep(); use provided ID or generate one
StepCommandHandler.cs Pass Id from command to factory; validate uniqueness
StepManipulator.cs Add HasStepWithId(string id) method for uniqueness check

Implementation Details

StepCommandParser.cs:

// Add to AddRunCommand and AddUsesCommand classes:
public string Id { get; set; }

// In ParseReplAddRunCommand and ParseReplAddUsesCommand:
case "--id":
    cmd.Id = GetNextArg(tokens, ref i, "--id");
    break;

StepFactory.cs:

// Update method signatures:
ActionStep CreateRunStep(
    string script,
    string id = null,        // NEW
    string name = null,
    // ... rest unchanged
);

ActionStep CreateUsesStep(
    string actionReference,
    string id = null,        // NEW  
    string name = null,
    // ... rest unchanged
);

// In implementation:
public ActionStep CreateRunStep(string script, string id = null, ...)
{
    var stepId = Guid.NewGuid();
    var step = new ActionStep
    {
        Id = stepId,
        Name = id ?? $"_dynamic_{stepId:N}",  // Use provided ID or generate
        DisplayName = name ?? "Run script",
        // ...
    };
    // ...
}

StepManipulator.cs:

// Add method to check for duplicate IDs:
public bool HasStepWithId(string id)
{
    if (string.IsNullOrEmpty(id))
        return false;
        
    // Check completed steps
    foreach (var step in _completedSteps)
    {
        if (step is IActionRunner runner && runner.Action?.Name == id)
            return true;
    }
    
    // Check current step
    if (_currentStep is IActionRunner currentRunner && currentRunner.Action?.Name == id)
        return true;
    
    // Check pending steps
    foreach (var step in _jobContext.JobSteps)
    {
        if (step is IActionRunner pendingRunner && pendingRunner.Action?.Name == id)
            return true;
    }
    
    return false;
}

StepCommandHandler.cs:

// In HandleAddRunCommand and HandleAddUsesCommand:
if (!string.IsNullOrEmpty(cmd.Id) && _manipulator.HasStepWithId(cmd.Id))
{
    throw new StepCommandException(StepCommandErrors.DuplicateId,
        $"Step with ID '{cmd.Id}' already exists.");
}

var actionStep = _factory.CreateRunStep(
    cmd.Script,
    cmd.Id,        // NEW
    cmd.Name,
    // ...
);

Command Examples

# Add step with custom ID
steps add run "echo hello" --id greet --name "Greeting"

# Reference in later step
steps add run "echo ${{ steps.greet.outputs.result }}"

# Duplicate ID returns error
steps add run "echo bye" --id greet
# Error: Step with ID 'greet' already exists

Testing

  • steps add run "echo test" --id my_step creates step with ID my_step
  • Step ID appears correctly in steps list output
  • Attempting duplicate ID returns clear error
  • Omitting --id still generates _dynamic_<guid> IDs
  • ID is correctly set on the underlying ActionStep.Name property

Chunk 3: Help Commands (--help)

Goal: Add --help flag support to provide usage information for all step commands.

Design

Command Output
steps List of available subcommands
steps --help Same as above
steps add --help Help for add command (shows run and uses subcommands)
steps add run --help Help for add run with all options
steps add uses --help Help for add uses with all options
steps edit --help Help for edit command
steps remove --help Help for remove command
steps move --help Help for move command
steps list --help Help for list command
steps export --help Help for export command

Output format: Text only (no JSON support needed)

Files to Modify

File Changes
StepCommandParser.cs Add HelpCommand class; detect --help flag and return appropriate help command
StepCommandHandler.cs Add HandleHelpCommand() with help text for each command

Implementation Details

StepCommandParser.cs:

// Add new command class:
public class HelpCommand : StepCommand
{
    /// <summary>
    /// The command to show help for (null = top-level help)
    /// </summary>
    public string Command { get; set; }
    
    /// <summary>
    /// Sub-command if applicable (e.g., "run" for "steps add run --help")
    /// </summary>
    public string SubCommand { get; set; }
}

// Modify ParseReplCommand to detect --help:
private StepCommand ParseReplCommand(string input)
{
    var tokens = Tokenize(input);
    
    // Handle bare "steps" command
    if (tokens.Count == 1 && tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
    {
        return new HelpCommand { Command = null };
    }
    
    if (tokens.Count < 2 || !tokens[0].Equals("steps", StringComparison.OrdinalIgnoreCase))
    {
        throw new StepCommandException(StepCommandErrors.ParseError,
            "Invalid command format. Expected: steps <command> [args...]");
    }
    
    // Check for --help anywhere in tokens
    if (tokens.Contains("--help") || tokens.Contains("-h"))
    {
        return ParseHelpCommand(tokens);
    }
    
    var subCommand = tokens[1].ToLower();
    // ... existing switch ...
}

private HelpCommand ParseHelpCommand(List<string> tokens)
{
    // Remove --help/-h from tokens
    tokens.RemoveAll(t => t == "--help" || t == "-h");
    
    // "steps --help" or "steps"
    if (tokens.Count == 1)
    {
        return new HelpCommand { Command = null };
    }
    
    // "steps add --help"
    var cmd = tokens[1].ToLower();
    
    // "steps add run --help"
    string subCmd = null;
    if (tokens.Count >= 3 && (cmd == "add"))
    {
        subCmd = tokens[2].ToLower();
        if (subCmd != "run" && subCmd != "uses")
            subCmd = null;
    }
    
    return new HelpCommand { Command = cmd, SubCommand = subCmd };
}

StepCommandHandler.cs:

private StepCommandResult HandleHelpCommand(HelpCommand cmd)
{
    string helpText = (cmd.Command, cmd.SubCommand) switch
    {
        (null, _) => GetTopLevelHelp(),
        ("add", null) => GetAddHelp(),
        ("add", "run") => GetAddRunHelp(),
        ("add", "uses") => GetAddUsesHelp(),
        ("edit", _) => GetEditHelp(),
        ("remove", _) => GetRemoveHelp(),
        ("move", _) => GetMoveHelp(),
        ("list", _) => GetListHelp(),
        ("export", _) => GetExportHelp(),
        _ => $"Unknown command: {cmd.Command}"
    };
    
    return new StepCommandResult
    {
        Success = true,
        Message = helpText
    };
}

private string GetTopLevelHelp() => @"
steps - Manipulate job steps during debug session

COMMANDS:
  list      Show all steps with status
  add       Add a new step (run or uses)
  edit      Modify a pending step
  remove    Delete a pending step
  move      Reorder a pending step
  export    Generate YAML for modified steps

Use 'steps <command> --help' for more information about a command.
".Trim();

private string GetAddHelp() => @"
steps add - Add a new step to the job

USAGE:
  steps add run <script> [options]     Add a shell command step
  steps add uses <action> [options]    Add an action step

Use 'steps add run --help' or 'steps add uses --help' for detailed options.
".Trim();

private string GetAddRunHelp() => @"
steps add run - Add a shell command step

USAGE:
  steps add run ""<script>"" [options]

OPTIONS:
  --id <id>                  Step ID for referencing in expressions
  --name ""<name>""            Display name for the step
  --shell <shell>            Shell to use (bash, sh, pwsh, python, cmd)
  --working-directory <dir>  Working directory for the script
  --if ""<condition>""         Condition expression (default: success())
  --env KEY=value            Environment variable (can repeat)
  --continue-on-error        Don't fail job if step fails
  --timeout <minutes>        Step timeout in minutes

POSITION OPTIONS:
  --here                     Insert before current step (default)
  --after <index>            Insert after step at index
  --before <index>           Insert before step at index  
  --at <index>               Insert at specific index
  --first                    Insert at first pending position
  --last                     Insert at end of job

EXAMPLES:
  steps add run ""npm test""
  steps add run ""echo hello"" --name ""Greeting"" --id greet
  steps add run ""./build.sh"" --shell bash --after 3
".Trim();

private string GetAddUsesHelp() => @"
steps add uses - Add an action step

USAGE:
  steps add uses <action@ref> [options]

OPTIONS:
  --id <id>                  Step ID for referencing in expressions
  --name ""<name>""            Display name for the step
  --with key=value           Action input (can repeat)
  --env KEY=value            Environment variable (can repeat)
  --if ""<condition>""         Condition expression (default: success())
  --continue-on-error        Don't fail job if step fails
  --timeout <minutes>        Step timeout in minutes

POSITION OPTIONS:
  --here                     Insert before current step (default)
  --after <index>            Insert after step at index
  --before <index>           Insert before step at index
  --at <index>               Insert at specific index
  --first                    Insert at first pending position
  --last                     Insert at end of job

EXAMPLES:
  steps add uses actions/checkout@v4
  steps add uses actions/setup-node@v4 --with node-version=20
  steps add uses ./my-action --name ""Local Action"" --after 2
".Trim();

private string GetEditHelp() => @"
steps edit - Modify a pending step

USAGE:
  steps edit <index> [modifications]

MODIFICATIONS:
  --name ""<name>""            Change display name
  --script ""<script>""        Change script (run steps only)
  --shell <shell>            Change shell (run steps only)
  --working-directory <dir>  Change working directory
  --if ""<condition>""         Change condition expression
  --with key=value           Set/update action input (uses steps only)
  --env KEY=value            Set/update environment variable
  --remove-with <key>        Remove action input
  --remove-env <key>         Remove environment variable
  --continue-on-error        Enable continue-on-error
  --no-continue-on-error     Disable continue-on-error
  --timeout <minutes>        Change timeout

EXAMPLES:
  steps edit 3 --name ""Updated Name""
  steps edit 4 --script ""npm run test:ci""
  steps edit 2 --env DEBUG=true --timeout 30
".Trim();

private string GetRemoveHelp() => @"
steps remove - Delete a pending step

USAGE:
  steps remove <index>

ARGUMENTS:
  <index>    1-based index of the step to remove (must be pending)

EXAMPLES:
  steps remove 5
  steps remove 3
".Trim();

private string GetMoveHelp() => @"
steps move - Reorder a pending step

USAGE:
  steps move <from> <position>

ARGUMENTS:
  <from>     1-based index of the step to move (must be pending)

POSITION OPTIONS:
  --here                     Move before current step
  --after <index>            Move after step at index
  --before <index>           Move before step at index
  --to <index>               Move to specific index
  --first                    Move to first pending position
  --last                     Move to end of job

EXAMPLES:
  steps move 5 --after 2
  steps move 4 --first
  steps move 3 --here
".Trim();

private string GetListHelp() => @"
steps list - Show all steps with status

USAGE:
  steps list [options]

OPTIONS:
  --verbose                  Show additional step details
  --output json|text         Output format (default: text)

OUTPUT:
  Shows all steps with:
  - Index number
  - Status indicator (completed, current, pending)
  - Step name
  - Step type (run/uses) and details
  - Change indicator ([ADDED], [MODIFIED], [MOVED])
".Trim();

private string GetExportHelp() => @"
steps export - Generate YAML for modified steps

USAGE:
  steps export [options]

OPTIONS:
  --changes-only             Only export added/modified steps
  --with-comments            Include change markers as YAML comments
  --output json|text         Output format (default: text)

OUTPUT:
  Generates valid YAML that can be pasted into a workflow file.
  
EXAMPLES:
  steps export
  steps export --changes-only --with-comments
".Trim();

Testing

  • steps shows top-level help
  • steps --help shows top-level help
  • steps -h shows top-level help
  • steps add --help shows add command help
  • steps add run --help shows add run help with all options
  • steps add uses --help shows add uses help with all options
  • steps edit --help shows edit help
  • steps remove --help shows remove help
  • steps move --help shows move help
  • steps list --help shows list help
  • steps export --help shows export help
  • --help can appear anywhere in command (e.g., steps add --help run)

Chunk 4: Browser Extension UI Updates

Goal: Update the Add Step form to use --here as default and add the ID field.

Changes to browser-ext/content/content.js

1. Update Position Dropdown

Current options:

  • "At end (default)"
  • "At first pending position"
  • "After current step"

New options:

  • "Before next step" (default) - uses --here
  • "At end"
  • "After current step"
// In showAddStepDialog():
<div class="dap-form-group">
  <label class="dap-label">Position</label>
  <select class="form-control dap-position-select">
    <option value="here" selected>Before next step</option>
    <option value="last">At end</option>
    <option value="after">After current step</option>
  </select>
</div>

2. Add ID Field

Add after the Name field:

<div class="dap-form-group">
  <label class="dap-label">ID (optional)</label>
  <input type="text" class="form-control dap-id-input" 
         placeholder="my_step_id">
  <span class="dap-help-text">Used to reference step outputs: steps.&lt;id&gt;.outputs</span>
</div>

3. Update handleAddStep()

async function handleAddStep(modal) {
  const type = modal.querySelector('.dap-step-type-select').value;
  const name = modal.querySelector('.dap-name-input').value.trim() || undefined;
  const id = modal.querySelector('.dap-id-input').value.trim() || undefined;  // NEW
  const positionSelect = modal.querySelector('.dap-position-select').value;

  let position = {};
  if (positionSelect === 'here') {
    position.here = true;  // NEW
  } else if (positionSelect === 'after') {
    const currentStep = stepsList.find((s) => s.status === 'current');
    if (currentStep) {
      position.after = currentStep.index;
    } else {
      position.here = true;
    }
  } else {
    position.last = true;
  }

  // Pass id to sendStepCommand
  result = await sendStepCommand('step.add', {
    type: 'run',
    script,
    id,        // NEW
    name,
    shell,
    position,
  });
  // ...
}

4. Update buildAddStepCommand()

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.id) cmd += ` --id ${quoteString(options.id)}`;  // NEW
  if (options.name) cmd += ` --name ${quoteString(options.name)}`;
  if (options.if) cmd += ` --if ${quoteString(options.if)}`;
  // ... rest of options ...

  // Position
  if (options.position) {
    if (options.position.here) cmd += ' --here';  // NEW
    else if (options.position.after !== undefined) cmd += ` --after ${options.position.after}`;
    else if (options.position.before !== undefined) cmd += ` --before ${options.position.before}`;
    else if (options.position.at !== undefined) cmd += ` --at ${options.position.at}`;
    else if (options.position.first) cmd += ' --first';
    // --last is default, no need to specify
  }

  return cmd;
}

CSS Updates (browser-ext/content/content.css)

.dap-help-text {
  font-size: 11px;
  color: var(--fgColor-muted, #8b949e);
  margin-top: 4px;
  display: block;
}

Testing

  • Position dropdown defaults to "Before next step"
  • ID field is visible and optional
  • ID placeholder text is helpful
  • Help text explains the purpose of ID
  • Adding step with ID works correctly
  • Adding step with "Before next step" uses --here flag
  • Form validation doesn't require ID

File Summary

Files to Create

None - all changes are modifications to existing files.

Files to Modify

File Chunks Changes
src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs 1, 2, 3 Add Here position type, Id property, HelpCommand class
src/Runner.Worker/Dap/StepCommands/StepManipulator.cs 1, 2 Handle Here position, add HasStepWithId() method
src/Runner.Worker/Dap/StepCommands/StepFactory.cs 2 Add id parameter to create methods
src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs 2, 3 Pass ID to factory, add help text handlers
browser-ext/content/content.js 4 Update form with ID field and position options
browser-ext/content/content.css 4 Add help text styling

Error Messages

Code Message
INVALID_POSITION Can only use --here when paused at a breakpoint
DUPLICATE_ID Step with ID '' already exists

Estimated Effort

Chunk Effort
Chunk 1: --here position ~1-2 hours
Chunk 2: --id option ~1 hour
Chunk 3: Help commands ~1-2 hours
Chunk 4: Browser extension UI ~30 min
Total ~4-5 hours