Skip to content

Instantly share code, notes, and snippets.

@galligan
Created February 7, 2026 12:12
Show Gist options
  • Select an option

  • Save galligan/8caddedbdbd830c9731de03eb177e4fd to your computer and use it in GitHub Desktop.

Select an option

Save galligan/8caddedbdbd830c9731de03eb177e4fd to your computer and use it in GitHub Desktop.
Console vs. Logger: Best Practices for CLI Tools & TypeScript Libraries

Console vs. Logger: Best Practices for CLI Tools & TypeScript Libraries

Research notes for the Outfitter Stack. Synthesized from codebase analysis, MCP spec, CLI guidelines, and logging library documentation.

The Core Distinction

Output is the product — what the user asked for. Logging is the process — how the program ran.

Concern Stream Audience Example
Output stdout User / pipe consumer outfitter list results, JSON data
Diagnostics stderr Operator / debugger Warnings, progress, spinners, log lines
Errors stderr (human) or stdout (JSON mode) User Validation failures, not-found

These are fundamentally different systems and should never be conflated.

The UNIX Convention

$ my-tool list 2>/dev/null | jq '.'     # stdout = data, stderr discarded
$ my-tool list --json 2>&1 | grep error  # both streams merged for grep
  • stdout: Line-buffered on TTY, block-buffered on pipe. ~2x faster than stderr (Orhun Parmaksiz, 2024).
  • stderr: Unbuffered — every write is a syscall. Use BufWriter for high-volume diagnostic output.

From clig.dev:

"The primary output for your command should go to stdout. Log messages, errors, and so on should all be sent to stderr."

From 12 Factor CLI Apps (Jeff Dickey / oclif creator):

"Using stdout for informational messages makes utilities unusable for pipeable CLIs."

Why Linters Flag console.log

Both ESLint's no-console and Biome's noConsole exist to catch debugging leftovers. But ESLint's own docs note:

"If you are developing for Node.js, console is used to output information to the user and so is not strictly used for debugging purposes."

The right approach for monorepos:

Layer noConsole Rationale
Libraries (packages/*) error Must be transport-agnostic; callers control output
Handlers / domain logic error Return Result<T, E>, never side-effect
CLI entry points (apps/*) off or allow: ["error", "warn"] This IS the output layer
Test files off Tests verify output behavior
Build scripts off Developer tooling

The rule isn't "never use console" — it's "only use console at the presentation boundary."

The Three-Layer Architecture

┌─────────────────────────────────────────────────┐
│  PRESENTATION LAYER                             │
│  CLI commands, MCP tool results, HTTP responses │
│  ✓ console / output() / process.stdout.write()  │
│  Decides: format, stream, exit code             │
└────────────────────┬────────────────────────────┘
                     │ calls
┌────────────────────┴────────────────────────────┐
│  HANDLER LAYER                                  │
│  Pure functions: (input, ctx) → Result<T, E>    │
│  ✗ No console, no direct I/O                    │
│  Uses: ctx.logger for diagnostics only          │
│  Returns: typed Result for caller to render     │
└────────────────────┬────────────────────────────┘
                     │ injected via
┌────────────────────┴────────────────────────────┐
│  INFRASTRUCTURE LAYER                           │
│  Logging, config, file I/O, database            │
│  ✗ No console                                   │
│  Provides: Logger interface, sinks, formatters  │
│  Silent by default (no-op if unconfigured)      │
└─────────────────────────────────────────────────┘

Key principle: Handlers never know about output format. The adapter decides. Libraries are silent. The application owns configuration.

When to Use What

Handler internals → ctx.logger

Structured diagnostics with automatic redaction. Goes to stderr via configured sinks.

const getUser: Handler<Input, User, NotFoundError> = async (input, ctx) => {
  ctx.logger.debug("Fetching user", { userId: input.id });
  const user = await db.users.findById(input.id);
  if (!user) {
    ctx.logger.info("User not found", { userId: input.id });
    return Result.err(NotFoundError.create("user", input.id));
  }
  return Result.ok(user);
};

CLI command output → output() or process.stdout.write()

User-facing results. Respects --json, TTY detection, piping.

const result = await getUser(input, ctx);
if (result.isErr()) {
  exitWithError(result.error);  // stderr + exit code
}
output(result.value);  // stdout, format auto-detected

MCP tool output → Return Result.ok(data)

The MCP protocol is the transport; tool results are the output.

handler: async (input, ctx) => {
  ctx.logger.debug("Processing tool call", { tool: "add" });
  return Result.ok({ sum: input.a + input.b });
}

MCP diagnostics → ctx.logger (routes to notifications/message)

The MCP spec defines a structured logging protocol via notifications/message with RFC 5424 severity levels. Critically:

"The server MUST NOT write anything to its stdout that is not a valid MCP message."

console.log() in an MCP server using stdio transport is fatal — it corrupts the JSON-RPC stream.

Summary Table

Context What to Use Stream Why
Handler trace ctx.logger.debug() stderr (via sink) Structured, redacted, filterable
Handler error Result.err(error) N/A (returned) Caller decides presentation
CLI result output(data) stdout Respects --json, TTY, piping
CLI error exitWithError(error) stderr + exit code Typed formatting + POSIX codes
MCP result Result.ok(data) tool response Protocol handles serialization
MCP diagnostic ctx.logger.info() notifications/message Structured protocol logging
MCP NEVER console.log() ☠️ corrupts stdout Breaks JSON-RPC stream

The "Silent Library" Principle

Libraries should produce zero output unless the consuming application explicitly configures logging.

LogTape articulates this best:

"If the application doesn't configure LogTape, logger.info() does nothing. No output, no errors, no side effects."

The LogTape library guide establishes:

  1. Don't configure logging in your library — configuration belongs to the application
  2. Namespace your log categories with your library name (e.g., ["outfitter", "config"])
  3. Use structured fieldslogger.info("Loaded config", { path, duration }) not string interpolation

This is why the no-op logger pattern exists:

// MCP server: silent unless caller provides a logger
const logger = providedLogger ?? createNoOpLogger();

Comparison: Pino vs. Winston vs. LogTape

Aspect LogTape Pino Winston
Library-friendly Yes (silent by default) No (requires init) No (requires init)
Zero dependencies Yes 1 dep 6+ deps
Bundle size 5.3 KB 3.1 KB 38.3 KB
Performance Good Excellent (5-10x Winston) Moderate
Structured logging Yes Yes (JSON-first) Yes

For libraries: LogTape or a custom Logger interface (what Outfitter does). For applications: Any — Pino for performance, LogTape for simplicity, or a custom sink system.

The --json Flag Contract

When --json is active:

  1. stdout contains only valid JSON — no spinners, no color, no progress bars
  2. stderr continues normally — human diagnostics still visible
  3. Error payloads are JSON too — not just success responses
  4. The schema is versioned — breaking changes to JSON output are breaking changes to the CLI

From the Heroku CLI Style Guide:

"Care should be taken that commands do not change their stdout after general availability in ways that will break current scripts."

How Other Tools Do It

Vite

  • createLogger() factory with numeric log levels
  • TTY detection for color/clearing
  • Message deduplication (warnOnce)
  • customLogger config for full replacement
  • Routes through console.log/console.warn/console.error respectively

Turborepo

  • Rust binary with typed event channels (TuiSender / AppReceiver)
  • Output modes: full, hash-only, new-only, errors-only, none
  • Declarative outputLogs in turbo.json

Commander.js

  • configureOutput() with separate writeOut and writeErr callbacks
  • Explicitly separates streams at the framework level

Ink (React for CLIs)

  • Separate useStdout and useStderr hooks
  • Intercepts console.log to prevent interference with React render loop
  • <Static> component for permanent output above the interactive area

Clack

  • All interactive prompts (spinners, selections) go to stderr
  • stdout stays clean for pipeable output

Anti-Patterns

1. Using console.log in handlers

// ❌ Handler is now coupled to terminal output
const handler = async (input, ctx) => {
  console.log("Processing...");  // Goes to stdout, corrupts pipes
  return Result.ok(data);
};

2. Using the logger for user-facing output

// ❌ Logger is for diagnostics, not product output
ctx.logger.info(`Created ${files.length} files`);  // User never sees this reliably

3. Mixing output and diagnostics on stdout

// ❌ Breaks piping: `my-tool | jq` fails
console.log("Starting process...");  // diagnostic on stdout
console.log(JSON.stringify(result)); // data on stdout

4. console.log in MCP server code

// ☠️ Fatal: corrupts JSON-RPC stream on stdio transport
console.log("Debug:", input);  // This breaks the protocol

Practical Recommendations

  1. Keep noConsole: "error" in library packages. This is correct. Libraries return data; callers render.

  2. Allow console in CLI entry points only. Use Biome overrides for apps/** and explicit output modules.

  3. Use output() for CLI results, not console.log. The output function handles --json, TTY detection, and stream routing.

  4. Use ctx.logger for handler diagnostics. It's structured, redacted, and routed to stderr.

  5. MCP servers must never console.log. Use ctx.logger which routes to notifications/message or stderr.

  6. The logger is not for users. It's for operators, debuggers, and AI assistants reading diagnostics. If a human needs to see it, it should be in the command's output.

  7. Error presentation belongs to the adapter. Handlers return Result.err(...). The CLI adapter formats it (human or JSON) and writes to stderr with the correct exit code.

Sources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment