agent-wire-cli 0.2.1__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.
Files changed (87) hide show
  1. agent_wire/__init__.py +1 -0
  2. agent_wire/__main__.py +3 -0
  3. agent_wire/assets/SKILL.md +26 -0
  4. agent_wire/assets/cursor_rule.mdc +13 -0
  5. agent_wire/assets/hermes_skill.md +28 -0
  6. agent_wire/assets/windsurf_rules.md +14 -0
  7. agent_wire/assets/wire-worker.md +20 -0
  8. agent_wire/auth/__init__.py +0 -0
  9. agent_wire/auth/browser_paths.py +143 -0
  10. agent_wire/auth/cookie_vault.py +181 -0
  11. agent_wire/auth/rate_limiter.py +26 -0
  12. agent_wire/cache/__init__.py +0 -0
  13. agent_wire/cache/ring_buffer.py +94 -0
  14. agent_wire/cache/sqlite_ledger.py +132 -0
  15. agent_wire/channels/__init__.py +41 -0
  16. agent_wire/channels/base.py +249 -0
  17. agent_wire/channels/tier1_global/__init__.py +0 -0
  18. agent_wire/channels/tier1_global/github.py +371 -0
  19. agent_wire/channels/tier1_global/hackernews.py +135 -0
  20. agent_wire/channels/tier1_global/reddit.py +371 -0
  21. agent_wire/channels/tier1_global/rss.py +129 -0
  22. agent_wire/channels/tier1_global/search.py +160 -0
  23. agent_wire/channels/tier1_global/stackoverflow.py +94 -0
  24. agent_wire/channels/tier1_global/web.py +98 -0
  25. agent_wire/channels/tier1_global/wikipedia.py +91 -0
  26. agent_wire/channels/tier1_global/x_twitter.py +380 -0
  27. agent_wire/channels/tier1_global/youtube.py +223 -0
  28. agent_wire/channels/tier2_pro/__init__.py +0 -0
  29. agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
  30. agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
  31. agent_wire/channels/tier2_pro/devto.py +114 -0
  32. agent_wire/channels/tier2_pro/fediverse.py +181 -0
  33. agent_wire/channels/tier2_pro/hubs.py +161 -0
  34. agent_wire/channels/tier2_pro/linkedin.py +146 -0
  35. agent_wire/channels/tier2_pro/markets.py +207 -0
  36. agent_wire/channels/tier2_pro/meta_social.py +104 -0
  37. agent_wire/channels/tier2_pro/news.py +173 -0
  38. agent_wire/channels/tier2_pro/polymarket.py +105 -0
  39. agent_wire/channels/tier2_pro/publishing.py +84 -0
  40. agent_wire/channels/tier2_pro/stocktwits.py +79 -0
  41. agent_wire/channels/tier3_regional/bilibili.py +171 -0
  42. agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
  43. agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
  44. agent_wire/channels/tier3_regional/v2ex.py +70 -0
  45. agent_wire/channels/tier3_regional/wechat.py +67 -0
  46. agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
  47. agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
  48. agent_wire/channels/tier3_regional/xueqiu.py +77 -0
  49. agent_wire/channels/tier3_regional/zhihu.py +57 -0
  50. agent_wire/cli.py +403 -0
  51. agent_wire/compression/__init__.py +0 -0
  52. agent_wire/compression/anchor_slicer.py +45 -0
  53. agent_wire/compression/bm25_slicer.py +151 -0
  54. agent_wire/compression/dam_formatter.py +74 -0
  55. agent_wire/compression/url_compactor.py +45 -0
  56. agent_wire/config.py +52 -0
  57. agent_wire/engine/__init__.py +0 -0
  58. agent_wire/engine/challenge.py +58 -0
  59. agent_wire/engine/http_pool.py +136 -0
  60. agent_wire/engine/stealth_browser.py +90 -0
  61. agent_wire/engine/waterfall.py +159 -0
  62. agent_wire/firewall/__init__.py +0 -0
  63. agent_wire/firewall/inbound_shield.py +116 -0
  64. agent_wire/firewall/outbound_privacy.py +120 -0
  65. agent_wire/installers/__init__.py +0 -0
  66. agent_wire/installers/client_installer.py +315 -0
  67. agent_wire/media/__init__.py +0 -0
  68. agent_wire/media/audio_transcriber.py +77 -0
  69. agent_wire/media/subtitle_cleaner.py +102 -0
  70. agent_wire/models.py +60 -0
  71. agent_wire/research/__init__.py +1 -0
  72. agent_wire/research/brief.py +227 -0
  73. agent_wire/research/briefing.py +41 -0
  74. agent_wire/research/cluster.py +128 -0
  75. agent_wire/research/engine.py +632 -0
  76. agent_wire/research/library.py +74 -0
  77. agent_wire/research/planner.py +232 -0
  78. agent_wire/research/scoring.py +89 -0
  79. agent_wire/research/store.py +149 -0
  80. agent_wire/research/watch.py +70 -0
  81. agent_wire/router.py +107 -0
  82. agent_wire/server.py +468 -0
  83. agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
  84. agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
  85. agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
  86. agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
  87. agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,70 @@
