nexus-agent-cli 0.5.0__tar.gz → 0.7.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.
Files changed (21) hide show
  1. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/PKG-INFO +68 -4
  2. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/README.md +65 -1
  3. nexus_agent_cli-0.7.0/nexus_agent_cli/__init__.py +1 -0
  4. nexus_agent_cli-0.7.0/nexus_agent_cli/api_client.py +323 -0
  5. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/commands/__init__.py +2 -0
  6. nexus_agent_cli-0.7.0/nexus_agent_cli/commands/discord/cmd.py +294 -0
  7. nexus_agent_cli-0.7.0/nexus_agent_cli/commands/run/__init__.py +0 -0
  8. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/commands/run/cmd.py +98 -4
  9. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/pyproject.toml +2 -2
  10. nexus_agent_cli-0.7.0/tests/__init__.py +0 -0
  11. nexus_agent_cli-0.7.0/tests/test_api_client_shapes.py +165 -0
  12. nexus_agent_cli-0.5.0/nexus_agent_cli/__init__.py +0 -1
  13. nexus_agent_cli-0.5.0/nexus_agent_cli/api_client.py +0 -162
  14. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/.gitignore +0 -0
  15. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/__main__.py +0 -0
  16. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/auth_pkce.py +0 -0
  17. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/commands/auth/__init__.py +0 -0
  18. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/commands/auth/cmd.py +0 -0
  19. {nexus_agent_cli-0.5.0/nexus_agent_cli/commands/run → nexus_agent_cli-0.7.0/nexus_agent_cli/commands/discord}/__init__.py +0 -0
  20. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/config.py +0 -0
  21. {nexus_agent_cli-0.5.0 → nexus_agent_cli-0.7.0}/nexus_agent_cli/main.py +0 -0
@@ -1,7 +1,7 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nexus-agent-cli
3
- Version: 0.5.0
4
- Summary: Nexus internal agent ops CLI — pull run params, push analysis results, send notifications. Admin only.
3
+ Version: 0.7.0
4
+ Summary: Nexus internal agent ops CLI — pull run params, push analysis results, drive the Discord KB / blog-draft data plane, send notifications. Admin only.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
7
7
  Requires-Dist: platformdirs>=4.2
@@ -85,6 +85,42 @@ nexus-agent push-config --ticker tqqq --time-range 2026-06-01_2026-06-14 \
85
85
  nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
86
86
  ```
87
87
 
88
+ ### `discord` —— Discord KB / blog draft 数据平面
89
+
90
+ ```bash
91
+ # 1) 起草前必调:取当前生效的风格 playbook
92
+ # ⚠️ 没有 active playbook 时返回 409 并退出 1 —— 必须中止本次运行,
93
+ # 不允许降级为无风格约束照写
94
+ nexus-agent discord playbook-active --out playbook.json --body-out playbook.md
95
+
96
+ # 2) 取材(窗口由服务端水位线定义,**没有 --date**)
97
+ nexus-agent discord clusters-pending --out clusters.json
98
+
99
+ # 3) 回写富化结果(只改 title/summary/tickers/topics,不碰 blog_state)
100
+ nexus-agent discord push-enrichment --file enrichment.json
101
+
102
+ # 4) 回传草稿(markdown 正文 + 结构化 meta)
103
+ # meta.json: {title, draft_date, source_cluster_ids: [...非空], front_matter?, critic_report?}
104
+ nexus-agent discord push-blog-draft --file draft.md --meta meta.json
105
+
106
+ # 5) 反馈折叠:够 10 条可折叠反馈才跑 playbook_reviser
107
+ nexus-agent discord feedback-pending --out feedback.json
108
+
109
+ # 6) 提交 playbook。meta.json: {rules: [...], change_log, derived_from?, folded_feedback_ids?}
110
+ # ⚠️ 不要在 meta 里放 token_est —— 服务端用 count_tokens 实算(客户端会直接拒绝)
111
+ nexus-agent discord push-playbook --file playbook.md --meta meta.json --activate # v1 才用 --activate
112
+ nexus-agent discord push-playbook --file playbook.md --meta meta.json # 修订版:先落库待审批
113
+ nexus-agent discord activate-playbook --version 2 # 相当于在 #blog-review 点 ✅
114
+ ```
115
+
116
+ ⚠️ **`clusters-pending` 刻意没有 `--date`。** 取材窗口是「上次成功运行覆盖到的时刻 →
117
+ 现在」,不是自然日 —— 按自然日切会出现某天的笔记谁也没收或被收两次。响应里的
118
+ `window` 字段会告诉你这次实际覆盖了哪一段。
119
+
120
+ ⚠️ **`push-blog-draft` 目前只落库。** 把草稿发到 `#blog-review` 并挂审批 reaction
121
+ 尚未接线,响应里的 `discord_delivery` 会是 `not_wired`。所以 `activate-playbook`
122
+ 是目前唯一的"✅"入口。
123
+
88
124
  ## 配置(环境变量,均可选)
