Skip to content

New API for decoupling Execution and Presentation #1520

@AllanOricil

Description

@AllanOricil

@jfeingold35 this is ready for review
If these aren't a good ideas, I'm fine if they do not get implemented. However, I would like to know your thoughts about it. And please understand my only goal is to learn where my way of thinking is wrong. I need your feedback because it could help me to stop thinking Im stupid all the time. Thanks in advance.

Update: refined the idea of decoupling execution from presentation with AI.

Update: added example showing how these changes stablish a framework for commands that have steps

Update: added ux module to ctx to avoid global state pollution during parallel testing

Decoupling execution from presentation could help with this idea #1519

Summary

Introduce a new pattern for defining commands where execution logic and UI presentation are fully separated. This proposal includes:

  1. defineCommand — A functional API
  2. Command — A class-based API with the same separation
  3. StreamingCommand — A class-based API for commands that yield incremental output

All APIs enable easier testing, better reusability, native streaming support, step-based progress reporting, and automatic --json handling—while remaining backwards compatible with the existing Command class.


Is your feature request related to a problem?

Yes. The current class-based pattern encourages mixing logic with side effects:

class MyCommand extends Command {
  async run() {
    this.log("Starting...");           // presentation
    const data = await fetchData();    // logic
    ux.action.start("Saving");         // presentation
    await save(data);                  // logic
    ux.action.stop();                  // presentation
    
    if (this.flags.json) {
      this.log(JSON.stringify(data));
    } else {
      this.log(`Done: ${data.id}`);
    }
  }
}

Problems with this pattern:

  1. Testing is painful — You must mock this.log, ux.action, capture stdout, etc.

  2. Logic is not reusable — The command's core behavior is tangled with CLI concerns.

  3. Streaming is awkward — No clear pattern for commands that yield incremental output.

  4. --json handling is manual — Every command implements it differently.

  5. Step-based progress is ad-hoc — There's no consistent way to report multi-step operations. Different developers implement step progress differently, leading to inconsistent UX and duplicated boilerplate:

// Developer A: Manual spinner management
class DeployA extends Command {
  async run() {
    ux.action.start("Installing");
    await install();
    ux.action.stop();
    
    ux.action.start("Building");
    await build();
    ux.action.stop();
    
    ux.action.start("Deploying");
    await deploy();
    ux.action.stop();
  }
}

// Developer B: Log-based steps
class DeployB extends Command {
  async run() {
    this.log("Step 1/3: Installing...");
    await install();
    this.log("Step 1/3: Done");
    
    this.log("Step 2/3: Building...");
    await build();
    this.log("Step 2/3: Done");
    
    this.log("Step 3/3: Deploying...");
    await deploy();
    this.log("Step 3/3: Done");
  }
}

// Developer C: Custom step wrapper (every project reinvents this)
class DeployC extends Command {
  async run() {
    await this.runStep("Installing", () => install());
    await this.runStep("Building", () => build());
    await this.runStep("Deploying", () => deploy());
  }
  
  private async runStep(name: string, fn: () => Promise<void>) {
    ux.action.start(name);
    try {
      await fn();
      ux.action.stop("done");
    } catch (e) {
      ux.action.stop("failed");
      throw e;
    }
  }
}

Problems with ad-hoc step progress:

  • No standard API — Every CLI invents its own step abstraction
  • Inconsistent output — Users see different progress styles across commands
  • Boilerplate everywhere — Start/stop spinner logic repeated in every command
  • Hard to test — Step logic is mixed with business logic
  • No --json awareness — Steps still output even when JSON is requested
  • No timing/metrics — Difficult to track how long each step took

Describe the solution you'd like

Three new APIs that enforce separation of concerns. Users choose whichever style fits their codebase.


Option 1: Functional API — defineCommand

import { defineCommand, Args, Flags } from "@oclif/core";

