From b930f28d14578c478de61e6923bcecdf9c5a6222 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 12 Sep 2026 14:30:13 -0500 Subject: [PATCH 1/2] feat: add --plain and non-TTY detection to park show --- README.md | 18 ++++++++- cmd/park/main.go | 11 +++++- cmd/park/main_test.go | 43 +++++++++++++++++++++ cmd/park/park.go | 13 ++++++- internal/render/render.go | 69 ++++++++++++++++++++++++---------- internal/render/render_test.go | 44 +++++++++++++++++++++- 6 files changed, 174 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a7b58df..140f659 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ The JSON output includes the canonical category enum, field kinds, the date form | `new [title]` | park a new note (alias `add`) | | `list [--all] [-c ] [--json]` | list parked notes grouped by category (alias `ls`) | | `assist` | open the tabbed TUI browser | -| `show ` | glamour-render a note, no TUI | +| `show [--plain]` | render a note to the terminal (plain when piped, or with `--plain`) | | `reclassify -c ` | reclassify a note (alias `recat`) | | `config` | print the default TOML config | | `schema` | print the frontmatter schema contract | @@ -194,6 +194,14 @@ synopsis: dashboard caching rework `schema_version` tracks the frontmatter contract exposed by `park schema`, so consumers can detect drift. +### `park show` options + +| option | purpose | +|--------|---------| +| `--plain` | force plain text output (no ANSI); auto-selected when stdout is not a terminal | + +`park show` renders a note's frontmatter summary and body through glamour. When stdout is not a terminal (piped to a file, a pager, or another process) it automatically emits plain text with no ANSI escape codes, so captured output stays readable; `--plain` forces that mode even on a terminal. `park assist` applies the same rule to the note it opens after the TUI exits. + ### Ingestion `park new` accepts content through two non-interactive paths: @@ -290,6 +298,14 @@ Automation does not have to stop at ingestion. A scheduled job or agent can recl find "$PARK_ROOT/_inbox" -name "*.md" -mtime +30 -exec park reclassify {} -c archive \; ``` +### Read a note from an agent + +`park show` writes to stdout and drops styling when output is redirected, so captured text is clean: + +```bash +park show inbox-note.md | grep -i "deadline" +``` + ## Configuration Config lives at `/config` as TOML. Run `park config` to print the default: diff --git a/cmd/park/main.go b/cmd/park/main.go index 7603a77..8b4114e 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -57,6 +57,7 @@ func newCommand() *cli.Command { schemaJSON bool listJSON bool listAll bool + showPlain bool ) defaultRoot := os.Getenv("PARK_ROOT") @@ -294,6 +295,13 @@ func newCommand() *cli.Command { Name: "show", Usage: "render a note to the terminal", ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "plain", + Destination: &showPlain, + Usage: "force plain text output (default when output is not a terminal)", + }, + }, Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { if cmd.NArg() < 1 { return ctx, styledExit(fmt.Errorf("usage: park show "), 2) @@ -305,7 +313,8 @@ func newCommand() *cli.Command { if err != nil { return styledExit(err, 1) } - if err := render.ShowFile(path, cmd.Root().Writer); err != nil { + plain := showPlain || !writerIsTTY(cmd.Root().Writer) + if err := render.ShowFile(path, cmd.Root().Writer, plain); err != nil { return styledExit(err, 1) } return nil diff --git a/cmd/park/main_test.go b/cmd/park/main_test.go index cfb3398..024751d 100644 --- a/cmd/park/main_test.go +++ b/cmd/park/main_test.go @@ -142,6 +142,49 @@ func TestShowMissingFile(t *testing.T) { } } +func TestShowPlainFlag(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "plain-note.md", "inbox", "a plain note") + + out, _, err := runPark(t, root, "show", "plain-note.md", "--plain") + if err != nil { + t.Fatalf("show --plain error = %v", err) + } + assertPlainShow(t, out, "a plain note") +} + +// TestShowNonTTYIsPlain verifies plain output is auto-selected when the +// writer is not a terminal; runPark wires a strings.Builder as cmd.Writer, +// which writerIsTTY treats as non-terminal. +func TestShowNonTTYIsPlain(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "piped-note.md", "inbox", "a piped note") + + out, _, err := runPark(t, root, "show", "piped-note.md") + if err != nil { + t.Fatalf("show error = %v", err) + } + assertPlainShow(t, out, "a piped note") +} + +func assertPlainShow(t *testing.T, out, synopsis string) { + t.Helper() + if strings.Contains(out, "\x1b[") { + t.Errorf("plain show output contains ANSI escapes:\n%q", out) + } + for _, want := range []string{synopsis, "body"} { + if !strings.Contains(out, want) { + t.Errorf("show output missing %q:\n%s", want, out) + } + } +} + func TestStyledExit(t *testing.T) { err := styledExit(errors.New("boom"), 7) if err == nil { diff --git a/cmd/park/park.go b/cmd/park/park.go index 4eb4f81..831727a 100644 --- a/cmd/park/park.go +++ b/cmd/park/park.go @@ -35,6 +35,17 @@ func stdinIsTTY() bool { return isTerminal(os.Stdin) } +// writerIsTTY reports whether w is connected to an interactive terminal. A +// writer that is not an *os.File (a buffer, a pipe) is treated as +// non-terminal so callers fall back to plain output. +func writerIsTTY(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return isTerminal(f) +} + // draftFromCmd builds a note.Draft from the CLI flags and positional args. func draftFromCmd(cmd *cli.Command) note.Draft { d := note.Draft{ @@ -181,7 +192,7 @@ func assistPark(cfg *config.Config, w io.Writer) error { return fmt.Errorf("unexpected model type from assist") } if final.ViewFile != "" { - if err := render.ShowFile(final.ViewFile, w); err != nil { + if err := render.ShowFile(final.ViewFile, w, !writerIsTTY(w)); err != nil { return err } } diff --git a/internal/render/render.go b/internal/render/render.go index 64e08a6..859e311 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -11,45 +11,76 @@ import ( "github.com/polymorcodeus/park/internal/note" ) +const ( + richStyle = "dark" + plainStyle = "notty" + wordWrap = 100 +) + var ( - renderer *glamour.TermRenderer - rendererOnce sync.Once - rendererErr error + renderersMu sync.Mutex + renderers = map[string]*glamour.TermRenderer{} + rendererErr error ) -func termRenderer() (*glamour.TermRenderer, error) { - rendererOnce.Do(func() { - renderer, rendererErr = glamour.NewTermRenderer( - glamour.WithStandardStyle("dark"), - glamour.WithWordWrap(100), - ) - }) +func termRenderer(style string) (*glamour.TermRenderer, error) { + renderersMu.Lock() + defer renderersMu.Unlock() + if rendererErr != nil { return nil, rendererErr } - return renderer, nil + if r, ok := renderers[style]; ok { + return r, nil + } + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(style), + glamour.WithWordWrap(wordWrap), + ) + if err != nil { + rendererErr = err + return nil, err + } + renderers[style] = r + return r, nil +} + +// noteHeader builds the frontmatter summary line rendered above the body. +// plain selects the decoration-free variant used by notty output. +func noteHeader(n note.Note, plain bool) string { + if plain { + return fmt.Sprintf( + "category: %s created: %s source: %s\n\n> %s\n\n---\n\n", + n.Category, n.Created, n.Source, n.Synopsis, + ) + } + return fmt.Sprintf( + "**category:** %s    **created:** %s    **source:** %s\n\n> %s\n\n---\n\n", + n.Category, n.Created, n.Source, n.Synopsis, + ) } // ShowFile renders a parked note's frontmatter summary + body to w via // glamour: the "look deeper" step after the synopsis in the list view -// earned a second look. -func ShowFile(path string, w io.Writer) error { +// earned a second look. When plain is true it uses glamour's notty style, +// producing readable text with no ANSI escapes for pipes and redirects. +func ShowFile(path string, w io.Writer, plain bool) error { n, err := note.Parse(path) if err != nil { return fmt.Errorf("show %q: %w", path, err) } - header := fmt.Sprintf( - "**category:** %s    **created:** %s    **source:** %s\n\n> %s\n\n---\n\n", - n.Category, n.Created, n.Source, n.Synopsis, - ) + style := richStyle + if plain { + style = plainStyle + } - renderer, err := termRenderer() + renderer, err := termRenderer(style) if err != nil { return fmt.Errorf("create glamour renderer: %w", err) } - out, err := renderer.Render(header + n.Body) + out, err := renderer.Render(noteHeader(n, plain) + n.Body) if err != nil { return fmt.Errorf("render %q: %w", path, err) } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 524897c..ddd5ff2 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -40,7 +40,7 @@ func TestShowFileRendersNote(t *testing.T) { } var buf bytes.Buffer - if err := ShowFile(path, &buf); err != nil { + if err := ShowFile(path, &buf, false); err != nil { t.Fatalf("ShowFile() error = %v", err) } @@ -59,8 +59,48 @@ func TestShowFileRendersNote(t *testing.T) { func TestShowFileMissing(t *testing.T) { var buf bytes.Buffer - err := ShowFile(filepath.Join(t.TempDir(), "missing.md"), &buf) + err := ShowFile(filepath.Join(t.TempDir(), "missing.md"), &buf, false) if err == nil { t.Fatal("expected error for missing file") } } + +func TestShowFilePlain(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if err := os.MkdirAll(cfg.Categories[0].Path, 0o755); err != nil { + t.Fatalf("create category folder: %v", err) + } + + path := filepath.Join(cfg.Categories[0].Path, "plain-note.md") + n := note.Note{ + Body: "# Hello\n\nbody content\n", + Metadata: note.Metadata{ + Category: "inbox", + Created: "2026-08-09", + Source: "terminal", + Synopsis: "a rendered note", + }, + } + if err := note.Write(path, n); err != nil { + t.Fatalf("Write() error = %v", err) + } + + var buf bytes.Buffer + if err := ShowFile(path, &buf, true); err != nil { + t.Fatalf("ShowFile() error = %v", err) + } + + out := buf.String() + if ansiEscape.MatchString(out) { + t.Errorf("plain output contains ANSI escapes: %q", out) + } + for _, want := range []string{"a rendered note", "body content", "category: inbox"} { + if !strings.Contains(out, want) { + t.Errorf("plain output missing %q; got %q", want, out) + } + } + if strings.Contains(out, " ") { + t.Errorf("plain output contains literal  : %q", out) + } +} From 178b3441db35b7934b1a759c5e5b876841c7b76d Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sat, 12 Sep 2026 14:31:44 -0500 Subject: [PATCH 2/2] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b043aa6..992ac75 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.5.0 +v0.5.1