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 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.
@@ -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.1.0"
8
- description = "Read the nulltap.sh cybersecurity feed from a terminal."
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"]
@@ -1,3 +1,3 @@
1
1
  """nulltap command-line client."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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 fetch_feed(
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
- if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
167
- raise FeedError("feed does not contain an items array")
202
+ return normalize_feed_payload(payload)
168
203
 
169
- items = [item for raw in payload["items"] if (item := normalize_item(raw))]
170
- return sorted(items, key=lambda item: item["date_published"], reverse=True)
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="Browse and read nulltap from a terminal.",
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("browse", parents=[common], help="browse recent articles")
244
- browse.add_argument("-n", "--limit", type=positive_int, default=50)
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("latest", parents=[common], help="browse recent articles")
248
- latest.add_argument("-n", "--limit", type=positive_int, default=50)
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("topics", parents=[common], help="choose a topic")
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("topic", parents=[common], help="browse articles for one topic")
254
- topic.add_argument("name", nargs="?", help="topic name, such as identity or cloud")
255
- topic.add_argument("-n", "--limit", type=positive_int, default=100)
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("search", parents=[common], help="search and choose an article")
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 an article or browse when omitted")
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="open one article in a browser")
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(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
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
- return [
615
- {"topic": topic, "articles": count}
616
- for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0]))
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(items: Sequence[dict[str, Any]], stdin: TextIO, stdout: TextIO, use_color: bool) -> str | None:
621
- topics = topic_counts(items)
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
- print(f"[{index}] {row['topic']:<12} {row['articles']} articles", file=stdout)
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], list[dict[str, Any]]] = fetch_feed,
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
- items = feed_loader(feed_url, timeout)
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: {chosen}",
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["topic"]) for row in topics)
874
+ width = max(len(row["label"]) for row in topics)
688
875
  for row in topics:
689
- print(f"{row['topic']:<{width}} {row['articles']}", file=stdout)
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
- if not selected and items:
705
- available = ", ".join(row["topic"] for row in topic_counts(items))
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
- heading = f"Topic: {safe_text(topic_name).lower()}"
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 fetch_feed, normalize_item, render_article, run, search_items
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: list(ITEMS if items is None else items),
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: list(ITEMS if items is None else items),
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
- self.assertIn({"topic": "identity", "articles": 1}, topics)
121
- self.assertIn({"topic": "ai", "articles": 1}, topics)
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"], "1\n1\nq\n")
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: ai", stdout)
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