export default defineCommand({
  description: "Fetch and save user data",
  args: { userId: Args.string({ required: true }) },
  flags: { force: Flags.boolean() },

  // Pure logic — no side effects, just return the result
  async execute(ctx) {
    const user = await fetchUser(ctx.args.userId);
    const processed = transform(user);
    await save(processed, { force: ctx.flags.force });
    return { user: processed, savedAt: new Date() };
  },

  // Optional hooks — presentation only
  onStart(ctx) {
    ctx.ux.action.start("Processing");
  },

  onResult(ctx, result) {
    ctx.ux.action.stop();
    return `✔ Saved user ${result.user.id}`;
  },
});

Streaming with defineCommand

export default defineCommand({
  description: "Stream logs from a file",
  args: { file: Args.string({ required: true }) },

  async *execute(ctx) {
    const lines: string[] = [];
    
    for await (const line of tailFile(ctx.args.file)) {
      lines.push(line);
      yield line;
    }
    
    return { lines, count: lines.length };
  },

  onChunk(ctx, line) {
    return line;
  },

  onResult(ctx, result) {
    return `\n✔ ${result.count} lines processed`;
  },
});

Steps with defineCommand

export default defineCommand({
  description: "Build and deploy application",
  args: { app: Args.string({ required: true }) },

  async execute(ctx) {
    ctx.step("Installing dependencies");
    await install(ctx.args.app);

    ctx.step("Building");
    await build(ctx.args.app);

    ctx.step("Running tests");
    await test(ctx.args.app);

    ctx.step("Deploying");
    const url = await deploy(ctx.args.app);

    return { url, app: ctx.args.app };
  },

  onStep(ctx, name) {
    ctx.ux.action.stop();
    ctx.ux.action.start(name);
  },

  onResult(ctx, result) {
    ctx.ux.action.stop();
    return `\n✔ ${result.app} deployed to ${result.url}`;
  },
});

Option 2: Class-based API — Command

For teams that prefer classes or need inheritance:

import { Command, Args, Flags } from "@oclif/core";

interface SaveUserResult {
  user: User;
  savedAt: Date;
}

export default class SaveUser extends Command<SaveUserResult> {
  static description = "Fetch and save user data";
  static args = { userId: Args.string({ required: true }) };
  static flags = { force: Flags.boolean() };

  // Pure logic — no side effects
  async execute(): Promise<SaveUserResult> {
    const user = await fetchUser(this.args.userId);
    const processed = transform(user);
    await save(processed, { force: this.flags.force });
    return { user: processed, savedAt: new Date() };
  }

  // Optional hooks
  onStart() {
    this.ux.action.start("Processing");
  }

  onResult(result: SaveUserResult) {
    this.ux.action.stop();
    return `✔ Saved user ${result.user.id}`;
  }
}

Steps with Command

import { Command, Args } from "@oclif/core";

interface DeployResult {
  url: string;
  app: string;
}

export default class Deploy extends Command<DeployResult> {
  static description = "Build and deploy application";
  static args = { app: Args.string({ required: true }) };

  async execute(): Promise<DeployResult> {
    this.step("Installing dependencies");
    await install(this.args.app);

    this.step("Building");
    await build(this.args.app);

    this.step("Running tests");
    await test(this.args.app);

    this.step("Deploying");
    const url = await deploy(this.args.app);

    return { url, app: this.args.app };
  }

  onStep(name: string) {
    this.ux.action.stop();
    this.ux.action.start(name);
  }

  onResult(result: DeployResult) {
    this.ux.action.stop();
    return `\n✔ ${result.app} deployed to ${result.url}`;
  }
}

Option 3: Class-based API — StreamingCommand

For commands that yield incremental output over time:

import { StreamingCommand, Args } from "@oclif/core";

interface StreamLogsResult {
  lines: string[];
  count: number;
}

export default class StreamLogs extends StreamingCommand<string, StreamLogsResult> {
  static description = "Stream logs from a file";
  static args = { file: Args.string({ required: true }) };

  async *execute(): AsyncGenerator<string, StreamLogsResult> {
    const lines: string[] = [];

    for await (const line of tailFile(this.args.file)) {
      lines.push(line);
      yield line;
    }

    return { lines, count: lines.length };
  }

  onChunk(line: string) {
    return line;
  }

  onResult(result: StreamLogsResult) {
    return `\n✔ ${result.count} lines processed`;
  }
}

Combining steps and streaming

import { StreamingCommand, Args } from "@oclif/core";

interface FileResult {
  filename: string;
  size: number;
}

interface BatchResult {
  results: FileResult[];
  report: Report;
}

export default class ProcessFiles extends StreamingCommand<FileResult, BatchResult> {
  static description = "Process files in a directory";
  static args = { dir: Args.string({ required: true }) };

  async *execute(): AsyncGenerator<FileResult, BatchResult> {
    const results: FileResult[] = [];

    this.step("Scanning");
    const files = await scanDirectory(this.args.dir);

    this.step(`Processing ${files.length} files`);
    for (const file of files) {
      const result = await processFile(file);
      results.push(result);
      yield result;
    }

    this.step("Generating report");
    const report = await generateReport(results);

    return { results, report };
  }

  onStep(name: string) {
    this.ux.action.stop();
    this.ux.action.start(name);
  }

  onChunk(result: FileResult) {
    return `  ✔ ${result.filename}`;
  }

  onResult(batch: BatchResult) {
    this.ux.action.stop();
    return `\n✔ Processed ${batch.results.length} files`;
  }
}

API Comparison

Feature defineCommand Command StreamingCommand
Style Functional Class-based Class-based
Output Single result Single result Yields chunks + result
Inheritance No Yes Yes
Composition Object spread Mixins / extends Mixins / extends
UX access ctx.ux this.ux this.ux
Type inference From execute signature Generic parameter Generic parameters

All APIs share the same underlying execution model and lifecycle hooks.


How the framework handles execution

Concern Developer writes Framework handles
Core logic execute() Invocation, error boundaries
Streaming yield values Iteration, backpressure
Steps this.step(name) / ctx.step(name) Triggers onStep hook
Presentation on* hooks Timing, lifecycle
JSON output Nothing Automatic serialization of return value
Testing Call execute() directly No mocks needed

Automatic --json behavior

  • When --json is passed, the framework skips all hooks and outputs the return value as JSON.
  • No conditional checks needed inside commands.

Testing

A major goal of this proposal is to make commands trivially testable. This section explains the testing improvements in detail.

Why ux is available via context

In the current oclif pattern, ux is typically imported as a global:

import { Command, ux } from "@oclif/core";

class Deploy extends Command {
  async run() {
    ux.action.start("Deploying");
    await deploy();
    ux.action.stop();
  }
}

This creates problems for testing:

  1. Global state pollutionux is a singleton; tests can interfere with each other
  2. Hard to mock — You need to stub the global module
  3. No isolation — Parallel tests may conflict

By providing ux through context (ctx.ux or this.ux), each command instance gets its own reference that can be easily mocked per test.

Testing without mocks: Pure execute()

For most tests, you don't need to mock anything. Since execute() is pure, just call it directly:

import Deploy from "./commands/deploy";

test("deploys to correct environment", async () => {
  const cmd = new Deploy(["my-app", "--env", "production"], config);
  await cmd.init();
  
  const result = await cmd.execute();

  expect(result.url).toBe("https://my-app.production.example.com");
  expect(result.app).toBe("my-app");
});

No stdout capturing. No spinner assertions. Just test the logic.

Testing with mocked ux

When you need to verify UI behavior, mock this.ux per test:

import Deploy from "./commands/deploy";