1
+ """Xiaoyuzhou (小宇宙) podcasts: episode show notes, audio URL, optional transcript; podcast feeds."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from agent_wire.channels.base import (
7
+ Channel,
8
+ WireUnsupported,
9
+ html_to_markdown,
10
+ listing,
11
+ script_json,
12
+ ts,
13
+ )
14
+ from agent_wire.media import audio_transcriber
15
+
16
+
17
+ def page_props(html: str) -> dict:
18
+ data = script_json(html, "script#__NEXT_DATA__") or {}
19
+ return (data.get("props") or {}).get("pageProps") or {}
20
+
21
+
22
+ def parse_episode(props: dict) -> dict:
23
+ ep = props.get("episode") or {}
24
+ if not ep:
25
+ raise WireUnsupported("xiaoyuzhou episode data missing")
26
+ notes = ep.get("shownotes") or ep.get("description") or ""
27
+ audio = (ep.get("enclosure") or {}).get("url") or (ep.get("media") or {}).get("source", {}).get(
28
+ "url"
29
+ )
30
+ return {"title": ep.get("title"), "author_handle": (ep.get("podcast") or {}).get("title"),
31
+ "timestamp": ts(ep.get("pubDate")), "body": html_to_markdown(notes) if "<" in notes else notes,
32
+ "metadata": {"length": f"{(ep.get('duration') or 0) // 60}m", "plays": ep.get("playCount"),
33
+ "comments": ep.get("commentCount"), "audio": audio}} # fmt: skip
34
+
35
+
36
+ class XiaoyuzhouChannel(Channel):
37
+ name = "xiaoyuzhou"
38
+
39
+ async def read(self, url, *, query=None, section=None, include_comments=True):
40
+ if re.search(r"/podcast/", urlsplit(url).path):
41
+ return await self.feed(url)
42
+ f = await self.fetch(url, archive=False)
43
+ info = parse_episode(page_props(f.text))
44
+ audio = info["metadata"].get("audio")
45
+ if audio and audio_transcriber.enabled():
46
+ try:
47
+ transcript = await audio_transcriber.transcribe(self.ctx.pool, audio)
48
+ info["body"] += f"\n\n## Transcript\n{transcript}"
49
+ except audio_transcriber.TranscriptionUnavailable as e:
50
+ info["body"] += f"\n\n_Transcript unavailable: {e}_"
51
+ return self.doc(f, url, **info)
52
+
53
+ async def feed(self, target, *, sort="hot", limit=10):
54
+ url = (
55
+ target
56
+ if target.startswith("http")
57
+ else f"https://www.xiaoyuzhoufm.com/podcast/{target}"
58
+ )
59
+ f = await self.fetch(url, archive=False)
60
+ props = page_props(f.text)
61
+ pod = props.get("podcast") or {}
62
+ episodes = pod.get("episodes") or props.get("episodes") or []
63
+ items = [{"title": e.get("title"), "url": f"https://www.xiaoyuzhoufm.com/episode/{e.get('eid')}",
64
+ "meta": [(e.get("pubDate") or "")[:10], f"{(e.get('duration') or 0) // 60}m"],
65
+ "snippet": e.get("description") or ""} for e in episodes[:limit]] # fmt: skip
66
+ return self.doc(f, url, title=pod.get("title") or "Xiaoyuzhou podcast", author_handle=pod.get("author"),
67
+ body=(pod.get("brief") or "") + "\n\n" + listing(items)) # fmt: skip
68
+
69
+
70
+ CHANNEL = XiaoyuzhouChannel
@@ -0,0 +1,77 @@
1
+ """Xueqiu (雪球): stock quotes and hot discussions, after priming the anonymous xq_a_token cookie."""
2
+
3
+ import re
4
+ from urllib.parse import quote, urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, ts
7
+ from agent_wire.engine.http_pool import Fetched
8
+ from agent_wire.engine.waterfall import WireFetchError
9
+ from agent_wire.firewall.outbound_privacy import parse_json
10
+ from agent_wire.models import WireComment
11
+
12
+
13
+ def parse_quote(data: dict) -> dict:
14
+ q = (data.get("data") or {}).get("quote") or {}
15
+ if not q:
16
+ raise WireUnsupported("xueqiu returned no quote (unknown symbol?)")
17
+ lines = [f"Price: {q.get('current')} {q.get('currency') or ''} ({q.get('percent'):+}%)" if q.get("percent") is not None
18
+ else f"Price: {q.get('current')}",
19
+ f"52W: {q.get('high52w')} / {q.get('low52w')} · P/E (TTM): {q.get('pe_ttm')} · Market cap: {q.get('market_capital')}"] # fmt: skip
20
+ return {"title": f"{q.get('name')} ({q.get('symbol')})", "body": "\n".join(lines),
21
+ "metadata": {"exchange": q.get("exchange")}} # fmt: skip
22
+
23
+
24
+ def parse_statuses(data: dict) -> list[WireComment]:
25
+ return [WireComment(id=str(s.get("id")), body=html_to_markdown(s.get("text") or s.get("description") or ""),
26
+ author_handle=(s.get("user") or {}).get("screen_name"), score=s.get("like_count") or 0,
27
+ timestamp=ts(s.get("created_at")))
28
+ for s in data.get("list", [])] # fmt: skip
29
+
30
+
31
+ class XueqiuChannel(Channel):
32
+ name = "xueqiu"
33
+
34
+ def __init__(self, ctx):
35
+ super().__init__(ctx)
36
+ self._primed = False
37
+
38
+ async def _get(self, url: str) -> Fetched:
39
+ if not self._primed: # anonymous visitor cookie, kept in the shared chrome session
40
+ await self.ctx.pool.get("https://xueqiu.com/", impersonate="chrome")
41
+ self._primed = True
42
+ f = await self.ctx.pool.get(
43
+ url, impersonate="chrome", headers={"Referer": "https://xueqiu.com/"}
44
+ )
45
+ if not f.ok:
46
+ raise WireFetchError(
47
+ f"xueqiu: HTTP {f.status}; its WAF needs a JS challenge: install the stealth extra "
48
+ "or run `wire auth login xueqiu.com`"
49
+ )
50
+ return f
51
+
52
+ async def _stock(self, url: str, symbol: str, include_comments: bool):
53
+ f = await self._get(
54
+ f"https://stock.xueqiu.com/v5/stock/quote.json?symbol={quote(symbol)}&extend=detail"
55
+ )
56
+ info = parse_quote(parse_json(f.content))
57
+ if include_comments:
58
+ try:
59
+ s = await self._get("https://xueqiu.com/query/v1/symbol/search/status.json?count=15&comment=0"
60
+ f"&symbol={quote(symbol)}&hl=0&source=all&sort=alpha&page=1") # fmt: skip
61
+ info["comments"] = parse_statuses(parse_json(s.content))
62
+ except (WireFetchError, ValueError):
63
+ pass
64
+ return self.doc(f, url, **info)
65
+
66
+ async def read(self, url, *, query=None, section=None, include_comments=True):
67
+ m = re.search(r"/S/([\w.]+)", urlsplit(url).path)
68
+ if not m:
69
+ return await self.ctx.channel("web").read(url, query=query, section=section)
70
+ return await self._stock(url, m[1].upper(), include_comments)
71
+
72
+ async def feed(self, target, *, sort="hot", limit=10):
73
+ sym = target.strip().upper()
74
+ return await self._stock(f"https://xueqiu.com/S/{sym}", sym, True)
75
+
76
+
77
+ CHANNEL = XueqiuChannel
@@ -0,0 +1,57 @@
1
+ """Zhihu: questions with top answers, single answers and zhuanlan articles from js-initialData."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, script_json, ts
7
+ from agent_wire.models import WireComment
8
+
9
+
10
+ def parse_initial(state: dict, kind: str, ident: str, answer_id: str | None = None) -> dict:
11
+ entities = (state.get("initialState") or {}).get("entities") or {}
12
+ if kind == "article":
13
+ a = (entities.get("articles") or {}).get(ident)
14
+ if not a:
15
+ raise WireUnsupported("zhihu article not in page state (login wall?)")
16
+ return {"title": a.get("title"), "author_handle": (a.get("author") or {}).get("name"),
17
+ "timestamp": ts(a.get("created")), "score": a.get("voteupCount"),
18
+ "body": html_to_markdown(a.get("content") or "")} # fmt: skip
19
+ q = (entities.get("questions") or {}).get(ident) or {}
20
+ answers = list((entities.get("answers") or {}).values())
21
+ if answer_id:
22
+ answers = [a for a in answers if str(a.get("id")) == answer_id] or answers
23
+ if not q and not answers:
24
+ raise WireUnsupported("zhihu question not in page state (login wall?)")
25
+ comments = [
26
+ WireComment(id=str(a.get("id")), body=html_to_markdown(a.get("content") or a.get("excerpt") or ""),
27
+ author_handle=(a.get("author") or {}).get("name"), score=a.get("voteupCount") or 0,
28
+ timestamp=ts(a.get("createdTime")))
29
+ for a in answers
30
+ ] # fmt: skip
31
+ return {"title": q.get("title"), "body": html_to_markdown(q.get("detail") or ""), "comments": comments,
32
+ "metadata": {"answers": q.get("answerCount"), "followers": q.get("followerCount"),
33
+ "views": q.get("visitCount")}} # fmt: skip
34
+
35
+
36
+ class ZhihuChannel(Channel):
37
+ name = "zhihu"
38
+
39
+ async def read(self, url, *, query=None, section=None, include_comments=True):
40
+ p = urlsplit(url)
41
+ if (p.hostname or "").startswith("zhuanlan") and (m := re.search(r"/p/(\d+)", p.path)):
42
+ kind, ident, answer = "article", m[1], None
43
+ elif m := re.search(r"/question/(\d+)(?:/answer/(\d+))?", p.path):
44
+ kind, ident, answer = "question", m[1], m[2]
45
+ else:
46
+ return await self.ctx.channel("web").read(url, query=query, section=section)
47
+ f = await self.fetch(url, archive=False)
48
+ state = script_json(f.text, "script#js-initialData") or {}
49
+ return self.doc(f, url, **parse_initial(state, kind, ident, answer))
50
+
51
+ async def search(self, query, *, limit=5, sort="relevance"):
52
+ raise WireUnsupported(
53
+ "zhihu search needs its signed API; use platforms=['web'] with 'site:zhihu.com'"
54
+ )
55
+
56
+
57
+ CHANNEL = ZhihuChannel
agent_wire/cli.py ADDED
@@ -0,0 +1,403 @@
1
+ """`wire` CLI: serve the MCP server, run the tools from a shell, install into clients, manage logins."""
2
+
3
+ import asyncio
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from agent_wire import __version__
12
+
13
+ app = typer.Typer(
14
+ no_args_is_help=True,
15
+ add_completion=False,
16
+ help="agent-wire: agents wired to the live internet.",
17
+ )
18
+ auth_app = typer.Typer(no_args_is_help=True, help="Local logins for wire_me and login-only sites.")
19
+ app.add_typer(auth_app, name="auth")
20
+ watch_app = typer.Typer(no_args_is_help=True, help="Watched topics: re-run and report what's new.")
21
+ app.add_typer(watch_app, name="watch")
22
+ library_app = typer.Typer(no_args_is_help=True, help="Saved research briefs: search, list, site.")
23
+ app.add_typer(library_app, name="library")
24
+
25
+ Profile = Annotated[str, typer.Option("--profile", help="standard, or local for small-VRAM models")]
26
+
27
+
28
+ def _utf8() -> None:
29
+ # Windows consoles default to a legacy code page; output contains ★ ♥ · and CJK text.
30
+ for stream in (sys.stdout, sys.stderr):
31
+ if hasattr(stream, "reconfigure"):
32
+ stream.reconfigure(encoding="utf-8", errors="replace")
33
+
34
+
35
+ def _run(profile: str, fn) -> None:
36
+ from agent_wire.server import Wire
37
+
38
+ _utf8()
39
+
40
+ async def go():
41
+ wire = Wire(profile)
42
+ try:
43
+ return await fn(wire)
44
+ finally:
45
+ await wire.close()
46
+
47
+ typer.echo(asyncio.run(go()))
48
+
49
+
50
+ @app.command()
51
+ def serve(
52
+ profile: Profile = "standard",
53
+ transport: Annotated[str, typer.Option(help="stdio, http or sse")] = "stdio",
54
+ port: int = 8765,
55
+ ) -> None:
56
+ """Run the MCP server (stdio by default)."""
57
+ from agent_wire.server import create_server
58
+
59
+ server = create_server(profile)
60
+ if transport == "stdio":
61
+ server.run(show_banner=False)
62
+ else:
63
+ server.run(transport=transport, port=port, show_banner=False)
64
+
65
+
66
+ @app.command()
67
+ def read(
68
+ urls: list[str],
69
+ query: Annotated[str | None, typer.Option("--query", "-q")] = None,
70
+ section: str | None = None,
71
+ no_comments: bool = False,
72
+ max_tokens: int | None = None,
73
+ force: bool = False,
74
+ profile: Profile = "standard",
75
+ ) -> None:
76
+ """Read one or more URLs."""
77
+ _run(profile, lambda w: w.read(urls, query, section, not no_comments, max_tokens, force))
78
+
79
+
80
+ @app.command()
81
+ def search(
82
+ query: str,
83
+ platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
84
+ limit: int = 5,
85
+ sort: str = "relevance",
86
+ expand: Annotated[int | None, typer.Option(help="top hits to auto-read")] = None,
87
+ max_tokens: int | None = None,
88
+ profile: Profile = "standard",
89
+ ) -> None:
90
+ """Search the web or platforms (reads the top hits too)."""
91
+ _run(profile, lambda w: w.search(query, platform or ["web"], limit, sort, expand, max_tokens))
92
+
93
+
94
+ @app.command()
95
+ def feed(
96
+ platform: str, target: str, sort: str = "hot", limit: int = 10, profile: Profile = "standard"
97
+ ) -> None:
98
+ """A subreddit, @user timeline, trending repos, market watchlist or RSS feed."""
99
+ _run(profile, lambda w: w.feed(platform, target, sort, limit))
100
+
101
+
102
+ @app.command()
103
+ def me(
104
+ platform: str, target: str = "latest_posts", limit: int = 5, profile: Profile = "standard"
105
+ ) -> None:
106
+ """Your own activity: latest_posts, likes, bookmarks, notifications, stats."""
107
+ _run(profile, lambda w: w.me(platform, target, limit))
108
+
109
+
110
+ @app.command()
111
+ def status(refresh: bool = False, profile: Profile = "standard") -> None:
112
+ """Channel health, sessions and route cache."""
113
+ _run(profile, lambda w: w.status(refresh))
114
+
115
+
116
+ @app.command()
117
+ def install(
118
+ client: Annotated[str, typer.Option("--client", "-c")] = "auto",
119
+ profile: Profile = "standard",
120
+ global_: Annotated[
121
+ bool, typer.Option("--global", help="user-wide config instead of this project")
122
+ ] = False,
123
+ project: Annotated[Path, typer.Option(help="project root for project-scoped clients")] = Path(
124
+ "."
125
+ ),
126
+ dry_run: bool = False,
127
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="don't ask before writing")] = False,
128
+ ) -> None:
129
+ """Install into agent clients (auto, all, claude-code, codex, cursor, ...)."""
130
+ from agent_wire.installers.client_installer import detect
131
+ from agent_wire.installers.client_installer import install as do_install
132
+
133
+ _utf8()
134
+ if client == "auto" and not dry_run and not yes:
135
+ found = detect(project.resolve(), Path.home())
136
+ typer.echo(f"detected clients: {', '.join(found)}")
137
+ if not typer.confirm("write agent-wire into all of them?", default=True):
138
+ typer.echo("nothing written; pick one with --client <name>")
139
+ raise typer.Exit(1)
140
+ try:
141
+ results = do_install(client, project=project.resolve(), home=Path.home(), profile=profile,
142
+ global_=global_, dry_run=dry_run) # fmt: skip
143
+ except ValueError as e:
144
+ typer.echo(f"error: {e}", err=True)
145
+ raise typer.Exit(2) from e
146
+ for r in results:
147
+ typer.echo(f"{r.client}:")
148
+ for f in r.files:
149
+ typer.echo(f" {'would write' if dry_run else 'wrote'} {f}")
150
+ for note in r.notes:
151
+ typer.echo(f" note: {note}")
152
+
153
+
154
+ @app.command()
155
+ def research(
156
+ topic: Annotated[
157
+ str, typer.Argument(help="topic, 'A vs B', or empty for what's trending")
158
+ ] = "",
159
+ days: int = 30,
160
+ platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
161
+ mode: Annotated[str, typer.Option(help="auto, topic, compare, discover, person")] = "auto",
162
+ depth: Annotated[str, typer.Option(help="quick, default, deep")] = "default",
163
+ github_user: Annotated[str | None, typer.Option(help="add this GitHub user's activity")] = None,
164
+ as_of: Annotated[str | None, typer.Option(help="end of the window, YYYY-MM-DD")] = None,
165
+ emit: Annotated[str, typer.Option(help="md, json or html")] = "md",
166
+ output: Annotated[
167
+ Path | None, typer.Option("--output", "-o", help="write to this file")
168
+ ] = None,
169
+ save_dir: Annotated[
170
+ Path | None, typer.Option(help="write <slug>.<ext> into this folder")
171
+ ] = None,
172
+ store: Annotated[bool, typer.Option(help="save the run to the local library")] = False,
173
+ preflight: Annotated[bool, typer.Option(help="show the plan; no network")] = False,
174
+ max_tokens: int | None = None,
175
+ profile: Profile = "standard",
176
+ ) -> None:
177
+ """What people said about a topic in the last N days, across sites, as one cited brief."""
178
+ from agent_wire.server import Wire
179
+
180
+ _utf8()
181
+
182
+ async def go():
183
+ wire = Wire(profile)
184
+ try:
185
+ return await wire.research(topic, days, platform, mode, depth, as_of, github_user, emit,
186
+ store, preflight, max_tokens or 100_000) # fmt: skip
187
+ finally:
188
+ await wire.close()
189
+
190
+ text = asyncio.run(go())
191
+ target = output
192
+ if save_dir and not target:
193
+ slug = re.sub(r"[^a-z0-9]+", "-", (topic or "trending").lower()).strip("-")[:60] or "brief"
194
+ target = save_dir / f"{slug}-{days}d.{emit}"
195
+ if target:
196
+ target.parent.mkdir(parents=True, exist_ok=True)
197
+ target.write_text(text, encoding="utf-8")
198
+ typer.echo(f"wrote {target}")
199
+ else:
200
+ typer.echo(text)
201
+
202
+
203
+ def _wire_call(profile: str, fn):
204
+ """Run fn(wire) with a Wire that is always closed; print the returned text."""
205
+ from agent_wire.server import Wire
206
+
207
+ _utf8()
208
+
209
+ async def go():
210
+ wire = Wire(profile)
211
+ try:
212
+ return await fn(wire)
213
+ finally:
214
+ await wire.close()
215
+
216
+ typer.echo(asyncio.run(go()))
217
+
218
+
219
+ @watch_app.command("add")
220
+ def watch_add(
221
+ topic: str,
222
+ days: int = 7,
223
+ platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
224
+ webhook: Annotated[
225
+ str | None, typer.Option(help="POST new findings (JSON) to this URL")
226
+ ] = None,
227
+ ) -> None:
228
+ """Watch a topic; `wire watch run` reports what appeared since the last run."""
229
+ from agent_wire.research.planner import normalize_sources
230
+ from agent_wire.research.watch import check_webhook
231
+
232
+ try:
233
+ sources = normalize_sources(platform)
234
+ check_webhook(webhook)
235
+ except ValueError as e:
236
+ typer.echo(f"error: {e}", err=True)
237
+ raise typer.Exit(2) from e
238
+
239
+ async def add(w):
240
+ w.store.add_watch(topic, days, ",".join(sources) if sources else None, webhook)
241
+ return (
242
+ f"watching {topic!r} (last {days} days"
243
+ + (f", webhook {webhook}" if webhook else "")
244
+ + ")"
245
+ )
246
+
247
+ _wire_call("standard", add)
248
+
249
+
250
+ @watch_app.command("list")
251
+ def watch_list() -> None:
252
+ """Watched topics."""
253
+
254
+ async def ls(w):
255
+ rows = w.store.watches()
256
+ return (
257
+ "\n".join(
258
+ f"{r['topic']} · {r['days']}d · {r['sources'] or 'default sources'}"
259
+ + (f" · webhook {r['webhook']}" if r["webhook"] else "")
260
+ + (f" · last run #{r['last_run_id']}" if r["last_run_id"] else "")
261
+ for r in rows
262
+ )
263
+ or "no watched topics"
264
+ )
265
+
266
+ _wire_call("standard", ls)
267
+
268
+
269
+ @watch_app.command("remove")
270
+ def watch_remove(topic: str) -> None:
271
+ """Stop watching a topic."""
272
+
273
+ async def rm(w):
274
+ return f"removed {topic!r}" if w.store.remove_watch(topic) else f"not watching {topic!r}"
275
+
276
+ _wire_call("standard", rm)
277
+
278
+
279
+ @watch_app.command("run")
280
+ def watch_run(
281
+ topic: Annotated[str | None, typer.Argument(help="one topic, or all")] = None,
282
+ ) -> None:
283
+ """Re-run watched topics and show only new findings (schedule this with cron / Task Scheduler)."""
284
+ from agent_wire.research.watch import run_watches
285
+
286
+ _wire_call("standard", lambda w: run_watches(w, topic))
287
+
288
+
289
+ @app.command()
290
+ def brief(
291
+ period: Annotated[str, typer.Argument(help="daily or weekly")] = "daily",
292
+ no_trending: bool = False,
293
+ ) -> None:
294
+ """Digest across every watched topic, plus what's trending; saved under the library."""
295
+ from agent_wire.research.briefing import digest
296
+
297
+ _wire_call("standard", lambda w: digest(w, period, trending=not no_trending))
298
+
299
+
300
+ @library_app.command("search")
301
+ def library_search(query: str, limit: int = 10) -> None:
302
+ """Offline search over saved briefs."""
303
+
304
+ async def s(w):
305
+ return w.library.search_markdown(query, limit)
306
+
307
+ _wire_call("standard", s)
308
+
309
+
310
+ @library_app.command("list")
311
+ def library_list(limit: int = 20) -> None:
312
+ """Saved runs, newest first."""
313
+
314
+ async def ls(w):
315
+ rows = w.store.runs(limit)
316
+ return "\n".join(f"#{r['id']} · {r['created_at'][:16]} · {r['topic']} ({r['mode']}, {r['days']}d)"
317
+ f" · {r['headline']}" for r in rows) or "library is empty (research --store)" # fmt: skip
318
+
319
+ _wire_call("standard", ls)
320
+
321
+
322
+ @library_app.command("show")
323
+ def library_show(run_id: int) -> None:
324
+ """Print a saved brief."""
325
+
326
+ async def show(w):
327
+ run = w.store.run(run_id)
328
+ return run["markdown"] if run else f"no run #{run_id}"
329
+
330
+ _wire_call("standard", show)
331
+
332
+
333
+ @library_app.command("feed")
334
+ def library_feed() -> None:
335
+ """Build WIRE_HOME/library/index.html, feed.xml (Atom) and one page per run."""
336
+
337
+ async def build(w):
338
+ out = w.library.build()
339
+ return f"wrote {out / 'index.html'} and {out / 'feed.xml'}"
340
+
341
+ _wire_call("standard", build)
342
+
343
+
344
+ @app.command()
345
+ def version() -> None:
346
+ """Print the version."""
347
+ typer.echo(__version__)
348
+
349
+
350
+ @auth_app.command("login")
351
+ def auth_login(
352
+ site: str,
353
+ from_browser: Annotated[
354
+ bool, typer.Option("--from-browser", help="copy this site's cookies from a local browser")
355
+ ] = False,
356
+ ) -> None:
357
+ """Save a session for one site in the encrypted vault (the only cookies agent-wire uses).
358
+
359
+ Opens a login window (stealth extra), or with --from-browser copies the site's cookies from
360
+ a browser you're already logged in to.
361
+ """
362
+ from agent_wire.auth.cookie_vault import CookieVault
363
+ from agent_wire.config import init_wire_home
364
+
365
+ _utf8()
366
+ vault = CookieVault(init_wire_home())
367
+ if from_browser:
368
+ n = asyncio.run(vault.import_from_browser(site))
369
+ typer.echo(
370
+ f"saved {n} cookies for {site}" if n else f"no {site} cookies found in local browsers"
371
+ )
372
+ return
373
+ try:
374
+ n = asyncio.run(vault.interactive_login(site))
375
+ except ImportError as e:
376
+ typer.echo("wire auth login needs the stealth extra: uv tool install 'agent-wire-cli[stealth]' "
377
+ "&& playwright install chromium", err=True) # fmt: skip
378
+ raise typer.Exit(1) from e
379
+ typer.echo(f"saved {n} cookies for {site}" if n else f"no cookies captured for {site}")
380
+
381
+
382
+ @auth_app.command("list")
383
+ def auth_list() -> None:
384
+ """Sites with a saved session (names only, never values)."""
385
+ from agent_wire.auth.cookie_vault import CookieVault
386
+ from agent_wire.config import init_wire_home
387
+
388
+ domains = CookieVault(init_wire_home()).domains()
389
+ typer.echo("\n".join(domains) if domains else "no saved sessions")
390
+
391
+
392
+ @auth_app.command("logout")
393
+ def auth_logout(site: str) -> None:
394
+ """Forget a saved session."""
395
+ from agent_wire.auth.cookie_vault import CookieVault
396
+ from agent_wire.config import init_wire_home
397
+
398
+ removed = CookieVault(init_wire_home()).remove(site)
399
+ typer.echo(f"removed {site}" if removed else f"no saved session for {site}")
400
+
401
+
402
+ if __name__ == "__main__":
403
+ app()
File without changes
@@ -0,0 +1,45 @@
1
+ """#L45-L120 line slicing for code and #heading section extraction for Markdown."""
2
+
3
+ import re
4
+
5
+ CONTEXT_LINES = 5
6
+
7
+ _LINE_ANCHOR = re.compile(r"#L(\d+)(?:-L?(\d+))?$")
8
+ _HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
9
+
10
+
11
+ def parse_line_anchor(url: str) -> tuple[int, int] | None:
12
+ if not (m := _LINE_ANCHOR.search(url)):
13
+ return None
14
+ a, b = int(m[1]), int(m[2] or m[1])
15
+ return min(a, b), max(a, b)
16
+
17
+
18
+ def slice_lines(text: str, start: int, end: int, context: int = CONTEXT_LINES) -> str:
19
+ lines = text.splitlines()
20
+ lo, hi = max(1, start - context), min(len(lines), end + context)
21
+ width = len(str(hi))
22
+ return "\n".join(f"{n:>{width}}| {lines[n - 1]}" for n in range(lo, hi + 1))
23
+
24
+
25
+ def slugify(title: str) -> str:
26
+ """GitHub-style heading anchor."""
27
+ s = re.sub(r"[^\w\s-]", "", title.strip().lower())
28
+ return s.replace(" ", "-")
29
+
30
+
31
+ def slice_section(markdown: str, section: str) -> str | None:
32
+ """Heading named/slugged `section` through the next heading of equal or higher level."""
33
+ target = slugify(section.lstrip("#"))
34
+ out, level, in_fence = None, 0, False
35
+ for line in markdown.splitlines():
36
+ if line.lstrip().startswith(("```", "~~~")):
37
+ in_fence = not in_fence
38
+ m = None if in_fence else _HEADING.match(line)
39
+ if out is not None:
40
+ if m and len(m[1]) <= level:
41
+ break
42
+ out.append(line)
43
+ elif m and slugify(m[2]) == target:
44
+ out, level = [line], len(m[1])
45
+ return "\n".join(out) if out else None