Skip to main content
ClaudeWave
Skill6.1k repo starsupdated today

writing-user-outputs

This Claude Code skill documents the CLI output formatting standards and shell integration architecture for the Worktrunk project. Load it before editing any code that produces user-facing output, including calls to message functions like warning_message or error_message, CLI help text, progress UI, or any strings displayed to users.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/max-sixty/worktrunk /tmp/writing-user-outputs && cp -r /tmp/writing-user-outputs/.claude/skills/writing-user-outputs ~/.claude/skills/writing-user-outputs
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# Output System Architecture

## Shell Integration

Worktrunk uses split file-based directive passing for shell integration:

1. Shell wrapper creates two temp files via `mktemp` (cd and exec)
2. Shell wrapper sets `WORKTRUNK_DIRECTIVE_CD_FILE` and `WORKTRUNK_DIRECTIVE_EXEC_FILE`
3. wt writes a raw path to the CD file; shell commands to the EXEC file (for `--execute`)
4. Shell wrapper reads the CD file with `cd -- "$(< file)"` (no shell parsing)
5. Shell wrapper sources the EXEC file if non-empty

When neither directive env var is set (direct binary call), commands execute
directly and shell integration hints are shown.

## Output Functions

The output system handles shell integration automatically. Just call output
functions — they do the right thing regardless of whether shell integration is
active.

```rust
// NEVER DO THIS - don't check mode in command code
if is_shell_integration_active() {
    // different behavior
}

// ALWAYS DO THIS - just call output functions
eprintln!("{}", success_message("Created worktree"));
output::change_directory(&path)?;  // Writes to directive file if set, else no-op
```

**Printing output:**

Use `eprintln!` and `println!` from `worktrunk::styling` (re-exported from
`anstream` for automatic color support and TTY detection):

```rust
use worktrunk::styling::{eprintln, println, stderr};

// Status messages to stderr
eprintln!("{}", success_message("Created worktree"));

// Primary output to stdout (tables, JSON, pipeable)
println!("{}", table_output);

// Flush before interactive prompts
stderr().flush()?;
```

**Shell integration functions** (`src/output/global.rs`):

| Function | Purpose |
|----------|---------|
| `change_directory(path)` | Shell cd after wt exits (writes to directive file if set) |
| `execute(command)` | Shell command after wt exits |
| `terminate_output()` | Reset ANSI state on stderr |
| `is_shell_integration_active()` | Check if directive file set (rarely needed) |
| `pre_hook_display_path(path)` | Compute display path for pre-hooks |
| `post_hook_display_path(path)` | Compute display path for post-hooks |

**Message formatting functions** (`worktrunk::styling`):

| Function | Symbol | Color |
|----------|--------|-------|
| `success_message()` | ✓ | green |
| `progress_message()` | ◎ | cyan |
| `info_message()` | ○ | symbol dim, text plain |
| `warning_message()` | ▲ | yellow |
| `hint_message()` | ↳ | dim |
| `error_message()` | ✗ | red |
| `prompt_message()` | ❯ | cyan |

**Section headings** (`worktrunk::styling`):

```rust
use worktrunk::styling::format_heading;

// Plain heading
format_heading("BINARIES", None)  // => "BINARIES" (cyan)

// Heading with suffix
format_heading("USER CONFIG", Some("@ ~/.config/wt.toml"))
// => "USER CONFIG @ ~/.config/wt.toml" (title cyan, suffix plain)
```

## stdout vs stderr

**Decision principle:** stdout carries the command's *answer*; stderr carries *narration* about producing it. The discriminating question is answer-vs-narration, not audience — `wt list` is "for the user" yet belongs on stdout because it *is* the answer. "Is this a message to the user?" doesn't discriminate, because nearly all output is.

- **stdout** → the answer, in whatever format the user selected. Data (tables, JSON, shell code, an expanded template) and `--dry-run` previews both qualify: a preview is the whole answer when nothing mutates. Human-formatted output belongs here too. Color strips automatically on a pipe (anstream), so `wt list | grep` stays safe.
- **stderr** → narration about doing it: progress, success/warning/error messages, hints, interactive prompts, and `-v`/`-vv` diagnostics.
- **directive file** → shell commands executed after wt exits (cd, exec).

The same line can flip streams between modes. `wt config shell uninstall` deletes the file, so `✓ Removed … @ ~/.zshrc` only narrates a side effect that already happened → stderr (the edited file is the answer; stdout is empty). `wt config shell uninstall --dry-run` mutates nothing, so `○ Will remove … @ ~/.zshrc` is the only answer there is → stdout. What flips isn't the wording, it's whether a side effect exists to be the answer.

For a split preview, the `--format=json` payload is the arbiter: a line json would carry goes to stdout, narration json omits stays on stderr. `wt step prune --dry-run` puts the removal plan on stdout (the same plan json emits) but keeps "Skipped young-branch (younger than 1d)" and "nothing to remove" on stderr. One case ignores all this: a preview shown *inside* an interactive prompt, such as the `?` re-preview during `wt config shell install`, is mid-prompt narration → stderr.

Examples:
- `wt list`, `wt config show` → human table/dump or `--format=json`, both to stdout
- `wt step prune --dry-run` → the removal plan to stdout (human or json); "nothing to remove" and skipped-young caveats to stderr
- `wt config shell init` → shell code to stdout (for `eval`)
- `wt switch` → status messages only (nothing to pipe)

## When to page output

Route long, human-oriented stdout through `crate::help_pager::show_help_in_pager`. The helper TTY-detects internally, so piping (`wt … | grep`) keeps working.

Page when output is human-oriented (headings, gutters, structure) and plausibly exceeds one screen. Don't page pipe-first data (tables, JSON, shell code), short output, or output already paged by a delegated tool (`git diff`).

Examples that page: `--help`, `wt config show`, `wt hook show`, `wt step {commit,squash} --dry-run`. Examples that don't: `wt list`, `wt step diff`, `wt step eval`, `--show-prompt` (pipe-first by design).

Build the whole output into a `String` first (don't stream), then:

```rust
if let Err(e) = crate::help_pager::show_help_in_pager(&out, true) {
    log::debug!("Pager failed, falling back to stdout: {}", e);
    println!("{}", out);
}
```

## Security

The split-trust design enforces two trust levels:

- `WORKTRUNK_DIRECTIVE_CD_FILE` holds a raw path (no shell parsing), so it's
  safe to pass through to a