test("shows correct steps during deployment", async () => {
  const cmd = new Deploy(["my-app"], config);
  await cmd.init();
  
  // Mock ux for this specific command instance
  const steps: string[] = [];
  cmd.ux = {
    action: {
      start: (name: string) => steps.push(`start: ${name}`),
      stop: () => steps.push("stop"),
    },
  } as typeof cmd.ux;

  await cmd.execute();

  expect(steps).toEqual([
    "start: Installing dependencies",
    "stop",
    "start: Building",
    "stop",
    "start: Deploying",
    "stop",
  ]);
});

Each test gets its own mock. No global pollution. Tests can run in parallel.

Comparison: Before and after

Before (current oclif):

import { Command, ux } from "@oclif/core";
import { stub, restore } from "sinon";
import { captureStdout } from "some-test-helper";

class Deploy extends Command {
  async run() {
    ux.action.start("Deploying");
    const result = await deploy(this.args.app);
    ux.action.stop();
    
    if (this.flags.json) {
      this.log(JSON.stringify(result));
    } else {
      this.log(`Deployed to ${result.url}`);
    }
    
    return result;
  }
}

// Test file
describe("Deploy", () => {
  let startStub: sinon.SinonStub;
  let stopStub: sinon.SinonStub;

  beforeEach(() => {
    // Must stub the global ux module
    startStub = stub(ux.action, "start");
    stopStub = stub(ux.action, "stop");
  });

  afterEach(() => {
    // Must restore to avoid polluting other tests
    restore();
  });

  test("deploys successfully", async () => {
    // Must capture stdout to check output
    const output = await captureStdout(async () => {
      await Deploy.run(["my-app"]);
    });

    expect(startStub.calledWith("Deploying")).toBe(true);
    expect(stopStub.called).toBe(true);
    expect(output).toContain("Deployed to");
  });

  test("returns JSON when flag is set", async () => {
    const output = await captureStdout(async () => {
      await Deploy.run(["my-app", "--json"]);
    });

    // Must parse stdout to verify result
    const result = JSON.parse(output);
    expect(result.url).toBeDefined();
  });
});

After (new pattern):

import { Command, Args, Flags } from "@oclif/core";

interface DeployResult {
  url: string;
  app: string;
}

class Deploy extends Command<DeployResult> {
  static args = { app: Args.string({ required: true }) };
  static flags = { json: Flags.boolean() };

  async execute(): Promise<DeployResult> {
    this.step("Deploying");
    return await deploy(this.args.app);
  }

  onStep(name: string) {
    this.ux.action.start(name);
  }

  onResult(result: DeployResult) {
    this.ux.action.stop();
    return `Deployed to ${result.url}`;
  }
}

// Test file
describe("Deploy", () => {
  test("deploys successfully", async () => {
    const cmd = new Deploy(["my-app"], config);
    await cmd.init();

    // Test pure logic — no mocks needed
    const result = await cmd.execute();

    expect(result.url).toBeDefined();
    expect(result.app).toBe("my-app");
  });

  test("shows deployment step", async () => {
    const cmd = new Deploy(["my-app"], config);
    await cmd.init();

    // Mock only for this instance — no global pollution
    const steps: string[] = [];
    cmd.ux = {
      action: {
        start: (name: string) => steps.push(name),
        stop: () => {},
      },
    } as typeof cmd.ux;

    await cmd.execute();

    expect(steps).toContain("Deploying");
  });

  test("returns structured result for JSON", async () => {
    const cmd = new Deploy(["my-app"], config);
    await cmd.init();

    // No need to test --json flag handling
    // Framework does this automatically
    const result = await cmd.execute();

    expect(result).toEqual({
      url: expect.any(String),
      app: "my-app",
    });
  });
});

Key improvements:

Aspect Before After
Global stubs Required for ux Not needed
Cleanup Must restore stubs Automatic (instance-scoped)
Stdout capture Required Not needed
Parallel tests Risk of conflicts Safe
--json testing Must parse stdout Test return value directly
Lines of test code ~40 ~25

Edge case: Testing interactive prompts