89
125
 
90
126
  | 变量 | 默认 | 说明 |
@@ -107,15 +143,43 @@ NEXUS_AGENT_API_BASE_URL=http://127.0.0.1:8000/api nexus-agent run-config
107
143
  `run-config/`、`upload/market-analysis/`、`upload/portfolio-recommendation/`、
108
144
  `upload/backtest/`、`notify/`。
109
145
 
146
+ `discord` 子命令组调用 `apps/discord_kb` 的 7 个 admin 端点
147
+ (`clusters/pending/`、`clusters/enrich/`、`blog-drafts/`、`style-playbook/active/`、
148
+ `style-playbook/`、`style-playbook/<version>/activate/`、`feedback/pending/`)。
149
+ 设计与不变量见 nexus-service `docs/DISCORD_KB_AND_BOT_DESIGN.md`(§A.6 是这一组的来源)。
150
+
151
+ ## 测试
152
+
153
+ ```bash
154
+ python -m unittest discover -s tests
155
+ ```
156
+
157
+ 只用标准库(`unittest` + `http.server`),不需要额外依赖、不出网、不读凭据目录 ——
158
+ `tests/test_api_client_shapes.py` 把请求打到 127.0.0.1 的捕获服务器上,验路径与
159
+ JSON body 的形状(这类错误在真实调用里只会表现为服务端 400/422,那时已分不清是
160
+ 本地拼错还是服务端变了)。
161
+
110
162
  ## 发布新版本
111
163
 
112
164
  ```bash
113
- # 1. 改 pyproject.toml 的 version
165
+ # 1. 版本号有**两个**事实源,必须一起改:
166
+ # pyproject.toml 的 version → 决定包版本
167
+ # nexus_agent_cli/__init__.py 的 __version__ → 决定 `nexus-agent version` 打印什么
168
+ # 只改前者的话,装上新版后 `nexus-agent version` 仍打印旧号 —— 不报错、只是骗人。
169
+ # (2026-08-22 发 0.7.0 时就差点这样发出去。)
114
170
  # 2. 构建并发布到 PyPI(用 .zshrc 里的 UV_PUBLISH_PASSWORD all-projects token)
115
171
  rm -rf dist && python -m build
172
+ python -m twine check dist/* # 先查,再传
116
173
  TWINE_USERNAME=__token__ TWINE_PASSWORD="$UV_PUBLISH_PASSWORD" twine upload dist/*
117
174
  ```
118
175
 
176
+ 发布前建议核一遍(同一版本号 PyPI 不允许重传,错了只能跳版本):
177
+
178
+ ```bash
179
+ grep '^version' pyproject.toml; grep __version__ nexus_agent_cli/__init__.py # 两处一致
180
+ python -c "import zipfile,glob; print(*sorted(zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist()),sep=chr(10))"
181
+ ```
182
+
119
183
  ## 迁移说明
120
184
 
121
185
  当前 AWS(EventBridge → ECS Fargate)的每周定时运行**保持不变**。本 CLI 提供并行的
@@ -74,6 +74,42 @@ nexus-agent push-config --ticker tqqq --time-range 2026-06-01_2026-06-14 \
74
74
  nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
