Rules for how 37signals CLI commands accept their primary text input. Companion to RUBRIC.md — the rubric covers structural contract (output envelope, exit codes, discovery); this document covers how commands receive content from humans and agents.
These conventions apply to all content-creation commands — commands whose primary purpose is to create or send text: adding a todo, writing a journal entry, replying to a thread, composing a message.
When a command needs text input (a title, message body, content), resolve from these sources in order. First non-empty value wins:
- Named flag (
--title,--content,--message/-m) - Positional argument (trailing arg after any required positional IDs)
- Stdin (when piped — i.e., stdin is not a terminal)
- $EDITOR (when interactive and the command supports multi-line input)
If both a flag and a positional arg provide the same value, error — the command must not silently pick one over the other:
Error: --title and positional argument are mutually exclusive
If a creation/send command has exactly one "primary text" input, accept it as a trailing positional arg. The flag form remains canonical; the positional form is a shorthand.
# Fluent (positional)
app todo add "Buy milk"
app journal write "Today was great"
# Canonical (flag)
app todo add --title "Buy milk"
app journal write --content "Today was great"- The command has at most one "text" arg
- No ambiguity with other positional args (or disambiguation is trivial — e.g., YYYY-MM-DD is a date, anything else is content)
- The command already uses its positional slot(s) for required identifiers AND adding text creates parsing ambiguity
- The command requires multiple text inputs (e.g.,
composeneeds both--subjectand--message) - The command's required positional (like a topic ID) and the text arg can't be disambiguated by format
All content-creation commands read stdin when it's a pipe and no explicit text was given via flag or positional. This enables Unix pipeline composition:
echo "Buy milk" | app todo add
cat notes.md | app journal write
pbpaste | app reply 123Stdin resolution sits at position 3 in the chain — after flags and positional args, before $EDITOR.
Always, for any command that accepts a text body or content. Even short-label commands like todo add benefit — it enables scripting.
Only if the command has no text input at all (e.g., todo complete <id>).
Every primary text flag gets a one-letter shorthand. Pick the letter that matches the semantic:
| Semantic | Long flag | Short | Mnemonic |
|---|---|---|---|
| Short label/title | --title |
-t |
title |
| Message body | --message |
-m |
message |
| General content | --content |
-c |
content |
Don't normalize everything to --message — a todo title is not a message. Pick the name that matches the role.
When a positional arg could be either a date or content (e.g., journal write), disambiguate by format:
func isDateArg(s string) bool {
_, err := time.Parse("2006-01-02", s)
return err == nil
}YYYY-MM-DD parses as a date; anything else is content. These formats are disjoint — no ambiguity.
For two-positional commands (journal write 2024-01-15 "Content"), accept MaximumNArgs(2) and slot the first as date-if-parseable, second as content.
When no text arrives from any source, hint both forms:
Error: title is required
Hint: app todo add "Buy milk" or app todo add --title "Buy milk"
When both a flag and positional supply the same field:
Error: --title and positional argument are mutually exclusive
When stdin is a pipe but empty:
Error: no content provided (use --content to provide inline, or pipe to stdin)
Standard pattern for a command with positional + flag + stdin text input:
func newFooCommand() *fooCommand {
c := &fooCommand{}
c.cmd = &cobra.Command{
Use: "foo [text]",
RunE: c.run,
Args: cobra.MaximumNArgs(1),
}
c.cmd.Flags().StringVarP(&c.text, "text", "t", "", "The text")
return c
}
func (c *fooCommand) run(cmd *cobra.Command, args []string) error {
text := c.text
// 1. Conflict check
if text != "" && len(args) > 0 {
return ErrUsage("--text and positional argument are mutually exclusive")
}
// 2. Positional
if text == "" && len(args) > 0 {
text = args[0]
}
// 3. Stdin
if text == "" && !stdinIsTerminal() {
var err error
text, err = readStdin()
if err != nil {
return err
}
}
// 4. $EDITOR (optional, for multi-line content)
if text == "" && stdinIsTerminal() {
var err error
text, err = editor.Open("")
if err != nil {
return err
}
}
// 5. Nothing
if text == "" {
return ErrUsageHint("text is required",
"app foo \"hello\" or app foo --text \"hello\"")
}
// ... proceed with text
}Use this table to audit content-creation commands across all 37signals CLIs. Each command should support all applicable input sources.
| ID | Criterion | Applies to |
|---|---|---|
| I1 | Named flag with semantic name (--title, --message, --content) |
All content commands |
| I2 | Short flag (-t, -m, -c) |
All content commands |
| I3 | Positional shorthand (when unambiguous) | Commands with a single text input |
| I4 | Stdin | All content commands |
| I5 | $EDITOR fallback |
Commands accepting multi-line input |
| I6 | Flag/positional conflict error | Commands offering positional shorthand |
| I7 | Missing-text error with hint showing both forms | All content commands |
| Command | Text field | I1 | I2 | I3 | I4 | I5 | I6 | I7 |
|---|---|---|---|---|---|---|---|---|
todo add |
title | --title |
-t |
"text" |
pipe | — | yes | yes |
journal write |
content | --content |
-c |
"text" |
pipe | $EDITOR |
yes | yes |
reply |
message | --message |
-m |
— (slot used by topic-id) | pipe | $EDITOR |
— | yes |
compose |
message | --message |
-m |
— (multiple required flags) | pipe | $EDITOR |
— | yes |
Audit pending.
Audit pending.
These conventions are candidates for a future rubric criterion under Tier 1 (Agent Contract) or Tier 4 (Developer Experience). The audit table above tracks conformance until then. Proposed criterion:
1A.11 Text input resolution chain: Content-creation commands accept their primary text via named flag, positional shorthand (when unambiguous), stdin, and
$EDITOR(when applicable), in that priority order. Flag and positional conflict is an error.
- RUBRIC.md — structural contract (output, exit codes, discovery)
- MAKEFILE-CONVENTION.md — build targets
prompts/close-gap.md— agent prompt for closing rubric gaps