One edge case is commands that use ux.prompt for interactive input:

class CreateUser extends Command<CreateUserResult> {
  async execute() {
    // This prompt happens inside execute — not ideal, but sometimes necessary
    const email = await this.ux.prompt("Enter email");
    return await createUser(email);
  }
}

Since prompts are accessed via this.ux, you can mock them per test:

test("creates user with prompted email", async () => {
  const cmd = new CreateUser([], config);
  await cmd.init();

  // Mock the prompt to return a test value
  cmd.ux = {
    ...cmd.ux,
    prompt: async () => "test@example.com",
  } as typeof cmd.ux;

  const result = await cmd.execute();

  expect(result.email).toBe("test@example.com");
});

However, for better separation, consider moving prompts to a hook:

class CreateUser extends Command<CreateUserResult> {
  private email?: string;

  async onStart() {
    // Prompt in hook — skipped during testing or --json
    this.email = await this.ux.prompt("Enter email");
  }

  async execute() {
    // Use flag if provided, otherwise use prompted value
    const email = this.flags.email ?? this.email;
    return await createUser(email);
  }
}

Now tests can bypass the prompt entirely:

test("creates user with email flag", async () => {
  const cmd = new CreateUser(["--email", "test@example.com"], config);
  await cmd.init();

  // No prompt mocking needed — flag takes precedence
  const result = await cmd.execute();

  expect(result.email).toBe("test@example.com");
});

Best practice: Keep prompts in hooks or accept flag alternatives. This keeps execute() pure and testable without mocking ux.prompt.

Testing StreamingCommand

import StreamLogs from "./commands/stream-logs";

test("streams all lines from file", async () => {
  const cmd = new StreamLogs(["test.log"], config);
  await cmd.init();
  
  const chunks: string[] = [];
  const generator = cmd.execute();
  
  for await (const chunk of generator) {
    chunks.push(chunk);
  }

  expect(chunks.length).toBeGreaterThan(0);
  expect(chunks[0]).toMatch(/^\[.*\]/); // log format
});

test("calls onChunk for each line", async () => {
  const cmd = new StreamLogs(["test.log"], config);
  await cmd.init();

  const displayed: string[] = [];
  const originalOnChunk = cmd.onChunk.bind(cmd);
  cmd.onChunk = (line: string) => {
    displayed.push(line);
    return originalOnChunk(line);
  };

  for await (const _ of cmd.execute()) {
    // drain the generator
  }

  expect(displayed.length).toBeGreaterThan(0);
});

Testing defineCommand

The functional API exposes execute for direct testing:

import { execute } from "./commands/deploy";

test("deploys to production", async () => {
  const result = await execute({
    args: { app: "my-app" },
    flags: { env: "production" },
  });

  expect(result.url).toContain("production");
});

test("with mocked ux", async () => {
  const steps: string[] = [];
  
  const result = await execute({
    args: { app: "my-app" },
    flags: {},
    ux: {
      action: {
        start: (name: string) => steps.push(name),
        stop: () => {},
      },
    },
  });

  expect(steps).toContain("Deploying");
});

Lifecycle hooks

All APIs support the same hooks:

Hook When it runs Use case
onStart() Before execute begins Initial spinner, log start message
onStep(name) When this.step(name) is called Update spinner, show progress
onChunk(chunk) Per yielded value (StreamingCommand only) Echo streamed output
onResult(result) After execute completes Summary message, cleanup
onError(error) On failure Custom error formatting

All hooks are optional. Return a string to print it, or void for no output.

Hook signatures:

// For defineCommand (functional)
onStart?: (ctx: CommandContext) => void;
onStep?: (ctx: CommandContext, name: string) => void;
onChunk?: (ctx: CommandContext, chunk: TChunk) => string | void;
onResult?: (ctx: CommandContext, result: TResult) => string | void;
onError?: (ctx: CommandContext, error: Error) => string | void;

