feat: add --dry-run mode and styled terminal output with NO_COLOR support
- Add `--dry-run` CLI flag to validate configuration and preview
articles without making LLM API calls
- Show feeds file validation, configuration summary, article
statistics
- Display filtered article counts, source breakdown, token estimates
- Skip API key validation when `--dry-run` is active
- Implement styled terminal output using `lipgloss`
- Add `internal/style` package with ANSI colour helpers
- Style errors, warnings, success messages, labels, filepaths, URLs
- Respect `$NO_COLOR` environment variable (no-color.org standard)
- Update `cmd/llm_aggregator.go` to support dry-run execution
- Add `runDryRun()` function that fetches and processes feeds without
LLM
- Move API key validation after dry-run check
- Replace raw `fmt.Fprintf` errors with styled error output
- Revise `internal/progress/progress.go` to use styled warnings and
debug
- Refactor `internal/llm/llm.go` client creation to use option slice
- Improve `configs/config.example.toml` with inline comments for all
options
- Add project logo (`assets/logo.svg`) to README
- Update `README.md` and `docs/llm_aggregator.1` with `--dry-run` docs
- Extend `internal/cli/args_test.go` with dry-run flag test cases
- Update `CHANGELOG.md` for version 0.7.0
@@ -6,6 +6,24 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-04-22 + +### Added + +- **`--dry-run` option**: Validate configuration and preview what would happen + without making any LLM API calls. Useful for validating feeds file and + configuration, checking article counts and filtering results and anything + else that can be done "offline". +- **Styled terminal output using `lipgloss`**: All CLI output now uses ANSI + colour codes for better readability: +- **`$NO_COLOR` environment variable support**: Respects the ["no +color"](https://no-color.org/) standard. When `NO_COLOR` is set, all styling is +disabled and plain text is output instead. + +### Changed + +- API key validation now only occurs when not using `--dry-run` mode + ## [0.6.0] - 2026-04-22 ### Added
@@ -1,6 +1,8 @@
<h1 align="center">llm_aggregator</h1> <p align="center"> + <img src="assets/logo.svg" alt="LLM Aggregator logo" width=500> + <br> <strong>A CLI tool to aggregate RSS feeds and summarise them with LLMs</strong> </p>@@ -32,6 +34,7 @@ --feeds-file FILE Path to file containing RSS feed URLs (one per line)
--prompt PROMPT User prompt for summarisation/analysis --api-key KEY API key (default: read from $LLM_AGGREGATOR_API_KEY) --model MODEL Model to use (default: deepseek-chat) + --dry-run Validate config, show article statistics, and exit without making LLM API calls. --base-url URL API base URL (default: https://api.deepseek.com) --max-tokens N Maximum tokens in response (default: 4000) --output FORMAT Output format: text, markdown, or json (default: text)
@@ -0,0 +1,231 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="156.72844mm" + height="50.466202mm" + viewBox="0 0 156.72844 50.466202" + version="1.1" + id="svg1" + inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + sodipodi:docname="logo.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview1" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:document-units="mm" + inkscape:zoom="1.5244262" + inkscape:cx="283.38532" + inkscape:cy="149.56447" + inkscape:window-width="2550" + inkscape:window-height="1386" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="0" + inkscape:current-layer="layer1" /> + <defs + id="defs1"> + <linearGradient + id="linearGradient41" + inkscape:collect="always"> + <stop + style="stop-color:#ff7f2a;stop-opacity:0.96862745;" + offset="0" + id="stop41" /> + <stop + style="stop-color:#ff7f2a;stop-opacity:0.80313462;" + offset="1" + id="stop42" /> + </linearGradient> + <linearGradient + id="linearGradient24" + inkscape:collect="always"> + <stop + style="stop-color:#ff7f2a;stop-opacity:1;" + offset="0" + id="stop24" /> + <stop + style="stop-color:#ff7f2a;stop-opacity:0.79687983;" + offset="1" + id="stop25" /> + </linearGradient> + <inkscape:path-effect + effect="roughen" + id="path-effect23" + is_visible="true" + lpeversion="1.2" + global_randomize="1;1" + displace_x="0.33;1" + displace_y="0.253686;1" + method="segments" + max_segment_size="0.5" + segments="14" + handles="along" + shift_nodes="true" + fixed_displacement="false" + spray_tool_friendly="false" /> + <inkscape:path-effect + effect="vonkoch" + id="path-effect22" + is_visible="true" + lpeversion="1" + ref_path="M 30.964804,81.639829 H 80.894288" + generator="m 30.964804,100.66629 h 16.643161 m 16.643162,0 h 16.643161" + similar_only="false" + nbgenerations="1" + drawall="true" + maxComplexity="1000" /> + <inkscape:path-effect + effect="rough_hatches" + id="path-effect21" + is_visible="true" + lpeversion="1.2" + direction="55.929546,76.639829 , 12.482371,0" + dist_rdm="75;1" + growth="0" + do_bend="true" + bender="55.929546,81.639829 , 5,0" + bottom_edge_variation="3.8052922;1" + top_edge_variation="3.8052922;1" + bottom_tgt_variation="0;1" + top_tgt_variation="0;1" + scale_bf="1" + scale_bb="1" + scale_tf="1" + scale_tb="1" + top_smth_variation="0;1" + bottom_smth_variation="0;1" + fat_output="true" + stroke_width_top="1" + stroke_width_bottom="1" + front_thickness="1" + back_thickness="0.25" /> + <rect + x="143.79384" + y="253.26271" + width="550.12744" + height="139.15532" + id="rect2" /> + <rect + x="134.51682" + y="274.59985" + width="320.05728" + height="201.31139" + id="rect1" /> + <rect + x="143.79384" + y="253.26271" + width="550.12744" + height="139.15532" + id="rect2-7" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient24" + id="linearGradient25" + x1="37.142391" + y1="91.641037" + x2="79.838783" + y2="63.523903" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient41" + id="linearGradient42" + x1="156.20631" + y1="73.243408" + x2="182.06714" + y2="57.796337" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient41" + id="linearGradient1" + gradientUnits="userSpaceOnUse" + x1="156.20631" + y1="73.243408" + x2="182.06714" + y2="57.796337" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient41" + id="linearGradient2" + gradientUnits="userSpaceOnUse" + x1="156.20631" + y1="73.243408" + x2="182.06714" + y2="57.796337" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient41" + id="linearGradient3" + gradientUnits="userSpaceOnUse" + x1="156.20631" + y1="73.243408" + x2="182.06714" + y2="57.796337" /> + </defs> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(-27.944815,-52.393707)"> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2.99622;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:19.4;stroke-dasharray:none;paint-order:stroke fill markers" + id="rect42" + width="156.72844" + height="50.466202" + x="27.944815" + y="52.393707" + ry="0" /> + <path + style="fill:url(#linearGradient25);fill-opacity:1;stroke:none;stroke-width:1.05833;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers" + d="m 35.75389,93.550226 h 7.853706 v 7.116064 l 6.81233,-7.116064 H 74.28478 l 6.609508,-30.936858 -49.929484,4.643697 z" + id="path3" + sodipodi:nodetypes="cccccccc" /> + <text + xml:space="preserve" + transform="scale(0.26458333)" + id="text1" + style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:condensed;font-size:96px;line-height:1;font-family:'Barlow Condensed';-inkscape-font-specification:'Barlow Condensed, Bold Condensed';letter-spacing:0px;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);fill:#000000;stroke:#ffffff;stroke-width:4;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers" /> + <g + id="text2" + style="font-weight:bold;font-stretch:condensed;font-size:96px;line-height:1;font-family:'Barlow Condensed';-inkscape-font-specification:'Barlow Condensed, Bold Condensed';letter-spacing:0px;white-space:pre;stroke-width:4;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers" + transform="scale(0.26458333)" + aria-label="LLM Aggregator"> + <path + style="fill:#ffffff" + d="m 149.16897,333.26367 q -0.48,0 -0.864,-0.288 -0.288,-0.384 -0.288,-0.864 v -64.896 q 0,-0.48 0.288,-0.768 0.384,-0.384 0.864,-0.384 h 11.232 q 0.48,0 0.768,0.384 0.384,0.288 0.384,0.768 v 53.952 q 0,0.48 0.48,0.48 h 20.16 q 0.48,0 0.768,0.384 0.384,0.288 0.384,0.768 v 9.312 q 0,0.48 -0.384,0.864 -0.288,0.288 -0.768,0.288 z m 40.89606,0 q -0.48,0 -0.864,-0.288 -0.288,-0.384 -0.288,-0.864 v -64.896 q 0,-0.48 0.288,-0.768 0.384,-0.384 0.864,-0.384 h 11.232 q 0.48,0 0.768,0.384 0.384,0.288 0.384,0.768 v 53.952 q 0,0.48 0.48,0.48 h 20.16 q 0.48,0 0.768,0.384 0.384,0.288 0.384,0.768 v 9.312 q 0,0.48 -0.384,0.864 -0.288,0.288 -0.768,0.288 z m 70.46405,-66.432 q 0.384,-0.768 1.44,-0.768 h 11.136 q 0.48,0 0.768,0.384 0.384,0.288 0.384,0.768 v 64.896 q 0,0.48 -0.384,0.864 -0.288,0.288 -0.768,0.288 h -11.232 q -0.48,0 -0.864,-0.288 -0.288,-0.384 -0.288,-0.864 v -43.104 q 0,-0.384 -0.192,-0.384 -0.192,0 -0.384,0.288 l -7.488,13.248 q -0.48,0.768 -0.768,0.768 -0.288,0 -0.768,-0.768 l -7.488,-13.248 q -0.192,-0.288 -0.384,-0.288 -0.192,0 -0.192,0.384 v 43.104 q 0,0.48 -0.384,0.864 -0.288,0.288 -0.768,0.288 h -11.232 q -0.48,0 -0.864,-0.288 -0.288,-0.384 -0.288,-0.864 v -64.896 q 0,-0.48 0.288,-0.768 0.384,-0.384 0.864,-0.384 h 11.04 q 0.96,0 1.44,0.768 l 8.544,15.264 q 0.288,0.384 0.576,0 z" + id="path4" /> + <path + d="m 329.84119,333.26367 c -0.768,0 -1.184,-0.352 -1.248,-1.056 l -1.536,-9.216 c 0,-0.32 -0.192,-0.48 -0.576,-0.48 h -12.192 c -0.384,0 -0.576,0.16 -0.576,0.48 l -1.536,9.216 c -0.064,0.704 -0.48,1.056 -1.248,1.056 h -11.232 c -0.832,0 -1.152,-0.416 -0.96,-1.248 l 14.112,-64.896 c 0.128,-0.704 0.544,-1.056 1.248,-1.056 h 12.96 c 0.704,0 1.12,0.352 1.248,1.056 l 14.016,64.896 0.096,0.384 c 0,0.576 -0.352,0.864 -1.056,0.864 z m -14.208,-21.696 c 0,0.32 0.128,0.48 0.384,0.48 h 8.64 c 0.32,0 0.448,-0.16 0.384,-0.48 l -4.512,-26.208 c -0.064,-0.192 -0.16,-0.288 -0.288,-0.288 -0.128,0 -0.224,0.096 -0.288,0.288 z m 53.56808,-26.496 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 11.232 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 45.888 c 0,7.104 -1.696,12.576 -5.088,16.416 -3.328,3.84 -9.344,5.76 -18.048,5.76 -1.728,0 -62.9088,-0.032 -63.8688,-0.096 -0.768,-0.064 -1.24338,-0.71842 -1.152,-1.248 l 1.62348,-9.408 c 0.14148,-0.81988 0.416,-1.184 1.248,-1.056 3.776,0.128 64.83732,-0.256 66.75732,-1.152 1.984,-0.832 3.296,-2.112 3.936,-3.84 0.704,-1.728 1.056,-4 1.056,-6.816 0,-0.192 -0.064,-0.288 -0.192,-0.288 -0.128,0 -0.256,0.096 -0.384,0.288 -1.152,1.536 -2.56,2.624 -4.224,3.264 -1.6,0.576 -3.168,0.864 -4.704,0.864 -4.416,0 -7.712,-1.408 -9.888,-4.224 -2.176,-2.88 -3.264,-6.848 -3.264,-11.904 v -18.24 c 0,-5.184 1.088,-9.152 3.264,-11.904 2.24,-2.816 5.536,-4.224 9.888,-4.224 3.904,0 6.88,1.44 8.928,4.32 0.128,0.192 0.256,0.256 0.384,0.192 0.128,-0.064 0.192,-0.192 0.192,-0.384 z m -4.608,36.864 c 1.344,0 2.432,-0.512 3.264,-1.536 0.896,-1.024 1.344,-2.336 1.344,-3.936 v -16.224 c 0,-1.6 -0.448,-2.912 -1.344,-3.936 -0.832,-1.024 -1.92,-1.536 -3.264,-1.536 -1.344,0 -2.432,0.512 -3.264,1.536 -0.832,1.024 -1.248,2.336 -1.248,3.936 v 16.224 c 0,1.6 0.416,2.912 1.248,3.936 0.832,1.024 1.92,1.536 3.264,1.536 z m 46.944,-36.864 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 11.232 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 45.888 c 0,7.104 -1.696,12.576 -5.088,16.416 -3.328,3.84 -9.344,5.76 -18.048,5.76 -1.728,0 -18.61796,-0.032 -19.57796,-0.096 -0.768,-0.064 -1.80732,-0.84753 -1.152,-1.248 3.13099,-1.91339 5.92988,-6.33498 6.7996,-9.408 0.22657,-0.80056 0.416,-1.184 1.248,-1.056 3.776,0.128 15.37036,-0.256 17.29036,-1.152 1.984,-0.832 3.296,-2.112 3.936,-3.84 0.704,-1.728 1.056,-4 1.056,-6.816 0,-0.192 -0.064,-0.288 -0.192,-0.288 -0.128,0 -0.256,0.096 -0.384,0.288 -1.152,1.536 -2.56,2.624 -4.224,3.264 -1.6,0.576 -3.168,0.864 -4.704,0.864 -4.416,0 -7.712,-1.408 -9.888,-4.224 -2.176,-2.88 -3.264,-6.848 -3.264,-11.904 v -18.24 c 0,-5.184 1.088,-9.152 3.264,-11.904 2.24,-2.816 5.536,-4.224 9.888,-4.224 3.904,0 6.88,1.44 8.928,4.32 0.128,0.192 0.256,0.256 0.384,0.192 0.128,-0.064 0.192,-0.192 0.192,-0.384 z m -4.608,36.864 c 1.344,0 2.432,-0.512 3.264,-1.536 0.896,-1.024 1.344,-2.336 1.344,-3.936 v -16.224 c 0,-1.6 -0.448,-2.912 -1.344,-3.936 -0.832,-1.024 -1.92,-1.536 -3.264,-1.536 -1.344,0 -2.432,0.512 -3.264,1.536 -0.832,1.024 -1.248,2.336 -1.248,3.936 v 16.224 c 0,1.6 0.416,2.912 1.248,3.936 0.832,1.024 1.92,1.536 3.264,1.536 z m 47.04,-38.592 c 1.728,0 3.072,0.32 4.032,0.96 0.512,0.32 0.704,0.8 0.576,1.44 l -1.824,11.232 c -0.064,0.704 -0.512,0.96 -1.344,0.768 -0.704,-0.256 -1.568,-0.384 -2.592,-0.384 -0.96,0 -1.792,0.128 -2.496,0.384 -1.472,0.256 -2.656,1.152 -3.552,2.688 -0.896,1.472 -1.344,3.2 -1.344,5.184 v 26.4 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -11.232 c -0.32,0 -0.608,-0.096 -0.864,-0.288 -0.192,-0.256 -0.288,-0.544 -0.288,-0.864 v -46.944 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 11.232 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 3.744 c 0,0.256 0.032,0.416 0.096,0.48 0.128,0 0.256,-0.096 0.384,-0.288 1.728,-3.776 4.416,-5.664 8.064,-5.664 z m 43.96795,28.32 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -21.12 c -0.32,0 -0.48,0.16 -0.48,0.48 v 3.552 c 0,1.6 0.416,2.944 1.248,4.032 0.896,1.024 2.016,1.536 3.36,1.536 1.216,0 2.208,-0.384 2.976,-1.152 0.768,-0.832 1.28,-1.856 1.536,-3.072 0.192,-0.64 0.608,-0.96 1.248,-0.96 l 11.04,0.576 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.352,0.48 0.288,0.864 -0.448,4.928 -2.176,8.672 -5.184,11.232 -2.944,2.496 -7.168,3.744 -12.672,3.744 -5.696,0 -10.144,-1.408 -13.344,-4.224 -3.2,-2.88 -4.8,-6.848 -4.8,-11.904 v -18.624 c 0,-4.864 1.6,-8.768 4.8,-11.712 3.264,-2.944 7.712,-4.416 13.344,-4.416 5.632,0 10.048,1.472 13.248,4.416 3.264,2.944 4.896,6.848 4.896,11.712 z m -18.144,-16.896 c -1.344,0 -2.464,0.512 -3.36,1.536 -0.832,1.024 -1.248,2.336 -1.248,3.936 v 3.744 c 0,0.32 0.16,0.48 0.48,0.48 h 8.256 c 0.32,0 0.48,-0.16 0.48,-0.48 v -3.744 c 0,-1.6 -0.448,-2.912 -1.344,-3.936 -0.832,-1.024 -1.92,-1.536 -3.264,-1.536 z m 46.36808,-9.696 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 11.232 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 45.888 c 0,7.104 -1.696,12.576 -5.088,16.416 -3.328,3.84 -9.344,5.76 -18.048,5.76 -1.728,0 -3.072,-0.032 -4.032,-0.096 -0.768,-0.064 -1.152,-0.48 -1.152,-1.248 v -9.408 c 0,-0.832 0.416,-1.184 1.248,-1.056 3.776,0.128 6.624,-0.256 8.544,-1.152 1.984,-0.832 3.296,-2.112 3.936,-3.84 0.704,-1.728 1.056,-4 1.056,-6.816 0,-0.192 -0.064,-0.288 -0.192,-0.288 -0.128,0 -0.256,0.096 -0.384,0.288 -1.152,1.536 -2.56,2.624 -4.224,3.264 -1.6,0.576 -3.168,0.864 -4.704,0.864 -4.416,0 -7.712,-1.408 -9.888,-4.224 -2.176,-2.88 -3.264,-6.848 -3.264,-11.904 v -18.24 c 0,-5.184 1.088,-9.152 3.264,-11.904 2.24,-2.816 5.536,-4.224 9.888,-4.224 3.904,0 6.88,1.44 8.928,4.32 0.128,0.192 0.256,0.256 0.384,0.192 0.128,-0.064 0.192,-0.192 0.192,-0.384 z m -4.608,36.864 c 1.344,0 2.432,-0.512 3.264,-1.536 0.896,-1.024 1.344,-2.336 1.344,-3.936 v -16.224 c 0,-1.6 -0.448,-2.912 -1.344,-3.936 -0.832,-1.024 -1.92,-1.536 -3.264,-1.536 -1.344,0 -2.432,0.512 -3.264,1.536 -0.832,1.024 -1.248,2.336 -1.248,3.936 v 16.224 c 0,1.6 0.416,2.912 1.248,3.936 0.832,1.024 1.92,1.536 3.264,1.536 z m 42.816,-38.784 c 5.44,0 9.856,1.472 13.248,4.416 3.392,2.944 5.088,6.816 5.088,11.616 v 32.928 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -11.232 c -0.32,0 -0.608,-0.096 -0.864,-0.288 -0.192,-0.256 -0.288,-0.544 -0.288,-0.864 v -2.112 c 0,-0.192 -0.064,-0.32 -0.192,-0.384 -0.128,-0.064 -0.256,0 -0.384,0.192 -2.112,2.688 -5.376,4.096 -9.792,4.224 -3.712,0 -6.944,-1.024 -9.696,-3.072 -2.688,-2.048 -4.032,-5.952 -4.032,-11.712 0,-5.952 1.632,-10.048 4.896,-12.288 3.328,-2.24 8.096,-3.36 14.304,-3.36 h 4.416 c 0.32,0 0.48,-0.16 0.48,-0.48 v -2.4 c 0,-1.728 -0.48,-3.136 -1.44,-4.224 -0.896,-1.152 -2.048,-1.728 -3.456,-1.728 -1.088,0 -2.016,0.352 -2.784,1.056 -0.704,0.64 -1.152,1.568 -1.344,2.784 -0.064,0.768 -0.48,1.152 -1.248,1.152 l -11.52,-0.096 c -0.768,0 -1.152,-0.384 -1.152,-1.152 0.32,-4.672 2.144,-8.384 5.472,-11.136 3.328,-2.816 7.552,-4.224 12.672,-4.224 z m -0.96,40.416 c 1.472,0 2.784,-0.48 3.936,-1.44 1.216,-1.024 1.824,-2.368 1.824,-4.032 v -4.512 c 0,-0.32 -0.16,-0.48 -0.48,-0.48 h -3.936 c -1.728,0 -3.136,0.512 -4.224,1.536 -1.024,0.96 -1.536,2.304 -1.536,4.032 0,1.6 0.384,2.816 1.152,3.648 0.832,0.832 1.92,1.248 3.264,1.248 z m 48.38391,-31.296 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -5.376 c -0.32,0 -0.48,0.16 -0.48,0.48 v 22.848 c 0,1.856 0.352,3.136 1.056,3.84 0.704,0.704 1.76,1.024 3.168,0.96 h 0.672 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 9.408 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -3.84 c -4.672,0 -8.192,-0.768 -10.56,-2.304 -2.368,-1.6 -3.552,-4.608 -3.552,-9.024 v -28.032 c 0,-0.32 -0.16,-0.48 -0.48,-0.48 h -3.168 c -0.32,0 -0.608,-0.096 -0.864,-0.288 -0.192,-0.256 -0.288,-0.544 -0.288,-0.864 v -7.2 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 3.168 c 0.32,0 0.48,-0.16 0.48,-0.48 v -10.656 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 10.752 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 10.656 c 0,0.32 0.16,0.48 0.48,0.48 h 5.376 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 z m 23.04004,41.76 c -5.696,0 -10.176,-1.44 -13.44,-4.32 -3.2,-2.88 -4.8,-6.816 -4.8,-11.808 v -18.624 c 0,-4.864 1.632,-8.768 4.896,-11.712 3.264,-2.944 7.712,-4.416 13.344,-4.416 5.696,0 10.176,1.472 13.44,4.416 3.264,2.944 4.896,6.848 4.896,11.712 v 18.624 c 0,4.992 -1.632,8.928 -4.896,11.808 -3.2,2.88 -7.68,4.32 -13.44,4.32 z m 0,-11.712 c 1.408,0 2.56,-0.512 3.456,-1.536 0.896,-1.024 1.344,-2.336 1.344,-3.936 v -16.608 c 0,-1.6 -0.448,-2.912 -1.344,-3.936 -0.896,-1.024 -2.048,-1.536 -3.456,-1.536 -1.408,0 -2.56,0.512 -3.456,1.536 -0.832,1.024 -1.248,2.336 -1.248,3.936 v 16.608 c 0,1.6 0.416,2.912 1.248,3.936 0.896,1.024 2.048,1.536 3.456,1.536 z m 46.94394,-38.976 c 1.728,0 3.072,0.32 4.032,0.96 0.512,0.32 0.704,0.8 0.576,1.44 l -1.824,11.232 c -0.064,0.704 -0.512,0.96 -1.344,0.768 -0.704,-0.256 -1.568,-0.384 -2.592,-0.384 -0.96,0 -1.792,0.128 -2.496,0.384 -1.472,0.256 -2.656,1.152 -3.552,2.688 -0.896,1.472 -1.344,3.2 -1.344,5.184 v 26.4 c 0,0.32 -0.128,0.608 -0.384,0.864 -0.192,0.192 -0.448,0.288 -0.768,0.288 h -11.232 c -0.32,0 -0.608,-0.096 -0.864,-0.288 -0.192,-0.256 -0.288,-0.544 -0.288,-0.864 v -46.944 c 0,-0.32 0.096,-0.576 0.288,-0.768 0.256,-0.256 0.544,-0.384 0.864,-0.384 h 11.232 c 0.32,0 0.576,0.128 0.768,0.384 0.256,0.192 0.384,0.448 0.384,0.768 v 3.744 c 0,0.256 0.032,0.416 0.096,0.48 0.128,0 0.256,-0.096 0.384,-0.288 1.728,-3.776 4.416,-5.664 8.064,-5.664 z" + id="path5" + sodipodi:nodetypes="sccssccssccsscccsscssccsccsssscsscscsscccssccscsscscsssscsscsssssssssscsscscsscccssccscsscscsssscsscsssssssccccsccssssscssssscssccssssssscscccccccscsscscssscsssssscssssscsscscsscccssccscsscscsssscsscssssssssssssscssscccscsssscsccccccsscsssscscsssssssscscssssscsssscsssssssssscsssscssscssssssscssssssscsscssccccsccssssscssssscssccs" /> + </g> + <g + id="g41" + style="fill:url(#linearGradient42);fill-opacity:1"> + <path + style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient1);fill-opacity:1;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000;stop-opacity:1" + d="m 167.89453,69.160156 c -1.6506,0.02471 -3.30484,0.710855 -4.49414,2.056641 l 2.62109,2.318359 c 0.91909,-1.040019 2.95852,-0.989664 3.74024,-0.121094 l 2.60156,-2.339843 c -1.17121,-1.301341 -2.81815,-1.938775 -4.46875,-1.914063 z" + id="path23" /> + <path + style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient2);fill-opacity:1;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000;stop-opacity:1" + d="m 167.83008,64.177734 c -2.92685,0.04833 -5.82619,1.275634 -8.01953,3.695313 l 2.59375,2.351562 c 3.18211,-3.510484 7.76623,-3.554039 11.13867,-0.189453 l 2.4707,-2.478515 C 173.711,65.259334 170.75692,64.1294 167.83008,64.177734 Z" + id="path23-0" /> + <path + style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient3);fill-opacity:1;stroke-linejoin:bevel;stroke-miterlimit:19.4;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000;stop-opacity:1" + d="m 167.78711,58.947266 c -4.18455,0.08353 -8.30253,2.036652 -11.72461,5.599609 l 2.52344,2.423828 c 2.90184,-3.021304 6.1191,-4.460515 9.27148,-4.523437 3.15238,-0.06292 6.33517,1.237086 9.22656,4.160156 l 2.48829,-2.460938 c -3.44463,-3.482354 -7.60061,-5.282743 -11.78516,-5.199218 z" + id="path23-0-6" /> + </g> + </g> +</svg>
@@ -6,10 +6,13 @@ "fmt"
"os" tea "github.com/charmbracelet/bubbletea" + "llm_aggregator/internal/aggregator" "llm_aggregator/internal/cli" "llm_aggregator/internal/config" + "llm_aggregator/internal/processor" "llm_aggregator/internal/progress" "llm_aggregator/internal/runtime" + "llm_aggregator/internal/style" "llm_aggregator/internal/tui" )@@ -25,7 +28,7 @@
// Parse command line arguments args, err := cli.ParseArgs() if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err) + fmt.Fprintln(os.Stderr, style.Errorf("parsing arguments: %v", err)) os.Exit(1) }@@ -35,15 +38,21 @@
// Bind CLI args to viper (highest precedence) config.BindCLIArgs(v, args.ToViperMap()) - // Validate API key + // Create runtime directly from viper configuration + rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt) + + // Run dry-run mode if requested (validates config, shows statistics, no LLM calls) + if args.DryRun { + runDryRun(rt, args.Verbose) + os.Exit(0) + } + + // Validate API key (required for actual execution) if v.GetString("api_key") == "" { - fmt.Fprintln(os.Stderr, "Error: OpenAI-compatible API key is required. Set via --api-key, LLM_AGGREGATOR_API_KEY environment variable, or config file.") + fmt.Fprintln(os.Stderr, style.Errorf("OpenAI-compatible API key is required. Set via --api-key, %s environment variable, or config file.", "LLM_AGGREGATOR_API_KEY")) os.Exit(1) } - // Create runtime directly from viper configuration - rt := config.ViperToRuntime(v, args.FeedsFile, args.Prompt) - // Run with TUI if requested if args.TUI { runWithTUI(rt)@@ -68,7 +77,7 @@ rt.Progress = tp
// 5. Run the program. This is a blocking call. if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "TUI Error: %v\n", err) + fmt.Fprintln(os.Stderr, style.Errorf("TUI error: %v", err)) os.Exit(1) } }@@ -85,20 +94,135 @@
// Execute the runtime err := rt.Execute(context.Background()) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, style.Errorf("execution failed: %v", err)) os.Exit(1) } // Write output if rt.OutputFile != "" { if err := rt.WriteOutputToFile(); err != nil { - fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + fmt.Fprintln(os.Stderr, style.Errorf("writing output to file: %v", err)) os.Exit(1) } } else { if err := rt.WriteOutput(os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + fmt.Fprintln(os.Stderr, style.Errorf("writing output: %v", err)) os.Exit(1) } } } + +func runDryRun(rt *runtime.Runtime, verbose bool) { + // Setup logger based on verbose flag + if verbose { + rt.Progress = progress.NewSimpleLogger(os.Stdout, true) + } else { + rt.Progress = &progress.NoopLogger{} + } + + // Print dry-run header + fmt.Println(style.Heading("========================================")) + fmt.Println(style.Heading(" llm_aggregator --dry-run")) + fmt.Println(style.Heading("========================================")) + fmt.Println() + + // Validate feeds file exists + if _, err := os.Stat(rt.FeedsFile); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, style.Errorf("Feeds file not found: %s", rt.FeedsFile)) + os.Exit(1) + } + fmt.Printf("%s Feeds file: %s\n", style.Success(""), style.Filepath(rt.FeedsFile)) + + // Print configuration summary + fmt.Println() + fmt.Println(style.Label("Configuration:")) + fmt.Printf(" Max articles per feed: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxArticlesPerFeed))) + fmt.Printf(" Max days old: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxDaysOld))) + fmt.Printf(" Max total articles: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTotalArticles))) + fmt.Printf(" Include keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.IncludeKeywords))) + fmt.Printf(" Exclude keywords: %s\n", style.Value(fmt.Sprintf("%v", rt.ExcludeKeywords))) + fmt.Printf(" Output format: %s\n", style.Value(rt.Output)) + fmt.Printf(" Model: %s\n", style.Value(rt.Model)) + fmt.Println() + + // Fetch and process feeds (but don't call LLM) + fmt.Println(style.Info("Fetching feeds...")) + + feedAgg := aggregator.NewFeedAggregatorWithProgress( + rt.MaxArticlesPerFeed, + rt.MaxDaysOld, + 5000, // max content length + nil, // No progress context for cleaner dry-run output + ) + + articles, err := feedAgg.ParseFeedsFromFile(rt.FeedsFile) + if err != nil { + fmt.Fprintln(os.Stderr, style.Errorf("Failed to parse feeds: %v", err)) + os.Exit(1) + } + + totalArticles := len(articles) + fmt.Printf("%s Fetched %d articles from feeds\n", style.Success(""), totalArticles) + + if totalArticles == 0 { + fmt.Fprintln(os.Stderr, style.Warning("No articles found. Check your feeds file or network connectivity.")) + fmt.Println() + fmt.Printf("%s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).") + os.Exit(0) + } + + // Process articles (filter and sort) + contentProc := processor.NewContentProcessor( + rt.MaxTotalArticles, + 3000, // max content per article + rt.IncludeKeywords, + rt.ExcludeKeywords, + ) + + processedArticles := contentProc.ProcessArticles(articles, "date", true) + filteredCount := totalArticles - len(processedArticles) + + fmt.Println() + fmt.Println(style.Label("Article statistics:")) + fmt.Printf(" Total fetched: %s\n", style.Value(fmt.Sprintf("%d", totalArticles))) + fmt.Printf(" After filtering: %s\n", style.Value(fmt.Sprintf("%d", len(processedArticles)))) + if filteredCount > 0 { + fmt.Printf(" Filtered out: %s\n", style.Value(fmt.Sprintf("%d", filteredCount))) + } + + // Group articles by source for summary + sourceCounts := make(map[string]int) + for _, article := range processedArticles { + if title, ok := article["source_feed"].(string); ok { + sourceCounts[title]++ + } + } + + if len(sourceCounts) > 0 { + fmt.Println() + fmt.Println(style.Label("Articles by source:")) + for source, count := range sourceCounts { + fmt.Printf(" %s: %s\n", style.Filepath(source), style.Value(fmt.Sprintf("%d", count))) + } + } + + // Estimate token count + estimatedTokens := contentProc.EstimateTokenCount(processedArticles, rt.Model) + fmt.Println() + fmt.Printf("Estimated token count: %s (for model: %s)\n", style.Value(fmt.Sprintf("~%d", estimatedTokens)), style.Value(rt.Model)) + fmt.Printf("Max tokens for response: %s\n", style.Value(fmt.Sprintf("%d", rt.MaxTokens))) + + // Prompt preview + fmt.Println() + fmt.Println(style.Label("Prompt:")) + fmt.Printf(" %s\n", style.Italic(rt.Prompt)) + if rt.SystemPrompt != "" { + fmt.Println(style.Label("System prompt:")) + fmt.Printf(" %s\n", style.Italic(rt.SystemPrompt)) + } + + fmt.Println() + fmt.Println(style.Heading("========================================")) + fmt.Printf(" %s %s\n", style.Success("Dry-run complete"), "(no LLM API calls made).") + fmt.Println(style.Heading("========================================")) +}
@@ -1,28 +1,32 @@
# llm_aggregator configuration file # Location: ~/.config/llm_aggregator/config.toml +# +# All options can be overridden via command-line arguments. +# Environment variables take precedence: LLM_AGGREGATOR_* prefix. # Feed aggregation options -max_articles_per_feed = 10 -max_days_old = 7 -max_total_articles = 20 +max_articles_per_feed = 10 # Maximum articles to fetch from each RSS feed +max_days_old = 7 # Only include articles published within this many days +max_total_articles = 20 # Maximum total articles to process across all feeds # Content filtering (comma-separated keywords) -# include_keywords = "linux,opensource" -# exclude_keywords = "windows,microsoft" +# include_keywords = "linux,opensource" # Only include articles containing at least one of these keywords +# exclude_keywords = "windows" # Exclude articles containing any of these keywords # LLM API options -# api_key = "your_api_key_here" # Can also be set via LLM_AGGREGATOR_API_KEY env var -model = "deepseek-chat" -max_tokens = 4000 -temperature = 0.7 +api_key = "your_api_key_here" # Can also be set via LLM_AGGREGATOR_API_KEY env var +base_url = "https://api.deepseek.com" # API base URL (OpenAI-compatible endpoints) +model = "deepseek-chat" # Model identifier (e.g., deepseek-chat, gpt-4, MiniMax-M2.7) +max_tokens = 4000 # Maximum tokens in LLM response +temperature = 0.7 # Sampling temperature (0.0 to 1.0, lower = more focused) -# System prompt for LLM API +# System prompt for LLM (instructions that guide the AI's behaviour) system_prompt = """You are an expert analyst and summariser. You analyse content from multiple sources and provide concise, insightful summaries based on user requests. Focus on key points, trends, and important information.""" # Output options -output = "text" # Options: text, json, markdown -# output_file = "" # Optional output file path -include_articles = false+output = "text" # Output format: text, json, or markdown +# output_file = "" # Optional: write output to file instead of stdout +include_articles = false # Include original article content in output
@@ -40,6 +40,11 @@ .TP 2
\(bu Format and output the result in the chosen format (text, markdown, or JSON) .PP +Output is styled with ANSI colours for better readability, but this software +respects the +.B NO_COLOR +environment variable (see https://no-color.org/). +.PP Feeds are fetched concurrently for optimal performance, with rate limiting to avoid overwhelming feed servers. .SH REQUIRED ARGUMENTS@@ -132,6 +137,24 @@ .B \-\-output json
is used. .SS Interface Options .TP +.B \-\-dry\-run +Validate configuration and preview what would happen without making any LLM +API calls. Useful for: +.RS 4 +.TP 2 +\(bu +Validating feeds file and configuration +.TP 2 +\(bu +Checking article counts and filtering results +.TP 2 +\(bu +Estimating token usage before running +.TP 2 +\(bu +Testing network connectivity to feeds +.RE +.TP .B \-\-tui Enable the TUI interface with a progress bar, live article counters, and elapsed time display. The TUI supports keyboard navigation (j/k, arrows,@@ -229,6 +252,18 @@ llm_aggregator \-\-feeds-file feeds.txt \\
\-\-prompt "What are the latest AI\-related trends in free software?" .fi .RE +.SS Validating Configuration (Dry Run) +.PP +Validate configuration and preview articles before making any LLM API calls: +.PP +.RS 4 +.nf +llm_aggregator \-\-feeds-file feeds.txt \-\-prompt "Summarise news" \-\-dry-run +.fi +.RE +.PP +This shows article counts, filtering statistics, token estimates, and the +prompt that would be sent. No API key is required for dry-run mode. .SS Using TUI .PP Enable the TUI interface with a progress bar:
@@ -44,6 +44,7 @@ SystemPrompt string `arg:"--system-prompt" help:"Custom system prompt for LLM"`
TUI bool `arg:"--tui" help:"Enable TUI interface with progress bar"` Verbose bool `arg:"-v,--verbose" help:"Show verbose output"` ShowVersion bool `arg:"--version" help:"Show version"` + DryRun bool `arg:"--dry-run" help:"Validate config, show article statistics, and exit without making LLM API calls"` } // Version returns the version string.@@ -58,6 +59,9 @@
Examples: # Basic usage with prompts llm_aggregator --feeds-file feeds.txt --prompt "What are the latest trends in free software?" + + # Validate configuration and preview articles (no LLM API calls) + llm_aggregator --feeds-file feeds.txt --prompt "Summarise news" --dry-run # With custom LLM model llm_aggregator --feeds-file feeds.txt --prompt "Summarise tech news" --model deepseek-coder
@@ -511,3 +511,69 @@ }
}) } } + +func TestDryRunFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantDryRun bool + wantErr bool + setupFunc func() + cleanupFunc func() + }{ + { + name: "dry-run flag not set", + args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test"}, + wantDryRun: false, + wantErr: false, + }, + { + name: "dry-run flag set", + args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run"}, + wantDryRun: true, + wantErr: false, + }, + { + name: "dry-run flag as --dry-run=true", + args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run=true"}, + wantDryRun: true, + wantErr: false, + }, + { + name: "dry-run with TUI", + args: []string{"--feeds-file", "/tmp/feeds.txt", "--prompt", "Test", "--dry-run", "--tui"}, + wantDryRun: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupFunc != nil { + tt.setupFunc() + } + if tt.cleanupFunc != nil { + defer tt.cleanupFunc() + } + + argsCopy := make([]string, len(os.Args)) + copy(argsCopy, os.Args) + defer func() { os.Args = argsCopy }() + + os.Args = append([]string{"llm_aggregator"}, tt.args...) + + parsedArgs, err := ParseArgs() + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } else if parsedArgs.DryRun != tt.wantDryRun { + t.Errorf("DryRun mismatch: want %v, got %v", tt.wantDryRun, parsedArgs.DryRun) + } + } + }) + } +}
@@ -11,6 +11,7 @@ "llm_aggregator/internal/cli"
"llm_aggregator/internal/defaults" "llm_aggregator/internal/progress" "llm_aggregator/internal/runtime" + "llm_aggregator/internal/style" ) // Config holds the application configuration loaded from TOML file, environment variables, and CLI arguments.@@ -93,7 +94,7 @@ // Try to read config file
if err := v.ReadInConfig(); err != nil { // If config file doesn't exist, that's OK - we'll use defaults + env vars if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - fmt.Fprintf(os.Stderr, "Warning: error reading config file: %v\n", err) + fmt.Fprintln(os.Stderr, style.Warningf("error reading config file: %v", err)) } } }
@@ -56,10 +56,12 @@ temperature = defaults.DefaultTemperature
} // Create OpenAI client configured for LLM - client := openai.NewClient( + clientOpts := []option.RequestOption{ option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), - ) + } + + client := openai.NewClient(clientOpts...) return &LLMClient{ client: client,
@@ -3,6 +3,8 @@
import ( "fmt" "io" + + "llm_aggregator/internal/style" ) // Logger provides logging interface for the aggregator@@ -39,12 +41,14 @@ fmt.Fprintf(sl.writer, format+"\n", args...)
} func (sl *SimpleLogger) Warningf(format string, args ...any) { - fmt.Fprintf(sl.writer, "Warning: "+format+"\n", args...) + // Use style.Warningf for orange bold WARNING prefix + fmt.Fprintf(sl.writer, "%s\n", style.Warningf(format, args...)) } func (sl *SimpleLogger) Debugf(format string, args ...any) { if sl.debug { - fmt.Fprintf(sl.writer, "Debug: "+format+"\n", args...) + // Use style.Debugf for styled debug output + fmt.Fprintf(sl.writer, "%s\n", style.Debugf(format, args...)) } }
@@ -0,0 +1,253 @@
+// Package style provides consistent text styling using lipgloss. +// It respects the NO_COLOR environment variable (https://no-color.org/). +package style + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Bold returns a bold style. +func Bold(text string) string { + return lipgloss.NewStyle().Bold(true).Render(text) +} + +// Italic returns an italic style. +func Italic(text string) string { + return lipgloss.NewStyle().Italic(true).Render(text) +} + +// Colour represents a lipgloss colour that can be applied. +type Colour lipgloss.Color + +// ANSI colour codes (standard 8-colour palette). +// Using ANSI escape codes for portability across terminals. +var ( + // Red for errors and warnings + Red = Colour("1") + + // Green for success messages + Green = Colour("2") + + // Yellow for emphasis + Yellow = Colour("3") + + // Blue for URLs + Blue = Colour("12") + + // Magenta for labels and info + Magenta = Colour("5") + + // Cyan for filepaths and values + Cyan = Colour("6") + + // White for headings + White = Colour("15") + + // Bright black for muted/debug text + BrightBlack = Colour("8") +) + +// NoColor returns true if the NO_COLOR environment variable is set. +// This respects https://no-color.org/ standard. +func NoColor() bool { + return os.Getenv("NO_COLOR") != "" +} + +// Warning returns an orange and bold warning message. +// Note: ANSI doesn't have orange, so we use red with bold styling. +func Warning(text string) string { + if NoColor() { + return lipgloss.NewStyle().Bold(true).Render("WARNING: " + text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Red)). + Bold(true). + Render("WARNING: " + text) +} + +// Warningf returns a formatted warning message. +func Warningf(format string, args ...any) string { + return Warning(fmt.Sprintf(format, args...)) +} + +// Error returns a red and bold error message. +func Error(text string) string { + if NoColor() { + return lipgloss.NewStyle().Bold(true).Render("ERROR: " + text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Red)). + Bold(true). + Render("ERROR: " + text) +} + +// Errorf returns a formatted error message. +func Errorf(format string, args ...any) string { + return Error(fmt.Sprintf(format, args...)) +} + +// Success returns a green success message. +func Success(text string) string { + if NoColor() { + return lipgloss.NewStyle().Bold(true).Render("✓ " + text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Green)). + Bold(true). + Render("✓ " + text) +} + +// Successf returns a formatted success message. +func Successf(format string, args ...any) string { + return Success(fmt.Sprintf(format, args...)) +} + +// Filepath returns a styled filepath in cyan. +func Filepath(filepath string) string { + if NoColor() { + return filepath + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)).Render(filepath) +} + +// FilepathStyled returns a lipgloss style for filepaths that can be applied to custom text. +var FilepathStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) + +// URL returns a styled URL in blue with underline. +func URL(url string) string { + if NoColor() { + return lipgloss.NewStyle().Underline(true).Render(url) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Blue)). + Underline(true). + Render(url) +} + +// URLStyled returns a lipgloss style for URLs that can be applied to custom text. +var URLStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Blue)).Underline(true) + +// Label returns a styled label/key in magenta and bold. +func Label(text string) string { + if NoColor() { + return lipgloss.NewStyle().Bold(true).Render(text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Magenta)). + Bold(true). + Render(text) +} + +// LabelStyled returns a lipgloss style for labels. +var LabelStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Magenta)).Bold(true) + +// Heading returns a styled heading. +func Heading(text string) string { + return lipgloss.NewStyle().Bold(true).Render(text) +} + +// Section returns a styled section header. +func Section(text string) string { + return lipgloss.NewStyle(). + Bold(true). + Underline(true). + Render(text) +} + +// Value returns styled value text. +func Value(text string) string { + if NoColor() { + return text + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)).Render(text) +} + +// ValueStyled returns a lipgloss style for values. +var ValueStyled = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) + +// Info returns a styled info message in cyan. +func Info(text string) string { + if NoColor() { + return "ℹ " + text + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)).Render("ℹ " + text) +} + +// Infof returns a formatted info message. +func Infof(format string, args ...any) string { + return Info(fmt.Sprintf(format, args...)) +} + +// Debug returns a styled debug message in grey. +func Debug(text string) string { + if NoColor() { + return lipgloss.NewStyle().Italic(true).Render("Debug: " + text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(BrightBlack)). + Italic(true). + Render("Debug: " + text) +} + +// Debugf returns a formatted debug message. +func Debugf(format string, args ...any) string { + return Debug(fmt.Sprintf(format, args...)) +} + +// Highlight returns a highlighted text (yellow bold). +func Highlight(text string) string { + if NoColor() { + return lipgloss.NewStyle().Bold(true).Render(text) + } + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(Yellow)). + Bold(true). + Render(text) +} + +// Highlightf returns a formatted highlighted message. +func Highlightf(format string, args ...any) string { + return Highlight(fmt.Sprintf(format, args...)) +} + +// ParseAndStyleURLs takes a string and styles any URLs found within it. +// This is useful for processing text that may contain URLs mixed with other content. +func ParseAndStyleURLs(text string) string { + // Simple URL pattern matching - matches http:// and https:// URLs + // This is a basic implementation; for production use, consider a regex library + parts := strings.Split(text, " ") + + for i, part := range parts { + if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") { + parts[i] = URL(part) + } + } + + return strings.Join(parts, " ") +} + +// ParseAndStyleFilepaths takes a string and styles any filepaths found within it. +// This looks for paths that start with / or are relative paths containing /. +func ParseAndStyleFilepaths(text string) string { + // Simple detection for filepaths - words containing / but not just URLs + parts := strings.Split(text, " ") + + for i, part := range parts { + // Skip if it's already a URL (starts with http) + if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") { + continue + } + + // Check if it looks like a filepath + if strings.Contains(part, "/") && !strings.Contains(part, "://") { + parts[i] = Filepath(part) + } + } + + return strings.Join(parts, " ") +} +