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.
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-outputsSKILL.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, shell code, pipeable)
println!("{}", table_output);
// Flush before interactive prompts
stderr().flush()?;
```
`src/` holds two crates, and the path differs between them: `worktrunk::styling`
from the binary's modules — the ones `src/main.rs` declares (`commands`, `cli`,
`display`, …), which the examples throughout this skill are written for — and
`crate::styling` from the library's, the ones `src/lib.rs` declares (`git`,
`config`, `shell_exec`, …), where `worktrunk::` does not resolve at all. The
guard tests accept either, so the compiler is the only thing that tells you the
path is wrong for the file.
Which `println!` is in scope decides whether a closed pipe panics: std's
panics on the `BrokenPipe` write error, anstream's drops it. `wt … | head`
closes the pipe, so command code imports the `worktrunk::styling` one and no
`std::println!` is left in `src/`.
The stderr macros carry the same rule for a different consequence: anstream's
`eprint!` / `eprintln!` strip ANSI when stderr isn't a terminal, std's keep it,
so a file importing one but not the other writes escapes on one line of a
message block and not the next under `wt … 2>log`. `eprint!` is the half that
slips — it has no newline, so it gets reached for mid-block in a file that
imported only `eprintln`. Every bare `eprint!` / `eprintln!` under `src/` must
resolve to anstream's: import it, or qualify the call as
`styling::eprintln!(…)`. `check_stderr_macros_come_from_styling` in
`tests/integration_tests/output_system_guard.rs` holds that statically, since
no snapshot can — the suite forces `CLICOLOR_FORCE=1`, so both printers emit
color and a snapshot agrees whichever macro is in scope. Its
`STD_STDERR_ALLOWED_PATHS` exempts whole files, not calls, so an entry is only
right where std's macro is right throughout.
**Output whose ANSI is already decided** declares that once at the top of the
command with `worktrunk::styling::ColorChoice::Always.write_global()` and then
prints through the same anstream macros — the statusline a shell prompt or
Claude Code renders, and the `--help-page` document whose escapes the docs
pipeline turns into HTML (`--plain` and `--help-md` declare `Never` the same
way). Neither consumer is ever a tty, so without the declaration anstream
would strip their color every time — and the test suite would not catch it,
because it forces color with `CLICOLOR_FORCE=1`;
`test_color_follows_the_consumer` pins the unforced behavior. Declare `Always`
only when the pipe is a courier rather than the destination; anything a person
reads directly stays on plain anstream, which is what strips color on a pipe
and honors `NO_COLOR`.
**`--format=json` answers** go through `crate::output::print_json`, never a
hand-rolled `println!("{}", serde_json::to_string_pretty(&v)?)`. It serializes
pretty with one trailing newline and prints through anstream, so no
`--format=json` surface panics when its consumer stops reading. Before that,
thirty call sites had open-coded those two lines, and whether any one of them
panicked under `| head -3` came down to which `println!` its module happened to
import. `wt switch --format=json` is the one non-caller: it emits its single
result as one compact line (still through anstream's `println!`), because that
is what a shell loop reads.
**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 sWorktrunk release workflow. Use when user asks to "do a release", "release a new version", "cut a release", or wants to publish a new version to crates.io and GitHub.
Worktrunk-specific guidance for tend CI workflows. Adds codecov polling, Rust test commands, labels, and review criteria on top of the generic tend-* skills. Use when operating in CI.
Guidance for Worktrunk (the `wt` CLI) — git worktree management, hooks, and config. Load when working out which worktree a `wt` command will act on, or reaching for the global `-C <path>` to target one; editing .config/wt.toml or ~/.config/worktrunk/config.toml; adding, modifying, or debugging hooks (post-merge, post-start, pre-commit, pre-merge, post-switch, etc.); configuring commit message generation or command aliases; or troubleshooting wt behavior. Also answers general worktrunk/wt questions.
Create a new worktrunk worktree (optionally in another repo) and switch this session's working directory into it. Use when launching a session that should work in its own worktree.