Skip to content
16 changes: 14 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ Repeatable `hey reply --to`, `--cc` and `--bcc` flags add recipients to that env

Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` keeps HEY's original body HTML and frames each entry with its From, To, CC and BCC headers.

Writing is Markdown too, for message bodies, drafts, journal entries, snippets and contact notes: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown; for a contact note whose Markdown would drop part of it, the editor is refused and `--note-html` is the way to change it). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim; each pair is mutually exclusive. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it — for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML, CSS); any other is dropped. Clip passages, event notes and time track notes are plain text.
Writing is Markdown too, for message bodies, drafts, journal entries, snippets and contact notes: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown; for a journal entry or contact note whose Markdown would drop part of it, the editor is refused and `--content-html` or `--note-html` is the way to change it). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim; each pair is mutually exclusive. HEY serves a journal entry, a snippet's `content_html` and a contact's `note_html` inside its editor's `<div class="trix-content">` wrapper, so `--content-html` and `--note-html` take that wrapper off, and HTML read back and written again does not sink one level deeper each time. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it — for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML, CSS); any other is dropped. Clip passages, event notes and time track notes are plain text.

Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown, `hey draft edit` revises it (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the CLI has no flag for it, and HEY's API schedules only to a whole hour — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides.

Expand Down Expand Up @@ -636,10 +636,22 @@ never accepted.
hey journal list # list entries
hey journal list --starts-on 2026-01-01 --ends-on 2026-01-31
hey journal read # read today's entry (or pass YYYY-MM-DD)
hey journal read 2026-03-15 --jq '.data.content_markdown' # the entry as Markdown; write it back only if content_markdown_lossless is true
hey journal write "..." # write today's entry (omit content: $EDITOR at a terminal, else stdin)
```

`hey journal read --json` answers `content`, the entry's HTML as HEY serves it,
`content_markdown`, the entry as Markdown, and `content_markdown_lossless`, which says
whether that Markdown holds everything in the entry. A write replaces the whole entry. When
`content_markdown_lossless` is `true`, `hey journal write` writes the Markdown back as the
same entry. When it is `false`, the entry holds an attachment, an image, a table or other
markup Markdown has no syntax for, and writing Markdown would drop it: change `content` and
write it back with `--content-html` instead, which takes off the wrapper HEY serves it in.

Saving an empty buffer in `$EDITOR` removes the day's entry, and `hey journal write` says
so rather than reporting a save. An empty day answers with an empty entry, so if the read
that pre-fills the editor fails for any other reason the command stops there instead of
opening a blank buffer over an entry it could not see.
opening a blank buffer over an entry it could not see. Nor is `$EDITOR` opened on an entry whose
`content_markdown_lossless` is `false`: saving its Markdown would drop the attachment or
image it holds, so the command refuses and writes nothing, and the entry is changed through
`content` and `--content-html` instead.
51 changes: 40 additions & 11 deletions internal/cmd/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func newJournalCommand() *journalCommand {
Use: "journal",
Short: "Read and write journal entries",
Annotations: map[string]string{
"agent_notes": "Subcommands: list, read, write. Read defaults to today. Write accepts --content, stdin, or opens $EDITOR; content is Markdown, or raw HTML via --content-html.",
"agent_notes": "Subcommands: list, read, write. Read defaults to today; its JSON answers content (HTML as HEY serves it), content_markdown (the form write takes) and content_markdown_lossless; when that is false, change content and write it with --content-html instead. Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR (refused for an entry whose Markdown is not lossless); content is Markdown, or raw HTML via --content-html.",
},
}