75
75
  ```
76
76
 
77
+ ### `discord` —— Discord KB / blog draft 数据平面
78
+
79
+ ```bash
80
+ # 1) 起草前必调:取当前生效的风格 playbook
81
+ # ⚠️ 没有 active playbook 时返回 409 并退出 1 —— 必须中止本次运行,
82
+ # 不允许降级为无风格约束照写
83
+ nexus-agent discord playbook-active --out playbook.json --body-out playbook.md
84
+
85
+ # 2) 取材(窗口由服务端水位线定义,**没有 --date**)
86
+ nexus-agent discord clusters-pending --out clusters.json
87
+
88
+ # 3) 回写富化结果(只改 title/summary/tickers/topics,不碰 blog_state)
89
+ nexus-agent discord push-enrichment --file enrichment.json
90
+
91
+ # 4) 回传草稿(markdown 正文 + 结构化 meta)
92
+ # meta.json: {title, draft_date, source_cluster_ids: [...非空], front_matter?, critic_report?}
93
+ nexus-agent discord push-blog-draft --file draft.md --meta meta.json
94
+
95
+ # 5) 反馈折叠:够 10 条可折叠反馈才跑 playbook_reviser
96
+ nexus-agent discord feedback-pending --out feedback.json
97
+
98
+ # 6) 提交 playbook。meta.json: {rules: [...], change_log, derived_from?, folded_feedback_ids?}
99
+ # ⚠️ 不要在 meta 里放 token_est —— 服务端用 count_tokens 实算(客户端会直接拒绝)
100
+ nexus-agent discord push-playbook --file playbook.md --meta meta.json --activate # v1 才用 --activate
101
+ nexus-agent discord push-playbook --file playbook.md --meta meta.json # 修订版:先落库待审批
102
+ nexus-agent discord activate-playbook --version 2 # 相当于在 #blog-review 点 ✅
103
+ ```
104
+
105
+ ⚠️ **`clusters-pending` 刻意没有 `--date`。** 取材窗口是「上次成功运行覆盖到的时刻 →
106
+ 现在」,不是自然日 —— 按自然日切会出现某天的笔记谁也没收或被收两次。响应里的
107
+ `window` 字段会告诉你这次实际覆盖了哪一段。
108
+
109
+ ⚠️ **`push-blog-draft` 目前只落库。** 把草稿发到 `#blog-review` 并挂审批 reaction
110
+ 尚未接线,响应里的 `discord_delivery` 会是 `not_wired`。所以 `activate-playbook`
111
+ 是目前唯一的"✅"入口。
112
+
77
113
  ## 配置(环境变量,均可选)
78
114
 
79
115
  | 变量 | 默认 | 说明 |
@@ -96,15 +132,43 @@ NEXUS_AGENT_API_BASE_URL=http://127.0.0.1:8000/api nexus-agent run-config
96
132
  `run-config/`、`upload/market-analysis/`、`upload/portfolio-recommendation/`、
97
133
  `upload/backtest/`、`notify/`。
98
134
 
135
+ `discord` 子命令组调用 `apps/discord_kb` 的 7 个 admin 端点
136
+ (`clusters/pending/`、`clusters/enrich/`、`blog-drafts/`、`style-playbook/active/`、
137
+ `style-playbook/`、`style-playbook/<version>/activate/`、`feedback/pending/`)。
138
+ 设计与不变量见 nexus-service `docs/DISCORD_KB_AND_BOT_DESIGN.md`(§A.6 是这一组的来源)。
139
+
140
+ ## 测试
141
+
142
+ ```bash
143
+ python -m unittest discover -s tests
144
+ ```
145
+
146
+ 只用标准库(`unittest` + `http.server`),不需要额外依赖、不出网、不读凭据目录 ——
147
+ `tests/test_api_client_shapes.py` 把请求打到 127.0.0.1 的捕获服务器上,验路径与
148
+ JSON body 的形状(这类错误在真实调用里只会表现为服务端 400/422,那时已分不清是
149
+ 本地拼错还是服务端变了)。
150
+
99
151
  ## 发布新版本
100
152
 
