-
Notifications
You must be signed in to change notification settings - Fork 83
Description
@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:
defineCommand— A functional APICommand— A class-based API with the same separationStreamingCommand— 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:
-
Testing is painful — You must mock
this.log,ux.action, capture stdout, etc. -
Logic is not reusable — The command's core behavior is tangled with CLI concerns.
-
Streaming is awkward — No clear pattern for commands that yield incremental output.
-
--jsonhandling is manual — Every command implements it differently. -
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
--jsonawareness — 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
--jsonis 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:
- Global state pollution —
uxis a singleton; tests can interfere with each other - Hard to mock — You need to stub the global module
- 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
- Add a generic type parameter for your result:
Command<TResult> - Rename
run()→execute() - Replace global
uximports withthis.ux - Move all
this.log,this.ux.*calls intoonStart,onStep,onResult,onError - Remove manual
--jsonhandling - 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 timingonProgress(current, total)— for progress bars within a stepstatic 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, mockuxper instance) - Eliminate global state pollution (
uxaccessed via context) - Support step-based progress (
this.step()/ctx.step()) - Handle
--jsonautomatically - Keep UI concerns in optional hooks
- Remain fully backwards compatible with existing commands
StreamingCommand additionally supports:
- Native streaming via generators (
yieldchunks) - Per-chunk presentation via
onChunkhook
Users choose whichever style fits their preferences.