Expand Down Expand Up @@ -131,10 +131,19 @@ func newJournalReadCommand() *journalReadCommand {
journalReadCommand.cmd = &cobra.Command{
Use: "read [date]",
Short: "Read a journal entry (default: today)",
Long: `Read a journal entry, today's by default.

JSON answers content, the entry's HTML as HEY serves it; content_markdown, the entry as
Markdown; and content_markdown_lossless, which says whether that Markdown holds everything in
the entry. Write content_markdown back with hey journal write only when
content_markdown_lossless is true. When it is false, the entry holds an attachment, an image
or other markup Markdown cannot carry, and writing the Markdown would drop it: change content
and write it back with hey journal write --content-html instead.`,
Example: ` hey journal read
hey journal read 2026-03-15
hey journal read --html > entry.html
hey journal read --json`,
hey journal read --json
hey journal read 2026-03-15 --jq '.data.content_markdown'`,
Comment thread
robzolkos marked this conversation as resolved.
RunE: journalReadCommand.run,
Args: cobra.MaximumNArgs(1),
}
Expand Down Expand Up @@ -185,7 +194,15 @@ func (c *journalReadCommand) run(cmd *cobra.Command, args []string) error {
return nil
}

return writeOK(map[string]string{"date": date, "content": content},
// content_markdown_lossless says whether the Markdown can be written back in place of
// the entry: one holding an attachment, an image or anything else Markdown cannot
// carry has to be changed as HTML instead.
return writeOK(map[string]any{
"date": date,
"content": content,
"content_markdown": htmlutil.ToMarkdown(content),
"content_markdown_lossless": htmlutil.MarkdownIsLossless(content),
},
output.WithSummary(fmt.Sprintf("Journal entry for %s", date)),
output.WithBreadcrumbs(output.Breadcrumb{
Action: "write",
Expand All @@ -212,8 +229,11 @@ func newJournalWriteCommand() *journalWriteCommand {

Content that trims to nothing — whitespace-only, or an emptied $EDITOR buffer — removes the
day's entry, and the command says "removed" rather than "saved". Omitting content reads
stdin when it is not a terminal, and otherwise opens $EDITOR on the day's existing entry; if
that entry cannot be read the command stops rather than opening a blank buffer over it.`,
stdin when it is not a terminal, and otherwise opens $EDITOR on the day's existing entry as
Markdown; if that entry cannot be read the command stops rather than opening a blank buffer
over it. An entry holding an attachment, an image or other content Markdown cannot carry is
not opened in $EDITOR, since saving it would drop that content: change the entry's HTML
(content in hey journal read --json) and write it back with --content-html.`,
Example: ` hey journal write "Shipped the pagination fix and paired with Jane on the cover art."
hey journal write 2026-03-15 "Retrospective: the migration took two days longer than planned."
hey journal write -c "Reviewed the Q3 numbers with Alice."
Expand All @@ -223,7 +243,7 @@ that entry cannot be read the command stops rather than opening a blank buffer o
}

journalWriteCommand.cmd.Flags().StringVarP(&journalWriteCommand.content, "content", "c", "", "Journal content as Markdown (or opens $EDITOR)")
journalWriteCommand.cmd.Flags().StringVar(&journalWriteCommand.contentHTML, "content-html", "", "Journal content as raw HTML instead of Markdown")
journalWriteCommand.cmd.Flags().StringVar(&journalWriteCommand.contentHTML, "content-html", "", "Journal content as raw HTML instead of Markdown; the trix-content wrapper HEY serves an entry in is taken off")
journalWriteCommand.cmd.MarkFlagsMutuallyExclusive("content", "content-html")

return journalWriteCommand
Expand Down Expand Up @@ -272,7 +292,9 @@ func (c *journalWriteCommand) run(cmd *cobra.Command, args []string) error {
}

if c.contentHTML != "" {
content = strings.TrimSpace(c.contentHTML)
// HTML read back from HEY carries its editor wrapper; writing it back as it is
// would nest the entry one level deeper on every round trip.
content = htmlutil.UnwrapTrixContent(strings.TrimSpace(c.contentHTML))
} else {
if content == "" && !stdinIsTerminal() {
piped, err := readStdin()
Expand Down Expand Up @@ -317,16 +339,23 @@ func (c *journalWriteCommand) run(cmd *cobra.Command, args []string) error {

type journalContentFetcher func(context.Context, string) (string, error)

// journalEntryFromEditor opens $EDITOR on the day's entry. A read that fails is fatal:
// an empty day answers 204 as an empty string, so anything else means we do not know
// what the day holds -- and saving an empty editor over it would replace the entry.
// journalEntryFromEditor prefills $EDITOR with the day's entry as Markdown — the same
// form the edited result is saved in.
// form the edited result is saved in. A read that fails is fatal: an empty day answers
// 204 as an empty string, so anything else means we do not know what the day holds --
// and saving an empty editor over it would replace the entry. So is an entry holding an
// attachment, an image or anything else Markdown cannot carry, because saving the
// Markdown would drop it; that entry is changed as HTML instead.
func journalEntryFromEditor(ctx context.Context, date string, fetch journalContentFetcher, open func(string) (string, error)) (string, error) {
existing, err := fetch(ctx, date)
if err != nil {
return "", apierr.FromSDK(err)
}
if !htmlutil.MarkdownIsLossless(existing) {
Comment thread
robzolkos marked this conversation as resolved.
return "", apierr.ErrUsageHint(
fmt.Sprintf("the journal entry for %s holds an attachment or other markup Markdown cannot carry, so editing it as Markdown would drop it", date),
fmt.Sprintf("Change its HTML instead: read it with `hey journal read %s --jq '.data.content'` and write it back with `hey journal write %s --content-html '<the changed HTML>'`", date, date),
)
}
edited, err := open(htmlutil.ToMarkdown(existing).String())
if err != nil {
return "", apierr.ErrAPI(0, fmt.Sprintf("could not open editor: %v", err))
Expand Down
Loading
Loading