101
153
  ```bash
102
- # 1. 改 pyproject.toml 的 version
154
+ # 1. 版本号有**两个**事实源,必须一起改:
155
+ # pyproject.toml 的 version → 决定包版本
156
+ # nexus_agent_cli/__init__.py 的 __version__ → 决定 `nexus-agent version` 打印什么
157
+ # 只改前者的话,装上新版后 `nexus-agent version` 仍打印旧号 —— 不报错、只是骗人。
158
+ # (2026-08-22 发 0.7.0 时就差点这样发出去。)
103
159
  # 2. 构建并发布到 PyPI(用 .zshrc 里的 UV_PUBLISH_PASSWORD all-projects token)
104
160
  rm -rf dist && python -m build
161
+ python -m twine check dist/* # 先查,再传
105
162
  TWINE_USERNAME=__token__ TWINE_PASSWORD="$UV_PUBLISH_PASSWORD" twine upload dist/*
106
163
  ```
107
164
 
165
+ 发布前建议核一遍(同一版本号 PyPI 不允许重传,错了只能跳版本):
166
+
167
+ ```bash
168
+ grep '^version' pyproject.toml; grep __version__ nexus_agent_cli/__init__.py # 两处一致
169
+ python -c "import zipfile,glob; print(*sorted(zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist()),sep=chr(10))"
170
+ ```
171
+
108
172
  ## 迁移说明
109
173
 
