nulltap 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- nulltap-0.2.0/PKG-INFO +120 -0
- nulltap-0.2.0/README.md +96 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/pyproject.toml +5 -3
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap/__init__.py +1 -1
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap/cli.py +232 -41
- nulltap-0.2.0/src/nulltap.egg-info/PKG-INFO +120 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/tests/test_cli.py +96 -7
- nulltap-0.1.0/PKG-INFO +0 -98
- nulltap-0.1.0/README.md +0 -76
- nulltap-0.1.0/src/nulltap.egg-info/PKG-INFO +0 -98
- {nulltap-0.1.0 → nulltap-0.2.0}/LICENSE +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/setup.cfg +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap/__main__.py +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap.egg-info/SOURCES.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap.egg-info/dependency_links.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap.egg-info/entry_points.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.2.0}/src/nulltap.egg-info/top_level.txt +0 -0
nulltap-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nulltap
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Read Nulltap cybersecurity and AI articles from a terminal.
|
|
5
|
+
Author: Justin Howe
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
+
Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
|
|
11
|
+
Keywords: cybersecurity,ai,news,cli,nulltap
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nulltap
|
|
26
|
+
|
|
27
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pipx install nulltap
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To upgrade an existing installation:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx upgrade nulltap
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
44
|
+
|
|
45
|
+
## Start here
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
nulltap # browse recent articles
|
|
49
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
50
|
+
nulltap topics # list every topic and its article count
|
|
51
|
+
nulltap topic identity # browse one topic
|
|
52
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
53
|
+
nulltap read 2 # read the second result in the terminal
|
|
54
|
+
nulltap read 2 --short # read its 1-minute version
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
58
|
+
|
|
59
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
60
|
+
|
|
61
|
+
## Reading modes
|
|
62
|
+
|
|
63
|
+
Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
nulltap search "token theft" --short
|
|
67
|
+
nulltap read 2 --short
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
|
|
71
|
+
|
|
72
|
+
## Topics
|
|
73
|
+
|
|
74
|
+
Nulltap currently publishes under these topics:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
endpoint
|
|
78
|
+
cloud
|
|
79
|
+
network
|
|
80
|
+
identity
|
|
81
|
+
appsec
|
|
82
|
+
AI
|
|
83
|
+
threats
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
87
|
+
|
|
88
|
+
## Search and recent filters
|
|
89
|
+
|
|
90
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
91
|
+
|
|
92
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
93
|
+
|
|
94
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
nulltap search ransomware --days 30
|
|
98
|
+
nulltap topic cloud --days 14
|
|
99
|
+
nulltap topics --days 90
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Automation
|
|
103
|
+
|
|
104
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
nulltap latest --days 7 --json
|
|
108
|
+
nulltap search ransomware --json
|
|
109
|
+
nulltap topics --plain
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
113
|
+
|
|
114
|
+
## Network and privacy
|
|
115
|
+
|
|
116
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
117
|
+
|
|
118
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
119
|
+
|
|
120
|
+
Licensed under the MIT License.
|
nulltap-0.2.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# nulltap
|
|
2
|
+
|
|
3
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pipx install nulltap
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To upgrade an existing installation:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pipx upgrade nulltap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
20
|
+
|
|
21
|
+
## Start here
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
nulltap # browse recent articles
|
|
25
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
26
|
+
nulltap topics # list every topic and its article count
|
|
27
|
+
nulltap topic identity # browse one topic
|
|
28
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
29
|
+
nulltap read 2 # read the second result in the terminal
|
|
30
|
+
nulltap read 2 --short # read its 1-minute version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
34
|
+
|
|
35
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
36
|
+
|
|
37
|
+
## Reading modes
|
|
38
|
+
|
|
39
|
+
Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
nulltap search "token theft" --short
|
|
43
|
+
nulltap read 2 --short
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
|
|
47
|
+
|
|
48
|
+
## Topics
|
|
49
|
+
|
|
50
|
+
Nulltap currently publishes under these topics:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
endpoint
|
|
54
|
+
cloud
|
|
55
|
+
network
|
|
56
|
+
identity
|
|
57
|
+
appsec
|
|
58
|
+
AI
|
|
59
|
+
threats
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
63
|
+
|
|
64
|
+
## Search and recent filters
|
|
65
|
+
|
|
66
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
67
|
+
|
|
68
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
69
|
+
|
|
70
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
nulltap search ransomware --days 30
|
|
74
|
+
nulltap topic cloud --days 14
|
|
75
|
+
nulltap topics --days 90
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Automation
|
|
79
|
+
|
|
80
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
nulltap latest --days 7 --json
|
|
84
|
+
nulltap search ransomware --json
|
|
85
|
+
nulltap topics --plain
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
89
|
+
|
|
90
|
+
## Network and privacy
|
|
91
|
+
|
|
92
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
93
|
+
|
|
94
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
95
|
+
|
|
96
|
+
Licensed under the MIT License.
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nulltap"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Read
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Read Nulltap cybersecurity and AI articles from a terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [{ name = "Justin Howe" }]
|
|
13
|
-
keywords = ["cybersecurity", "news", "cli", "nulltap"]
|
|
13
|
+
keywords = ["cybersecurity", "ai", "news", "cli", "nulltap"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
16
|
"Environment :: Console",
|
|
@@ -29,6 +29,8 @@ nulltap = "nulltap.cli:main"
|
|
|
29
29
|
[project.urls]
|
|
30
30
|
Homepage = "https://nulltap.sh"
|
|
31
31
|
Repository = "https://github.com/JustinHowe/nulltap"
|
|
32
|
+
Issues = "https://github.com/JustinHowe/nulltap/issues"
|
|
33
|
+
Releases = "https://github.com/JustinHowe/nulltap/releases"
|
|
32
34
|
|
|
33
35
|
[tool.setuptools.packages.find]
|
|
34
36
|
where = ["src"]
|
|
@@ -14,6 +14,7 @@ import urllib.request
|
|
|
14
14
|
import webbrowser
|
|
15
15
|
from collections import Counter
|
|
16
16
|
from collections.abc import Callable, Iterable, Sequence
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
17
18
|
from typing import Any, TextIO
|
|
18
19
|
|
|
19
20
|
from . import __version__
|
|
@@ -90,6 +91,10 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
|
|
|
90
91
|
read_time = max(0, int(raw.get("read_time_minutes", 0)))
|
|
91
92
|
except (TypeError, ValueError):
|
|
92
93
|
read_time = 0
|
|
94
|
+
try:
|
|
95
|
+
short_read_time = max(0, int(raw.get("short_read_time_minutes", 0)))
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
short_read_time = 0
|
|
93
98
|
|
|
94
99
|
content_url = safe_http_url(raw.get("content_url"))
|
|
95
100
|
if content_url and not same_origin(url, content_url):
|
|
@@ -104,11 +109,42 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
|
|
|
104
109
|
"date_published": safe_text(raw.get("date_published")),
|
|
105
110
|
"tags": tags,
|
|
106
111
|
"read_time_minutes": read_time,
|
|
112
|
+
"short_read_available": bool(raw.get("short_read_available", False)),
|
|
113
|
+
"short_read_time_minutes": short_read_time,
|
|
107
114
|
"author": safe_text(raw.get("author")),
|
|
108
115
|
"image": safe_http_url(raw.get("image")),
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
|
|
119
|
+
def normalize_topic(raw: Any) -> dict[str, str] | None:
|
|
120
|
+
if not isinstance(raw, dict):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
topic_id = safe_text(raw.get("id") or raw.get("topic")).lower()
|
|
124
|
+
if not topic_id:
|
|
125
|
+
return None
|
|
126
|
+
return {
|
|
127
|
+
"topic": topic_id,
|
|
128
|
+
"label": safe_text(raw.get("label")) or topic_id,
|
|
129
|
+
"description": safe_text(raw.get("description")),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def normalize_feed_payload(payload: Any) -> dict[str, list[dict[str, Any]]]:
|
|
134
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
|
135
|
+
raise FeedError("feed does not contain an items array")
|
|
136
|
+
|
|
137
|
+
items = [item for raw in payload["items"] if (item := normalize_item(raw))]
|
|
138
|
+
raw_topics = payload.get("topics", [])
|
|
139
|
+
if not isinstance(raw_topics, list):
|
|
140
|
+
raw_topics = []
|
|
141
|
+
topics = [topic for raw in raw_topics if (topic := normalize_topic(raw))]
|
|
142
|
+
return {
|
|
143
|
+
"items": sorted(items, key=lambda item: item["date_published"], reverse=True),
|
|
144
|
+
"topics": list({topic["topic"]: topic for topic in topics}.values()),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
112
148
|
def _fetch_json(
|
|
113
149
|
url: str,
|
|
114
150
|
*,
|
|
@@ -150,11 +186,11 @@ def _fetch_json(
|
|
|
150
186
|
raise FeedError(f"{label} returned invalid JSON") from exc
|
|
151
187
|
|
|
152
188
|
|
|
153
|
-
def
|
|
189
|
+
def fetch_feed_document(
|
|
154
190
|
url: str = DEFAULT_FEED_URL,
|
|
155
191
|
timeout: float = 10.0,
|
|
156
192
|
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
157
|
-
) -> list[dict[str, Any]]:
|
|
193
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
158
194
|
payload = _fetch_json(
|
|
159
195
|
url,
|
|
160
196
|
timeout=timeout,
|
|
@@ -163,11 +199,16 @@ def fetch_feed(
|
|
|
163
199
|
accept="application/feed+json, application/json",
|
|
164
200
|
opener=opener,
|
|
165
201
|
)
|
|
166
|
-
|
|
167
|
-
raise FeedError("feed does not contain an items array")
|
|
202
|
+
return normalize_feed_payload(payload)
|
|
168
203
|
|
|
169
|
-
|
|
170
|
-
|
|
204
|
+
|
|
205
|
+
def fetch_feed(
|
|
206
|
+
url: str = DEFAULT_FEED_URL,
|
|
207
|
+
timeout: float = 10.0,
|
|
208
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
209
|
+
) -> list[dict[str, Any]]:
|
|
210
|
+
"""Return feed items for callers using the 0.1.0 list-based API."""
|
|
211
|
+
return fetch_feed_document(url, timeout, opener)["items"]
|
|
171
212
|
|
|
172
213
|
|
|
173
214
|
def fetch_article(
|
|
@@ -189,6 +230,13 @@ def fetch_article(
|
|
|
189
230
|
)
|
|
190
231
|
if not isinstance(payload, dict) or not isinstance(payload.get("content_text"), str):
|
|
191
232
|
raise FeedError("article does not contain terminal-readable text")
|
|
233
|
+
short_content = payload.get("short_content_text", "")
|
|
234
|
+
if short_content and not isinstance(short_content, str):
|
|
235
|
+
raise FeedError("article contains invalid 1-minute text")
|
|
236
|
+
try:
|
|
237
|
+
short_read_time = max(0, int(payload.get("short_read_time_minutes", 0) or 0))
|
|
238
|
+
except (TypeError, ValueError):
|
|
239
|
+
short_read_time = 0
|
|
192
240
|
|
|
193
241
|
raw_sources = payload.get("sources", [])
|
|
194
242
|
if not isinstance(raw_sources, list):
|
|
@@ -205,6 +253,8 @@ def fetch_article(
|
|
|
205
253
|
return {
|
|
206
254
|
**item,
|
|
207
255
|
"content_text": safe_article_text(payload["content_text"]),
|
|
256
|
+
"short_content_text": safe_article_text(short_content),
|
|
257
|
+
"short_read_time_minutes": short_read_time,
|
|
208
258
|
"sources": sources,
|
|
209
259
|
}
|
|
210
260
|
|
|
@@ -219,6 +269,16 @@ def positive_int(value: str) -> int:
|
|
|
219
269
|
return parsed
|
|
220
270
|
|
|
221
271
|
|
|
272
|
+
def positive_days(value: str) -> int:
|
|
273
|
+
try:
|
|
274
|
+
parsed = int(value)
|
|
275
|
+
except ValueError as exc:
|
|
276
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
277
|
+
if parsed < 1 or parsed > 36500:
|
|
278
|
+
raise argparse.ArgumentTypeError("must be between 1 and 36500")
|
|
279
|
+
return parsed
|
|
280
|
+
|
|
281
|
+
|
|
222
282
|
def build_parser() -> argparse.ArgumentParser:
|
|
223
283
|
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
|
|
224
284
|
common.add_argument(
|
|
@@ -230,42 +290,90 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
230
290
|
common.add_argument("--json", action="store_true", help="emit JSON for scripts")
|
|
231
291
|
common.add_argument("--plain", action="store_true", help="print once without menus or a pager")
|
|
232
292
|
common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
|
|
293
|
+
common.add_argument("--days", type=positive_days, metavar="N", help="limit the feed to the last N days")
|
|
233
294
|
common.add_argument("--no-color", action="store_true", help="disable terminal color")
|
|
295
|
+
reading_mode = common.add_mutually_exclusive_group()
|
|
296
|
+
reading_mode.add_argument(
|
|
297
|
+
"--short",
|
|
298
|
+
action="store_true",
|
|
299
|
+
help="read 1-minute versions (or set NULLTAP_READING_MODE=short)",
|
|
300
|
+
)
|
|
301
|
+
reading_mode.add_argument("--full", action="store_true", help="force full articles")
|
|
234
302
|
|
|
235
303
|
parser = argparse.ArgumentParser(
|
|
236
304
|
prog="nulltap",
|
|
237
305
|
parents=[common],
|
|
238
|
-
description="
|
|
306
|
+
description="Read Nulltap articles without leaving the terminal.",
|
|
307
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
308
|
+
epilog=textwrap.dedent(
|
|
309
|
+
"""
|
|
310
|
+
examples:
|
|
311
|
+
nulltap browse recent articles
|
|
312
|
+
nulltap latest --days 7 show the last seven days
|
|
313
|
+
nulltap topics list the current topic catalog
|
|
314
|
+
nulltap topic identity browse one topic
|
|
315
|
+
nulltap search "token theft" search titles, summaries, and tags
|
|
316
|
+
nulltap read 2 read the second result in the terminal
|
|
317
|
+
nulltap read 2 --short read its 1-minute version
|
|
318
|
+
|
|
319
|
+
Run 'nulltap COMMAND --help' for command-specific options.
|
|
320
|
+
"""
|
|
321
|
+
),
|
|
239
322
|
)
|
|
240
323
|
parser.add_argument("--version", action="version", version=f"nulltap {__version__}")
|
|
241
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
324
|
+
subparsers = parser.add_subparsers(dest="command", title="commands", metavar="COMMAND")
|
|
242
325
|
|
|
243
|
-
browse = subparsers.add_parser(
|
|
244
|
-
|
|
326
|
+
browse = subparsers.add_parser(
|
|
327
|
+
"browse",
|
|
328
|
+
parents=[common],
|
|
329
|
+
help="browse the feed (default)",
|
|
330
|
+
description="Browse recent Nulltap articles, newest first.",
|
|
331
|
+
)
|
|
332
|
+
browse.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
|
|
245
333
|
browse.add_argument("-t", "--topic", help="limit results to one topic")
|
|
246
334
|
|
|
247
|
-
latest = subparsers.add_parser(
|
|
248
|
-
|
|
335
|
+
latest = subparsers.add_parser(
|
|
336
|
+
"latest",
|
|
337
|
+
parents=[common],
|
|
338
|
+
help="browse the newest articles",
|
|
339
|
+
description="Browse the newest Nulltap articles.",
|
|
340
|
+
)
|
|
341
|
+
latest.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
|
|
249
342
|
latest.add_argument("-t", "--topic", help="limit results to one topic")
|
|
250
343
|
|
|
251
|
-
subparsers.add_parser(
|
|
344
|
+
subparsers.add_parser(
|
|
345
|
+
"topics",
|
|
346
|
+
parents=[common],
|
|
347
|
+
help="list topics or choose one interactively",
|
|
348
|
+
description="List every Nulltap topic and its article count.",
|
|
349
|
+
)
|
|
252
350
|
|
|
253
|
-
topic = subparsers.add_parser(
|
|
254
|
-
|
|
255
|
-
|
|
351
|
+
topic = subparsers.add_parser(
|
|
352
|
+
"topic",
|
|
353
|
+
parents=[common],
|
|
354
|
+
help="browse articles for one topic",
|
|
355
|
+
description="Browse articles filed under one topic.",
|
|
356
|
+
)
|
|
357
|
+
topic.add_argument("name", nargs="?", help="topic ID; run 'nulltap topics' to list them")
|
|
358
|
+
topic.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum articles shown (default: 100)")
|
|
256
359
|
|
|
257
|
-
search = subparsers.add_parser(
|
|
360
|
+
search = subparsers.add_parser(
|
|
361
|
+
"search",
|
|
362
|
+
parents=[common],
|
|
363
|
+
help="search titles, summaries, and topic tags",
|
|
364
|
+
description="Search article titles, summaries, and topic tags.",
|
|
365
|
+
)
|
|
258
366
|
search.add_argument("query", nargs="*", help="words to search for")
|
|
259
367
|
search.add_argument("-t", "--topic", help="limit results to one topic")
|
|
260
|
-
search.add_argument("-n", "--limit", type=positive_int, default=100)
|
|
368
|
+
search.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum matches shown (default: 100)")
|
|
261
369
|
|
|
262
|
-
read = subparsers.add_parser("read", parents=[common], help="read
|
|
370
|
+
read = subparsers.add_parser("read", parents=[common], help="read in the terminal or browse when omitted")
|
|
263
371
|
read.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
264
372
|
|
|
265
373
|
show = subparsers.add_parser("show", parents=[common], help="alias for read")
|
|
266
374
|
show.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
267
375
|
|
|
268
|
-
open_command = subparsers.add_parser("open", parents=[common], help="
|
|
376
|
+
open_command = subparsers.add_parser("open", parents=[common], help="optional handoff to a web browser")
|
|
269
377
|
open_command.add_argument("target", help="result number, article ID, slug, or URL")
|
|
270
378
|
|
|
271
379
|
return parser
|
|
@@ -278,6 +386,33 @@ def filter_topic(items: Iterable[dict[str, Any]], topic: str | None) -> list[dic
|
|
|
278
386
|
return [item for item in items if wanted in item["tags"]]
|
|
279
387
|
|
|
280
388
|
|
|
389
|
+
def filter_days(
|
|
390
|
+
items: Iterable[dict[str, Any]],
|
|
391
|
+
days: int | None,
|
|
392
|
+
*,
|
|
393
|
+
now: datetime | None = None,
|
|
394
|
+
) -> list[dict[str, Any]]:
|
|
395
|
+
if not days:
|
|
396
|
+
return list(items)
|
|
397
|
+
|
|
398
|
+
current = now or datetime.now(timezone.utc)
|
|
399
|
+
if current.tzinfo is None:
|
|
400
|
+
current = current.replace(tzinfo=timezone.utc)
|
|
401
|
+
cutoff = current.astimezone(timezone.utc) - timedelta(days=days)
|
|
402
|
+
selected = []
|
|
403
|
+
for item in items:
|
|
404
|
+
raw_date = item.get("date_published", "")
|
|
405
|
+
try:
|
|
406
|
+
published = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
|
|
407
|
+
except (AttributeError, TypeError, ValueError):
|
|
408
|
+
continue
|
|
409
|
+
if published.tzinfo is None:
|
|
410
|
+
published = published.replace(tzinfo=timezone.utc)
|
|
411
|
+
if published.astimezone(timezone.utc) >= cutoff:
|
|
412
|
+
selected.append(item)
|
|
413
|
+
return selected
|
|
414
|
+
|
|
415
|
+
|
|
281
416
|
def search_items(items: Iterable[dict[str, Any]], query: str) -> list[dict[str, Any]]:
|
|
282
417
|
phrase = safe_text(query).lower()
|
|
283
418
|
tokens = [token for token in phrase.split() if token]
|
|
@@ -439,6 +574,20 @@ def render_markdown(markdown: str, width: int, use_color: bool) -> tuple[str, li
|
|
|
439
574
|
return "\n".join(output), links
|
|
440
575
|
|
|
441
576
|
|
|
577
|
+
def select_reading_mode(article: dict[str, Any], short_mode: bool) -> dict[str, Any]:
|
|
578
|
+
if not short_mode:
|
|
579
|
+
return {**article, "reading_mode": "full"}
|
|
580
|
+
short_content = article.get("short_content_text", "")
|
|
581
|
+
if not short_content:
|
|
582
|
+
raise FeedError("this article does not include a 1-minute read")
|
|
583
|
+
return {
|
|
584
|
+
**article,
|
|
585
|
+
"content_text": short_content,
|
|
586
|
+
"read_time_minutes": article.get("short_read_time_minutes", 1) or 1,
|
|
587
|
+
"reading_mode": "short",
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
442
591
|
def render_article(item: dict[str, Any], use_color: bool, width: int | None = None) -> str:
|
|
443
592
|
width = width or terminal_width()
|
|
444
593
|
lines: list[str] = []
|
|
@@ -446,6 +595,8 @@ def render_article(item: dict[str, Any], use_color: bool, width: int | None = No
|
|
|
446
595
|
lines.extend(paint(line, "1", use_color) for line in title_lines)
|
|
447
596
|
|
|
448
597
|
metadata = [display_date(item["date_published"]), *item["tags"]]
|
|
598
|
+
if item.get("reading_mode") == "short":
|
|
599
|
+
metadata.append("1-minute read")
|
|
449
600
|
if item["read_time_minutes"]:
|
|
450
601
|
metadata.append(f"{item['read_time_minutes']} min")
|
|
451
602
|
if item["author"]:
|
|
@@ -529,8 +680,9 @@ def show_article(
|
|
|
529
680
|
use_color: bool,
|
|
530
681
|
use_pager: bool,
|
|
531
682
|
pager: Callable[[str], None],
|
|
683
|
+
short_mode: bool,
|
|
532
684
|
) -> None:
|
|
533
|
-
article = article_loader(item, timeout)
|
|
685
|
+
article = select_reading_mode(article_loader(item, timeout), short_mode)
|
|
534
686
|
rendered = render_article(article, use_color)
|
|
535
687
|
if use_pager:
|
|
536
688
|
pager(rendered)
|
|
@@ -549,6 +701,7 @@ def browse_items(
|
|
|
549
701
|
article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
|
|
550
702
|
timeout: float,
|
|
551
703
|
pager: Callable[[str], None],
|
|
704
|
+
short_mode: bool,
|
|
552
705
|
) -> int:
|
|
553
706
|
if not items:
|
|
554
707
|
print("No published articles matched.", file=stdout)
|
|
@@ -599,6 +752,7 @@ def browse_items(
|
|
|
599
752
|
use_color=use_color,
|
|
600
753
|
use_pager=True,
|
|
601
754
|
pager=pager,
|
|
755
|
+
short_mode=short_mode,
|
|
602
756
|
)
|
|
603
757
|
continue
|
|
604
758
|
print(f"Choose an article from {start + 1} to {end}, or use n, p, or q.", file=stdout)
|
|
@@ -609,23 +763,41 @@ def json_dump(value: Any, stream: TextIO) -> None:
|
|
|
609
763
|
print(file=stream)
|
|
610
764
|
|
|
611
765
|
|
|
612
|
-
def topic_counts(
|
|
766
|
+
def topic_counts(
|
|
767
|
+
items: Iterable[dict[str, Any]],
|
|
768
|
+
catalog: Iterable[dict[str, str]] = (),
|
|
769
|
+
) -> list[dict[str, Any]]:
|
|
613
770
|
counts = Counter(tag for item in items for tag in item["tags"])
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
771
|
+
rows = []
|
|
772
|
+
seen = set()
|
|
773
|
+
for entry in catalog:
|
|
774
|
+
topic = entry["topic"]
|
|
775
|
+
if topic in seen:
|
|
776
|
+
continue
|
|
777
|
+
seen.add(topic)
|
|
778
|
+
rows.append({**entry, "articles": counts.get(topic, 0)})
|
|
779
|
+
for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0])):
|
|
780
|
+
if topic not in seen:
|
|
781
|
+
rows.append({"topic": topic, "label": topic, "description": "", "articles": count})
|
|
782
|
+
return rows
|
|
618
783
|
|
|
619
784
|
|
|
620
|
-
def choose_topic(
|
|
621
|
-
|
|
785
|
+
def choose_topic(
|
|
786
|
+
items: Sequence[dict[str, Any]],
|
|
787
|
+
catalog: Sequence[dict[str, str]],
|
|
788
|
+
stdin: TextIO,
|
|
789
|
+
stdout: TextIO,
|
|
790
|
+
use_color: bool,
|
|
791
|
+
) -> str | None:
|
|
792
|
+
topics = topic_counts(items, catalog)
|
|
622
793
|
if not topics:
|
|
623
794
|
print("No published topics yet.", file=stdout)
|
|
624
795
|
return None
|
|
625
796
|
print(file=stdout)
|
|
626
797
|
print(paint("Topics", "1;38;5;214", use_color), file=stdout)
|
|
627
798
|
for index, row in enumerate(topics, 1):
|
|
628
|
-
|
|
799
|
+
noun = "article" if row["articles"] == 1 else "articles"
|
|
800
|
+
print(f"[{index}] {row['label']:<12} {row['articles']} {noun}", file=stdout)
|
|
629
801
|
choice = read_prompt("\nChoose a topic number, or q to quit\n> ", stdin, stdout).lower()
|
|
630
802
|
if choice in {"q", "quit", "exit"}:
|
|
631
803
|
return None
|
|
@@ -641,7 +813,7 @@ def run(
|
|
|
641
813
|
stdin: TextIO = sys.stdin,
|
|
642
814
|
stdout: TextIO = sys.stdout,
|
|
643
815
|
stderr: TextIO = sys.stderr,
|
|
644
|
-
feed_loader: Callable[[str, float],
|
|
816
|
+
feed_loader: Callable[[str, float], Any] = fetch_feed_document,
|
|
645
817
|
article_loader: Callable[[dict[str, Any], float], dict[str, Any]] = fetch_article,
|
|
646
818
|
browser_opener: Callable[..., bool] = webbrowser.open,
|
|
647
819
|
pager: Callable[[str], None] = pydoc.pager,
|
|
@@ -654,26 +826,40 @@ def run(
|
|
|
654
826
|
json_output = getattr(args, "json", False)
|
|
655
827
|
plain = getattr(args, "plain", False)
|
|
656
828
|
no_color = getattr(args, "no_color", False)
|
|
829
|
+
configured_mode = safe_text(os.environ.get("NULLTAP_READING_MODE", "full")).lower()
|
|
830
|
+
short_mode = getattr(args, "short", False) or (
|
|
831
|
+
configured_mode == "short" and not getattr(args, "full", False)
|
|
832
|
+
)
|
|
657
833
|
page_size = min(getattr(args, "page_size", 5), 50)
|
|
658
834
|
|
|
659
835
|
if timeout <= 0 or timeout > 120:
|
|
660
836
|
parser.error("--timeout must be greater than 0 and no more than 120 seconds")
|
|
661
837
|
|
|
662
|
-
|
|
838
|
+
loaded_feed = feed_loader(feed_url, timeout)
|
|
839
|
+
if isinstance(loaded_feed, dict):
|
|
840
|
+
feed = normalize_feed_payload(loaded_feed)
|
|
841
|
+
else:
|
|
842
|
+
feed = normalize_feed_payload({"items": list(loaded_feed), "topics": []})
|
|
843
|
+
items = filter_days(feed["items"], getattr(args, "days", None))
|
|
844
|
+
topic_catalog = feed["topics"]
|
|
663
845
|
use_color = color_enabled(stdout, no_color)
|
|
664
846
|
interactive = interactive_enabled(stdin, stdout, plain, json_output)
|
|
665
847
|
|
|
666
848
|
if command == "topics":
|
|
667
|
-
topics = topic_counts(items)
|
|
849
|
+
topics = topic_counts(items, topic_catalog)
|
|
668
850
|
if json_output:
|
|
669
851
|
json_dump(topics, stdout)
|
|
670
852
|
return 0
|
|
671
853
|
if interactive:
|
|
672
|
-
chosen = choose_topic(items, stdin, stdout, use_color)
|
|
854
|
+
chosen = choose_topic(items, topic_catalog, stdin, stdout, use_color)
|
|
673
855
|
if chosen:
|
|
856
|
+
chosen_label = next(
|
|
857
|
+
(row["label"] for row in topics if row["topic"] == chosen),
|
|
858
|
+
chosen,
|
|
859
|
+
)
|
|
674
860
|
return browse_items(
|
|
675
861
|
filter_topic(items, chosen),
|
|
676
|
-
heading=f"Topic: {
|
|
862
|
+
heading=f"Topic: {chosen_label}",
|
|
677
863
|
page_size=page_size,
|
|
678
864
|
stdin=stdin,
|
|
679
865
|
stdout=stdout,
|
|
@@ -681,12 +867,13 @@ def run(
|
|
|
681
867
|
article_loader=article_loader,
|
|
682
868
|
timeout=timeout,
|
|
683
869
|
pager=pager,
|
|
870
|
+
short_mode=short_mode,
|
|
684
871
|
)
|
|
685
872
|
return 0
|
|
686
873
|
if topics:
|
|
687
|
-
width = max(len(row["
|
|
874
|
+
width = max(len(row["label"]) for row in topics)
|
|
688
875
|
for row in topics:
|
|
689
|
-
print(f"{row['
|
|
876
|
+
print(f"{row['label']:<{width}} {row['articles']}", file=stdout)
|
|
690
877
|
else:
|
|
691
878
|
print("No published topics yet.", file=stdout)
|
|
692
879
|
return 0
|
|
@@ -694,21 +881,24 @@ def run(
|
|
|
694
881
|
if command == "topic":
|
|
695
882
|
topic_name = args.name
|
|
696
883
|
if not topic_name and interactive:
|
|
697
|
-
topic_name = choose_topic(items, stdin, stdout, use_color)
|
|
884
|
+
topic_name = choose_topic(items, topic_catalog, stdin, stdout, use_color)
|
|
698
885
|
if not topic_name:
|
|
699
886
|
return 0
|
|
700
887
|
if not topic_name:
|
|
701
888
|
print("nulltap: provide a topic name or run this command in a terminal", file=stderr)
|
|
702
889
|
return 1
|
|
703
890
|
selected = filter_topic(items, topic_name)
|
|
704
|
-
|
|
705
|
-
|
|
891
|
+
available_rows = topic_counts(items, topic_catalog)
|
|
892
|
+
wanted_topic = safe_text(topic_name).lower()
|
|
893
|
+
if wanted_topic not in {row["topic"] for row in available_rows}:
|
|
894
|
+
available = ", ".join(row["topic"] for row in available_rows)
|
|
706
895
|
print(f"nulltap: no topic named '{safe_text(topic_name)}'", file=stderr)
|
|
707
896
|
if available:
|
|
708
897
|
print(f"available topics: {available}", file=stderr)
|
|
709
898
|
return 1
|
|
710
899
|
selected = selected[: args.limit]
|
|
711
|
-
|
|
900
|
+
label = next((row["label"] for row in available_rows if row["topic"] == wanted_topic), wanted_topic)
|
|
901
|
+
heading = f"Topic: {label}"
|
|
712
902
|
elif command == "search":
|
|
713
903
|
query = " ".join(args.query)
|
|
714
904
|
if not query and interactive:
|
|
@@ -734,7 +924,7 @@ def run(
|
|
|
734
924
|
print(f"nulltap: article not found: {safe_text(target)}", file=stderr)
|
|
735
925
|
return 1
|
|
736
926
|
if command in {"read", "show"}:
|
|
737
|
-
article = article_loader(item, timeout)
|
|
927
|
+
article = select_reading_mode(article_loader(item, timeout), short_mode)
|
|
738
928
|
if json_output:
|
|
739
929
|
json_dump(article, stdout)
|
|
740
930
|
else:
|
|
@@ -767,6 +957,7 @@ def run(
|
|
|
767
957
|
article_loader=article_loader,
|
|
768
958
|
timeout=timeout,
|
|
769
959
|
pager=pager,
|
|
960
|
+
short_mode=short_mode,
|
|
770
961
|
)
|
|
771
962
|
else:
|
|
772
963
|
print_items(selected, stdout, use_color)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nulltap
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Read Nulltap cybersecurity and AI articles from a terminal.
|
|
5
|
+
Author: Justin Howe
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
+
Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
|
|
11
|
+
Keywords: cybersecurity,ai,news,cli,nulltap
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nulltap
|
|
26
|
+
|
|
27
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and choose the full or 1-minute version without opening a browser.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pipx install nulltap
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To upgrade an existing installation:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx upgrade nulltap
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
44
|
+
|
|
45
|
+
## Start here
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
nulltap # browse recent articles
|
|
49
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
50
|
+
nulltap topics # list every topic and its article count
|
|
51
|
+
nulltap topic identity # browse one topic
|
|
52
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
53
|
+
nulltap read 2 # read the second result in the terminal
|
|
54
|
+
nulltap read 2 --short # read its 1-minute version
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
58
|
+
|
|
59
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
60
|
+
|
|
61
|
+
## Reading modes
|
|
62
|
+
|
|
63
|
+
Full articles are the default. Add `--short` to use the 1-minute version when you read from a list, search result, topic, or direct `read` command:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
nulltap search "token theft" --short
|
|
67
|
+
nulltap read 2 --short
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set the `NULLTAP_READING_MODE` environment variable to `short` if you want that behavior by default. The `--full` flag overrides the environment for one command. Nulltap stops with a clear message instead of silently showing the full article when a requested 1-minute version is unavailable.
|
|
71
|
+
|
|
72
|
+
## Topics
|
|
73
|
+
|
|
74
|
+
Nulltap currently publishes under these topics:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
endpoint
|
|
78
|
+
cloud
|
|
79
|
+
network
|
|
80
|
+
identity
|
|
81
|
+
appsec
|
|
82
|
+
AI
|
|
83
|
+
threats
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
87
|
+
|
|
88
|
+
## Search and recent filters
|
|
89
|
+
|
|
90
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
91
|
+
|
|
92
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
93
|
+
|
|
94
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
nulltap search ransomware --days 30
|
|
98
|
+
nulltap topic cloud --days 14
|
|
99
|
+
nulltap topics --days 90
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Automation
|
|
103
|
+
|
|
104
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
nulltap latest --days 7 --json
|
|
108
|
+
nulltap search ransomware --json
|
|
109
|
+
nulltap topics --plain
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
113
|
+
|
|
114
|
+
## Network and privacy
|
|
115
|
+
|
|
116
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
117
|
+
|
|
118
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
119
|
+
|
|
120
|
+
Licensed under the MIT License.
|
|
@@ -2,10 +2,23 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import re
|
|
4
4
|
import unittest
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
from datetime import datetime, timezone
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
from nulltap import __version__
|
|
8
|
-
from nulltap.cli import
|
|
10
|
+
from nulltap.cli import (
|
|
11
|
+
build_parser,
|
|
12
|
+
fetch_feed,
|
|
13
|
+
fetch_feed_document,
|
|
14
|
+
filter_days,
|
|
15
|
+
normalize_item,
|
|
16
|
+
render_article,
|
|
17
|
+
run,
|
|
18
|
+
search_items,
|
|
19
|
+
select_reading_mode,
|
|
20
|
+
FeedError,
|
|
21
|
+
)
|
|
9
22
|
|
|
10
23
|
|
|
11
24
|
ITEMS = [
|
|
@@ -18,6 +31,8 @@ ITEMS = [
|
|
|
18
31
|
"date_published": "2026-07-26T12:00:00Z",
|
|
19
32
|
"tags": ["cloud", "identity"],
|
|
20
33
|
"read_time_minutes": 6,
|
|
34
|
+
"short_read_available": True,
|
|
35
|
+
"short_read_time_minutes": 1,
|
|
21
36
|
"author": "nulltap",
|
|
22
37
|
"image": "",
|
|
23
38
|
},
|
|
@@ -30,11 +45,23 @@ ITEMS = [
|
|
|
30
45
|
"date_published": "2026-07-25T12:00:00Z",
|
|
31
46
|
"tags": ["ai", "appsec"],
|
|
32
47
|
"read_time_minutes": 4,
|
|
48
|
+
"short_read_available": True,
|
|
49
|
+
"short_read_time_minutes": 1,
|
|
33
50
|
"author": "nulltap",
|
|
34
51
|
"image": "",
|
|
35
52
|
},
|
|
36
53
|
]
|
|
37
54
|
|
|
55
|
+
TOPICS = [
|
|
56
|
+
{"id": "endpoint", "label": "endpoint", "description": "Endpoint security."},
|
|
57
|
+
{"id": "cloud", "label": "cloud", "description": "Cloud security."},
|
|
58
|
+
{"id": "network", "label": "network", "description": "Network security."},
|
|
59
|
+
{"id": "identity", "label": "identity", "description": "Identity security."},
|
|
60
|
+
{"id": "appsec", "label": "appsec", "description": "Application security."},
|
|
61
|
+
{"id": "ai", "label": "AI", "description": "AI security."},
|
|
62
|
+
{"id": "threats", "label": "threats", "description": "Threat activity."},
|
|
63
|
+
]
|
|
64
|
+
|
|
38
65
|
|
|
39
66
|
class FakeResponse:
|
|
40
67
|
def __init__(self, payload):
|
|
@@ -70,10 +97,15 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
70
97
|
args,
|
|
71
98
|
stdout=stdout,
|
|
72
99
|
stderr=stderr,
|
|
73
|
-
feed_loader=lambda _url, _timeout:
|
|
100
|
+
feed_loader=lambda _url, _timeout: {
|
|
101
|
+
"items": list(ITEMS if items is None else items),
|
|
102
|
+
"topics": TOPICS,
|
|
103
|
+
},
|
|
74
104
|
article_loader=lambda item, _timeout: {
|
|
75
105
|
**item,
|
|
76
106
|
"content_text": "## Access path\n\nThe token crossed a trust boundary.\n\n[Image: Token path]",
|
|
107
|
+
"short_content_text": "The token crossed a trust boundary. Revoke it and inspect the audit log.",
|
|
108
|
+
"short_read_time_minutes": 1,
|
|
77
109
|
"sources": [{"label": "Primary advisory", "url": "https://example.com/advisory"}],
|
|
78
110
|
},
|
|
79
111
|
browser_opener=opener or (lambda _url, **_kwargs: True),
|
|
@@ -90,10 +122,15 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
90
122
|
stdin=stdin,
|
|
91
123
|
stdout=stdout,
|
|
92
124
|
stderr=stderr,
|
|
93
|
-
feed_loader=lambda _url, _timeout:
|
|
125
|
+
feed_loader=lambda _url, _timeout: {
|
|
126
|
+
"items": list(ITEMS if items is None else items),
|
|
127
|
+
"topics": TOPICS,
|
|
128
|
+
},
|
|
94
129
|
article_loader=lambda item, _timeout: {
|
|
95
130
|
**item,
|
|
96
131
|
"content_text": "## Access path\n\nThe token crossed a trust boundary.",
|
|
132
|
+
"short_content_text": "The short read keeps the affected boundary and the immediate check.",
|
|
133
|
+
"short_read_time_minutes": 1,
|
|
97
134
|
"sources": [],
|
|
98
135
|
},
|
|
99
136
|
pager=pages.append,
|
|
@@ -117,8 +154,17 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
117
154
|
code, stdout, _ = self.invoke(["topics", "--json"])
|
|
118
155
|
self.assertEqual(code, 0)
|
|
119
156
|
topics = json.loads(stdout)
|
|
120
|
-
|
|
121
|
-
self.
|
|
157
|
+
counts = {row["topic"]: row["articles"] for row in topics}
|
|
158
|
+
self.assertEqual(counts["identity"], 1)
|
|
159
|
+
self.assertEqual(counts["ai"], 1)
|
|
160
|
+
self.assertEqual(counts["endpoint"], 0)
|
|
161
|
+
|
|
162
|
+
def test_help_surfaces_examples_and_date_filter(self):
|
|
163
|
+
help_text = build_parser().format_help()
|
|
164
|
+
self.assertIn("nulltap latest --days 7", help_text)
|
|
165
|
+
self.assertIn("nulltap topics", help_text)
|
|
166
|
+
self.assertIn("limit the feed to the last N days", help_text)
|
|
167
|
+
self.assertIn("nulltap read 2 --short", help_text)
|
|
122
168
|
|
|
123
169
|
def test_search_uses_title_summary_and_tags(self):
|
|
124
170
|
results = search_items(ITEMS, "production credentials")
|
|
@@ -143,6 +189,25 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
143
189
|
self.assertIn("Access path", stdout)
|
|
144
190
|
self.assertNotIn("## Access path", stdout)
|
|
145
191
|
|
|
192
|
+
def test_short_mode_reads_the_one_minute_version(self):
|
|
193
|
+
code, stdout, stderr = self.invoke(["read", "1", "--short"])
|
|
194
|
+
self.assertEqual(code, 0)
|
|
195
|
+
self.assertEqual(stderr, "")
|
|
196
|
+
self.assertIn("1-minute read", stdout)
|
|
197
|
+
self.assertIn("Revoke it and inspect the audit log.", stdout)
|
|
198
|
+
self.assertNotIn("Access path", stdout)
|
|
199
|
+
|
|
200
|
+
def test_full_flag_overrides_short_environment_default(self):
|
|
201
|
+
with patch.dict("os.environ", {"NULLTAP_READING_MODE": "short"}):
|
|
202
|
+
code, stdout, _ = self.invoke(["read", "1", "--full"])
|
|
203
|
+
self.assertEqual(code, 0)
|
|
204
|
+
self.assertIn("Access path", stdout)
|
|
205
|
+
self.assertNotIn("1-minute read", stdout)
|
|
206
|
+
|
|
207
|
+
def test_short_mode_fails_clearly_when_article_has_no_short_read(self):
|
|
208
|
+
with self.assertRaisesRegex(FeedError, "does not include a 1-minute read"):
|
|
209
|
+
select_reading_mode({**ITEMS[0], "content_text": "Full article"}, True)
|
|
210
|
+
|
|
146
211
|
def test_list_does_not_require_browser_urls(self):
|
|
147
212
|
code, stdout, _ = self.invoke(["latest"])
|
|
148
213
|
self.assertEqual(code, 0)
|
|
@@ -177,13 +242,27 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
177
242
|
self.assertIn("The AI Gateway Had Root", pages[0])
|
|
178
243
|
|
|
179
244
|
def test_topics_menu_opens_a_topic_and_article(self):
|
|
180
|
-
code, stdout, _, pages = self.invoke_interactive(["topics"], "
|
|
245
|
+
code, stdout, _, pages = self.invoke_interactive(["topics"], "6\n1\nq\n")
|
|
181
246
|
self.assertEqual(code, 0)
|
|
182
247
|
self.assertIn("Topics", stdout)
|
|
183
|
-
self.assertIn("Topic:
|
|
248
|
+
self.assertIn("Topic: AI", stdout)
|
|
184
249
|
self.assertEqual(len(pages), 1)
|
|
185
250
|
self.assertIn("The AI Gateway Had Root", pages[0])
|
|
186
251
|
|
|
252
|
+
def test_known_empty_topic_is_not_reported_as_invalid(self):
|
|
253
|
+
code, stdout, stderr = self.invoke(["topic", "endpoint"])
|
|
254
|
+
self.assertEqual(code, 0)
|
|
255
|
+
self.assertIn("No published articles matched.", stdout)
|
|
256
|
+
self.assertEqual(stderr, "")
|
|
257
|
+
|
|
258
|
+
def test_days_filter_uses_publication_time(self):
|
|
259
|
+
selected = filter_days(
|
|
260
|
+
ITEMS,
|
|
261
|
+
1,
|
|
262
|
+
now=datetime(2026, 7, 26, 18, 0, tzinfo=timezone.utc),
|
|
263
|
+
)
|
|
264
|
+
self.assertEqual([item["id"] for item in selected], ["2026-07-26-cloud-token"])
|
|
265
|
+
|
|
187
266
|
def test_direct_read_uses_pager_in_a_terminal(self):
|
|
188
267
|
code, _, _, pages = self.invoke_interactive(["read", "1"], "")
|
|
189
268
|
self.assertEqual(code, 0)
|
|
@@ -246,6 +325,16 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
246
325
|
self.assertEqual(len(items), 1)
|
|
247
326
|
self.assertEqual(seen, {"url": "https://nulltap.sh/feed.json", "timeout": 3})
|
|
248
327
|
|
|
328
|
+
def test_fetch_feed_document_includes_topic_catalog(self):
|
|
329
|
+
payload = json.dumps({"items": ITEMS, "topics": TOPICS}).encode()
|
|
330
|
+
document = fetch_feed_document(
|
|
331
|
+
"https://nulltap.sh/feed.json",
|
|
332
|
+
3,
|
|
333
|
+
opener=lambda _request, timeout: FakeResponse(payload),
|
|
334
|
+
)
|
|
335
|
+
self.assertEqual(document["topics"][0]["topic"], "endpoint")
|
|
336
|
+
self.assertEqual(document["topics"][5]["label"], "AI")
|
|
337
|
+
|
|
249
338
|
def test_unknown_topic_returns_helpful_error(self):
|
|
250
339
|
code, _, stderr = self.invoke(["topic", "nope"])
|
|
251
340
|
self.assertEqual(code, 1)
|
nulltap-0.1.0/PKG-INFO
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: nulltap
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Read the nulltap.sh cybersecurity feed from a terminal.
|
|
5
|
-
Author: Justin Howe
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
-
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
-
Keywords: cybersecurity,news,cli,nulltap
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Security
|
|
18
|
-
Requires-Python: >=3.10
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
License-File: LICENSE
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
|
|
23
|
-
# nulltap
|
|
24
|
-
|
|
25
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
26
|
-
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
pipx install nulltap
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
36
|
-
|
|
37
|
-
Run `nulltap --help` after installation.
|
|
38
|
-
|
|
39
|
-
## Commands
|
|
40
|
-
|
|
41
|
-
```sh
|
|
42
|
-
nulltap # paginated recent articles; type a number to read
|
|
43
|
-
nulltap browse # explicit form of the default command
|
|
44
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
45
|
-
nulltap latest --topic identity
|
|
46
|
-
nulltap topics # choose a topic, then choose an article
|
|
47
|
-
nulltap topic ai # paginated articles tagged AI
|
|
48
|
-
nulltap topic # choose a topic interactively
|
|
49
|
-
nulltap search # enter search words interactively
|
|
50
|
-
nulltap search "token theft"
|
|
51
|
-
nulltap search gateway --topic appsec
|
|
52
|
-
nulltap read 1 # directly read the newest article
|
|
53
|
-
nulltap read # browse first when no number is supplied
|
|
54
|
-
nulltap --plain # print once; no prompts or pager
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
58
|
-
|
|
59
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
60
|
-
|
|
61
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
62
|
-
|
|
63
|
-
## Search
|
|
64
|
-
|
|
65
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
66
|
-
|
|
67
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
68
|
-
|
|
69
|
-
## Scripts
|
|
70
|
-
|
|
71
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
nulltap search ransomware --json
|
|
75
|
-
nulltap topics --json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Use a different compatible feed during development:
|
|
79
|
-
|
|
80
|
-
```sh
|
|
81
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
82
|
-
# or
|
|
83
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
87
|
-
|
|
88
|
-
## Development
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
python -m venv .venv
|
|
92
|
-
# Windows: .venv\Scripts\activate
|
|
93
|
-
# macOS/Linux: source .venv/bin/activate
|
|
94
|
-
python -m pip install -e .
|
|
95
|
-
python -m unittest discover -s tests -v
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Licensed under the MIT License.
|
nulltap-0.1.0/README.md
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# nulltap
|
|
2
|
-
|
|
3
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
pipx install nulltap
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
14
|
-
|
|
15
|
-
Run `nulltap --help` after installation.
|
|
16
|
-
|
|
17
|
-
## Commands
|
|
18
|
-
|
|
19
|
-
```sh
|
|
20
|
-
nulltap # paginated recent articles; type a number to read
|
|
21
|
-
nulltap browse # explicit form of the default command
|
|
22
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
23
|
-
nulltap latest --topic identity
|
|
24
|
-
nulltap topics # choose a topic, then choose an article
|
|
25
|
-
nulltap topic ai # paginated articles tagged AI
|
|
26
|
-
nulltap topic # choose a topic interactively
|
|
27
|
-
nulltap search # enter search words interactively
|
|
28
|
-
nulltap search "token theft"
|
|
29
|
-
nulltap search gateway --topic appsec
|
|
30
|
-
nulltap read 1 # directly read the newest article
|
|
31
|
-
nulltap read # browse first when no number is supplied
|
|
32
|
-
nulltap --plain # print once; no prompts or pager
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
36
|
-
|
|
37
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
38
|
-
|
|
39
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
40
|
-
|
|
41
|
-
## Search
|
|
42
|
-
|
|
43
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
44
|
-
|
|
45
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
46
|
-
|
|
47
|
-
## Scripts
|
|
48
|
-
|
|
49
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
nulltap search ransomware --json
|
|
53
|
-
nulltap topics --json
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Use a different compatible feed during development:
|
|
57
|
-
|
|
58
|
-
```sh
|
|
59
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
60
|
-
# or
|
|
61
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
65
|
-
|
|
66
|
-
## Development
|
|
67
|
-
|
|
68
|
-
```sh
|
|
69
|
-
python -m venv .venv
|
|
70
|
-
# Windows: .venv\Scripts\activate
|
|
71
|
-
# macOS/Linux: source .venv/bin/activate
|
|
72
|
-
python -m pip install -e .
|
|
73
|
-
python -m unittest discover -s tests -v
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Licensed under the MIT License.
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: nulltap
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Read the nulltap.sh cybersecurity feed from a terminal.
|
|
5
|
-
Author: Justin Howe
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
-
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
-
Keywords: cybersecurity,news,cli,nulltap
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Security
|
|
18
|
-
Requires-Python: >=3.10
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
License-File: LICENSE
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
|
|
23
|
-
# nulltap
|
|
24
|
-
|
|
25
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
26
|
-
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
pipx install nulltap
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
36
|
-
|
|
37
|
-
Run `nulltap --help` after installation.
|
|
38
|
-
|
|
39
|
-
## Commands
|
|
40
|
-
|
|
41
|
-
```sh
|
|
42
|
-
nulltap # paginated recent articles; type a number to read
|
|
43
|
-
nulltap browse # explicit form of the default command
|
|
44
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
45
|
-
nulltap latest --topic identity
|
|
46
|
-
nulltap topics # choose a topic, then choose an article
|
|
47
|
-
nulltap topic ai # paginated articles tagged AI
|
|
48
|
-
nulltap topic # choose a topic interactively
|
|
49
|
-
nulltap search # enter search words interactively
|
|
50
|
-
nulltap search "token theft"
|
|
51
|
-
nulltap search gateway --topic appsec
|
|
52
|
-
nulltap read 1 # directly read the newest article
|
|
53
|
-
nulltap read # browse first when no number is supplied
|
|
54
|
-
nulltap --plain # print once; no prompts or pager
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
58
|
-
|
|
59
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
60
|
-
|
|
61
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
62
|
-
|
|
63
|
-
## Search
|
|
64
|
-
|
|
65
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
66
|
-
|
|
67
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
68
|
-
|
|
69
|
-
## Scripts
|
|
70
|
-
|
|
71
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
nulltap search ransomware --json
|
|
75
|
-
nulltap topics --json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Use a different compatible feed during development:
|
|
79
|
-
|
|
80
|
-
```sh
|
|
81
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
82
|
-
# or
|
|
83
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
87
|
-
|
|
88
|
-
## Development
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
python -m venv .venv
|
|
92
|
-
# Windows: .venv\Scripts\activate
|
|
93
|
-
# macOS/Linux: source .venv/bin/activate
|
|
94
|
-
python -m pip install -e .
|
|
95
|
-
python -m unittest discover -s tests -v
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Licensed under the MIT License.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|