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 +86 -0
- package/feeds.json +39 -0
- package/fetch.py +145 -0
- package/install.sh +69 -0
- package/newsline +257 -0
- package/package.json +35 -0
- package/readme/ko.md +83 -0
- package/refresh.sh +68 -0
- package/resolve.py +104 -0
- package/statusline.sh +42 -0
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
|