pubkit 0.1.0__py3-none-any.whl

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.
pubkit/adapters/x.py ADDED
@@ -0,0 +1,216 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """X / Twitter, API v2.
4
+
5
+ The interesting problem here is not the API, it is that a 6,000-word article
6
+ and a 280-character post are different media. The ThreadRenderer does the
7
+ splitting; this adapter deals with the parts that bite:
8
+
9
+ * media upload still lives on the v1.1 host and needs its own flow
10
+ * `x-rate-limit-reset` is an epoch, not a delta
11
+ * a thread is a chain — a failure at tweet 7 of 11 leaves a visible,
12
+ half-finished thread, so the chain is checkpointed per tweet and resumable
13
+ * posting is irreversible and instantly public, so the publish guard matters
14
+ more here than anywhere else
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ from ..core.adapter import AdapterError, Context, Fingerprint, PublishedRef, RemoteRef
23
+ from ..core.capabilities import Capabilities
24
+ from ..core.ir import Document
25
+ from ..render.html import ThreadRenderer, plain
26
+ from .api_base import ApiAdapter
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ class XAdapter(ApiAdapter):
32
+ name = "x"
33
+ base_url = "https://api.twitter.com"
34
+ rate = 0.2 # v2 free/basic tiers are stingy; be a good citizen
35
+ burst = 2
36
+
37
+ capabilities = Capabilities(
38
+ tables=False,
39
+ code_blocks="none",
40
+ inline_html=False,
41
+ animated_gif=True,
42
+ headings=1,
43
+ max_body_chars=280,
44
+ # promo mode never needs more than max_posts; see __init__.
45
+ image_upload="api",
46
+ canonical_url=False,
47
+ tags=None, # hashtags are body text here, not metadata
48
+ threads=True,
49
+ drafts=False, # no draft concept — publish is one-shot
50
+ )
51
+
52
+ def __init__(self, limit: int = 280, mode: str = "promo", max_posts: int = 12) -> None:
53
+ """`mode`:
54
+
55
+ promo (default)
56
+ A hook, three or four of the article's sharpest claims, and a link.
57
+ This is what actually works on X, and it is what a long-form author
58
+ wants: the article lives on the blog, the thread sells it.
59
+
60
+ full
61
+ Serialise the whole body. Honest, occasionally right, usually a
62
+ 180-tweet wall nobody reads. Opt in deliberately.
63
+ """
64
+ super().__init__()
65
+ self.mode = mode
66
+ self.max_posts = max_posts
67
+ self.renderer = ThreadRenderer(limit=limit)
68
+
69
+ def refine_plan(self, doc: Document, p):
70
+ """promo mode summarises rather than serialises, so the generic
71
+ 'body is too long' degradation is simply wrong here."""
72
+ from ..core.capabilities import Degradation, DegradationKind
73
+
74
+ if self.mode != "promo":
75
+ return p
76
+ n = len(self._thread(doc))
77
+ p.degradations = [
78
+ d for d in p.degradations
79
+ if d.kind not in (DegradationKind.SPLIT_INTO_THREAD, DegradationKind.CODE_FLATTENED,
80
+ DegradationKind.HEADINGS_CLAMPED)
81
+ ]
82
+ p.blocking = [b for b in p.blocking if "body is" not in b]
83
+ p.degradations.insert(0, Degradation(
84
+ kind=DegradationKind.SPLIT_INTO_THREAD,
85
+ detail=f"promo thread: {n} posts — hook, key claims, link to the full article",
86
+ count=n,
87
+ ))
88
+ p.estimated_body_chars = sum(len(t) for t in self._thread(doc))
89
+ return p
90
+
91
+ def headers(self, ctx: Context) -> dict[str, str]:
92
+ return {
93
+ "authorization": f"Bearer {ctx.tokens.require(self.name, 'bearer')}",
94
+ "content-type": "application/json",
95
+ }
96
+
97
+ async def authenticate(self, ctx: Context) -> None:
98
+ await self.request(ctx, "GET", "/2/users/me")
99
+
100
+ def _thread(self, doc: Document) -> list[str]:
101
+ if self.mode == "full":
102
+ return self.renderer.render(doc)[: self.max_posts * 4]
103
+ return self._promo(doc)
104
+
105
+ def _promo(self, doc: Document) -> list[str]:
106
+ """Hook, the strongest claims, then the link."""
107
+ from ..core.ir import Callout, ListBlock, Paragraph
108
+
109
+ link = doc.canonical_url or ""
110
+ posts: list[str] = []
111
+
112
+ opener = doc.subtitle or next(
113
+ (b.text for b in doc.blocks if isinstance(b, Paragraph)), doc.title
114
+ )
115
+ posts.append(f"{doc.title}\n\n{plain(opener)}"[:265])
116
+
117
+ # Callouts and short standalone list items are the claims an author
118
+ # already marked as load-bearing; they make far better tweets than
119
+ # arbitrary paragraph slices.
120
+ claims: list[str] = []
121
+ for b in doc.blocks:
122
+ if isinstance(b, Callout):
123
+ claims.append(plain(b.text))
124
+ elif isinstance(b, ListBlock):
125
+ claims.extend(plain(i) for i in b.items if 40 <= len(i) <= 230)
126
+ if len(claims) >= self.max_posts - 2:
127
+ break
128
+ for c in claims[: self.max_posts - 2]:
129
+ posts.append(c[:265])
130
+
131
+ posts.append(("Full write-up: " + link).strip() if link else "Full write-up in the replies.")
132
+ total = len(posts)
133
+ return [f"{p} ({i+1}/{total})" if total > 1 else p for i, p in enumerate(posts)]
134
+
135
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
136
+ # No drafts on this platform. The "draft" is the rendered thread, which
137
+ # we hold locally so `plan` can show it before anything is posted.
138
+ return RemoteRef(id=f"local:{doc.content_id[:12]}", extra={"thread": self._thread(doc)})
139
+
140
+ async def push_content(self, doc, plan, ref, ctx) -> None:
141
+ ref.extra["thread"] = self._thread(doc)
142
+
143
+ async def push_media(self, doc, plan, ref, ctx) -> None:
144
+ """Upload the first few figures and pin them to early tweets.
145
+
146
+ Deliberately not all of them: a thread where every tweet carries an
147
+ image reads as a slideshow and performs worse than one good visual on
148
+ the opening post.
149
+ """
150
+ figures = doc.figures[:4]
151
+ media_ids: list[str] = []
152
+ for fig in figures:
153
+ asset = doc.assets[fig.asset_id]
154
+ media_ids.append(await self._upload_media(ctx, asset.path))
155
+ ref.extra["media_ids"] = media_ids
156
+
157
+ async def _upload_media(self, ctx: Context, path: Path) -> str:
158
+ data = base64.b64encode(path.read_bytes()).decode()
159
+ await self.bucket.acquire()
160
+ client = await self.client(ctx)
161
+ r = await client.post(
162
+ "https://upload.twitter.com/1.1/media/upload.json",
163
+ data={"media_data": data},
164
+ headers={"authorization": client.headers["authorization"]},
165
+ )
166
+ if r.status_code >= 400:
167
+ raise AdapterError(f"x: media upload failed {r.status_code} {r.text[:200]}")
168
+ return str(r.json()["media_id_string"])
169
+
170
+ async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
171
+ thread = ref.extra.get("thread", [])
172
+ over = [i for i, t in enumerate(thread) if len(t) > 280]
173
+ if over:
174
+ raise AdapterError(f"tweets {over} exceed 280 characters after rendering")
175
+ return Fingerprint(
176
+ words=sum(len(t.split()) for t in thread),
177
+ headings=[],
178
+ images=len(ref.extra.get("media_ids", [])),
179
+ links=sum(t.count("http") for t in thread),
180
+ )
181
+
182
+ async def publish(self, doc, ref, ctx) -> PublishedRef:
183
+ """Post the chain, checkpointing after every tweet.
184
+
185
+ If tweet 7 of 11 fails, the already-posted 6 are recorded so a resumed
186
+ run continues the chain instead of starting a second one beside it.
187
+ """
188
+ self.guard_publish(ctx)
189
+ thread: list[str] = ref.extra["thread"]
190
+ media_ids: list[str] = ref.extra.get("media_ids", [])
191
+ posted: list[str] = ref.extra.setdefault("posted", [])
192
+
193
+ reply_to = posted[-1] if posted else None
194
+ first_url = ref.extra.get("first_url")
195
+
196
+ for i, text in enumerate(thread):
197
+ if i < len(posted):
198
+ continue
199
+ payload: dict = {"text": text}
200
+ if reply_to:
201
+ payload["reply"] = {"in_reply_to_tweet_id": reply_to}
202
+ if i == 0 and media_ids:
203
+ payload["media"] = {"media_ids": media_ids[:1]}
204
+ elif i in (3, 6) and len(media_ids) > 1:
205
+ payload["media"] = {"media_ids": [media_ids.pop(1)]}
206
+
207
+ r = await self.request(ctx, "POST", "/2/tweets", json=payload)
208
+ tid = r.json()["data"]["id"]
209
+ posted.append(tid)
210
+ reply_to = tid
211
+ if i == 0:
212
+ first_url = f"https://x.com/i/status/{tid}"
213
+ ref.extra["first_url"] = first_url
214
+ log.info("x: posted %d/%d", i + 1, len(thread))
215
+
216
+ return PublishedRef(id=posted[0], url=first_url or f"https://x.com/i/status/{posted[0]}")
pubkit/browserctl.py ADDED
@@ -0,0 +1,78 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Playwright lifecycle: one browser, many adapters, sessions persisted.
4
+
5
+ The interactive login flow is the important part. pubkit opens a *visible*
6
+ window, the person signs in themselves — password manager, MFA, device
7
+ confirmation, whatever the platform demands — and pubkit saves only the
8
+ resulting storage_state. It never sees, types or stores a password.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import Sequence
15
+ from contextlib import asynccontextmanager
16
+
17
+ from .core.auth import SessionStore
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ LOGIN_URLS = {
22
+ "medium": ("https://medium.com/m/signin", "https://medium.com/me/stories/drafts"),
23
+ "substack": ("https://substack.com/sign-in", "https://substack.com/home"),
24
+ }
25
+
26
+
27
+ @asynccontextmanager
28
+ async def browser_page(platform: str, sessions: SessionStore, *, headless: bool = True):
29
+ from playwright.async_api import async_playwright
30
+
31
+ state = sessions.load(platform)
32
+ async with async_playwright() as pw:
33
+ browser = await pw.chromium.launch(headless=headless)
34
+ context = await browser.new_context(
35
+ storage_state=state,
36
+ viewport={"width": 1440, "height": 900},
37
+ )
38
+ page = await context.new_page()
39
+ try:
40
+ yield page, _uploader(page)
41
+ finally:
42
+ # Refresh the stored session on the way out: cookies rotate, and a
43
+ # session that silently expires mid-run is a bad afternoon.
44
+ try:
45
+ sessions.save(platform, await context.storage_state())
46
+ except Exception: # noqa: BLE001
47
+ log.debug("could not refresh %s session", platform)
48
+ await context.close()
49
+ await browser.close()
50
+
51
+
52
+ def _uploader(page):
53
+ async def upload(selector: str, paths: Sequence[str]) -> None:
54
+ await page.set_input_files(selector, list(paths))
55
+ return upload
56
+
57
+
58
+ async def interactive_login(platform: str, sessions: SessionStore, timeout: float = 300.0) -> None:
59
+ from playwright.async_api import async_playwright
60
+
61
+ login_url, success_url = LOGIN_URLS.get(platform, ("https://" + platform, "https://" + platform))
62
+ async with async_playwright() as pw:
63
+ browser = await pw.chromium.launch(headless=False)
64
+ context = await browser.new_context(viewport={"width": 1280, "height": 900})
65
+ page = await context.new_page()
66
+ await page.goto(login_url)
67
+ print(f"Sign in to {platform} in the window that opened. Waiting up to {timeout:.0f}s…")
68
+ deadline = asyncio.get_event_loop().time() + timeout
69
+ host = success_url.split("/")[2]
70
+ while asyncio.get_event_loop().time() < deadline:
71
+ if host in page.url and "sign" not in page.url and "login" not in page.url:
72
+ await asyncio.sleep(2)
73
+ sessions.save(platform, await context.storage_state())
74
+ await browser.close()
75
+ return
76
+ await asyncio.sleep(1.5)
77
+ await browser.close()
78
+ raise TimeoutError(f"no {platform} login detected within {timeout:.0f}s")
pubkit/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """pubkit CLI."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table as RichTable
16
+
17
+ from . import __version__
18
+ from .core.adapter import Context
19
+ from .core.auth import SessionStore, TokenStore
20
+ from .core.capabilities import plan as make_plan
21
+ from .core.checks import run_checks
22
+ from .core.loader import load_document, load_series
23
+ from .core.runner import Pipeline
24
+ from .core.state import StateStore
25
+ from .registry import build_adapter, list_adapters
26
+
27
+ app = typer.Typer(add_completion=False, help="Publish one source to many platforms, safely.")
28
+ auth_app = typer.Typer(help="Credentials. pubkit never accepts a password.")
29
+ app.add_typer(auth_app, name="auth")
30
+ console = Console()
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ if value:
35
+ Console().print(f"pubkit {__version__}")
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback()
40
+ def _root(
41
+ version: bool = typer.Option(
42
+ None, "--version", "-V", callback=_version_callback, is_eager=True, help="Show the version and exit."
43
+ ),
44
+ ) -> None:
45
+ """Publish one source to many platforms, safely."""
46
+
47
+
48
+ def _setup_logging(verbose: bool) -> None:
49
+ logging.basicConfig(
50
+ level=logging.DEBUG if verbose else logging.INFO,
51
+ format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
52
+ datefmt="%H:%M:%S",
53
+ )
54
+
55
+
56
+ def _docs(path: Path):
57
+ if path.is_dir():
58
+ files = sorted(path.glob("*.md"))
59
+ if not files:
60
+ raise typer.BadParameter(f"no .md files in {path}")
61
+ return load_series(files)
62
+ return load_document(path)
63
+
64
+
65
+ # ---------------------------------------------------------------- validate
66
+ @app.command()
67
+ def validate(
68
+ path: Path = typer.Argument(..., help="A .md file, or a directory for a series"),
69
+ strict: bool = typer.Option(False, help="Treat warnings as errors"),
70
+ verbose: bool = typer.Option(False, "-v"),
71
+ ):
72
+ """Run the check pipeline. No network, no browser, no side effects."""
73
+ _setup_logging(verbose)
74
+ target = _docs(path)
75
+ documents = target.documents if hasattr(target, "documents") else [target]
76
+
77
+ failed = False
78
+ for doc in documents:
79
+ res = run_checks(doc)
80
+ console.print(f"\n[bold]{doc.id}[/] — {doc.word_count:,} words, "
81
+ f"{len(doc.figures)} figures, {len(doc.tables)} tables")
82
+ if not res.findings:
83
+ console.print(" [green]all checks passed[/]")
84
+ for f in res.findings:
85
+ colour = {"error": "red", "warn": "yellow", "info": "dim"}[f.severity.value]
86
+ console.print(f" [{colour}]{f}[/]")
87
+ if res.errors or (strict and res.findings):
88
+ failed = True
89
+
90
+ raise typer.Exit(1 if failed else 0)
91
+
92
+
93
+ # -------------------------------------------------------------------- plan
94
+ @app.command()
95
+ def plan(
96
+ path: Path = typer.Argument(...),
97
+ platforms: str = typer.Option(..., "--to", help="Comma-separated: medium,x,substack,devto"),
98
+ verbose: bool = typer.Option(False, "-v"),
99
+ ):
100
+ """Show exactly what each platform will get, including every degradation."""
101
+ _setup_logging(verbose)
102
+ target = _docs(path)
103
+ documents = target.documents if hasattr(target, "documents") else [target]
104
+ names = [p.strip() for p in platforms.split(",") if p.strip()]
105
+
106
+ for doc in documents:
107
+ for name in names:
108
+ adapter = build_adapter(name)
109
+ p = make_plan(doc, name, adapter.capabilities, adapter)
110
+ console.print()
111
+ console.print(p.human())
112
+
113
+ console.print("\n[dim]Nothing has been sent. Use `pubkit publish` to apply.[/]")
114
+
115
+
116
+ # ----------------------------------------------------------------- publish
117
+ @app.command()
118
+ def publish(
119
+ path: Path = typer.Argument(...),
120
+ platforms: str = typer.Option(..., "--to"),
121
+ confirm: bool = typer.Option(False, "--confirm", help="Actually publish (not just draft)"),
122
+ draft_only: bool = typer.Option(False, "--draft-only", help="Create/update drafts and stop"),
123
+ headless: bool = typer.Option(True, help="Browser adapters run headless"),
124
+ email_subscribers: bool = typer.Option(False, help="Substack: send the email too"),
125
+ verbose: bool = typer.Option(False, "-v"),
126
+ ):
127
+ """Create or update drafts and, with --confirm, publish.
128
+
129
+ Safe to re-run. A dropped connection costs you one step, not one run.
130
+ """
131
+ _setup_logging(verbose)
132
+ confirm = confirm or os.environ.get("PUBKIT_CONFIRM") == "1"
133
+ target = _docs(path)
134
+ names = [p.strip() for p in platforms.split(",") if p.strip()]
135
+
136
+ tokens, sessions = TokenStore(), SessionStore()
137
+ adapters = [build_adapter(n) for n in names]
138
+
139
+ def ctx_for(name: str) -> Context:
140
+ return Context(
141
+ platform=name,
142
+ tokens=tokens,
143
+ sessions=sessions,
144
+ confirm=confirm and not draft_only,
145
+ headless=headless,
146
+ options={"email_subscribers": email_subscribers},
147
+ )
148
+
149
+ pipe = Pipeline(StateStore())
150
+
151
+ async def go():
152
+ if hasattr(target, "documents"):
153
+ return await pipe.run_series(target, adapters, ctx_for, publish=confirm and not draft_only)
154
+ return await pipe.run([target], adapters, ctx_for, publish=confirm and not draft_only)
155
+
156
+ report = asyncio.run(go())
157
+ console.print(f"\n[bold]{report.run_id}[/]")
158
+ console.print(report.human())
159
+ if not confirm and not draft_only:
160
+ console.print("\n[yellow]Drafts only — nothing is public. Re-run with --confirm to publish.[/]")
161
+ raise typer.Exit(0 if report.ok else 1)
162
+
163
+
164
+ # ------------------------------------------------------------------ status
165
+ @app.command()
166
+ def status(as_json: bool = typer.Option(False, "--json")):
167
+ """What exists where, and whether it is published."""
168
+ store = StateStore()
169
+ rows = store._conn.execute(
170
+ "SELECT document_id, platform, url, published, updated_at FROM remotes ORDER BY document_id"
171
+ ).fetchall()
172
+ if as_json:
173
+ print(json.dumps([dict(r) for r in rows], indent=2))
174
+ return
175
+ t = RichTable("document", "platform", "state", "url")
176
+ for r in rows:
177
+ t.add_row(
178
+ r["document_id"],
179
+ r["platform"],
180
+ "published" if r["published"] else "draft",
181
+ r["url"] or "",
182
+ )
183
+ console.print(t)
184
+
185
+
186
+ @app.command()
187
+ def platforms():
188
+ """List available adapters and what they can do."""
189
+ t = RichTable("platform", "kind", "tables", "images", "tags", "threads")
190
+ for name, adapter in list_adapters():
191
+ c = adapter.capabilities
192
+ t.add_row(
193
+ name,
194
+ "browser" if c.image_upload == "browser_paste" else "api",
195
+ "yes" if c.tables else "no → images",
196
+ c.image_upload,
197
+ f"{c.tags.max_count}×{c.tags.max_len}" if c.tags else "—",
198
+ "yes" if c.threads else "—",
199
+ )
200
+ console.print(t)
201
+
202
+
203
+ # -------------------------------------------------------------------- init
204
+ @app.command()
205
+ def init(
206
+ path: Path = typer.Argument(Path("."), help="Where to scaffold"),
207
+ series: bool = typer.Option(False, help="Include a two-part series example"),
208
+ force: bool = typer.Option(False, help="Overwrite existing files"),
209
+ ):
210
+ """Scaffold a content repo that validates on the first try."""
211
+ from .scaffold import init_repo
212
+
213
+ written = init_repo(path, series=series, force=force)
214
+ if not written:
215
+ console.print("[yellow]nothing written — files already exist (use --force)[/]")
216
+ raise typer.Exit(1)
217
+ for p in written:
218
+ console.print(f" [green]+[/] {p}")
219
+ console.print(
220
+ f"\nNext:\n"
221
+ f" pubkit validate {path / 'content'}\n"
222
+ f" pubkit plan {path / 'content'} --to medium,devto\n"
223
+ f" pubkit auth login devto"
224
+ )
225
+
226
+
227
+ @app.command()
228
+ def doctor():
229
+ """Diagnose the environment. Run this before opening an issue."""
230
+ from .scaffold import diagnose
231
+
232
+ findings = diagnose()
233
+ t = RichTable("", "check", "state")
234
+ problems = []
235
+ for f in findings:
236
+ t.add_row("[green]✓[/]" if f.ok else "[red]✗[/]", f.label, f.detail)
237
+ if not f.ok and f.fix:
238
+ problems.append((f.label, f.fix))
239
+ console.print(t)
240
+ for label, fix in problems:
241
+ console.print(f"[yellow]{label}[/]: " + fix.replace("[", "\\["))
242
+ console.print(f"\npubkit {__version__}")
243
+
244
+
245
+ # -------------------------------------------------------------------- auth
246
+ @auth_app.command("login")
247
+ def auth_login(
248
+ platform: str = typer.Argument(...),
249
+ token: str = typer.Option(None, "--token", help="API platforms only. Prefer stdin."),
250
+ ):
251
+ """Authenticate.
252
+
253
+ API platforms: paste a token (it goes to your OS keychain).
254
+ Browser platforms: a real browser window opens and you sign in yourself —
255
+ pubkit stores only the resulting session, never a password.
256
+ """
257
+ tokens = TokenStore()
258
+ adapter = build_adapter(platform)
259
+
260
+ if adapter.capabilities.image_upload != "browser_paste" and adapter.name not in ("substack",):
261
+ if token is None:
262
+ token = typer.prompt(f"{platform} API token", hide_input=True)
263
+ tokens.set(platform, token.strip())
264
+ console.print(f"[green]stored {platform} token in the system keychain[/]")
265
+ return
266
+
267
+ from .browserctl import interactive_login
268
+
269
+ console.print(
270
+ f"Opening a browser window for {platform}. Sign in there — including MFA — "
271
+ "and pubkit will save the session when it sees you are logged in."
272
+ )
273
+ asyncio.run(interactive_login(platform, SessionStore()))
274
+ console.print(f"[green]saved {platform} session (encrypted)[/]")
275
+
276
+
277
+ @auth_app.command("logout")
278
+ def auth_logout(platform: str = typer.Argument(...)):
279
+ TokenStore().delete(platform)
280
+ SessionStore().forget(platform)
281
+ console.print(f"[green]forgot all {platform} credentials[/]")
282
+
283
+
284
+ @auth_app.command("list")
285
+ def auth_list():
286
+ tokens, sessions = TokenStore(), SessionStore()
287
+ t = RichTable("platform", "token", "session")
288
+ for name, _ in list_adapters():
289
+ t.add_row(
290
+ name,
291
+ "yes" if tokens.get(name) else "—",
292
+ "yes" if sessions.exists(name) else "—",
293
+ )
294
+ console.print(t)
295
+
296
+
297
+ def main() -> None: # pragma: no cover
298
+ try:
299
+ app()
300
+ except KeyboardInterrupt:
301
+ console.print("\n[yellow]interrupted — re-run the same command to resume[/]")
302
+ sys.exit(130)
303
+
304
+
305
+ if __name__ == "__main__": # pragma: no cover
306
+ main()
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0