110
174
  当前 AWS(EventBridge → ECS Fargate)的每周定时运行**保持不变**。本 CLI 提供并行的
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from nexus_agent_cli.auth_pkce import ensure_fresh_id_token
9
+ from nexus_agent_cli.config import api_base_url
10
+
11
+
12
+ def _headers() -> dict[str, str]:
13
+ token = ensure_fresh_id_token()
14
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
15
+
16
+
17
+ def _raise_for_error(response: httpx.Response) -> None:
18
+ if response.status_code < 400:
19
+ return
20
+ try:
21
+ detail: Any = response.json()
22
+ except json.JSONDecodeError:
23
+ detail = response.text
24
+ raise RuntimeError(f"HTTP {response.status_code}: {detail}")
25
+
26
+
27
+ def get_json(path: str, *, params: dict[str, Any] | None = None, timeout: float = 60.0) -> Any:
28
+ with httpx.Client(timeout=timeout) as client:
29
+ response = client.get(f"{api_base_url()}{path}", headers=_headers(), params=params)
30
+ _raise_for_error(response)
31
+ return response.json()
32
+
33
+
34
+ def post_json(path: str, body: Any, *, timeout: float = 120.0) -> Any:
35
+ headers = {**_headers(), "Content-Type": "application/json"}
36
+ with httpx.Client(timeout=timeout) as client:
37
+ response = client.post(f"{api_base_url()}{path}", headers=headers, json=body)
38
+ _raise_for_error(response)
39
+ return response.json()
40
+
41
+
42
+ # ── Nexus agent endpoints (admin only) ──────────────────────────────────────
43
+
44
+
45
+ def get_run_config(tickers: str | None = None) -> Any:
46
+ """GET /nexus-agent/run-config/ — tickers + date range for this week's run."""
47
+ params = {"tickers": tickers} if tickers else None
48
+ return get_json("/nexus-agent/run-config/", params=params)
49
+
50
+
51
+ def get_strategy_data(
52
+ ticker: str, start_date: str | None = None, end_date: str | None = None
53
+ ) -> Any:
54
+ """GET /nexus-agent/strategy-data/ — strategy trigger rows (Sheets creds stay server-side)."""
55
+ params: dict[str, Any] = {"ticker": ticker}
56
+ if start_date:
57
+ params["start_date"] = start_date
58
+ if end_date:
59
+ params["end_date"] = end_date
60
+ return get_json("/nexus-agent/strategy-data/", params=params)
61
+
62
+
63
+ def get_backtest(ticker: str) -> Any:
64
+ """GET /nexus-agent/backtest/ — latest aggregated backtest data for one ticker."""
65
+ return get_json("/nexus-agent/backtest/", params={"ticker": ticker})
66
+
67
+
68
+ def get_market_analysis(ticker: str) -> Any:
69
+ """GET /nexus-agent/market-analysis/ — latest market analysis report for one ticker."""
70
+ return get_json("/nexus-agent/market-analysis/", params={"ticker": ticker})
71
+
72
+
73
+ def get_portfolio_recommendation() -> Any:
74
+ """GET /nexus-agent/portfolio-recommendation/ — latest portfolio recommendation."""
75
+ return get_json("/nexus-agent/portfolio-recommendation/")
76
+
77
+
78
+ def upload_market_analysis(
79
+ ticker: str, content: str, language: str = "zh", metadata: Any = None
80
+ ) -> Any:
81
+ """POST /nexus-agent/upload/market-analysis/
82
+
83
+ metadata: 结构化 metadata(emit_metadata.py 产出)。服务端存成 sidecar,
84
+ 读端点原样返回给前端右侧面板。省略时后端不写 sidecar,前端退回 markdown 渲染。
85
+ """
86
+ body: dict[str, Any] = {"ticker": ticker, "content": content, "language": language}
87
+ if metadata is not None:
88
+ body["metadata"] = metadata
89
+ return post_json("/nexus-agent/upload/market-analysis/", body)
90
+
91
+
92
+ def upload_portfolio_recommendation(
93
+ content: str,
94
+ content_cn: str | None = None,
95
+ time_range: str | None = None,
96
+ metadata: Any = None,
97
+ ) -> Any:
98
+ """POST /nexus-agent/upload/portfolio-recommendation/"""
99
+ body: dict[str, Any] = {"content": content}
100
+ if content_cn is not None:
101
+ body["content_cn"] = content_cn
102
+ if time_range is not None:
103
+ body["time_range"] = time_range
104
+ if metadata is not None:
105
+ body["metadata"] = metadata
106
+ return post_json("/nexus-agent/upload/portfolio-recommendation/", body)
107
+
108
+
109
+ def upload_backtest(ticker: str, levels: dict[str, Any]) -> Any:
110
+ """POST /nexus-agent/upload/backtest/ — levels: {aggressive/conservative/normal: {...}}"""
111
+ return post_json("/nexus-agent/upload/backtest/", {"ticker": ticker, **levels})
112
+
113
+
114
+ def get_position_config(
115
+ ticker: str, kind: str = "normal", date_range: str | None = None
116
+ ) -> Any:
117
+ """GET /nexus-agent/position-config/ — signal_position config from S3.
118
+
119
+ Mirrors the worker's download_latest_normal_config_from_s3: the server reads
120
+ configs/signal_position/{ticker}/<date_range>/{ticker}-{kind}.json and returns it
121
+ as JSON. With date_range, fetch that specific historical version; otherwise the
122
+ latest. This is the Position-Research baseline the local skill cannot otherwise read.
123
+ """
124
+ params: dict[str, Any] = {"ticker": ticker, "kind": kind}
125
+ if date_range:
126
+ params["date_range"] = date_range
127
+ return get_json("/nexus-agent/position-config/", params=params)
128
+
129
+
130
+ def list_history(
131
+ artifact_type: str, ticker: str | None = None, kind: str | None = None, since: str | None = None
132
+ ) -> Any:
133
+ """GET /nexus-agent/history/ — list historical versions of an artifact.
134
+
135
+ artifact_type: config / market-analysis / portfolio.
136
+ Returns {type, count, items: [{key, created_at, date_range?}, ...]} newest-first.
137
+ """
138
+ params: dict[str, Any] = {"type": artifact_type}
139
+ if ticker:
140
+ params["ticker"] = ticker
141
+ if kind:
142
+ params["kind"] = kind
143
+ if since:
144
+ params["since"] = since
145
+ return get_json("/nexus-agent/history/", params=params)
146
+
147
+
148
+ def upload_position_config(
149
+ ticker: str, configs: dict[str, Any], time_range: str | None = None
150
+ ) -> Any:
151
+ """POST /nexus-agent/upload/position-config/ — write the 3 position configs to S3.
152
+
153
+ configs: {aggressive/conservative/normal: {<level>: {...}}}. Distinct from
154
+ upload_backtest (which uploads backtest aggregation, not position configs).
155
+ Server writes configs/signal_position/{ticker}/{time_range}/{ticker}-{kind}.json
156
+ and invalidates the dashboard cache.
157
+ """
158
+ body: dict[str, Any] = {"ticker": ticker, **configs}
159
+ if time_range is not None:
160
+ body["time_range"] = time_range
161
+ return post_json("/nexus-agent/upload/position-config/", body)
162
+
163
+
164
+ def upload_position_report(ticker: str, content: str) -> Any:
165
+ """POST /nexus-agent/upload/position-report/ — archive a position-update narrative (md)."""
166
+ return post_json(
167
+ "/nexus-agent/upload/position-report/", {"ticker": ticker, "content": content}
168
+ )
169
+
170
+
171
+ # ── discord_kb endpoints (admin only) ───────────────────────────────────────
172
+ #
173
+ # 这些端点是 Discord KB / blog draft 的**数据平面**:Discord bot token、OpenAI key、
174
+ # DB 凭据全部只在服务端,本地运行看不到任何密钥。
175
+ # 设计见 nexus-service/docs/DISCORD_KB_AND_BOT_DESIGN.md §A.6。
176
+
177
+
178
+ def discord_clusters_pending() -> Any:
179
+ """GET /discord-kb/clusters/pending/ — 取材:窗口内 pending + 所有 deferred 的簇。
180
+
181
+ ⚠️ **刻意不接受 date 参数。** 取材窗口由服务端的水位线定义(上次成功运行覆盖到的
182
+ 时刻 → 现在),不是任何自然日边界 —— 见设计文档 §B.6:cron 在 UTC 01:00 跑,而
183
+ 北京时间晚上 11 点记的笔记属于 UTC 前一天下午,按自然日切窗口会出现某天的笔记
184
+ 谁也没收、或者被收两次。加一个 --date 就等于把那个坑重新开出来。
185
+ 响应里的 window 字段会告诉你这次实际覆盖了哪一段。
186
+ """
187
+ return get_json("/discord-kb/clusters/pending/")
188
+
189
+
190
+ def discord_push_enrichment(clusters: list[dict[str, Any]]) -> Any:
191
+ """POST /discord-kb/clusters/enrich/ — 回写富化结果(title/summary/tickers/topics)。
192
+
193
+ 只改描述性字段,**不碰 blog_state** —— 状态迁移只走服务端的 service 层。
194
+ """
195
+ return post_json("/discord-kb/clusters/enrich/", {"clusters": clusters})
196
+
197
+
198
+ def discord_push_blog_draft(
199
+ title: str,
200
+ body_md: str,
201
+ draft_date: str,
202
+ source_cluster_ids: list[str],
203
+ front_matter: Any = None,
204
+ critic_report: Any = None,
205
+ ) -> Any:
206
+ """POST /discord-kb/blog-drafts/ — 提交草稿。
207
+
208
+ 服务端落库并把来源簇标 `drafting`(同一事务)。`source_cluster_ids` 必须非空:
209
+ 草稿要可追溯到来源,且标不上 drafting 的簇明天会被当 pending 重新起草。
210
+ ⚠️ 发 #blog-review 与挂审批 reaction **尚未接线**,响应里的 discord_delivery
211
+ 会是 "not_wired"。
212
+ """
213
+ body: dict[str, Any] = {
214
+ "title": title,
215
+ "body_md": body_md,
216
+ "draft_date": draft_date,
217
+ "source_cluster_ids": source_cluster_ids,
218
+ }
219
+ if front_matter is not None:
220
+ body["front_matter"] = front_matter
221
+ if critic_report is not None:
222
+ body["critic_report"] = critic_report
223
+ return post_json("/discord-kb/blog-drafts/", body)
224
+
225
+
226
+ def discord_playbook_active() -> Any:
227
+ """GET /discord-kb/style-playbook/active/ — 当前生效的风格 playbook。起草前必调。
228
+
229
+ ⚠️ 没有 active 时服务端返回 **409**(不是 200 + null):调用方必须中止本次运行
230
+ 并报警,绝不允许降级为无风格约束照写(§6.5)。所以这里的 RuntimeError
231
+ 不要 catch 掉当成"没配置"。
232
+ """
233
+ return get_json("/discord-kb/style-playbook/active/")
234
+
235
+
236
+ def discord_feedback_pending() -> Any:
237
+ """GET /discord-kb/feedback/pending/ — 可折叠的审批反馈。
238
+
239
+ 过滤条件全在服务端(verdict ∈ {revised, rejected} 且 note 非空 且未折叠 且指向
240
+ 当前 active 版本),本地不要重复实现 —— approved 只进指标,expired 绝不当负信号
241
+ (§C.1)。响应带 should_revise 供本地判断够不够触发一次 revise。
242
+ """
243
+ return get_json("/discord-kb/feedback/pending/")
244
+
245
+
246
+ def discord_push_playbook(
247
+ body_md: str,
248
+ rules: list[Any],
249
+ change_log: str = "",
250
+ derived_from: Any = None,
251
+ folded_feedback_ids: list[str] | None = None,
252
+ activate: bool = False,
253
+ ) -> Any:
254
+ """POST /discord-kb/style-playbook/ — 提交一版 playbook。
255
+
256
+ ⚠️ **不要传 token_est** —— 服务端用 count_tokens 实算,不接受调用方上报(§C.5)。
257
+ 超 2000 token 或 40 条规则会 422 拒收;体积变大而 change_log 的「删除」段为空
258
+ 同样 422。
259
+ `activate=True` 只应用于 v1(首次从语料抽取);修订版应先落库,你在 #blog-review
260
+ ✅ 之后再调 activate。
261
+ """
262
+ body: dict[str, Any] = {"body_md": body_md, "rules": rules, "change_log": change_log}
263
+ if derived_from is not None:
264
+ body["derived_from"] = derived_from
265
+ if folded_feedback_ids:
266
+ body["folded_feedback_ids"] = folded_feedback_ids
267
+ if activate:
268
+ body["activate"] = True
269
+ return post_json("/discord-kb/style-playbook/", body)
270
+
271
+
272
+ def discord_activate_playbook(version: int) -> Any:
273
+ """POST /discord-kb/style-playbook/{version}/activate/ — 相当于在 #blog-review 点 ✅。
274
+
275
+ 折叠标记(folded_into)在这一步才写:若提交时就标记而最终没 ✅,那批反馈会被
276
+ 永久消费掉、信号白丢且看不出来(§6.6)。
277
+ """
278
+ return post_json(f"/discord-kb/style-playbook/{int(version)}/activate/", {})
279
+
280
+
281
+ def notify(
282
+ to: list[str],
283
+ subject: str,
284
+ body_text: str | None = None,
285
+ body_html: str | None = None,
286
+ cc: list[str] | None = None,
287
+ ) -> Any:
288
+ """POST /nexus-agent/notify/ — send a notification email via server-side SES."""
289
+ body: dict[str, Any] = {"to": to, "subject": subject}
290
+ if cc:
291
+ body["cc"] = cc
292
+ if body_text is not None:
293
+ body["body_text"] = body_text
294
+ if body_html is not None:
295
+ body["body_html"] = body_html
296
+ return post_json("/nexus-agent/notify/", body)
297
+
298
+
299
+ def notify_report(
300
+ kind: str,
301
+ time_range: str,
302
+ ticker: str | None = None,
303
+ to: list[str] | None = None,
304
+ cc: list[str] | None = None,
305
+ expires_in: int | None = None,
306
+ metadata: Any = None,
307
+ ) -> Any:
308
+ """POST /nexus-agent/notify/report/ — templated weekly email with 24h presigned links.
309
+
310
+ Presigning happens server-side because the local analysis run holds no S3 credentials.
311
+ """
312
+ body: dict[str, Any] = {"kind": kind, "time_range": time_range}
313
+ if ticker:
314
+ body["ticker"] = ticker
315
+ if to:
316
+ body["to"] = to
317
+ if cc:
318
+ body["cc"] = cc
319
+ if expires_in is not None:
320
+ body["expires_in"] = expires_in
321
+ if metadata is not None:
322
+ body["metadata"] = metadata
323
+ return post_json("/nexus-agent/notify/report/", body)
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  import typer
4
4
 
5
5
  from nexus_agent_cli.commands.auth.cmd import register as register_auth
6
+ from nexus_agent_cli.commands.discord.cmd import register as register_discord
6
7
  from nexus_agent_cli.commands.run.cmd import register as register_run
7
8
 
8
9
 
9
10
  def register_all(app: typer.Typer) -> None:
10
11
  register_auth(app)
11
12
  register_run(app)
13
+ register_discord(app)