newsline-cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # newsline
2
+
3
+ Locale-aware one-line news in your Claude Code status line — **fully local**, and it
4
+ **composes with your existing status line** (e.g. an HUD) instead of replacing it.
5
+
6
+ **English** · [한국어](readme/ko.md)
7
+
8
+ ## What it is
9
+
10
+ A Claude Code status-line utility that shows a rotating, region-appropriate news
11
+ headline at the bottom of your session. Everything runs locally on your machine —
12
+ locale detection, feed fetch, parsing, caching, rendering. It never reads your code,
13
+ prompts, files, or Claude conversations.
14
+
15
+ ## Install
16
+
17
+ **curl | sh** (macOS / Linux / WSL):
18
+ ```sh
19
+ curl -fsSL https://raw.githubusercontent.com/itdar/cc-plugin/master/install.sh | sh
20
+ ```
21
+
22
+ **Homebrew:**
23
+ ```sh
24
+ brew install itdar/tap/newsline
25
+ ```
26
+
27
+ **From source:**
28
+ ```sh
29
+ git clone https://github.com/itdar/cc-plugin && cd cc-plugin && ./install.sh
30
+ ```
31
+
32
+ The **curl | sh** installer runs setup automatically — it prompts you for a language and
33
+ topic, then wires the status line. For **Homebrew** or **from source**, run setup yourself:
34
+ ```sh
35
+ newsline init
36
+ ```
37
+ `init` asks for a language and topic (a menu — just pick a number), then registers the
38
+ status line, **keeping whatever status line you already have** (your HUD stays visible,
39
+ news shows on the line below). It appears on your next message to Claude — Claude Code
40
+ hot-reloads settings, so no restart is needed.
41
+
42
+ _(Skip auto-setup during install with `NEWSLINE_NO_INIT=1`.)_
43
+
44
+ ## Configure
45
+
46
+ Choose during `newsline init`, or edit `~/.config/newsline/config.json` (or set the env var):
47
+
48
+ | Key / env var | Default | Meaning |
49
+ |---|---|---|
50
+ | `lang` / `NEWSLINE_LANG` | `auto` | `ko` `ja` `en` `es` `fr` `de` `pt` `zh`, or `auto` |
51
+ | `topic` / `NEWSLINE_TOPIC` | `general` | `general` `tech` `business` `world` `sports` `science` `health` `entertainment` |
52
+ | `rotate` / `NEWSLINE_ROTATE` | `6` | seconds per headline |
53
+ | `count` / `NEWSLINE_COUNT` | `15` | headlines cached for rotation |
54
+ | `maxlen` / `NEWSLINE_MAXLEN` | `120` | max chars; `max` = no truncation; `NN%` ≈ of terminal width |
55
+ | `icon` / `NEWSLINE_ICON` | `📰` | leading glyph; `none` to remove |
56
+ | `linkhint` / `NEWSLINE_LINKHINT` | off | append a `↗` "opens externally" hint |
57
+ | `api` / `NEWSLINE_API` | — | optional curation API (server-first, local fallback) |
58
+ | `endpoint` / `NEWSLINE_ENDPOINT` | — | click-redirect wrapper |
59
+
60
+ ## How it works
61
+
62
+ - **`newsline render`** (what the status line runs) prints instantly from cache and never
63
+ blocks; it kicks off a background refresh when the cache is stale.
64
+ - **`newsline refresh`** fetches a keyless regional RSS/Atom feed locally, parses the top
65
+ headlines, dedupes, and caches them.
66
+ - Headlines are clickable [OSC 8] links. Whether a plain click vs `Cmd`/`Ctrl`+click opens
67
+ them is decided by your terminal, not by newsline.
68
+
69
+ ## Uninstall
70
+
71
+ ```sh
72
+ newsline uninstall # removes the wiring and restores your previous status line
73
+ ```
74
+
75
+ ## Privacy
76
+
77
+ Runs locally; detects locale from your system settings (no IP geolocation); fetches public
78
+ feeds directly; never accesses your code, prompts, files, or Claude conversations. Clicks
79
+ pass through a redirect so the project can measure engagement and, in future, use affiliate
80
+ links to fund itself — disclosed here.
81
+
82
+ ## Requirements
83
+
84
+ `bash`, `python3`. macOS and Linux natively; Windows via WSL or Git Bash.
85
+
86
+ [OSC 8]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
package/feeds.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "_comment": "locale language -> ordered keyless RSS/Atom feeds. National outlet first (in-language, higher quality), Google News locale feed last as a guaranteed fallback. fetch.py tries each in order; the first that yields an item wins, so a dead/blocked feed is skipped gracefully. All URLs here were reachability-validated. When adding feeds, also add the article-link domain to ../server/allowlist.json.",
3
+ "en": [
4
+ "https://feeds.bbci.co.uk/news/rss.xml",
5
+ "https://feeds.npr.org/1001/rss.xml",
6
+ "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en"
7
+ ],
8
+ "ko": [
9
+ "https://www.yna.co.kr/rss/news.xml",
10
+ "https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko"
11
+ ],
12
+ "ja": [
13
+ "https://www.nhk.or.jp/rss/news/cat0.xml",
14
+ "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja"
15
+ ],
16
+ "es": [
17
+ "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/portada",
18
+ "https://news.google.com/rss?hl=es&gl=ES&ceid=ES:es"
19
+ ],
20
+ "fr": [
21
+ "https://www.lemonde.fr/rss/une.xml",
22
+ "https://news.google.com/rss?hl=fr&gl=FR&ceid=FR:fr"
23
+ ],
24
+ "de": [
25
+ "https://www.tagesschau.de/index~rss2.xml",
26
+ "https://www.spiegel.de/schlagzeilen/tops/index.rss",
27
+ "https://news.google.com/rss?hl=de&gl=DE&ceid=DE:de"
28
+ ],
29
+ "pt": [
30
+ "https://g1.globo.com/rss/g1/",
31
+ "https://news.google.com/rss?hl=pt-BR&gl=BR&ceid=BR:pt-419"
32
+ ],
33
+ "zh": [
34
+ "https://news.google.com/rss?hl=zh-CN&gl=CN&ceid=CN:zh-Hans"
35
+ ],
36
+ "default": [
37
+ "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en"
38
+ ]
39
+ }
package/fetch.py ADDED
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """newsline · fetch + parse (stdlib only).
3
+
4
+ Reads a locale-appropriate keyless feed on the USER'S machine, extracts the top
5
+ N headlines, and prints one ready-to-render status line PER headline (newline
6
+ separated). Each line is an OSC 8 terminal hyperlink whose target is the redirect
7
+ wrapper, so the click destination can be swapped to an affiliate URL later,
8
+ server-side, with no plugin change.
9
+
10
+ Handles both RSS (<channel><item>) and Atom (<feed><entry>).
11
+
12
+ Usage: fetch.py <lang> <feeds.json> <endpoint> [count]
13
+ Exits non-zero on failure so refresh.sh keeps the previous cache.
14
+ """
15
+ import os
16
+ import re
17
+ import sys
18
+ import json
19
+ import urllib.request
20
+ import urllib.parse
21
+ import xml.etree.ElementTree as ET
22
+
23
+ TIMEOUT = 5
24
+ def _maxlen():
25
+ # NEWSLINE_MAXLEN: a number (chars), "max"/"full" (no truncation), or "NN%"
26
+ # (approx of $COLUMNS, fallback 120 — exact terminal width isn't available at
27
+ # fetch time, so % is best-effort). Default 120.
28
+ raw = (os.environ.get("NEWSLINE_MAXLEN") or "").strip().lower()
29
+ if raw in ("max", "full", "0", "none"):
30
+ return 100000
31
+ if raw.endswith("%"):
32
+ try:
33
+ return max(20, int(os.environ.get("COLUMNS") or 120) * int(raw[:-1]) // 100)
34
+ except Exception:
35
+ return 120
36
+ try:
37
+ return int(raw)
38
+ except Exception:
39
+ return 120
40
+
41
+
42
+ MAX_TITLE = _maxlen() # headline truncation length
43
+
44
+ # Leading glyph: default 📰; set NEWSLINE_ICON="none" to remove, or a custom glyph.
45
+ _icon = os.environ.get("NEWSLINE_ICON", "\U0001F4F0")
46
+ ICON = "" if _icon.strip().lower() in ("none", "off") else _icon
47
+ # Optional trailing "opens externally" hint (↗); off by default.
48
+ LINKHINT = (os.environ.get("NEWSLINE_LINKHINT") or "").strip().lower() in ("1", "true", "yes", "on")
49
+ ATOM = "{http://www.w3.org/2005/Atom}"
50
+ _ws = re.compile(r"\s+")
51
+
52
+
53
+ def clean(title):
54
+ return _ws.sub(" ", title).strip()
55
+
56
+
57
+ def parse_items(data):
58
+ """Return [(title, link), ...] from an RSS or Atom document."""
59
+ out = []
60
+ root = ET.fromstring(data)
61
+
62
+ # RSS
63
+ for it in root.findall(".//channel/item"):
64
+ t = clean(it.findtext("title") or "")
65
+ l = (it.findtext("link") or "").strip()
66
+ if t and l:
67
+ out.append((t, l))
68
+ if out:
69
+ return out
70
+
71
+ # Atom
72
+ for e in root.findall(f".//{ATOM}entry"):
73
+ t = clean(e.findtext(f"{ATOM}title") or "")
74
+ link = ""
75
+ for ln in e.findall(f"{ATOM}link"):
76
+ if ln.get("rel", "alternate") in ("alternate", ""):
77
+ link = ln.get("href", "").strip()
78
+ break
79
+ if t and link:
80
+ out.append((t, link))
81
+ return out
82
+
83
+
84
+ def top_headlines(urls, count):
85
+ """First feed that yields items wins; return up to `count` unique ones."""
86
+ for u in urls:
87
+ try:
88
+ req = urllib.request.Request(u, headers={"User-Agent": "newsline/0.1"})
89
+ data = urllib.request.urlopen(req, timeout=TIMEOUT).read()
90
+ items = parse_items(data)
91
+ except Exception:
92
+ continue
93
+ seen, picked = set(), []
94
+ for title, link in items:
95
+ # collapse auto-generated series like "...시세표(16일)-1/-2/-3" to one
96
+ key = re.sub(r"\s*-\s*\d+\s*$", "", title).strip().lower()
97
+ if key in seen:
98
+ continue
99
+ seen.add(key)
100
+ picked.append((title, link))
101
+ if len(picked) >= count:
102
+ break
103
+ if picked:
104
+ return picked
105
+ return []
106
+
107
+
108
+ def osc8(url, text):
109
+ esc = "\x1b" # ESC]8;;URL ESC\ TEXT ESC]8;;ESC\
110
+ return f"{esc}]8;;{url}{esc}\\{text}{esc}]8;;{esc}\\"
111
+
112
+
113
+ def render(title, link, endpoint, lang):
114
+ wrapped = endpoint + "?" + urllib.parse.urlencode({"u": link, "c": lang})
115
+ if len(title) > MAX_TITLE:
116
+ title = title[: MAX_TITLE - 1] + "…"
117
+ label = f"{ICON} {title}" if ICON else title
118
+ if LINKHINT:
119
+ label = f"{label} ↗"
120
+ return osc8(wrapped, label)
121
+
122
+
123
+ def main():
124
+ if len(sys.argv) < 4:
125
+ return 2
126
+ lang, feeds_path, endpoint = sys.argv[1], sys.argv[2], sys.argv[3]
127
+ count = int(sys.argv[4]) if len(sys.argv) > 4 else 5
128
+
129
+ with open(feeds_path, encoding="utf-8") as f:
130
+ feeds = json.load(f)
131
+ urls = feeds.get(lang) or feeds.get("default") or []
132
+ if not urls:
133
+ return 1
134
+
135
+ picked = top_headlines(urls, count)
136
+ if not picked:
137
+ return 1
138
+
139
+ for title, link in picked:
140
+ sys.stdout.write(render(title, link, endpoint, lang) + "\n")
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ sys.exit(main())
package/install.sh ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env sh
2
+ # newsline · installer — works from a local checkout OR piped from the web:
3
+ # ./install.sh
4
+ # curl -fsSL https://raw.githubusercontent.com/itdar/cc-plugin/master/install.sh | sh
5
+ # No build step; copies the scripts onto your PATH. Idempotent (safe to re-run).
6
+ set -eu
7
+
8
+ REPO="${NEWSLINE_REPO:-itdar/cc-plugin}"
9
+ REF="${NEWSLINE_REF:-master}"
10
+ PREFIX="${NEWSLINE_PREFIX:-$HOME/.local}"
11
+ SHARE="$PREFIX/share/newsline"
12
+ BIN="$PREFIX/bin"
13
+ FILES="newsline statusline.sh refresh.sh fetch.py resolve.py feeds.json"
14
+
15
+ command -v python3 >/dev/null 2>&1 || { echo "✗ newsline needs python3 (not found)"; exit 1; }
16
+
17
+ # Source = a local checkout ONLY when actually run as ./install.sh; else (curl|sh) download.
18
+ SELFDIR=""
19
+ case "$0" in */install.sh|install.sh) SELFDIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P || true)" ;; esac
20
+ if [ -n "$SELFDIR" ] && [ -f "$SELFDIR/newsline" ] && [ -f "$SELFDIR/fetch.py" ]; then
21
+ SRCDIR="$SELFDIR"
22
+ else
23
+ command -v curl >/dev/null 2>&1 || { echo "✗ curl required to download"; exit 1; }
24
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
25
+ echo "↓ downloading $REPO@$REF …"
26
+ curl -fsSL "https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" | tar -xz -C "$TMP"
27
+ SRCDIR="$(find "$TMP" -maxdepth 1 -mindepth 1 -type d | head -1)"
28
+ fi
29
+
30
+ mkdir -p "$SHARE" "$BIN"
31
+ for f in $FILES; do cp "$SRCDIR/$f" "$SHARE/$f"; done
32
+ chmod +x "$SHARE/newsline" "$SHARE/statusline.sh" "$SHARE/refresh.sh"
33
+ ln -sf "$SHARE/newsline" "$BIN/newsline"
34
+
35
+ echo "✔ installed: $BIN/newsline"
36
+
37
+ # Register $BIN on PATH for future shells (idempotent), by shell rc file.
38
+ case ":$PATH:" in
39
+ *":$BIN:"*)
40
+ :
41
+ ;;
42
+ *)
43
+ case "$(basename "${SHELL:-sh}")" in
44
+ zsh) rc="$HOME/.zshrc" ; line="export PATH=\"$BIN:\$PATH\"" ;;
45
+ bash) rc="$HOME/.bashrc" ; line="export PATH=\"$BIN:\$PATH\"" ;;
46
+ fish) rc="$HOME/.config/fish/config.fish" ; line="fish_add_path \"$BIN\"" ;;
47
+ *) rc="$HOME/.profile" ; line="export PATH=\"$BIN:\$PATH\"" ;;
48
+ esac
49
+ mkdir -p "$(dirname "$rc")"
50
+ if [ -f "$rc" ] && grep -qF "$BIN" "$rc" 2>/dev/null; then
51
+ echo "✔ PATH already configured in $rc"
52
+ else
53
+ printf '\n# added by newsline installer\n%s\n' "$line" >> "$rc"
54
+ echo "✔ added $BIN to PATH in $rc"
55
+ fi
56
+ echo
57
+ echo "→ apply to THIS shell now: source $rc"
58
+ echo " (new terminals apply it automatically)"
59
+ ;;
60
+ esac
61
+
62
+ # Auto-setup: run init now (prompts via the terminal even under curl|sh) unless
63
+ # disabled (NEWSLINE_NO_INIT=1) or there is no terminal to prompt from.
64
+ if [ "${NEWSLINE_NO_INIT:-}" != "1" ] && { [ -t 0 ] || [ -r /dev/tty ]; }; then
65
+ echo
66
+ "$BIN/newsline" init
67
+ else
68
+ echo "next: newsline init"
69
+ fi
package/newsline ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bash
2
+ # newsline — single CLI entrypoint for the local news status-line utility.
3
+ #
4
+ # newsline init interactive setup: language, topic, status-line wiring.
5
+ # COMPOSES with an existing status line (e.g. your HUD),
6
+ # so rate-limit / context info stays visible.
7
+ # newsline render what settings.json runs each refresh (base HUD + news).
8
+ # newsline refresh force a cache refresh now.
9
+ # newsline once fetch + print headlines once (debug).
10
+ # newsline uninstall remove the wiring, restore what was there.
11
+ # newsline help
12
+ #
13
+ # Test overrides: NEWSLINE_SETTINGS, NEWSLINE_CONFIG_DIR, NEWSLINE_CACHE.
14
+ set -u
15
+
16
+ # resolve our real dir even when invoked via a PATH symlink (so helpers are found)
17
+ SOURCE="${BASH_SOURCE[0]}"
18
+ while [ -h "$SOURCE" ]; do
19
+ DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
20
+ SOURCE="$(readlink "$SOURCE")"
21
+ case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac
22
+ done
23
+ HERE="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
24
+ CONFIG_DIR="${NEWSLINE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/newsline}"
25
+ CONFIG="$CONFIG_DIR/config.json"
26
+ SETTINGS="${NEWSLINE_SETTINGS:-$HOME/.claude/settings.json}"
27
+
28
+ # Command written into settings.json. ALWAYS an absolute path — Claude Code runs
29
+ # the status line in a non-login shell whose PATH may not include ~/.local/bin.
30
+ NL_BIN="$(command -v newsline 2>/dev/null || echo "$HERE/newsline")"
31
+ RENDER_CMD="\"$NL_BIN\" render"
32
+
33
+ # ---- tiny JSON helpers (python; no jq dependency) ----
34
+ cfg_get() { # cfg_get <key>
35
+ python3 - "$CONFIG" "$1" <<'PY' 2>/dev/null
36
+ import json,sys
37
+ try: d=json.load(open(sys.argv[1]))
38
+ except Exception: d={}
39
+ v=d.get(sys.argv[2])
40
+ print("" if v is None else v)
41
+ PY
42
+ }
43
+ settings_statusline_cmd() { # print current statusLine.command of a settings file
44
+ python3 - "$1" <<'PY' 2>/dev/null
45
+ import json,sys
46
+ try: d=json.load(open(sys.argv[1]))
47
+ except Exception: d={}
48
+ sl=d.get("statusLine")
49
+ print(sl.get("command","") if isinstance(sl,dict) else "")
50
+ PY
51
+ }
52
+
53
+ load_config() {
54
+ [ -f "$CONFIG" ] || return 0
55
+ local lang topic rotate count api endpoint maxlen icon linkhint
56
+ lang=$(cfg_get lang); topic=$(cfg_get topic); rotate=$(cfg_get rotate)
57
+ count=$(cfg_get count); api=$(cfg_get api); endpoint=$(cfg_get endpoint)
58
+ maxlen=$(cfg_get maxlen); icon=$(cfg_get icon); linkhint=$(cfg_get linkhint)
59
+ [ -n "$lang" ] && [ "$lang" != "auto" ] && export NEWSLINE_LANG="$lang"
60
+ [ -n "$topic" ] && export NEWSLINE_TOPIC="$topic"
61
+ [ -n "$rotate" ] && export NEWSLINE_ROTATE="$rotate"
62
+ [ -n "$count" ] && export NEWSLINE_COUNT="$count"
63
+ [ -n "$maxlen" ] && export NEWSLINE_MAXLEN="$maxlen"
64
+ [ -n "$icon" ] && export NEWSLINE_ICON="$icon"
65
+ [ -n "$linkhint" ] && export NEWSLINE_LINKHINT="$linkhint"
66
+ [ -n "$api" ] && export NEWSLINE_API="$api"
67
+ [ -n "$endpoint" ] && export NEWSLINE_ENDPOINT="$endpoint"
68
+ }
69
+
70
+ detect_lang() {
71
+ local raw="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
72
+ if command -v defaults >/dev/null 2>&1; then
73
+ raw="$(defaults read NSGlobalDomain AppleLocale 2>/dev/null || echo "$raw")"
74
+ fi
75
+ raw="${raw%%.*}"; raw="${raw%%@*}"; local l="${raw%%_*}"; l="${l%%-*}"
76
+ echo "${l:-en}"
77
+ }
78
+
79
+ lang_label() { # code -> native (English) display name
80
+ case "$1" in
81
+ ko) echo "한국어 (Korean)" ;; ja) echo "日本語 (Japanese)" ;;
82
+ en) echo "English" ;; es) echo "Español (Spanish)" ;;
83
+ fr) echo "Français (French)" ;; de) echo "Deutsch (German)" ;;
84
+ pt) echo "Português (Portuguese)" ;; zh) echo "中文 (Chinese)" ;;
85
+ *) echo "$1" ;;
86
+ esac
87
+ }
88
+
89
+ # choose <default_code> <code1> <label1> <code2> <label2> ... -> sets $CHOICE
90
+ CHOICE=""
91
+ choose() {
92
+ local def="$1"; shift
93
+ local -a codes=() labels=()
94
+ while [ $# -gt 0 ]; do codes+=("$1"); labels+=("${2:-$1}"); shift 2; done
95
+ local i defnum=1
96
+ for i in "${!codes[@]}"; do
97
+ [ "${codes[$i]}" = "$def" ] && defnum=$((i + 1))
98
+ printf " %2d) %s\n" "$((i + 1))" "${labels[$i]}" >&2
99
+ done
100
+ printf " # [%d]: " "$defnum" >&2
101
+ local sel; read -r sel < "${NL_TTY:-/dev/stdin}"
102
+ case "$sel" in ''|*[!0-9]*) sel="$defnum" ;; esac
103
+ if [ "$sel" -ge 1 ] 2>/dev/null && [ "$sel" -le "${#codes[@]}" ] 2>/dev/null; then
104
+ CHOICE="${codes[$((sel - 1))]}"
105
+ else
106
+ CHOICE="$def"
107
+ fi
108
+ }
109
+
110
+ # ---- subcommands ----
111
+ cmd_render() { # base (existing HUD) first, newsline below — never blocks
112
+ load_config
113
+ local base input=""
114
+ base=$(cfg_get base_statusline)
115
+ [ -t 0 ] || input=$(cat 2>/dev/null) # consume the session JSON if piped
116
+ if [ -n "$base" ]; then
117
+ printf '%s' "$input" | eval "$base" 2>/dev/null # your HUD, unchanged
118
+ printf '\n'
119
+ fi
120
+ "$HERE/statusline.sh" # the news line, below
121
+ }
122
+
123
+ cmd_refresh() { load_config; "$HERE/refresh.sh"; }
124
+
125
+ cmd_once() {
126
+ load_config; "$HERE/refresh.sh"
127
+ local dir="${NEWSLINE_CACHE:-$HOME/.cache/newsline}"
128
+ [ -s "$dir/line" ] && cat "$dir/line" && echo
129
+ }
130
+
131
+ cmd_init() {
132
+ local lang="" topic="" api="" endpoint="" rotate="6" count="15" compose="auto" yes="0" base_override="" a
133
+ while [ $# -gt 0 ]; do
134
+ case "$1" in
135
+ --lang) lang="$2"; shift 2;; --topic) topic="$2"; shift 2;;
136
+ --api) api="$2"; shift 2;; --endpoint) endpoint="$2"; shift 2;;
137
+ --rotate) rotate="$2"; shift 2;; --count) count="$2"; shift 2;;
138
+ --base) base_override="$2"; shift 2;;
139
+ --compose) compose="yes"; shift;; --no-compose) compose="no"; shift;;
140
+ --settings) SETTINGS="$2"; shift 2;; --yes|-y) yes="1"; shift;;
141
+ *) shift;;
142
+ esac
143
+ done
144
+
145
+ local detected; detected=$(detect_lang)
146
+ # Interactive input source: stdin if it's a tty, else the controlling terminal
147
+ # (/dev/tty) — this lets `curl … | sh` still prompt the user.
148
+ NL_TTY=""
149
+ if [ "$yes" = "0" ]; then
150
+ if [ "${NEWSLINE_FORCE_TTY:-}" = "1" ] || [ -t 0 ]; then NL_TTY=/dev/stdin
151
+ elif [ -r /dev/tty ]; then NL_TTY=/dev/tty; fi
152
+ fi
153
+ if [ -n "$NL_TTY" ]; then
154
+ if [ -z "$lang" ]; then
155
+ # languages that actually have bundled feeds, offered as a numbered menu
156
+ local avail c; local -a menu=(auto "Auto-detect (now: $(lang_label "$detected"))")
157
+ avail=$(python3 -c "import json,sys;d=json.load(open(sys.argv[1]));print(' '.join(k for k in d if k not in ('default','_comment')))" "$HERE/feeds.json" 2>/dev/null)
158
+ for c in $avail; do menu+=("$c" "$(lang_label "$c")"); done
159
+ echo "Select news language:" >&2
160
+ choose "$detected" "${menu[@]}"; lang="$CHOICE"
161
+ fi
162
+ if [ -z "$topic" ]; then
163
+ echo "Select topic:" >&2
164
+ choose general \
165
+ general "General" tech "Tech" business "Business" world "World" \
166
+ sports "Sports" science "Science" health "Health" entertainment "Entertainment"
167
+ topic="$CHOICE"
168
+ fi
169
+ else
170
+ lang="${lang:-$detected}"; topic="${topic:-general}"
171
+ fi
172
+
173
+ # Always KEEP an existing status line and show newsline below it (no prompt).
174
+ # Only --no-compose (or an explicit --base) changes this.
175
+ local existing base=""
176
+ if [ -n "$base_override" ]; then
177
+ base="$base_override" # caller supplied the base explicitly
178
+ elif [ "$compose" != "no" ]; then
179
+ existing=$(settings_statusline_cmd "$SETTINGS")
180
+ if [ -n "$existing" ] && ! printf '%s' "$existing" | grep -q "newsline"; then
181
+ base="$existing" # first run: capture the real status line
182
+ else
183
+ base=$(cfg_get base_statusline) # re-init: KEEP the base we already saved
184
+ fi
185
+ fi
186
+
187
+ mkdir -p "$CONFIG_DIR"
188
+ python3 - "$CONFIG" "$lang" "$topic" "$rotate" "$count" "$api" "$endpoint" "$base" <<'PY'
189
+ import json,sys
190
+ _,path,lang,topic,rotate,count,api,endpoint,base=sys.argv
191
+ d={"lang":lang,"topic":topic,"rotate":int(rotate),"count":int(count)}
192
+ if api: d["api"]=api
193
+ if endpoint: d["endpoint"]=endpoint
194
+ if base: d["base_statusline"]=base
195
+ json.dump(d,open(path,"w"),indent=2,ensure_ascii=False)
196
+ PY
197
+
198
+ python3 - "$SETTINGS" "$RENDER_CMD" <<'PY'
199
+ import json,os,sys
200
+ path,cmd=sys.argv[1],sys.argv[2]
201
+ try: d=json.load(open(path))
202
+ except Exception: d={}
203
+ d["statusLine"]={"type":"command","command":cmd,"refreshInterval":5}
204
+ os.makedirs(os.path.dirname(path) or ".",exist_ok=True)
205
+ json.dump(d,open(path,"w"),indent=2,ensure_ascii=False)
206
+ PY
207
+
208
+ echo "✔ newsline installed (lang=$lang, topic=$topic)."
209
+ [ -n "$base" ] && echo " composing with your existing status line — it stays visible above the news."
210
+ echo " It appears on your next message to Claude — no restart needed."
211
+ }
212
+
213
+ cmd_uninstall() {
214
+ local base; base=$(cfg_get base_statusline)
215
+ python3 - "$SETTINGS" "$base" <<'PY'
216
+ import json,sys
217
+ path,base=sys.argv[1],sys.argv[2]
218
+ try: d=json.load(open(path))
219
+ except Exception: d={}
220
+ sl=d.get("statusLine"); cmd=sl.get("command","") if isinstance(sl,dict) else ""
221
+ if "newsline" in cmd:
222
+ if base: d["statusLine"]={"type":"command","command":base}
223
+ else: d.pop("statusLine",None)
224
+ json.dump(d,open(path,"w"),indent=2,ensure_ascii=False); print("reverted status line")
225
+ else:
226
+ print("status line not managed by newsline; left as-is")
227
+ PY
228
+ rm -f "$CONFIG" 2>/dev/null
229
+ echo "✔ newsline uninstalled."
230
+ }
231
+
232
+ usage() {
233
+ cat <<'EOF'
234
+ newsline — local news status-line utility
235
+ newsline init [--lang ko] [--topic tech] set up & wire the status line
236
+ newsline uninstall remove & restore previous status line
237
+ EOF
238
+ if [ "${1:-}" = "--all" ]; then
239
+ cat <<'EOF'
240
+
241
+ internal (Claude Code calls these automatically; for debugging only):
242
+ newsline render draw the status line now (HUD + news)
243
+ newsline refresh force a cache refresh
244
+ newsline once fetch + print headlines once
245
+ EOF
246
+ fi
247
+ }
248
+
249
+ case "${1:-help}" in
250
+ render) shift; cmd_render "$@";;
251
+ refresh) shift; cmd_refresh "$@";;
252
+ once) shift; cmd_once "$@";;
253
+ init) shift; cmd_init "$@";;
254
+ uninstall) shift; cmd_uninstall "$@";;
255
+ help|-h|--help) shift; usage "$@";;
256
+ *) usage; exit 1;;
257
+ esac
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "newsline-cli",
3
+ "version": "0.1.1",
4
+ "description": "Locale-aware one-line news in your Claude Code status line — local, composes with your existing status line.",
5
+ "bin": {
6
+ "newsline": "newsline"
7
+ },
8
+ "files": [
9
+ "newsline",
10
+ "statusline.sh",
11
+ "refresh.sh",
12
+ "fetch.py",
13
+ "resolve.py",
14
+ "feeds.json",
15
+ "install.sh",
16
+ "README.md",
17
+ "readme/"
18
+ ],
19
+ "os": [
20
+ "darwin",
21
+ "linux"
22
+ ],
23
+ "keywords": [
24
+ "claude-code",
25
+ "statusline",
26
+ "news",
27
+ "cli"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/itdar/cc-plugin.git"
32
+ },
33
+ "homepage": "https://github.com/itdar/cc-plugin",
34
+ "license": "MIT"
35
+ }
package/readme/ko.md ADDED
@@ -0,0 +1,83 @@
1
+ # newsline
2
+
3
+ Claude Code 상태줄에 지역·언어에 맞는 뉴스 한 줄 — **완전 로컬**, 그리고 기존 상태줄(예: HUD)을
4
+ **덮지 않고 그 아래에 붙입니다(compose)**.
5
+
6
+ [English](../README.md) · **한국어**
7
+
8
+ ## 무엇인가
9
+
10
+ 세션 하단에 지역 언어 뉴스 헤드라인을 **회전 표시**하는 Claude Code 상태줄 유틸리티입니다.
11
+ 로케일 감지·피드 수집·파싱·캐시·렌더링 전부 **로컬에서** 돌아갑니다. 코드·프롬프트·파일·Claude
12
+ 대화는 절대 읽지 않습니다.
13
+
14
+ ## 설치
15
+
16
+ **curl | sh** (macOS / Linux / WSL):
17
+ ```sh
18
+ curl -fsSL https://raw.githubusercontent.com/itdar/cc-plugin/master/install.sh | sh
19
+ ```
20
+
21
+ **Homebrew:**
22
+ ```sh
23
+ brew install itdar/tap/newsline
24
+ ```
25
+
26
+ **소스에서:**
27
+ ```sh
28
+ git clone https://github.com/itdar/cc-plugin && cd cc-plugin && ./install.sh
29
+ ```
30
+
31
+ **curl | sh** 설치는 셋업까지 **자동**입니다 — 언어·분야를 물어본 뒤 상태줄을 연결해요.
32
+ **Homebrew**나 **소스** 설치는 직접 실행:
33
+ ```sh
34
+ newsline init
35
+ ```
36
+ `init`이 언어·분야를 물어보고(메뉴에서 번호 선택), 상태줄을 등록합니다. **기존 상태줄은 그대로
37
+ 두고**(HUD 유지) 그 아래 줄에 뉴스를 보여줍니다. Claude Code가 설정을 핫리로드하므로 **재시작
38
+ 없이 다음 메시지 때 바로** 나타납니다.
39
+
40
+ _(설치 중 자동 셋업을 끄려면 `NEWSLINE_NO_INIT=1`.)_
41
+
42
+ ## 설정
43
+
44
+ `newsline init`에서 고르거나, `~/.config/newsline/config.json`을 편집(또는 환경변수 설정):
45
+
46
+ | 키 / 환경변수 | 기본값 | 의미 |
47
+ |---|---|---|
48
+ | `lang` / `NEWSLINE_LANG` | `auto` | `ko` `ja` `en` `es` `fr` `de` `pt` `zh` 또는 `auto` |
49
+ | `topic` / `NEWSLINE_TOPIC` | `general` | `general` `tech` `business` `world` `sports` `science` `health` `entertainment` |
50
+ | `rotate` / `NEWSLINE_ROTATE` | `6` | 헤드라인당 노출 초 |
51
+ | `count` / `NEWSLINE_COUNT` | `15` | 회전용으로 캐시할 헤드라인 수 |
52
+ | `maxlen` / `NEWSLINE_MAXLEN` | `120` | 최대 글자수; `max`=무제한; `NN%`≈터미널 폭 비율 |
53
+ | `icon` / `NEWSLINE_ICON` | `📰` | 앞 아이콘; `none`이면 제거 |
54
+ | `linkhint` / `NEWSLINE_LINKHINT` | off | 끝에 `↗`(외부 열기) 힌트 추가 |
55
+ | `api` / `NEWSLINE_API` | — | 선택: 큐레이션 API (서버 우선, 실패 시 로컬 폴백) |
56
+ | `endpoint` / `NEWSLINE_ENDPOINT` | — | 클릭 리다이렉트 래퍼 |
57
+
58
+ ## 동작 방식
59
+
60
+ - **`newsline render`** (상태줄이 호출) — 캐시에서 **즉시** 출력하고 절대 막지 않으며, 캐시가
61
+ 낡았으면 백그라운드 refresh를 트리거합니다.
62
+ - **`newsline refresh`** — keyless 지역 RSS/Atom 피드를 로컬에서 받아 상위 헤드라인을 파싱·중복
63
+ 제거·캐시합니다.
64
+ - 헤드라인은 클릭 가능한 [OSC 8] 링크입니다. 그냥 클릭 vs `Cmd`/`Ctrl`+클릭으로 열리는지는
65
+ newsline이 아니라 **터미널 설정**이 결정합니다.
66
+
67
+ ## 제거
68
+
69
+ ```sh
70
+ newsline uninstall # 연결을 제거하고 이전 상태줄로 복구
71
+ ```
72
+
73
+ ## 프라이버시
74
+
75
+ 로컬에서 실행되며, 로케일은 시스템 설정에서 감지(IP 지오로케이션 없음), 공개 피드를 직접
76
+ 받아옵니다. 코드·프롬프트·파일·Claude 대화는 접근하지 않습니다. 클릭은 리다이렉트를 거치는데,
77
+ 이는 참여도 측정과 향후 제휴 링크(운영 자금)를 위한 것이며 여기에 명시합니다.
78
+
79
+ ## 요구사항
80
+
81
+ `bash`, `python3`. macOS·Linux는 기본 지원, Windows는 WSL 또는 Git Bash.
82
+
83
+ [OSC 8]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
package/refresh.sh ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # newsline · background refresh — detects locale, fetches a keyless regional
3
+ # RSS feed LOCALLY, parses the top headline, and atomically writes the
4
+ # ready-to-print status line (with an OSC 8 click link) to the cache.
5
+ #
6
+ # Safe to run detached (statusline.sh backgrounds it) or standalone for tests:
7
+ # NEWSLINE_ENDPOINT=http://localhost:8787/r ./refresh.sh && cat ~/.cache/newsline/line
8
+ #
9
+ # Only dependency: python3 (stdlib urllib — no curl, no API key, no server).
10
+
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ CACHE_DIR="${NEWSLINE_CACHE:-$HOME/.cache/newsline}"
13
+ CACHE_FILE="$CACHE_DIR/line"
14
+ FEEDS="${NEWSLINE_FEEDS:-$HERE/feeds.json}"
15
+ # The redirect wrapper you control. This is the ONE remote piece — clicks pass
16
+ # through it so monetization can be switched on later WITHOUT re-shipping.
17
+ ENDPOINT="${NEWSLINE_ENDPOINT:-https://newsline.thesockerrr.workers.dev/r}"
18
+ COUNT="${NEWSLINE_COUNT:-15}" # how many headlines to cache for rotation
19
+ LOCK="$CACHE_DIR/refresh.lock"
20
+ mkdir -p "$CACHE_DIR" 2>/dev/null
21
+
22
+ # --- single-flight: if another refresh is already running, bail quietly ---
23
+ if ! mkdir "$LOCK" 2>/dev/null; then exit 0; fi
24
+ trap 'rmdir "$LOCK" 2>/dev/null' EXIT
25
+
26
+ # --- detect locale language (region-appropriate news) ---
27
+ # priority: explicit NEWSLINE_LANG > macOS system region > locale env vars > en.
28
+ # (macOS terminals often have LANG=en_US even for non-US users, so the system
29
+ # region AppleLocale is the truer "where am I" signal for regional news.)
30
+ raw="${NEWSLINE_LANG:-}"
31
+ if [ -z "$raw" ] && command -v defaults >/dev/null 2>&1; then
32
+ raw="$(defaults read NSGlobalDomain AppleLocale 2>/dev/null)" # e.g. ko_KR
33
+ fi
34
+ [ -z "$raw" ] && raw="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}"
35
+ case "$raw" in ""|C|POSIX|C.*|POSIX.*) raw="en_US" ;; esac
36
+ lang="${raw%%.*}" # strip .UTF-8
37
+ lang="${lang%%@*}" # strip @variant
38
+ lang="${lang%%_*}" # strip _REGION
39
+ lang="${lang%%-*}" # strip -Script (e.g. zh-Hans)
40
+ [ -z "$lang" ] && lang="en"
41
+
42
+ # derive coarse country/region from raw (ko_KR -> KR); empty if none
43
+ reg="${raw%%.*}"; reg="${reg%%@*}"
44
+ case "$reg" in
45
+ *_*) country="${reg#*_}"; country="${country%%-*}" ;;
46
+ *) country="" ;;
47
+ esac
48
+
49
+ # --- server-first feed selection (COARSE context only), local fallback ---
50
+ # resolve.py tries $NEWSLINE_API/feed; on any failure it returns local feeds.json.
51
+ # We send lang/country/localtime/dow/tz only — no personal data, no tracking id.
52
+ RESOLVED="$CACHE_DIR/feeds.resolved.json"
53
+ export NEWSLINE_COUNTRY="$country"
54
+ export NEWSLINE_LOCALTIME="$(date +%H%M)"
55
+ export NEWSLINE_DOW="$(date +%u)"
56
+ export NEWSLINE_TZ="$(date +%z)"
57
+ if ! python3 "$HERE/resolve.py" "$lang" "$FEEDS" "${NEWSLINE_API:-https://newsline.thesockerrr.workers.dev}" > "$RESOLVED" 2>/dev/null \
58
+ || [ ! -s "$RESOLVED" ]; then
59
+ cp "$FEEDS" "$RESOLVED" 2>/dev/null # last-ditch fallback to bundled feeds
60
+ fi
61
+
62
+ # --- fetch + parse + build the line; write atomically, keep old cache on failure ---
63
+ if python3 "$HERE/fetch.py" "$lang" "$RESOLVED" "$ENDPOINT" "$COUNT" > "$CACHE_DIR/line.tmp" 2>/dev/null \
64
+ && [ -s "$CACHE_DIR/line.tmp" ]; then
65
+ mv -f "$CACHE_DIR/line.tmp" "$CACHE_FILE"
66
+ else
67
+ rm -f "$CACHE_DIR/line.tmp" 2>/dev/null # graceful fallback: previous cache stays
68
+ fi
package/resolve.py ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """newsline · resolve which feeds to use — server-first, local fallback, topic-aware.
3
+
4
+ Tries the operator's curation API (NEWSLINE_API) with a short timeout, sending only
5
+ COARSE CONTEXT (lang, country, localtime, dow, tz, topic) — never personal data. On
6
+ ANY failure it falls back to the bundled feeds.json. If a topic preference is set
7
+ (NEWSLINE_TOPIC), a keyless Google News topic feed is prepended so that topic leads.
8
+
9
+ Prints a feeds.json-shaped object fetch.py consumes: {"<lang>": [...], "default": [...]}
10
+
11
+ Usage: resolve.py <lang> <local_feeds.json> [api_base]
12
+ Context from env: NEWSLINE_COUNTRY, NEWSLINE_LOCALTIME, NEWSLINE_DOW, NEWSLINE_TZ, NEWSLINE_TOPIC
13
+ """
14
+ import os
15
+ import sys
16
+ import json
17
+ import urllib.request
18
+ import urllib.parse
19
+
20
+ TIMEOUT = float(os.environ.get("NEWSLINE_API_TIMEOUT") or 2)
21
+
22
+ # user-friendly topic -> Google News section topic
23
+ GN_TOPICS = {
24
+ "tech": "TECHNOLOGY", "technology": "TECHNOLOGY",
25
+ "business": "BUSINESS", "biz": "BUSINESS",
26
+ "sports": "SPORTS", "sport": "SPORTS",
27
+ "world": "WORLD", "international": "WORLD",
28
+ "science": "SCIENCE", "sci": "SCIENCE",
29
+ "health": "HEALTH",
30
+ "entertainment": "ENTERTAINMENT", "ent": "ENTERTAINMENT",
31
+ "nation": "NATION", "national": "NATION",
32
+ }
33
+ LANG_COUNTRY = {"ko": "KR", "ja": "JP", "en": "US", "de": "DE",
34
+ "fr": "FR", "es": "ES", "pt": "BR", "zh": "CN"}
35
+
36
+
37
+ def topic_feed(lang, country, topic):
38
+ topic = (topic or "").strip().lower()
39
+ if topic in ("", "general", "all", "none"):
40
+ return None
41
+ t = GN_TOPICS.get(topic)
42
+ if not t:
43
+ return None
44
+ gl = country or LANG_COUNTRY.get(lang, lang.upper())
45
+ return (f"https://news.google.com/rss/headlines/section/topic/{t}"
46
+ f"?hl={lang}&gl={gl}&ceid={gl}:{lang}")
47
+
48
+
49
+ def local_feeds(lang, path):
50
+ try:
51
+ feeds = json.load(open(path, encoding="utf-8"))
52
+ except Exception:
53
+ feeds = {}
54
+ default = feeds.get("default") or []
55
+ return (feeds.get(lang) or default or []), default
56
+
57
+
58
+ def server_feeds(lang, api_base):
59
+ params = {
60
+ "lang": lang,
61
+ "country": os.environ.get("NEWSLINE_COUNTRY", ""),
62
+ "localtime": os.environ.get("NEWSLINE_LOCALTIME", ""),
63
+ "dow": os.environ.get("NEWSLINE_DOW", ""),
64
+ "tz": os.environ.get("NEWSLINE_TZ", ""),
65
+ "topic": os.environ.get("NEWSLINE_TOPIC", ""),
66
+ }
67
+ url = api_base.rstrip("/") + "/feed?" + urllib.parse.urlencode(params)
68
+ req = urllib.request.Request(url, headers={"User-Agent": "newsline/0.1"})
69
+ obj = json.loads(urllib.request.urlopen(req, timeout=TIMEOUT).read())
70
+ feeds = obj.get("feeds")
71
+ if isinstance(feeds, list) and feeds and all(isinstance(x, str) for x in feeds):
72
+ return feeds
73
+ return None
74
+
75
+
76
+ def main():
77
+ if len(sys.argv) < 3:
78
+ return 2
79
+ lang, local_path = sys.argv[1], sys.argv[2]
80
+ api_base = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("NEWSLINE_API", "")
81
+
82
+ local_urls, default = local_feeds(lang, local_path)
83
+
84
+ feeds = None
85
+ if api_base:
86
+ try:
87
+ feeds = server_feeds(lang, api_base) # server-first
88
+ except Exception:
89
+ feeds = None # any issue -> local fallback
90
+ feeds = feeds or local_urls or default
91
+
92
+ # topic preference leads (keyless Google News topic feed), general as backup
93
+ tfeed = topic_feed(lang, os.environ.get("NEWSLINE_COUNTRY", ""), os.environ.get("NEWSLINE_TOPIC", ""))
94
+ if tfeed:
95
+ feeds = [tfeed] + [f for f in feeds if f != tfeed]
96
+
97
+ if not feeds:
98
+ return 1
99
+ sys.stdout.write(json.dumps({lang: feeds, "default": default or feeds}, ensure_ascii=False))
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ sys.exit(main())
package/statusline.sh ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # newsline · statusLine entry — MUST stay fast. Prints the cached headline
3
+ # instantly and, if the cache is stale, kicks off a DETACHED background
4
+ # refresh. It never fetches in the foreground, so the status line never lags.
5
+ #
6
+ # Wire it into ~/.claude/settings.json via settings.snippet.json.
7
+
8
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ CACHE_DIR="${NEWSLINE_CACHE:-$HOME/.cache/newsline}"
10
+ CACHE_FILE="$CACHE_DIR/line"
11
+ TTL="${NEWSLINE_TTL:-900}" # refresh cache older than this many seconds
12
+ mkdir -p "$CACHE_DIR" 2>/dev/null
13
+
14
+ # --- decide whether a refresh is due (missing or older than TTL) ---
15
+ needs_refresh=1
16
+ if [ -f "$CACHE_FILE" ]; then
17
+ now=$(date +%s)
18
+ mtime=$(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)
19
+ if [ $((now - mtime)) -lt "$TTL" ]; then needs_refresh=0; fi
20
+ fi
21
+
22
+ # --- fire-and-forget background refresh; never block the status line ---
23
+ if [ "$needs_refresh" = "1" ]; then
24
+ ( "$HERE/refresh.sh" >/dev/null 2>&1 & ) 2>/dev/null
25
+ fi
26
+
27
+ # --- print one headline, rotating across the cached set by wall-clock time ---
28
+ # The cache holds N headlines (one per line). We show a different one every
29
+ # NEWSLINE_ROTATE seconds — a ticker feel with zero stored state.
30
+ if [ -s "$CACHE_FILE" ]; then
31
+ n=$(awk 'END{print NR}' "$CACHE_FILE" 2>/dev/null)
32
+ if [ "${n:-0}" -gt 0 ] 2>/dev/null; then
33
+ rot="${NEWSLINE_ROTATE:-6}" # seconds each headline stays up
34
+ now="${NEWSLINE_NOW:-$(date +%s)}" # NEWSLINE_NOW overrides clock (tests/debug)
35
+ idx=$(( (now / rot) % n + 1 ))
36
+ sed -n "${idx}p" "$CACHE_FILE"
37
+ else
38
+ printf '📰 …'
39
+ fi
40
+ else
41
+ printf '📰 …' # first run, before the first refresh lands
42
+ fi