nexus-agent-cli 0.1.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.
- nexus_agent_cli-0.1.0/.gitignore +19 -0
- nexus_agent_cli-0.1.0/PKG-INFO +88 -0
- nexus_agent_cli-0.1.0/README.md +77 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/__init__.py +1 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/__main__.py +4 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/api_client.py +85 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/auth_pkce.py +233 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/commands/__init__.py +11 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/commands/auth/__init__.py +0 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/commands/auth/cmd.py +52 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/commands/run/__init__.py +0 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/commands/run/cmd.py +126 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/config.py +79 -0
- nexus_agent_cli-0.1.0/nexus_agent_cli/main.py +30 -0
- nexus_agent_cli-0.1.0/pyproject.toml +26 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexus-agent-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nexus internal agent ops CLI — pull run params, push analysis results, send notifications. Admin only.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: platformdirs>=4.2
|
|
8
|
+
Requires-Dist: rich>=13.7
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# nexus-agent-cli
|
|
13
|
+
|
|
14
|
+
Nexus **内部** agent 运维 CLI。用于把"每周分析任务"从 AWS 跑逐步迁到本地 Claude 跑:
|
|
15
|
+
拉取运行参数、回传分析结果、发通知 —— 让 S3 / SES / Google Sheets 凭证只留在服务端。
|
|
16
|
+
|
|
17
|
+
> 与 strategy provider 用的 `nexusquant-cli` 区分开:这是 **admin 专用** 工具。
|
|
18
|
+
> 需要 Cognito `custom:userType=admin`。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd nexus-agent-cli
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 登录(一次)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
nexus-agent auth # 打开浏览器走 Cognito 登录(admin 账号)
|
|
31
|
+
nexus-agent auth --status # 查看本机登录状态
|
|
32
|
+
nexus-agent auth --refresh # 刷新 token
|
|
33
|
+
nexus-agent auth --logout # 清除本机 token
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
凭据存放在当前用户的 config 目录(如 macOS `~/Library/Application Support/nexus-agent-cli/credentials.json`),权限 `0600`,与 nexusquant-cli 互不影响。
|
|
37
|
+
|
|
38
|
+
## 命令
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 拉取本周运行参数(tickers + 日期区间)
|
|
42
|
+
nexus-agent run-config
|
|
43
|
+
nexus-agent run-config --tickers TQQQ,SOXL,IBIT
|
|
44
|
+
|
|
45
|
+
# 回传市场分析报告(markdown)
|
|
46
|
+
nexus-agent push-market-analysis --ticker tqqq --file report_cn.md --language zh
|
|
47
|
+
|
|
48
|
+
# 回传投资组合推荐(英文必填,中文可选)
|
|
49
|
+
nexus-agent push-portfolio --file portfolio.md --file-cn portfolio_cn.md \
|
|
50
|
+
--time-range 2026-06-01_2026-06-14
|
|
51
|
+
|
|
52
|
+
# 回传回测聚合数据(至少一个档位)
|
|
53
|
+
nexus-agent push-backtest --ticker tqqq \
|
|
54
|
+
--aggressive aggressive_aggregated.json \
|
|
55
|
+
--conservative conservative_aggregated.json \
|
|
56
|
+
--normal normal_aggregated.json
|
|
57
|
+
|
|
58
|
+
# 服务端 SES 发通知邮件
|
|
59
|
+
nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 配置(环境变量,均可选)
|
|
63
|
+
|
|
64
|
+
| 变量 | 默认 | 说明 |
|
|
65
|
+
|------|------|------|
|
|
66
|
+
| `NEXUS_AGENT_API_ENDPOINT` | `https://api.nexusquant.co` | 后端 host(会自动加 `/api`) |
|
|
67
|
+
| `NEXUS_AGENT_API_BASE_URL` | — | 直接覆盖完整 API base(含 `/api`) |
|
|
68
|
+
| `NEXUS_AGENT_COGNITO_DOMAIN` | `auth.lookatwallstreet.com` | Cognito Hosted UI 域名 |
|
|
69
|
+
| `NEXUS_AGENT_COGNITO_CLIENT_ID` | (内置) | Cognito app client id |
|
|
70
|
+
| `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8252/callback` | 本地回调(须 localhost+端口) |
|
|
71
|
+
|
|
72
|
+
例如指向本地后端调试:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
NEXUS_AGENT_API_BASE_URL=http://127.0.0.1:8000/api nexus-agent run-config
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 对应后端
|
|
79
|
+
|
|
80
|
+
这些命令调用 nexus-service `apps/nexus_agent` 的 admin 端点:
|
|
81
|
+
`run-config/`、`upload/market-analysis/`、`upload/portfolio-recommendation/`、
|
|
82
|
+
`upload/backtest/`、`notify/`。
|
|
83
|
+
|
|
84
|
+
## 迁移说明
|
|
85
|
+
|
|
86
|
+
当前 AWS(EventBridge → ECS Fargate)的每周定时运行**保持不变**。本 CLI 提供并行的
|
|
87
|
+
"本地驱动"路径,逐步把运行从云上挪到本地,详见 nexus-claude-plugin 的
|
|
88
|
+
`nexus-agent / weekly-analysis-run` skill。
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# nexus-agent-cli
|
|
2
|
+
|
|
3
|
+
Nexus **内部** agent 运维 CLI。用于把"每周分析任务"从 AWS 跑逐步迁到本地 Claude 跑:
|
|
4
|
+
拉取运行参数、回传分析结果、发通知 —— 让 S3 / SES / Google Sheets 凭证只留在服务端。
|
|
5
|
+
|
|
6
|
+
> 与 strategy provider 用的 `nexusquant-cli` 区分开:这是 **admin 专用** 工具。
|
|
7
|
+
> 需要 Cognito `custom:userType=admin`。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cd nexus-agent-cli
|
|
13
|
+
pip install -e .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 登录(一次)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
nexus-agent auth # 打开浏览器走 Cognito 登录(admin 账号)
|
|
20
|
+
nexus-agent auth --status # 查看本机登录状态
|
|
21
|
+
nexus-agent auth --refresh # 刷新 token
|
|
22
|
+
nexus-agent auth --logout # 清除本机 token
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
凭据存放在当前用户的 config 目录(如 macOS `~/Library/Application Support/nexus-agent-cli/credentials.json`),权限 `0600`,与 nexusquant-cli 互不影响。
|
|
26
|
+
|
|
27
|
+
## 命令
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 拉取本周运行参数(tickers + 日期区间)
|
|
31
|
+
nexus-agent run-config
|
|
32
|
+
nexus-agent run-config --tickers TQQQ,SOXL,IBIT
|
|
33
|
+
|
|
34
|
+
# 回传市场分析报告(markdown)
|
|
35
|
+
nexus-agent push-market-analysis --ticker tqqq --file report_cn.md --language zh
|
|
36
|
+
|
|
37
|
+
# 回传投资组合推荐(英文必填,中文可选)
|
|
38
|
+
nexus-agent push-portfolio --file portfolio.md --file-cn portfolio_cn.md \
|
|
39
|
+
--time-range 2026-06-01_2026-06-14
|
|
40
|
+
|
|
41
|
+
# 回传回测聚合数据(至少一个档位)
|
|
42
|
+
nexus-agent push-backtest --ticker tqqq \
|
|
43
|
+
--aggressive aggressive_aggregated.json \
|
|
44
|
+
--conservative conservative_aggregated.json \
|
|
45
|
+
--normal normal_aggregated.json
|
|
46
|
+
|
|
47
|
+
# 服务端 SES 发通知邮件
|
|
48
|
+
nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 配置(环境变量,均可选)
|
|
52
|
+
|
|
53
|
+
| 变量 | 默认 | 说明 |
|
|
54
|
+
|------|------|------|
|
|
55
|
+
| `NEXUS_AGENT_API_ENDPOINT` | `https://api.nexusquant.co` | 后端 host(会自动加 `/api`) |
|
|
56
|
+
| `NEXUS_AGENT_API_BASE_URL` | — | 直接覆盖完整 API base(含 `/api`) |
|
|
57
|
+
| `NEXUS_AGENT_COGNITO_DOMAIN` | `auth.lookatwallstreet.com` | Cognito Hosted UI 域名 |
|
|
58
|
+
| `NEXUS_AGENT_COGNITO_CLIENT_ID` | (内置) | Cognito app client id |
|
|
59
|
+
| `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8252/callback` | 本地回调(须 localhost+端口) |
|
|
60
|
+
|
|
61
|
+
例如指向本地后端调试:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
NEXUS_AGENT_API_BASE_URL=http://127.0.0.1:8000/api nexus-agent run-config
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 对应后端
|
|
68
|
+
|
|
69
|
+
这些命令调用 nexus-service `apps/nexus_agent` 的 admin 端点:
|
|
70
|
+
`run-config/`、`upload/market-analysis/`、`upload/portfolio-recommendation/`、
|
|
71
|
+
`upload/backtest/`、`notify/`。
|
|
72
|
+
|
|
73
|
+
## 迁移说明
|
|
74
|
+
|
|
75
|
+
当前 AWS(EventBridge → ECS Fargate)的每周定时运行**保持不变**。本 CLI 提供并行的
|
|
76
|
+
"本地驱动"路径,逐步把运行从云上挪到本地,详见 nexus-claude-plugin 的
|
|
77
|
+
`nexus-agent / weekly-analysis-run` skill。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,85 @@
|
|
|
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 upload_market_analysis(ticker: str, content: str, language: str = "zh") -> Any:
|
|
52
|
+
"""POST /nexus-agent/upload/market-analysis/"""
|
|
53
|
+
return post_json(
|
|
54
|
+
"/nexus-agent/upload/market-analysis/",
|
|
55
|
+
{"ticker": ticker, "content": content, "language": language},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def upload_portfolio_recommendation(
|
|
60
|
+
content: str, content_cn: str | None = None, time_range: str | None = None
|
|
61
|
+
) -> Any:
|
|
62
|
+
"""POST /nexus-agent/upload/portfolio-recommendation/"""
|
|
63
|
+
body: dict[str, Any] = {"content": content}
|
|
64
|
+
if content_cn is not None:
|
|
65
|
+
body["content_cn"] = content_cn
|
|
66
|
+
if time_range is not None:
|
|
67
|
+
body["time_range"] = time_range
|
|
68
|
+
return post_json("/nexus-agent/upload/portfolio-recommendation/", body)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def upload_backtest(ticker: str, levels: dict[str, Any]) -> Any:
|
|
72
|
+
"""POST /nexus-agent/upload/backtest/ — levels: {aggressive/conservative/normal: {...}}"""
|
|
73
|
+
return post_json("/nexus-agent/upload/backtest/", {"ticker": ticker, **levels})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def notify(
|
|
77
|
+
to: list[str], subject: str, body_text: str | None = None, body_html: str | None = None
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""POST /nexus-agent/notify/ — send a notification email via server-side SES."""
|
|
80
|
+
body: dict[str, Any] = {"to": to, "subject": subject}
|
|
81
|
+
if body_text is not None:
|
|
82
|
+
body["body_text"] = body_text
|
|
83
|
+
if body_html is not None:
|
|
84
|
+
body["body_html"] = body_html
|
|
85
|
+
return post_json("/nexus-agent/notify/", body)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cognito Hosted UI login via OAuth 2.0 Authorization Code + PKCE.
|
|
3
|
+
|
|
4
|
+
Ported from nexusquant-cli. Nexus service reads ``custom:userType`` from JWT
|
|
5
|
+
claims; the new agent endpoints require ``admin``. The CLI stores and sends the
|
|
6
|
+
ID token as ``Authorization: Bearer``, refreshing via the Cognito refresh token
|
|
7
|
+
when the ID token is near expiry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import secrets
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import webbrowser
|
|
20
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from nexus_agent_cli.config import (
|
|
26
|
+
clear_credentials,
|
|
27
|
+
cognito_client_id,
|
|
28
|
+
cognito_domain_host,
|
|
29
|
+
load_credentials,
|
|
30
|
+
redirect_uri,
|
|
31
|
+
save_credentials,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64url(data: bytes) -> str:
|
|
36
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
40
|
+
verifier = secrets.token_urlsafe(48)
|
|
41
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
42
|
+
return verifier, challenge
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_query(path: str) -> dict[str, str]:
|
|
46
|
+
query = urllib.parse.urlparse(path).query
|
|
47
|
+
return dict(urllib.parse.parse_qsl(query))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _token_url(domain_host: str) -> str:
|
|
51
|
+
return f"https://{domain_host}/oauth2/token"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _post_token(domain_host: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
55
|
+
with httpx.Client(timeout=30.0) as client:
|
|
56
|
+
response = client.post(
|
|
57
|
+
_token_url(domain_host),
|
|
58
|
+
data=body,
|
|
59
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
60
|
+
)
|
|
61
|
+
if response.status_code >= 400:
|
|
62
|
+
try:
|
|
63
|
+
detail: Any = response.json()
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
detail = response.text
|
|
66
|
+
raise RuntimeError(f"Cognito token endpoint error ({response.status_code}): {detail}")
|
|
67
|
+
return response.json()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def refresh_tokens(*, domain_host: str, client_id: str, refresh_token: str) -> dict[str, Any]:
|
|
71
|
+
return _post_token(
|
|
72
|
+
domain_host,
|
|
73
|
+
{
|
|
74
|
+
"grant_type": "refresh_token",
|
|
75
|
+
"client_id": client_id,
|
|
76
|
+
"refresh_token": refresh_token,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def exchange_code_for_tokens(
|
|
82
|
+
*,
|
|
83
|
+
domain_host: str,
|
|
84
|
+
client_id: str,
|
|
85
|
+
code: str,
|
|
86
|
+
redirect_uri_value: str,
|
|
87
|
+
code_verifier: str,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return _post_token(
|
|
90
|
+
domain_host,
|
|
91
|
+
{
|
|
92
|
+
"grant_type": "authorization_code",
|
|
93
|
+
"client_id": client_id,
|
|
94
|
+
"code": code,
|
|
95
|
+
"redirect_uri": redirect_uri_value,
|
|
96
|
+
"code_verifier": code_verifier,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_browser_login() -> dict[str, Any]:
|
|
102
|
+
domain_host = cognito_domain_host()
|
|
103
|
+
client_id = cognito_client_id()
|
|
104
|
+
redirect_uri_value = redirect_uri()
|
|
105
|
+
verifier, challenge = _pkce_pair()
|
|
106
|
+
|
|
107
|
+
auth_params = {
|
|
108
|
+
"response_type": "code",
|
|
109
|
+
"client_id": client_id,
|
|
110
|
+
"redirect_uri": redirect_uri_value,
|
|
111
|
+
"scope": "openid email phone",
|
|
112
|
+
"code_challenge_method": "S256",
|
|
113
|
+
"code_challenge": challenge,
|
|
114
|
+
}
|
|
115
|
+
auth_url = f"https://{domain_host}/oauth2/authorize?{urllib.parse.urlencode(auth_params)}"
|
|
116
|
+
|
|
117
|
+
code_holder: dict[str, str | None] = {"code": None, "error": None}
|
|
118
|
+
done = threading.Event()
|
|
119
|
+
|
|
120
|
+
class Handler(BaseHTTPRequestHandler):
|
|
121
|
+
def log_message(self, _format: str, *_args: object) -> None:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
def do_GET(self) -> None: # noqa: N802
|
|
125
|
+
params = _parse_query(self.path)
|
|
126
|
+
if "code" in params:
|
|
127
|
+
code_holder["code"] = params["code"]
|
|
128
|
+
self.send_response(200)
|
|
129
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
130
|
+
self.end_headers()
|
|
131
|
+
self.wfile.write(
|
|
132
|
+
b"<html><body><p>Nexus agent CLI login successful. You can close this tab.</p></body></html>"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
error = (
|
|
136
|
+
params.get("error_description") or params.get("error") or "unknown_error"
|
|
137
|
+
)
|
|
138
|
+
code_holder["error"] = error
|
|
139
|
+
self.send_response(400)
|
|
140
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
141
|
+
self.end_headers()
|
|
142
|
+
self.wfile.write(
|
|
143
|
+
f"<html><body><p>Nexus agent CLI login failed: {error}</p></body></html>".encode(
|
|
144
|
+
"utf-8"
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
done.set()
|
|
148
|
+
|
|
149
|
+
parsed = urllib.parse.urlparse(redirect_uri_value)
|
|
150
|
+
if parsed.hostname not in ("127.0.0.1", "localhost") or not parsed.port:
|
|
151
|
+
raise RuntimeError("NEXUS_AGENT_REDIRECT_URI must be localhost with a port.")
|
|
152
|
+
|
|
153
|
+
bind_host = "127.0.0.1" if parsed.hostname == "localhost" else parsed.hostname
|
|
154
|
+
server = HTTPServer((bind_host or "127.0.0.1", parsed.port), Handler)
|
|
155
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
156
|
+
thread.start()
|
|
157
|
+
webbrowser.open(auth_url)
|
|
158
|
+
|
|
159
|
+
if not done.wait(timeout=300):
|
|
160
|
+
server.shutdown()
|
|
161
|
+
raise TimeoutError("No OAuth callback received within 5 minutes.")
|
|
162
|
+
server.shutdown()
|
|
163
|
+
|
|
164
|
+
if code_holder["error"]:
|
|
165
|
+
raise RuntimeError(f"Cognito error: {code_holder['error']}")
|
|
166
|
+
code = code_holder["code"]
|
|
167
|
+
if not code:
|
|
168
|
+
raise RuntimeError("No authorization code in callback.")
|
|
169
|
+
|
|
170
|
+
tokens = exchange_code_for_tokens(
|
|
171
|
+
domain_host=domain_host,
|
|
172
|
+
client_id=client_id,
|
|
173
|
+
code=code,
|
|
174
|
+
redirect_uri_value=redirect_uri_value,
|
|
175
|
+
code_verifier=verifier,
|
|
176
|
+
)
|
|
177
|
+
if "id_token" not in tokens:
|
|
178
|
+
raise RuntimeError("Cognito token response missing id_token.")
|
|
179
|
+
return tokens
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def persist_tokens(tokens: dict[str, Any]) -> None:
|
|
183
|
+
save_credentials(
|
|
184
|
+
{
|
|
185
|
+
"id_token": tokens["id_token"],
|
|
186
|
+
"access_token": tokens.get("access_token"),
|
|
187
|
+
"refresh_token": tokens.get("refresh_token"),
|
|
188
|
+
"expires_in": int(tokens.get("expires_in") or 0),
|
|
189
|
+
"saved_at": time.time(),
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ensure_fresh_id_token(*, force_refresh: bool = False) -> str:
|
|
195
|
+
creds = load_credentials()
|
|
196
|
+
if not creds or not creds.get("id_token"):
|
|
197
|
+
raise RuntimeError("尚未登录。请先执行:nexus-agent auth")
|
|
198
|
+
|
|
199
|
+
expires_in = int(creds.get("expires_in") or 0)
|
|
200
|
+
saved_at = float(creds.get("saved_at") or 0)
|
|
201
|
+
remaining = saved_at + expires_in - time.time() if expires_in > 0 else 999999
|
|
202
|
+
refresh_token = creds.get("refresh_token")
|
|
203
|
+
if not force_refresh and (remaining > 120 or not refresh_token):
|
|
204
|
+
return str(creds["id_token"])
|
|
205
|
+
if not refresh_token:
|
|
206
|
+
return str(creds["id_token"])
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
new_tokens = refresh_tokens(
|
|
210
|
+
domain_host=cognito_domain_host(),
|
|
211
|
+
client_id=cognito_client_id(),
|
|
212
|
+
refresh_token=refresh_token,
|
|
213
|
+
)
|
|
214
|
+
except RuntimeError as exc:
|
|
215
|
+
if "invalid_grant" in str(exc):
|
|
216
|
+
clear_credentials()
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
"Refresh token expired or revoked. Please run: nexus-agent auth"
|
|
219
|
+
) from exc
|
|
220
|
+
raise
|
|
221
|
+
merged = {
|
|
222
|
+
"id_token": new_tokens.get("id_token") or creds["id_token"],
|
|
223
|
+
"access_token": new_tokens.get("access_token") or creds.get("access_token"),
|
|
224
|
+
"refresh_token": new_tokens.get("refresh_token") or refresh_token,
|
|
225
|
+
"expires_in": int(new_tokens.get("expires_in") or expires_in),
|
|
226
|
+
"saved_at": time.time(),
|
|
227
|
+
}
|
|
228
|
+
save_credentials(merged)
|
|
229
|
+
return str(merged["id_token"])
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def login_and_save() -> None:
|
|
233
|
+
persist_tokens(run_browser_login())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from nexus_agent_cli.commands.auth.cmd import register as register_auth
|
|
6
|
+
from nexus_agent_cli.commands.run.cmd import register as register_run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_all(app: typer.Typer) -> None:
|
|
10
|
+
register_auth(app)
|
|
11
|
+
register_run(app)
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from nexus_agent_cli.auth_pkce import ensure_fresh_id_token, login_and_save
|
|
7
|
+
from nexus_agent_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command(
|
|
14
|
+
"auth",
|
|
15
|
+
help=(
|
|
16
|
+
"Login with Cognito Hosted UI and save local tokens. Use --refresh to force "
|
|
17
|
+
"refresh, --status to inspect local login state, or --logout to clear tokens."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
def auth(
|
|
21
|
+
logout: bool = typer.Option(False, "--logout", help="Remove saved tokens and exit."),
|
|
22
|
+
refresh: bool = typer.Option(
|
|
23
|
+
False, "--refresh", help="Refresh the saved ID token using the refresh token."
|
|
24
|
+
),
|
|
25
|
+
status: bool = typer.Option(
|
|
26
|
+
False, "--status", help="Print local login state without showing token values."
|
|
27
|
+
),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Authenticate this machine (admin) for Nexus agent API calls."""
|
|
30
|
+
if logout:
|
|
31
|
+
clear_credentials()
|
|
32
|
+
console.print("[green]已退出登录(本机保存的 token 已清除)。[/green]")
|
|
33
|
+
raise typer.Exit(0)
|
|
34
|
+
|
|
35
|
+
if status:
|
|
36
|
+
creds = load_credentials()
|
|
37
|
+
if creds and creds.get("id_token"):
|
|
38
|
+
console.print(f"[green]已登录。本地凭据: {CREDENTIALS_PATH}[/green]")
|
|
39
|
+
else:
|
|
40
|
+
console.print("[yellow]尚未登录。请执行:nexus-agent auth[/yellow]")
|
|
41
|
+
raise typer.Exit(0)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if refresh:
|
|
45
|
+
ensure_fresh_id_token(force_refresh=True)
|
|
46
|
+
console.print("[green]token 已刷新。[/green]")
|
|
47
|
+
else:
|
|
48
|
+
login_and_save()
|
|
49
|
+
console.print("[green]登录成功,token 已保存在本机。[/green]")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
console.print(f"[red]{e}[/red]")
|
|
52
|
+
raise typer.Exit(1) from e
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.json import JSON
|
|
9
|
+
|
|
10
|
+
from nexus_agent_cli import api_client
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run_err(e: Exception) -> None:
|
|
16
|
+
console.print(f"[red]{e}[/red]")
|
|
17
|
+
raise typer.Exit(1) from e
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(app: typer.Typer) -> None:
|
|
21
|
+
@app.command("run-config", help="Pull this week's run params (tickers + date range).")
|
|
22
|
+
def run_config(
|
|
23
|
+
tickers: str = typer.Option(
|
|
24
|
+
None, "--tickers", help="Comma-separated override, e.g. TQQQ,SOXL,IBIT."
|
|
25
|
+
),
|
|
26
|
+
) -> None:
|
|
27
|
+
try:
|
|
28
|
+
data = api_client.get_run_config(tickers=tickers)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
_run_err(e)
|
|
31
|
+
console.print(JSON(json.dumps(data)))
|
|
32
|
+
|
|
33
|
+
@app.command(
|
|
34
|
+
"push-market-analysis", help="Upload a market analysis markdown report for one ticker."
|
|
35
|
+
)
|
|
36
|
+
def push_market_analysis(
|
|
37
|
+
ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
|
|
38
|
+
file: Path = typer.Option(
|
|
39
|
+
..., "--file", exists=True, readable=True, help="Path to the markdown report."
|
|
40
|
+
),
|
|
41
|
+
language: str = typer.Option("zh", "--language", help="zh (default) or en."),
|
|
42
|
+
) -> None:
|
|
43
|
+
try:
|
|
44
|
+
content = file.read_text(encoding="utf-8")
|
|
45
|
+
data = api_client.upload_market_analysis(ticker, content, language=language)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
_run_err(e)
|
|
48
|
+
console.print(f"[green]已上传:[/green] {data.get('key')}")
|
|
49
|
+
|
|
50
|
+
@app.command(
|
|
51
|
+
"push-portfolio", help="Upload a portfolio recommendation report (English + optional CN)."
|
|
52
|
+
)
|
|
53
|
+
def push_portfolio(
|
|
54
|
+
file: Path = typer.Option(
|
|
55
|
+
..., "--file", exists=True, readable=True, help="English markdown report."
|
|
56
|
+
),
|
|
57
|
+
file_cn: Path = typer.Option(
|
|
58
|
+
None, "--file-cn", exists=True, readable=True, help="Chinese markdown report (optional)."
|
|
59
|
+
),
|
|
60
|
+
time_range: str = typer.Option(
|
|
61
|
+
None, "--time-range", help="e.g. 2026-06-01_2026-06-14 (optional, for the S3 key)."
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
try:
|
|
65
|
+
content = file.read_text(encoding="utf-8")
|
|
66
|
+
content_cn = file_cn.read_text(encoding="utf-8") if file_cn else None
|
|
67
|
+
data = api_client.upload_portfolio_recommendation(
|
|
68
|
+
content, content_cn=content_cn, time_range=time_range
|
|
69
|
+
)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
_run_err(e)
|
|
72
|
+
console.print(JSON(json.dumps(data)))
|
|
73
|
+
|
|
74
|
+
@app.command("push-backtest", help="Upload aggregated backtest JSON for one ticker.")
|
|
75
|
+
def push_backtest(
|
|
76
|
+
ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
|
|
77
|
+
aggressive: Path = typer.Option(
|
|
78
|
+
None, "--aggressive", exists=True, readable=True, help="aggressive_aggregated.json"
|
|
79
|
+
),
|
|
80
|
+
conservative: Path = typer.Option(
|
|
81
|
+
None, "--conservative", exists=True, readable=True, help="conservative_aggregated.json"
|
|
82
|
+
),
|
|
83
|
+
normal: Path = typer.Option(
|
|
84
|
+
None, "--normal", exists=True, readable=True, help="normal_aggregated.json"
|
|
85
|
+
),
|
|
86
|
+
) -> None:
|
|
87
|
+
levels: dict[str, object] = {}
|
|
88
|
+
for name, path in (
|
|
89
|
+
("aggressive", aggressive),
|
|
90
|
+
("conservative", conservative),
|
|
91
|
+
("normal", normal),
|
|
92
|
+
):
|
|
93
|
+
if path is not None:
|
|
94
|
+
try:
|
|
95
|
+
levels[name] = json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except Exception as e:
|
|
97
|
+
_run_err(e)
|
|
98
|
+
if not levels:
|
|
99
|
+
console.print("[red]至少提供一个档位文件: --aggressive / --conservative / --normal[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
try:
|
|
102
|
+
data = api_client.upload_backtest(ticker, levels)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
_run_err(e)
|
|
105
|
+
console.print(JSON(json.dumps(data)))
|
|
106
|
+
|
|
107
|
+
@app.command("notify", help="Send a notification email via server-side SES.")
|
|
108
|
+
def notify(
|
|
109
|
+
to: list[str] = typer.Option(..., "--to", help="Recipient (repeatable)."),
|
|
110
|
+
subject: str = typer.Option(..., "--subject", help="Email subject."),
|
|
111
|
+
body_file: Path = typer.Option(
|
|
112
|
+
None, "--body-file", exists=True, readable=True, help="Plain-text body file."
|
|
113
|
+
),
|
|
114
|
+
body: str = typer.Option(None, "--body", help="Inline plain-text body."),
|
|
115
|
+
) -> None:
|
|
116
|
+
body_text = body
|
|
117
|
+
if body_file is not None:
|
|
118
|
+
body_text = body_file.read_text(encoding="utf-8")
|
|
119
|
+
if not body_text:
|
|
120
|
+
console.print("[red]需要 --body 或 --body-file 之一。[/red]")
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
try:
|
|
123
|
+
data = api_client.notify(list(to), subject, body_text=body_text)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
_run_err(e)
|
|
126
|
+
console.print(f"[green]已发送:[/green] {data}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_config_dir
|
|
9
|
+
|
|
10
|
+
# 独立的凭据目录,避免与 strategy-provider 用的 nexusquant-cli 互相覆盖。
|
|
11
|
+
CONFIG_DIR = Path(user_config_dir("nexus-agent-cli", appauthor=False))
|
|
12
|
+
CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
|
|
13
|
+
|
|
14
|
+
# 默认指向生产 nexus-service(与 nexusquant-cli 同一后端),admin 身份登录。
|
|
15
|
+
DEFAULT_API_ENDPOINT = "https://api.nexusquant.co"
|
|
16
|
+
DEFAULT_COGNITO_DOMAIN = "auth.lookatwallstreet.com"
|
|
17
|
+
DEFAULT_COGNITO_CLIENT_ID = "5o7hhd4hn4birr8c29vndehiat"
|
|
18
|
+
# 与 nexusquant-cli (8251) 错开端口,便于两套 CLI 同机登录。
|
|
19
|
+
DEFAULT_REDIRECT_URI = "http://127.0.0.1:8252/callback"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _env(name: str, default: str | None = None) -> str | None:
|
|
23
|
+
value = os.environ.get(name)
|
|
24
|
+
if value is not None and value.strip():
|
|
25
|
+
return value.strip()
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def api_base_url() -> str:
|
|
30
|
+
endpoint = (
|
|
31
|
+
_env("NEXUS_AGENT_API_ENDPOINT", DEFAULT_API_ENDPOINT) or DEFAULT_API_ENDPOINT
|
|
32
|
+
).rstrip("/")
|
|
33
|
+
base_override = _env("NEXUS_AGENT_API_BASE_URL")
|
|
34
|
+
if base_override:
|
|
35
|
+
return base_override.rstrip("/")
|
|
36
|
+
return f"{endpoint}/api"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cognito_domain_host() -> str:
|
|
40
|
+
domain = (
|
|
41
|
+
_env("NEXUS_AGENT_COGNITO_DOMAIN", DEFAULT_COGNITO_DOMAIN) or DEFAULT_COGNITO_DOMAIN
|
|
42
|
+
)
|
|
43
|
+
return domain.removeprefix("https://").removeprefix("http://").split("/")[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cognito_client_id() -> str:
|
|
47
|
+
return (
|
|
48
|
+
_env("NEXUS_AGENT_COGNITO_CLIENT_ID", DEFAULT_COGNITO_CLIENT_ID)
|
|
49
|
+
or DEFAULT_COGNITO_CLIENT_ID
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def redirect_uri() -> str:
|
|
54
|
+
return _env("NEXUS_AGENT_REDIRECT_URI", DEFAULT_REDIRECT_URI) or DEFAULT_REDIRECT_URI
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_credentials() -> dict[str, Any] | None:
|
|
58
|
+
if not CREDENTIALS_PATH.is_file():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_credentials(data: dict[str, Any]) -> None:
|
|
67
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
CREDENTIALS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
69
|
+
try:
|
|
70
|
+
CREDENTIALS_PATH.chmod(0o600)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clear_credentials() -> None:
|
|
76
|
+
try:
|
|
77
|
+
CREDENTIALS_PATH.unlink(missing_ok=True)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from nexus_agent_cli import __version__
|
|
7
|
+
from nexus_agent_cli.commands import register_all
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="nexus-agent",
|
|
12
|
+
help=(
|
|
13
|
+
"Nexus internal agent ops CLI. Login once with Cognito (admin), then pull "
|
|
14
|
+
"run params, push analysis results (market analysis / portfolio / backtest), "
|
|
15
|
+
"and send notifications — keeping S3/SES/Sheets credentials server-side. "
|
|
16
|
+
"Requires custom:userType=admin."
|
|
17
|
+
),
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
register_all(app)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("version", help="Print this CLI package version string.")
|
|
25
|
+
def version_cmd() -> None:
|
|
26
|
+
console.print(__version__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
app()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nexus-agent-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Nexus internal agent ops CLI — pull run params, push analysis results, send notifications. Admin only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"platformdirs>=4.2",
|
|
14
|
+
"rich>=13.7",
|
|
15
|
+
"typer>=0.12",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
nexus-agent = "nexus_agent_cli.main:app"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["nexus_agent_cli"]
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 100
|
|
26
|
+
target-version = "py310"
|