Skip to content

Commit 6f99340

Browse files
committed
Add CLI text input conventions and conformance audit
INPUT-CONVENTIONS.md standardizes how content-creation commands accept text: positional shorthand, stdin piping, short flags, and $EDITOR fallback, with a resolution chain, disambiguation patterns, and a cross-CLI conformance audit table (hey-cli filled in, basecamp-cli and fizzy-cli pending). Adds prompts/close-input-gap.md for agents closing convention gaps, mirroring the existing close-gap.md pattern for rubric gaps.
1 parent f3381c5 commit 6f99340

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

INPUT-CONVENTIONS.md

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# CLI Text Input Conventions
2+
3+
Rules for how 37signals CLI commands accept their primary text input. Companion to [RUBRIC.md](RUBRIC.md) — the rubric covers structural contract (output envelope, exit codes, discovery); this document covers how commands receive content from humans and agents.
4+
5+
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.
6+
7+
---
8+
9+
## The Resolution Chain
10+
11+
When a command needs text input (a title, message body, content), resolve from these sources in order. First non-empty value wins:
12+
13+
1. **Named flag** (`--title`, `--content`, `--message` / `-m`)
14+
2. **Positional argument** (trailing arg after any required positional IDs)
15+
3. **Stdin** (when piped — i.e., stdin is not a terminal)
16+
4. **$EDITOR** (when interactive and the command supports multi-line input)
17+
18+
If both a flag and a positional arg provide the same value, **error** — the command must not silently pick one over the other:
19+
20+
```
21+
Error: --title and positional argument are mutually exclusive
22+
```
23+
24+
---
25+
26+
## Convention 1: Positional Shorthand
27+
28+
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.
29+
30+
```bash
31+
# Fluent (positional)
32+
app todo add "Buy milk"
33+
app journal write "Today was great"
34+
35+
# Canonical (flag)
36+
app todo add --title "Buy milk"
37+
app journal write --content "Today was great"
38+
```
39+
40+
### When to offer positional shorthand
41+
42+
- The command has at most one "text" arg
43+
- No ambiguity with other positional args (or disambiguation is trivial — e.g., YYYY-MM-DD is a date, anything else is content)
44+
45+
### When NOT to offer positional shorthand
46+
47+
- The command already uses its positional slot(s) for required identifiers AND adding text creates parsing ambiguity
48+
- The command requires multiple text inputs (e.g., `compose` needs both `--subject` and `--message`)
49+
- The command's required positional (like a topic ID) and the text arg can't be disambiguated by format
50+
51+
---
52+
53+
## Convention 2: Stdin as Implicit Content
54+
55+
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:
56+
57+
```bash
58+
echo "Buy milk" | app todo add
59+
cat notes.md | app journal write
60+
pbpaste | app reply 123
61+
```
62+
63+
Stdin resolution sits at position 3 in the chain — after flags and positional args, before `$EDITOR`.
64+
65+
### When to offer stdin
66+
67+
Always, for any command that accepts a text body or content. Even short-label commands like `todo add` benefit — it enables scripting.
68+
69+
### When NOT to offer stdin
70+
71+
Only if the command has no text input at all (e.g., `todo complete <id>`).
72+
73+
---
74+
75+
## Convention 3: Short Flags
76+
77+
Every primary text flag gets a one-letter shorthand. Pick the letter that matches the semantic:
78+
79+
| Semantic | Long flag | Short | Mnemonic |
80+
|----------|-----------|-------|----------|
81+
| Short label/title | `--title` | `-t` | **t**itle |
82+
| Message body | `--message` | `-m` | **m**essage |
83+
| General content | `--content` | `-c` | **c**ontent |
84+
85+
Don't normalize everything to `--message` — a todo title is not a message. Pick the name that matches the role.
86+
87+
---
88+
89+
## Disambiguation Patterns
90+
91+
When a positional arg could be either a date or content (e.g., `journal write`), disambiguate by format:
92+
93+
```go
94+
func isDateArg(s string) bool {
95+
_, err := time.Parse("2006-01-02", s)
96+
return err == nil
97+
}
98+
```
99+
100+
YYYY-MM-DD parses as a date; anything else is content. These formats are disjoint — no ambiguity.
101+
102+
For two-positional commands (`journal write 2024-01-15 "Content"`), accept `MaximumNArgs(2)` and slot the first as date-if-parseable, second as content.
103+
104+
---
105+
106+
## Error Messages
107+
108+
### Missing text
109+
110+
When no text arrives from any source, hint both forms:
111+
112+
```
113+
Error: title is required
114+
Hint: app todo add "Buy milk" or app todo add --title "Buy milk"
115+
```
116+
117+
### Flag/positional conflict
118+
119+
When both a flag and positional supply the same field:
120+
121+
```
122+
Error: --title and positional argument are mutually exclusive
123+
```
124+
125+
### Empty stdin
126+
127+
When stdin is a pipe but empty:
128+
129+
```
130+
Error: no content provided (use --content to provide inline, or pipe to stdin)
131+
```
132+
133+
---
134+
135+
## Implementation Template
136+
137+
Standard pattern for a command with positional + flag + stdin text input:
138+
139+
```go
140+
func newFooCommand() *fooCommand {
141+
c := &fooCommand{}
142+
c.cmd = &cobra.Command{
143+
Use: "foo [text]",
144+
RunE: c.run,
145+
Args: cobra.MaximumNArgs(1),
146+
}
147+
c.cmd.Flags().StringVarP(&c.text, "text", "t", "", "The text")
148+
return c
149+
}
150+
151+
func (c *fooCommand) run(cmd *cobra.Command, args []string) error {
152+
text := c.text
153+
154+
// 1. Conflict check
155+
if text != "" && len(args) > 0 {
156+
return ErrUsage("--text and positional argument are mutually exclusive")
157+
}
158+
159+
// 2. Positional
160+
if text == "" && len(args) > 0 {
161+
text = args[0]
162+
}
163+
164+
// 3. Stdin
165+
if text == "" && !stdinIsTerminal() {
166+
var err error
167+
text, err = readStdin()
168+
if err != nil {
169+
return err
170+
}
171+
}
172+
173+
// 4. $EDITOR (optional, for multi-line content)
174+
if text == "" && stdinIsTerminal() {
175+
var err error
176+
text, err = editor.Open("")
177+
if err != nil {
178+
return err
179+
}
180+
}
181+
182+
// 5. Nothing
183+
if text == "" {
184+
return ErrUsageHint("text is required",
185+
"app foo \"hello\" or app foo --text \"hello\"")
186+
}
187+
188+
// ... proceed with text
189+
}
190+
```
191+
192+
---
193+
194+
## Conformance Audit
195+
196+
Use this table to audit content-creation commands across all 37signals CLIs. Each command should support all applicable input sources.
197+
198+
### Audit criteria
199+
200+
| ID | Criterion | Applies to |
201+
|----|-----------|-----------|
202+
| I1 | Named flag with semantic name (`--title`, `--message`, `--content`) | All content commands |
203+
| I2 | Short flag (`-t`, `-m`, `-c`) | All content commands |
204+
| I3 | Positional shorthand (when unambiguous) | Commands with a single text input |
205+
| I4 | Stdin | All content commands |
206+
| I5 | `$EDITOR` fallback | Commands accepting multi-line input |
207+
| I6 | Flag/positional conflict error | Commands offering positional shorthand |
208+
| I7 | Missing-text error with hint showing both forms | All content commands |
209+
210+
### Current status
211+
212+
#### hey-cli
213+
214+
| Command | Text field | I1 | I2 | I3 | I4 | I5 | I6 | I7 |
215+
|---------|-----------|----|----|----|----|----|----|-----|
216+
| `todo add` | title | `--title` | `-t` | `"text"` | pipe || yes | yes |
217+
| `journal write` | content | `--content` | `-c` | `"text"` | pipe | `$EDITOR` | yes | yes |
218+
| `reply` | message | `--message` | `-m` | — (slot used by topic-id) | pipe | `$EDITOR` || yes |
219+
| `compose` | message | `--message` | `-m` | — (multiple required flags) | pipe | `$EDITOR` || yes |
220+
221+
#### basecamp-cli
222+
223+
_Audit pending._
224+
225+
#### fizzy-cli
226+
227+
_Audit pending._
228+
229+
---
230+
231+
## Adding to the Rubric
232+
233+
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:
234+
235+
> **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.
236+
237+
---
238+
239+
## References
240+
241+
- [RUBRIC.md](RUBRIC.md) — structural contract (output, exit codes, discovery)
242+
- [MAKEFILE-CONVENTION.md](MAKEFILE-CONVENTION.md) — build targets
243+
- `prompts/close-gap.md` — agent prompt for closing rubric gaps

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ Usage in a workflow:
5151
cli-binary: ./dist/myapp
5252
```
5353
54-
## Rubric
54+
## Standards
55+
56+
### Rubric
5557
5658
[RUBRIC.md](RUBRIC.md) codifies design decisions from `basecamp-cli` into a reusable standard covering:
5759

@@ -60,6 +62,14 @@ Usage in a workflow:
6062
- **Tier 3** — Agent integration: skills, pagination, observability
6163
- **Tier 4** — Distribution & ecosystem: builds, testing, shell completion, DX
6264

65+
### Input Conventions
66+
67+
[INPUT-CONVENTIONS.md](INPUT-CONVENTIONS.md) standardizes how content-creation commands accept text input — positional shorthand, stdin piping, short flags, `$EDITOR` fallback — with a resolution chain, disambiguation patterns, and a cross-CLI conformance audit table.
68+
69+
### Makefile Convention
70+
71+
[MAKEFILE-CONVENTION.md](MAKEFILE-CONVENTION.md) standardizes Make targets across all CLI repos.
72+
6373
## Agent prompts
6474

6575
Reusable agent prompts in `prompts/`:
@@ -68,6 +78,7 @@ Reusable agent prompts in `prompts/`:
6878
|--------|---------|
6979
| `seed-cli.md` | Bootstrap a new CLI from the seed templates |
7080
| `close-gap.md` | Close a specific rubric gap in an existing CLI |
81+
| `close-input-gap.md` | Close a specific input convention gap in a content command |
7182

7283
## Skills
7384

prompts/close-input-gap.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Close an Input Convention Gap
2+
3+
You are closing a specific gap in a Go CLI's compliance with the 37signals CLI input conventions.
4+
5+
## Input
6+
7+
- **Criterion ID**: e.g., "I2" (short flag for primary text)
8+
- **Command**: e.g., "todo add", "journal write"
9+
- **CLI repo**: The repository you're working in
10+
11+
## Process
12+
13+
1. Read INPUT-CONVENTIONS.md to understand the criterion
14+
2. Read the command's current implementation
15+
3. Identify what's missing (positional? stdin? short flag? conflict check?)
16+
4. Implement the minimum change
17+
5. Add tests covering each input path and the conflict case
18+
6. Verify the change doesn't break the surface test
19+
20+
## Criterion Reference
21+
22+
| ID | What to implement |
23+
|----|-------------------|
24+
| I1 | Named flag with semantic name (`--title`, `--message`, `--content`) |
25+
| I2 | Short flag (`-t`, `-m`, `-c`) — use `StringVarP` instead of `StringVar` |
26+
| I3 | Positional shorthand — add `Args: cobra.MaximumNArgs(1)`, update `Use:`, resolve in `run()` |
27+
| I4 | Stdin — check `!stdinIsTerminal()`, call `readStdin()` |
28+
| I5 | `$EDITOR` fallback — call `editor.Open("")` when stdin is a terminal and no text given |
29+
| I6 | Flag/positional conflict — error when both flag and positional provide the same field |
30+
| I7 | Missing-text hint — `ErrUsageHint` showing both positional and flag forms |
31+
32+
## Implementation Pattern
33+
34+
See the "Implementation Template" section in INPUT-CONVENTIONS.md for the standard `run()` pattern.
35+
36+
## Test Pattern
37+
38+
For each command, test:
39+
40+
- Positional arg works: `app foo "text" --json`
41+
- Short flag works: `app foo -t "text" --json`
42+
- Long flag works: `app foo --title "text" --json`
43+
- Conflict errors: `app foo --title "X" "Y"` → "mutually exclusive"
44+
- Empty errors: `app foo` → "required" with hint
45+
- Stdin works: pipe content, verify it's used
46+
47+
Use `httptest.NewServer` to mock the API. Set `HEY_TOKEN` / `APP_TOKEN` env var for auth. Use `--base-url` to point at the test server.

0 commit comments

Comments
 (0)