From 2888af63f039b6752a0013fd39908abe10820d85 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:50:35 -0400 Subject: [PATCH 1/7] Stop journal entries and snippets nesting deeper on every HTML round trip HEY serves a journal entry's content_html and a snippet's content_html through trix_html_for_rich_text_editing, and both are rich text attributes, so each read comes wrapped in Action Text's
layout. hey journal read --json answers that HTML as content, and hey snippet list answers it as content_html. Writing either back with --content-html stored the wrapper as part of the entry, and the next read wrapped it again: the entry sank one div deeper on every trip. journal write and snippet create/update now take the wrapper off --content-html with htmlutil.UnwrapTrixContent, as contact note set does for --note-html. A div of the writer's own, or the wrapper's class anywhere but the top level, is left alone. hey journal read --json also answers content_markdown, the entry as Markdown, which is the form journal write takes by default, so an entry can be read, changed and written back without handling HTML at all. --message-html needs nothing: a message's content is served from the entry's Action Text body rather than the rich text record, so HEY answers it without the layout, and draft show answers Markdown only. --- docs/cli.md | 7 +- internal/cmd/journal.go | 11 ++- internal/cmd/journal_test.go | 152 +++++++++++++++++++++++++++++++++++ internal/cmd/snippet.go | 20 +++-- internal/cmd/snippet_test.go | 121 ++++++++++++++++++++++++++++ skills/hey/SKILL.md | 7 +- 6 files changed, 303 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 196bf184..1e47a107 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 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. HEY serves a journal entry, a snippet's `content_html` and a contact's `note_html` inside its editor's `
` 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. @@ -636,9 +636,14 @@ 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, to edit and write back 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, and +`content_markdown`, the entry as Markdown, which `hey journal write` writes back as the same +entry. A write replaces the whole entry. + 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 diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index befc0945..b6ca7e6c 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -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) and content_markdown (the form write takes). Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR; content is Markdown, or raw HTML via --content-html.", }, } @@ -134,7 +134,8 @@ func newJournalReadCommand() *journalReadCommand { 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'`, RunE: journalReadCommand.run, Args: cobra.MaximumNArgs(1), } @@ -185,7 +186,7 @@ func (c *journalReadCommand) run(cmd *cobra.Command, args []string) error { return nil } - return writeOK(map[string]string{"date": date, "content": content}, + return writeOK(map[string]any{"date": date, "content": content, "content_markdown": htmlutil.ToMarkdown(content)}, output.WithSummary(fmt.Sprintf("Journal entry for %s", date)), output.WithBreadcrumbs(output.Breadcrumb{ Action: "write", @@ -272,7 +273,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() diff --git a/internal/cmd/journal_test.go b/internal/cmd/journal_test.go index 836adef7..d34ae607 100644 --- a/internal/cmd/journal_test.go +++ b/internal/cmd/journal_test.go @@ -9,9 +9,11 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "sync/atomic" "testing" + "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/output" ) @@ -371,3 +373,153 @@ func TestJournalReadReturns204NoFallbackContent(t *testing.T) { t.Errorf("summary = %q, want to contain %q", resp.Summary, "No journal entry") } } + +// An entry as HEY's web editor saves it; journalStore answers it the way +// _journal_entry.jbuilder does, with content_html inside Action Text's layout. +const webEditedJournalStored = "
Shipped the pagination fix

\n
    \n
  • Paired with Jane on the cover art
  • \n
" + +// journalStore stands in for HEY's journal: it keeps what was written and serves it back +// wrapped for the editor, as every read of it does. +type journalStore struct { + mu sync.Mutex + stored string + writes []string +} + +func newJournalStore(t *testing.T, stored string) (*httptest.Server, *journalStore) { + t.Helper() + store := &journalStore{stored: stored} + server := httptest.NewServer(http.HandlerFunc(store.serve)) + t.Cleanup(server.Close) + return server, store +} + +func (s *journalStore) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + if r.URL.Path != "/calendar/days/2026-03-15/journal_entry.json" { + http.NotFound(w, r) + return + } + switch r.Method { + case http.MethodGet: + case http.MethodPatch: + var body struct { + CalendarJournalEntry struct { + Content string `json:"content"` + } `json:"calendar_journal_entry"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + s.writes = append(s.writes, body.CalendarJournalEntry.Content) + s.stored = body.CalendarJournalEntry.Content + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if s.stored == "" { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "type": "Calendar::JournalEntry", + "starts_at": "2026-03-15T00:00:00Z", + "content": htmlutil.ToText(s.stored), + "content_html": "
\n " + s.stored + "\n
\n", + }) +} + +func (s *journalStore) snapshot() (stored string, writes []string) { + s.mu.Lock() + defer s.mu.Unlock() + return s.stored, append([]string(nil), s.writes...) +} + +func readJournalEntry(t *testing.T, server *httptest.Server) map[string]any { + t.Helper() + resp, err := runJournalRead(t, server, "2026-03-15") + if err != nil { + t.Fatalf("journal read: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v, want the entry", resp.Data) + } + return data +} + +// content is served inside HEY's editor wrapper, and writing it back used to store the +// wrapper too, so the entry sank one div deeper on every round trip. +func TestJournalHTMLRoundTripDoesNotNest(t *testing.T) { + server, store := newJournalStore(t, webEditedJournalStored) + for range 3 { + content, _ := readJournalEntry(t, server)["content"].(string) + if _, err := runJournalWrite(t, server, "2026-03-15", "--content-html", content+"
Reviewed the Q3 numbers with Alice
"); err != nil { + t.Fatal(err) + } + } + stored, writes := store.snapshot() + for _, written := range writes { + if strings.Contains(written, "trix-content") { + t.Errorf("wrote %q, want HEY's wrapper taken off", written) + } + } + if strings.Count(stored, "Reviewed the Q3 numbers") != 3 || !strings.Contains(stored, "Shipped") { + t.Errorf("stored = %q, want the entry and every addition", stored) + } + content, _ := readJournalEntry(t, server)["content"].(string) + if strings.Count(content, "trix-content") != 1 { + t.Errorf("content = %q, want one wrapper", content) + } +} + +// Divs of the writer's own are theirs: one named like the wrapper, one inside another, +// and even the wrapper's class where it does not stand at the top level. +func TestJournalWriteKeepsTheWritersOwnDivs(t *testing.T) { + server, store := newJournalStore(t, "") + own := `
Retrospective: the migration took two days longer than planned.
` + + `
Quoted from the offsite notes
` + if _, err := runJournalWrite(t, server, "2026-03-15", "--content-html", own); err != nil { + t.Fatal(err) + } + if stored, _ := store.snapshot(); stored != own { + t.Errorf("stored = %q, want %q", stored, own) + } +} + +func TestJournalReadAnswersTheEntryAsMarkdown(t *testing.T) { + server, _ := newJournalStore(t, webEditedJournalStored) + entry := readJournalEntry(t, server) + if want := "
\n " + webEditedJournalStored + "\n
\n"; entry["content"] != want { + t.Errorf("content = %q, want it as HEY served it", entry["content"]) + } + if want := "**Shipped** the pagination fix \n\n- Paired with Jane on the cover art"; entry["content_markdown"] != want { + t.Errorf("content_markdown = %q, want %q", entry["content_markdown"], want) + } +} + +// content_markdown is what journal write takes, so it goes round without loss. +func TestJournalMarkdownWritesBackWithoutLoss(t *testing.T) { + server, store := newJournalStore(t, webEditedJournalStored) + first, _ := readJournalEntry(t, server)["content_markdown"].(string) + if _, err := runJournalWrite(t, server, "2026-03-15", first); err != nil { + t.Fatal(err) + } + settled, _ := readJournalEntry(t, server)["content_markdown"].(string) + for range 2 { + if _, err := runJournalWrite(t, server, "2026-03-15", settled); err != nil { + t.Fatal(err) + } + if again, _ := readJournalEntry(t, server)["content_markdown"].(string); again != settled { + t.Fatalf("content_markdown = %q, want it unchanged at %q", again, settled) + } + } + if _, writes := store.snapshot(); writes[1] != writes[0] || writes[2] != writes[0] { + t.Errorf("writes = %q, want the same HTML each time", writes) + } +} diff --git a/internal/cmd/snippet.go b/internal/cmd/snippet.go index eca172d9..87b0cc64 100644 --- a/internal/cmd/snippet.go +++ b/internal/cmd/snippet.go @@ -160,10 +160,7 @@ func (c *snippetCreateCommand) run(cmd *cobra.Command, _ []string) error { if name == "" { return apierr.ErrUsage("--name is required") } - content := c.contentHTML - if content == "" { - content = htmlutil.FromMarkdown(c.content) - } + content := snippetContent(c.content, c.contentHTML) if strings.TrimSpace(content) == "" { return apierr.ErrUsage("--content is required") } @@ -220,10 +217,7 @@ func (c *snippetUpdateCommand) run(cmd *cobra.Command, args []string) error { return apierr.ErrUsage("--name cannot be empty") } } - content := c.contentHTML - if content == "" { - content = htmlutil.FromMarkdown(c.content) - } + content := snippetContent(c.content, c.contentHTML) if contentChanged && strings.TrimSpace(content) == "" { return apierr.ErrUsage("--content cannot be empty") } @@ -233,6 +227,16 @@ func (c *snippetUpdateCommand) run(cmd *cobra.Command, args []string) error { return writeMutation(cmd, fmt.Sprintf("Snippet %d updated", snippetID), map[string]any{"id": snippetID}) } +// snippetContent is the snippet body to send: the Markdown converted, or the raw HTML +// less the editor wrapper HEY serves content_html in, which writing back as it is would +// nest one level deeper on every round trip. +func snippetContent(markdown, rawHTML string) string { + if rawHTML != "" { + return htmlutil.UnwrapTrixContent(rawHTML) + } + return htmlutil.FromMarkdown(markdown) +} + type snippetDeleteCommand struct { cmd *cobra.Command } diff --git a/internal/cmd/snippet_test.go b/internal/cmd/snippet_test.go index 2f63e758..8c04e865 100644 --- a/internal/cmd/snippet_test.go +++ b/internal/cmd/snippet_test.go @@ -1,12 +1,18 @@ package cmd import ( + "encoding/json" "io" + "maps" "net/http" + "slices" "strings" + "sync" "testing" "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/htmlutil" ) const snippetsJSON = `[ @@ -218,3 +224,118 @@ func snippetMutationHandler(t *testing.T, method, path string, validate func(*ht w.WriteHeader(http.StatusFound) }) } + +// snippetStore stands in for HEY's snippets: it keeps what was written and lists it the +// way _snippet.jbuilder does, with content_html inside Action Text's layout. +type snippetStore struct { + mu sync.Mutex + snippets map[int64]string + nextID int64 + writes []string +} + +func newSnippetStore(t *testing.T) (http.Handler, *snippetStore) { + t.Helper() + store := &snippetStore{snippets: map[int64]string{44: "
Office hours are Monday through Thursday.
"}, nextID: 45} + return http.HandlerFunc(store.serve), store +} + +func (s *snippetStore) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/snippets.json": + listed := make([]generated.Snippet, 0, len(s.snippets)) + for _, id := range slices.Sorted(maps.Keys(s.snippets)) { + listed = append(listed, generated.Snippet{ + Id: id, + Name: "Office hours", + Content: htmlutil.ToText(s.snippets[id]), + ContentHtml: "
\n " + s.snippets[id] + "\n
\n", + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(listed) + case r.Method == http.MethodPost && r.URL.Path == "/snippets": + s.save(w, r, s.nextID) + s.nextID++ + case r.Method == http.MethodPatch && r.URL.Path == "/snippets/44": + s.save(w, r, 44) + default: + http.NotFound(w, r) + } +} + +func (s *snippetStore) save(w http.ResponseWriter, r *http.Request, id int64) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + content := r.PostForm.Get("snippet[content]") + s.writes = append(s.writes, content) + s.snippets[id] = content + w.Header().Set("Location", "/snippets") + w.WriteHeader(http.StatusFound) +} + +func (s *snippetStore) snapshot() (snippets map[int64]string, writes []string) { + s.mu.Lock() + defer s.mu.Unlock() + return maps.Clone(s.snippets), append([]string(nil), s.writes...) +} + +func (s *snippetStore) newest() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Max(slices.Collect(maps.Keys(s.snippets))) +} + +func listedSnippetHTML(t *testing.T, handler http.Handler, id int64) string { + t.Helper() + response, err := runJSONCommand(t, handler, "snippet", "list") + if err != nil { + t.Fatalf("snippet list: %v", err) + } + for _, item := range response.Data.([]any) { + snippet := item.(map[string]any) + if snippet["id"] == float64(id) { + return snippet["content_html"].(string) + } + } + t.Fatalf("snippet %d not listed in %#v", id, response.Data) + return "" +} + +// content_html is served inside HEY's editor wrapper, and writing it back used to store +// the wrapper too, so the snippet sank one div deeper on every round trip. Each trip reads +// the newest snippet: the one updated, or the copy just created from the one before it. +func TestSnippetHTMLRoundTripDoesNotNest(t *testing.T) { + for name, args := range map[string][]string{ + "update": {"snippet", "update", "44"}, + "create": {"snippet", "create", "--name", "Office hours"}, + } { + t.Run(name, func(t *testing.T) { + handler, store := newSnippetStore(t) + for range 3 { + content := listedSnippetHTML(t, handler, store.newest()) + if _, err := runJSONCommand(t, handler, append(args, "--content-html", content+"
Closed on Fridays.
")...); err != nil { + t.Fatal(err) + } + } + newest := store.newest() + snippets, writes := store.snapshot() + for _, written := range writes { + if strings.Contains(written, "trix-content") { + t.Errorf("wrote %q, want HEY's wrapper taken off", written) + } + } + if last := snippets[newest]; strings.Count(last, "Closed on Fridays.") != 3 || !strings.Contains(last, "Monday through Thursday") { + t.Errorf("snippet %d = %q, want the content and every addition", newest, last) + } + if listed := listedSnippetHTML(t, handler, newest); strings.Count(listed, "trix-content") != 1 { + t.Errorf("content_html = %q, want one wrapper", listed) + } + }) + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index d6433bdd..f1aeb470 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -273,7 +273,7 @@ postings, not the box, label or contact around them. | List time track categories | `hey timetrack categories --json` | | Create time track category | `hey timetrack category create "Client work"` | | List journal entries | `hey journal list --json` | -| Read journal entry | `hey journal read 2024-03-15 --json` | +| Read journal entry | `hey journal read 2024-03-15 --json` (`content_markdown` is the form `journal write` takes) | | Write journal entry | `hey journal write "Shipped the pagination fix."` (whitespace-only content removes the entry) | | Check auth status | `hey auth status --json` | | Print bearer token | `hey auth token` (refuses a `--cookie` login) | @@ -582,7 +582,9 @@ are plain text). To send raw HTML instead, use the flag's HTML twin: `--message-html` on `compose`, `reply`, `forward`, `draft edit` and `bulk-reply send`; `--content-html` on `journal write` and `snippet create`/`update`; `--note-html` on `contact note set`. Each pair is mutually -exclusive. A fenced code block's language (` ```ruby `) survives the conversion for the +exclusive. `--content-html` and `--note-html` take off the `
` +wrapper HEY serves journal entries, snippets and notes in, so HTML read back can be written +again without nesting. A fenced code block's language (` ```ruby `) survives the conversion for the languages HEY highlights — Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML and CSS; any other is dropped. @@ -973,6 +975,7 @@ accepted. An existing file needs `--force`. ```bash hey journal list --json # Entries on the personal calendar, 4 years back to 1 year ahead hey journal read 2026-03-15 --json # Read entry by date +hey journal read 2026-03-15 --jq '.data.content_markdown' # The entry as Markdown, ready to edit and write back 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 # $EDITOR at a terminal; otherwise the entry is read from stdin From 17bc1d64fc7900322de25dbce865b0616843a0b6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:58:17 -0400 Subject: [PATCH 2/7] Say when a journal entry's Markdown would lose part of it A journal entry can hold what Markdown has no syntax for: HEY's web editor attaches files and images to one. Writing content_markdown back in its place would drop them, the same risk contact notes carry. hey journal read --json now answers content_markdown_lossless, as contact note show answers note_markdown_lossless. When it is false, the way to add to an entry is to change content and write it with --content-html, which takes HEY's wrapper off. The docs and the skill say so, with a recipe for each case. --- docs/cli.md | 10 ++++++--- internal/cmd/journal.go | 12 +++++++++-- internal/cmd/journal_test.go | 42 +++++++++++++++++++++++++++++++++++- skills/embed_test.go | 21 ++++++++++++++++++ skills/hey/SKILL.md | 25 ++++++++++++++++++++- 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 1e47a107..60a60893 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -640,9 +640,13 @@ hey journal read 2026-03-15 --jq '.data.content_markdown' # the entry as Markdo 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, and -`content_markdown`, the entry as Markdown, which `hey journal write` writes back as the same -entry. A write replaces the whole entry. +`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 diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index b6ca7e6c..1d27a67d 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -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; its JSON answers content (HTML as HEY serves it) and content_markdown (the form write takes). Write replaces the whole entry and 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; content is Markdown, or raw HTML via --content-html.", }, } @@ -186,7 +186,15 @@ func (c *journalReadCommand) run(cmd *cobra.Command, args []string) error { return nil } - return writeOK(map[string]any{"date": date, "content": content, "content_markdown": htmlutil.ToMarkdown(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", diff --git a/internal/cmd/journal_test.go b/internal/cmd/journal_test.go index d34ae607..0ceb519f 100644 --- a/internal/cmd/journal_test.go +++ b/internal/cmd/journal_test.go @@ -498,7 +498,7 @@ func TestJournalReadAnswersTheEntryAsMarkdown(t *testing.T) { if want := "
\n " + webEditedJournalStored + "\n
\n"; entry["content"] != want { t.Errorf("content = %q, want it as HEY served it", entry["content"]) } - if want := "**Shipped** the pagination fix \n\n- Paired with Jane on the cover art"; entry["content_markdown"] != want { + if want := "**Shipped** the pagination fix\n\n- Paired with Jane on the cover art"; entry["content_markdown"] != want { t.Errorf("content_markdown = %q, want %q", entry["content_markdown"], want) } } @@ -523,3 +523,43 @@ func TestJournalMarkdownWritesBackWithoutLoss(t *testing.T) { t.Errorf("writes = %q, want the same HTML each time", writes) } } + +// A journal entry can hold what Markdown cannot carry — HEY's web editor attaches files +// and images to one — so content_markdown_lossless says so, and a figure survives being +// changed as HTML. +const attachedJournalStored = `
Offsite agenda, signed off:
offsite-agenda.pdf
` + +func TestJournalReadSaysWhenItsMarkdownIsLossless(t *testing.T) { + for _, tt := range []struct { + name string + stored string + want bool + }{ + {name: "an entry from HEY's editor", stored: webEditedJournalStored, want: true}, + {name: "an entry with an attachment", stored: attachedJournalStored, want: false}, + } { + t.Run(tt.name, func(t *testing.T) { + server, _ := newJournalStore(t, tt.stored) + if lossless, present := readJournalEntry(t, server)["content_markdown_lossless"]; !present || lossless != tt.want { + t.Errorf("content_markdown_lossless = %#v (present %v), want %v", lossless, present, tt.want) + } + }) + } +} + +func TestJournalEntryWithAnAttachmentKeepsItWhenChangedAsHTML(t *testing.T) { + server, store := newJournalStore(t, attachedJournalStored) + content, _ := readJournalEntry(t, server)["content"].(string) + if _, err := runJournalWrite(t, server, "2026-03-15", "--content-html", content+"
Booked the venue for the second day.
"); err != nil { + t.Fatal(err) + } + stored, _ := store.snapshot() + attachments := htmlutil.ExtractAttachments(stored) + if len(attachments) != 1 || attachments[0].Filename != "offsite-agenda.pdf" || attachments[0].URL != "/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnt9fQ--9c2d/offsite-agenda.pdf" || + !strings.Contains(stored, "Booked the venue for the second day.") { + t.Errorf("stored = %q, want the attachment and the addition", stored) + } + if strings.Contains(stored, "trix-content") { + t.Errorf("stored = %q, want HEY's wrapper taken off", stored) + } +} diff --git a/skills/embed_test.go b/skills/embed_test.go index 12c11c1e..56d032c1 100644 --- a/skills/embed_test.go +++ b/skills/embed_test.go @@ -83,3 +83,24 @@ func TestHeySkillAddsToAContactNoteWithoutLosingIt(t *testing.T) { } } } + +// Writing a journal entry replaces it, so the skill's recipe for adding to one must not +// write back Markdown that has lost part of the entry, nor write after a failed read. +func TestHeySkillAddsToAJournalEntryWithoutLosingIt(t *testing.T) { + data, err := FS.ReadFile("hey/SKILL.md") + if err != nil { + t.Fatal(err) + } + content := string(data) + + for _, want := range []string{ + "content_markdown_lossless", + "entry=$(hey journal read 2026-03-15 --jq '.data.content_markdown') &&", + "entry=$(hey journal read 2026-03-15 --jq '.data.content') &&", + `hey journal write 2026-03-15 --content-html "$entry`, + } { + if !strings.Contains(content, want) { + t.Errorf("embedded HEY skill does not contain %q", want) + } + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index f1aeb470..14f5c6dc 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -273,7 +273,7 @@ postings, not the box, label or contact around them. | List time track categories | `hey timetrack categories --json` | | Create time track category | `hey timetrack category create "Client work"` | | List journal entries | `hey journal list --json` | -| Read journal entry | `hey journal read 2024-03-15 --json` (`content_markdown` is the form `journal write` takes) | +| Read journal entry | `hey journal read 2024-03-15 --json` (`content_markdown` is the form `journal write` takes when `content_markdown_lossless` is true) | | Write journal entry | `hey journal write "Shipped the pagination fix."` (whitespace-only content removes the entry) | | Check auth status | `hey auth status --json` | | Print bearer token | `hey auth token` (refuses a `--cookie` login) | @@ -987,6 +987,29 @@ rather than "saved"; only do that when removal is the intent. (A literal `""` is no content and falls through to stdin or `$EDITOR`.) A day with no entry is not an error: `--json` answers `ok` with the summary "No journal entry for " and no `data`. +**A journal entry is written whole**, so to add to one, read it, change it and write all of +it back. `hey journal read --json` answers `content` (the HTML as HEY serves it), +`content_markdown` and `content_markdown_lossless`. When `content_markdown_lossless` is +`true`, add to the Markdown: + +```bash +entry=$(hey journal read 2026-03-15 --jq '.data.content_markdown') && + printf '%s\n\nBooked the venue for the second day.\n' "$entry" | hey journal write 2026-03-15 +``` + +When it is `false`, the entry holds an attachment or other markup Markdown cannot carry, and +writing Markdown would drop it. Add to the HTML instead — `--content-html` takes off the +wrapper HEY serves the entry in, so this does not nest: + +```bash +entry=$(hey journal read 2026-03-15 --jq '.data.content') && + hey journal write 2026-03-15 --content-html "$entry

Booked the venue for the second day.

" +``` + +Keep the `&&`: a failed read must not go on to write. A day with no entry answers no `data`, +which `--jq` prints as `null`, so check for that before adding to it, or the entry starts +with the word null. + ### Authentication Data commands use the credentials HEY already stores and refresh expiring OAuth tokens automatically. Run the requested data command without a login preflight. Use `hey auth status --json` when the user asks for authentication status or when an explicit authentication check helps diagnose a failure; it reports whether credentials are available without changing them. From 7091dd59fa9e54fe77931d23e1d1a6895ff0b04e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:04:58 -0400 Subject: [PATCH 3/7] Refuse to open $EDITOR on a journal entry Markdown cannot carry hey journal write with no content, at a terminal, opens $EDITOR on the day's entry as Markdown and saves what comes back. For an entry holding an attachment, an image or anything else Markdown has no syntax for, saving dropped it. The editor is no longer opened on such an entry: the command answers a usage error that says why and points at changing content and writing it back with --content-html, and writes nothing. It is the same test that sets content_markdown_lossless. A lossless entry and an empty day open the editor as before. --- docs/cli.md | 5 +++- internal/cmd/journal.go | 23 ++++++++++----- internal/cmd/journal_test.go | 57 ++++++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 3 +- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 60a60893..cae743d2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -651,4 +651,7 @@ write it back with `--content-html` instead, which takes off the wrapper HEY ser 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. diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index 1d27a67d..3c4ad80e 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -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; 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; 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.", }, } @@ -221,8 +221,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." @@ -328,16 +331,22 @@ 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) { + return "", apierr.ErrUsageHint( + fmt.Sprintf("the journal entry for %s holds an attachment or other content Markdown cannot carry, so editing it as Markdown would drop it", date), + fmt.Sprintf("change the entry's HTML and write it back: entry=$(hey journal read %s --jq '.data.content') && hey journal write %s --content-html \"$entry...\"", date, date)) + } edited, err := open(htmlutil.ToMarkdown(existing).String()) if err != nil { return "", apierr.ErrAPI(0, fmt.Sprintf("could not open editor: %v", err)) diff --git a/internal/cmd/journal_test.go b/internal/cmd/journal_test.go index 0ceb519f..314fadaa 100644 --- a/internal/cmd/journal_test.go +++ b/internal/cmd/journal_test.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "testing" + "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/output" ) @@ -563,3 +564,59 @@ func TestJournalEntryWithAnAttachmentKeepsItWhenChangedAsHTML(t *testing.T) { t.Errorf("stored = %q, want HEY's wrapper taken off", stored) } } + +// Saving the Markdown of an entry that holds an attachment would drop the attachment, so +// the editor is not opened on one, and nothing is written. +func TestJournalEntryFromEditorRefusesAnEntryMarkdownCannotCarry(t *testing.T) { + opened := false + _, err := journalEntryFromEditor(t.Context(), "2026-03-15", + func(context.Context, string) (string, error) { + return "
\n " + attachedJournalStored + "\n
\n", nil + }, + func(string) (string, error) { + opened = true + return "", nil + }) + + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Hint, "--content-html") { + t.Fatalf("error = %#v, want a usage error pointing at --content-html", err) + } + if opened { + t.Error("the editor was opened on an entry its Markdown cannot carry") + } +} + +func TestJournalWriteAtATerminalRefusesAnEntryMarkdownCannotCarry(t *testing.T) { + previous := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = previous }) + // An editor that saves what it was handed, so a missing guard writes rather than hangs. + t.Setenv("EDITOR", "true") + + server, store := newJournalStore(t, attachedJournalStored) + _, err := runJournalWrite(t, server, "2026-03-15") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage { + t.Fatalf("error = %#v, want a usage error", err) + } + if stored, writes := store.snapshot(); len(writes) != 0 || stored != attachedJournalStored { + t.Errorf("writes = %q, stored = %q, want the entry untouched", writes, stored) + } +} + +func TestJournalEntryFromEditorOpensAnEmptyDay(t *testing.T) { + prefilled := "unset" + content, err := journalEntryFromEditor(t.Context(), "2026-03-15", + func(context.Context, string) (string, error) { return "", nil }, + func(existing string) (string, error) { + prefilled = existing + return "Booked the venue for the offsite.", nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefilled != "" || content != "Booked the venue for the offsite." { + t.Errorf("prefilled = %q, content = %q, want an empty editor and what was typed", prefilled, content) + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 14f5c6dc..bc63df19 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -998,7 +998,8 @@ entry=$(hey journal read 2026-03-15 --jq '.data.content_markdown') && ``` When it is `false`, the entry holds an attachment or other markup Markdown cannot carry, and -writing Markdown would drop it. Add to the HTML instead — `--content-html` takes off the +writing Markdown would drop it — `hey journal write` refuses to open `$EDITOR` on such an +entry for the same reason. Add to the HTML instead — `--content-html` takes off the wrapper HEY serves the entry in, so this does not nest: ```bash From 6e4194fb75fb8fdf70f455d808e0dcdf939ea46c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:13:54 -0400 Subject: [PATCH 4/7] Give the journal editor refusal a hint that can be followed as written The hint read as a shell command ending in "$entry...", which run as it stood would write three literal dots into the entry. It now names the two commands and a placeholder for the changed HTML, in the form contact note set's refusal uses. The general writing paragraph in docs/cli.md now says the journal refuses its editor the way a contact note does, and a journal entry carrying an attribute such as a colour is pinned as not lossless. --- docs/cli.md | 2 +- internal/cmd/journal.go | 5 +++-- internal/cmd/journal_test.go | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index cae743d2..73c43c23 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. HEY serves a journal entry, a snippet's `content_html` and a contact's `note_html` inside its editor's `
` 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. +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 `
` 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. diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index 3c4ad80e..dae6bd1d 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -344,8 +344,9 @@ func journalEntryFromEditor(ctx context.Context, date string, fetch journalConte } if !htmlutil.MarkdownIsLossless(existing) { return "", apierr.ErrUsageHint( - fmt.Sprintf("the journal entry for %s holds an attachment or other content Markdown cannot carry, so editing it as Markdown would drop it", date), - fmt.Sprintf("change the entry's HTML and write it back: entry=$(hey journal read %s --jq '.data.content') && hey journal write %s --content-html \"$entry...\"", date, date)) + 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 ''`", date, date), + ) } edited, err := open(htmlutil.ToMarkdown(existing).String()) if err != nil { diff --git a/internal/cmd/journal_test.go b/internal/cmd/journal_test.go index 314fadaa..54066a9b 100644 --- a/internal/cmd/journal_test.go +++ b/internal/cmd/journal_test.go @@ -538,6 +538,7 @@ func TestJournalReadSaysWhenItsMarkdownIsLossless(t *testing.T) { }{ {name: "an entry from HEY's editor", stored: webEditedJournalStored, want: true}, {name: "an entry with an attachment", stored: attachedJournalStored, want: false}, + {name: "an entry with a colour", stored: `
Retrospective: the migration took two days longer than planned.
`, want: false}, } { t.Run(tt.name, func(t *testing.T) { server, _ := newJournalStore(t, tt.stored) From bef7f12b790381c14bbfd3e5ef522db1d1fe49bd Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 15:24:31 -0400 Subject: [PATCH 5/7] Say in --content-html's help that HEY's wrapper is taken off snippet create, snippet update and journal write describe --content-html as raw HTML, which no longer says everything: the trix-content wrapper HEY serves content_html in is taken off, so HTML read back can be written again. The flag help says so now, as docs/cli.md does. --- internal/cmd/journal.go | 2 +- internal/cmd/snippet.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index dae6bd1d..e31c35f8 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -235,7 +235,7 @@ not opened in $EDITOR, since saving it would drop that content: change the entry } 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 diff --git a/internal/cmd/snippet.go b/internal/cmd/snippet.go index 87b0cc64..5a8013b3 100644 --- a/internal/cmd/snippet.go +++ b/internal/cmd/snippet.go @@ -147,7 +147,7 @@ func newSnippetCreateCommand() *snippetCreateCommand { } createCommand.cmd.Flags().StringVar(&createCommand.name, "name", "", "Snippet name (required)") createCommand.cmd.Flags().StringVar(&createCommand.content, "content", "", "Snippet content as Markdown") - createCommand.cmd.Flags().StringVar(&createCommand.contentHTML, "content-html", "", "Snippet content as raw HTML instead of Markdown") + createCommand.cmd.Flags().StringVar(&createCommand.contentHTML, "content-html", "", "Snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off") createCommand.cmd.MarkFlagsMutuallyExclusive("content", "content-html") return createCommand } @@ -192,7 +192,7 @@ func newSnippetUpdateCommand() *snippetUpdateCommand { } updateCommand.cmd.Flags().StringVar(&updateCommand.name, "name", "", "New snippet name") updateCommand.cmd.Flags().StringVar(&updateCommand.content, "content", "", "New snippet content as Markdown") - updateCommand.cmd.Flags().StringVar(&updateCommand.contentHTML, "content-html", "", "New snippet content as raw HTML instead of Markdown") + updateCommand.cmd.Flags().StringVar(&updateCommand.contentHTML, "content-html", "", "New snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off") updateCommand.cmd.MarkFlagsMutuallyExclusive("content", "content-html") return updateCommand } From f4e039e03ab092e2744a6cbc0ca66cd3b8cc095f Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 17:00:36 -0400 Subject: [PATCH 6/7] Make the skill's journal recipe refuse an entry its Markdown would drop The recipe for adding to a journal entry read content_markdown and wrote it back, trusting the reader to have looked at content_markdown_lossless first. The read now checks the flag itself in --jq and fails when it is false, so the && stops before anything is written. A day with no entry fails the same check, instead of starting an entry with the word null. The quick content_markdown examples say it is written back only when the flag is true, as the contact note ones do. --- docs/cli.md | 2 +- skills/embed_test.go | 2 +- skills/hey/SKILL.md | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 73c43c23..3cdb14eb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -636,7 +636,7 @@ 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, to edit and write back +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) ``` diff --git a/skills/embed_test.go b/skills/embed_test.go index 56d032c1..f4b26793 100644 --- a/skills/embed_test.go +++ b/skills/embed_test.go @@ -95,7 +95,7 @@ func TestHeySkillAddsToAJournalEntryWithoutLosingIt(t *testing.T) { for _, want := range []string{ "content_markdown_lossless", - "entry=$(hey journal read 2026-03-15 --jq '.data.content_markdown') &&", + "--jq 'if .data.content_markdown_lossless then .data.content_markdown else error(", "entry=$(hey journal read 2026-03-15 --jq '.data.content') &&", `hey journal write 2026-03-15 --content-html "$entry`, } { diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index bc63df19..8f08830b 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -975,7 +975,7 @@ accepted. An existing file needs `--force`. ```bash hey journal list --json # Entries on the personal calendar, 4 years back to 1 year ahead hey journal read 2026-03-15 --json # Read entry by date -hey journal read 2026-03-15 --jq '.data.content_markdown' # The entry as Markdown, ready to edit and write back +hey journal read 2026-03-15 --jq '.data.content_markdown' # The entry as Markdown; write it back only if content_markdown_lossless is true (see below) 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 # $EDITOR at a terminal; otherwise the entry is read from stdin @@ -990,10 +990,11 @@ no content and falls through to stdin or `$EDITOR`.) A day with no entry is not **A journal entry is written whole**, so to add to one, read it, change it and write all of it back. `hey journal read --json` answers `content` (the HTML as HEY serves it), `content_markdown` and `content_markdown_lossless`. When `content_markdown_lossless` is -`true`, add to the Markdown: +`true`, add to the Markdown. The read checks the flag itself and fails when it is `false` — +or when the day has no entry, which answers no `data` — so nothing is written: ```bash -entry=$(hey journal read 2026-03-15 --jq '.data.content_markdown') && +entry=$(hey journal read 2026-03-15 --jq 'if .data.content_markdown_lossless then .data.content_markdown else error("content_markdown would drop part of this entry, or there is none: change content with --content-html") end') && printf '%s\n\nBooked the venue for the second day.\n' "$entry" | hey journal write 2026-03-15 ``` @@ -1008,8 +1009,8 @@ entry=$(hey journal read 2026-03-15 --jq '.data.content') && ``` Keep the `&&`: a failed read must not go on to write. A day with no entry answers no `data`, -which `--jq` prints as `null`, so check for that before adding to it, or the entry starts -with the word null. +which a bare `--jq '.data.content'` prints as `null`, so check for that before adding to the +HTML, or the entry starts with the word null. ### Authentication From eeddd50e3866383cc53d3f914aec9c0f8ab2dd4c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 17:06:39 -0400 Subject: [PATCH 7/7] Fail the journal HTML recipe on an empty day, and explain the read's fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill's recipe for adding to an entry as HTML read .data.content bare, which a day with no entry answers as null, so the && went on to write "null

…

". The read now errors when there is no content. hey journal read --help now says what content, content_markdown and content_markdown_lossless are, and that the Markdown is written back only when the flag is true. --- internal/cmd/journal.go | 8 ++++++++ skills/embed_test.go | 2 +- skills/hey/SKILL.md | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index e31c35f8..e605422a 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -131,6 +131,14 @@ 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 diff --git a/skills/embed_test.go b/skills/embed_test.go index f4b26793..3c74e86f 100644 --- a/skills/embed_test.go +++ b/skills/embed_test.go @@ -96,7 +96,7 @@ func TestHeySkillAddsToAJournalEntryWithoutLosingIt(t *testing.T) { for _, want := range []string{ "content_markdown_lossless", "--jq 'if .data.content_markdown_lossless then .data.content_markdown else error(", - "entry=$(hey journal read 2026-03-15 --jq '.data.content') &&", + "entry=$(hey journal read 2026-03-15 --jq '.data.content // error(", `hey journal write 2026-03-15 --content-html "$entry`, } { if !strings.Contains(content, want) { diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 8f08830b..4ed1d5ee 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -1004,13 +1004,13 @@ entry for the same reason. Add to the HTML instead — `--content-html` takes of wrapper HEY serves the entry in, so this does not nest: ```bash -entry=$(hey journal read 2026-03-15 --jq '.data.content') && +entry=$(hey journal read 2026-03-15 --jq '.data.content // error("no journal entry for this day")') && hey journal write 2026-03-15 --content-html "$entry

Booked the venue for the second day.

" ``` Keep the `&&`: a failed read must not go on to write. A day with no entry answers no `data`, -which a bare `--jq '.data.content'` prints as `null`, so check for that before adding to the -HTML, or the entry starts with the word null. +which a bare `--jq '.data.content'` prints as `null`; the `// error(...)` makes that read fail +instead, so the entry never starts with the word null. ### Authentication