BUCKYVERSE

Date: 2025-03-25

Status

Accepted

Context

As we continue our migration to Elixir (as outlined in ADR-0029), we need a standardized approach for building command-line interface (CLI) tools. These tools are essential for various system operations, including:

  1. Build process orchestration
  2. Asset management
  3. Content validation
  4. Development utilities
  5. Deployment automation

Our current CLI tools, built in Ruby, lack several features that would improve developer experience and system reliability:

Decision

We will implement all CLI tools using Elixir, following a standardized architecture that leverages:

1. Project Structure

We will organize CLI projects with a consistent directory structure:

lib/
  my_cli_app/
    cli.ex          # Entry point with main/1
    commands/       # Modules for each CLI command
    shell.ex        # Wrapper for system commands
    progress.ex     # Progress bar utilities
test/
  support/          # Test helpers and mocks

2. Dependencies

We will use the following dependencies for CLI tool development:

defp deps do
  [
    {:optimus, "~> 0.2.0"},       # CLI argument parsing
    {:prompt, "~> 0.6.0"},        # Interactive prompts
    {:progress_bar, "~> 2.0"},    # Progress bars
    {:patch, "~> 0.12.0", only: :test},  # Function patching for tests
    {:exvcr, "~> 0.13.0", only: :test}   # HTTP request recording
  ]
end

3. Command Structure

All CLI tools will implement a consistent command pattern:

# Entry point (lib/my_cli_app/cli.ex)
defmodule MyCLIApp.CLI do
  def main(argv) do
    optimus = MyCLIApp.Optimus.definition()

    case Optimus.parse!(optimus, argv) do
      {:ok, opts} -> MyCLIApp.Router.dispatch(opts)
      {:error, msg} ->
        IO.puts(msg)
        System.halt(1)
    end
  end
end

# Command behavior (lib/my_cli_app/command.ex)
defmodule MyCLIApp.Command do
  @callback run(map()) :: :ok | {:error, String.t()}
end

# Individual commands (lib/my_cli_app/commands/*.ex)
defmodule MyCLIApp.Commands.BuildBook do
  @behaviour MyCLIApp.Command
  
  def run(opts) do
    # Command implementation
  end
end

4. System Command Execution

We will use a standardized pattern for executing system commands:

defmodule MyCLIApp.Shell do
  def run(cmd, args, opts \\ []) do
    Task.Supervisor.async_nolink(MyCLIApp.TaskSupervisor, fn ->
      System.cmd(cmd, args, Keyword.merge(opts, stderr_to_stdout: true))
    end)
    |> Task.await(5000)
    |> handle_result()
  end

  defp handle_result({output, 0}), do: {:ok, output}
  defp handle_result({output, status}), do: {:error, %{status: status, output: output}}
end

5. Concurrency

For performance optimization, we will leverage Elixir's concurrency capabilities:

defmodule MyCLIApp.AssetProcessor do
  def process_all(assets) do
    assets
    |> Task.async_stream(&process_asset/1, max_concurrency: 4)
    |> Enum.reduce([], fn {:ok, result}, acc -> [result | acc] end)
  end
  
  defp process_asset(asset) do
    # Process individual asset
  end
end

6. Progress Visualization

For improved user experience, we will implement progress visualization:

defmodule MyCLIApp.Progress do
  def with_progress(items, operation) do
    total = Enum.count(items)
    
    items
    |> Enum.with_index(1)
    |> Enum.each(fn {item, index} ->
      result = operation.(item)
      ProgressBar.render(index, total)
      result
    end)
  end
end

7. Testing Strategy

We will follow a comprehensive testing strategy for CLI tools:

defmodule MyCLIApp.CommandTest do
  use ExUnit.Case
  import ExUnit.CaptureIO
  
  describe "command execution" do
    test "handles successful command" do
      output = capture_io(fn -> 
        assert MyCLIApp.CLI.main(["build", "--format", "pdf"]) == :ok
      end)
      
      assert output =~ "Build completed successfully"
    end
    
    test "handles command failure" do
      Patch.with_mocked_function MyCLIApp.Commands.Build, :run, fn _ -> 
        {:error, "Build failed"}
      end do
        output = capture_io(fn -> 
          assert MyCLIApp.CLI.main(["build", "--format", "pdf"]) == :error
        end)
        
        assert output =~ "Build failed"
      end
    end
  end
end

8. Distribution

We will build distributable binaries using escript:

# In mix.exs
def project do
  [
    app: :my_cli_app,
    version: "0.1.0",
    elixir: "~> 1.18",
    escript: [main_module: MyCLIApp.CLI],
    deps: deps()
  ]
end

For standalone binaries that don't require Erlang/Elixir installation, we will explore Burrito for cross-platform distribution.

Consequences

Positive

  1. Consistency: Standardized approach across all CLI tools
  2. Concurrency: Efficient parallel execution of tasks
  3. Reliability: Better error handling and supervision
  4. Testability: Comprehensive testing strategy
  5. User Experience: Improved progress visualization
  6. Maintainability: Clear separation of concerns
  7. Performance: Optimized execution of operations

Negative

  1. Migration Effort: Existing Ruby tools need porting
  2. Learning Curve: Development team needs to learn Elixir
  3. Integration: Integration with existing non-Elixir components
  4. Distribution: More complex distribution process than Ruby gems

Migration Strategy

  1. Phase 1: Core Framework

    • Implement common CLI framework
    • Create shell command execution wrapper
    • Establish testing patterns
  2. Phase 2: Essential Tools

    • Port build command tools
    • Port validation tools
    • Implement progress visualization
  3. Phase 3: Supporting Tools

    • Port development utilities
    • Port asset management tools
    • Implement additional helpers
  4. Phase 4: Advanced Features

    • Implement concurrent processing
    • Add interactive prompts
    • Create cross-platform distribution

Compliance

Implementation must:

  1. Follow the Elixir style guide
  2. Include comprehensive tests (minimum 80% coverage)
  3. Provide detailed documentation
  4. Maintain backward compatibility with existing CLI workflows
  5. Support all target platforms (macOS, Linux, Windows)

References