// For Command / StreamingCommand (class-based)
onStart?(): void;
onStep?(name: string): void;
onChunk?(chunk: TChunk): string | void;  // StreamingCommand only
onResult?(result: TResult): string | void;
onError?(error: Error): string | void;

When to use Command vs StreamingCommand

Use case Class Example
Single operation with result Command Fetch user, save file
Sequential operations with progress Command + steps Build → Test → Deploy
Incremental data over time StreamingCommand Log tailing, LLM tokens
Batch processing with per-item output StreamingCommand Process files, show each
Batch processing with phases StreamingCommand + steps Scan → Process each → Report

Context object

All APIs receive context with parsed args, flags, config, and ux:

interface CommandContext<TArgs, TFlags> {
  args: TArgs;
  flags: TFlags;
  config: Config;
  ux: UX;                          // full ux module, mockable per instance
  step: (name: string) => void;
  signal?: AbortSignal;
}

For defineCommand, this is passed to execute(ctx) and all hooks.
For Command and StreamingCommand, these are available as this.args, this.flags, this.config, this.ux, this.step().


Backwards compatibility

The existing Command class behavior remains available. The new APIs are opt-in:

// Legacy pattern — still works, no changes required
class LegacyCommand extends Command {
  async run() {
    // existing mixed pattern
  }
}

// New functional pattern
export default defineCommand({ ... });

// New class-based pattern
export default class MyCommand extends Command<TResult> {
  async execute() { ... }
}

// New streaming pattern
export default class MyCommand extends StreamingCommand<TChunk, TResult> {
  async *execute() { ... }
}

The framework detects which pattern is in use:

  • If run() exists → legacy mode
  • If execute() exists → new decoupled mode

Migration path

From legacy Command to new Command

  1. Add a generic type parameter for your result: Command<TResult>
  2. Rename run()execute()
  3. Replace global ux imports with this.ux
  4. Move all this.log, this.ux.* calls into onStart, onStep, onResult, onError
  5. Remove manual --json handling
  6. Return your result object from execute()

Before

import { Command, ux } from "@oclif/core";

class Deploy extends Command {
  async run() {
    ux.action.start("Building");
    await build();
    ux.action.stop();
    
    ux.action.start("Deploying");
    const result = await deploy(this.args.app);
    ux.action.stop();
    
    if (this.flags.json) {
      this.log(JSON.stringify(result));
    } else {
      this.log(`Deployed ${result.url}`);
    }
  }
}

After

import { Command } from "@oclif/core";

interface DeployResult {
  url: string;
  app: string;
}

class Deploy extends Command<DeployResult> {
  async execute() {
    this.step("Building");
    await build();

    this.step("Deploying");
    return await deploy(this.args.app);
  }

  onStep(name: string) {
    this.ux.action.stop();
    this.ux.action.start(name);
  }

  onResult(result: DeployResult) {
    this.ux.action.stop();
    return `Deployed ${result.url}`;
  }
}

Potential future extensions

  • onStepComplete(name, duration) — called when the next step starts or execute finishes, with timing
  • onProgress(current, total) — for progress bars within a step
  • static loading = "Processing..." — automatic spinner before first output
  • Middleware hooks for cross-cutting concerns (logging, analytics)
  • SteppedCommand — declarative step definitions for complex pipelines

Summary

This proposal introduces three new APIs—defineCommand (functional), Command (class-based), and StreamingCommand (class-based for incremental output)—that enforce a clean separation between execution logic and presentation. All APIs:

  • Make commands easier to test (call execute() directly, mock ux per instance)
  • Eliminate global state pollution (ux accessed via context)
  • Support step-based progress (this.step() / ctx.step())
  • Handle --json automatically
  • Keep UI concerns in optional hooks
  • Remain fully backwards compatible with existing commands

StreamingCommand additionally supports:

  • Native streaming via generators (yield chunks)
  • Per-chunk presentation via onChunk hook

Users choose whichever style fits their preferences.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions