lingguang 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.
lingguang/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """灵光 MCP 与 CLI 工具。"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """CLI 入口。"""
2
+
3
+ from .main import app
4
+
5
+ __all__ = ["app"]
lingguang/cli/main.py ADDED
@@ -0,0 +1,377 @@
1
+ """CLI 入口。core 的薄壳,不含业务判断。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional
9
+
10
+ import typer
11
+
12
+ from ..core.auth import (
13
+ Credentials,
14
+ clear_credentials,
15
+ describe_status,
16
+ get_cookie,
17
+ parse_cookie_string,
18
+ save_credentials,
19
+ )
20
+ from ..core.errors import LingguangError
21
+ from ..core.service import Service
22
+
23
+ app = typer.Typer(
24
+ name="lingguang",
25
+ help="灵光 AI 的 MCP 与 CLI 工具",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ )
29
+
30
+ auth_app = typer.Typer(help="管理登录凭据", no_args_is_help=True)
31
+ sessions_app = typer.Typer(help="查看与管理会话", no_args_is_help=True)
32
+ app.add_typer(auth_app, name="auth")
33
+ app.add_typer(sessions_app, name="sessions")
34
+
35
+
36
+ def _fail(message: str, code: int = 1) -> None:
37
+ """错误统一走 stderr,stdout 只留数据。"""
38
+ typer.echo(f"错误: {message}", err=True)
39
+ raise typer.Exit(code)
40
+
41
+
42
+ def _service() -> Service:
43
+ try:
44
+ return Service()
45
+ except LingguangError as exc:
46
+ _fail(str(exc))
47
+
48
+
49
+ def _dump(data: object) -> None:
50
+ typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
51
+
52
+
53
+ # ---------------- auth ----------------
54
+
55
+
56
+ @auth_app.command("status")
57
+ def auth_status() -> None:
58
+ """检查登录态。"""
59
+ status = describe_status()
60
+ if status.get("imported"):
61
+ try:
62
+ svc = Service()
63
+ status = svc.auth_status()
64
+ except LingguangError as exc:
65
+ status["probe"] = f"无法探测: {exc}"
66
+ _dump(status)
67
+
68
+
69
+ @auth_app.command("import")
70
+ def auth_import(
71
+ source: Annotated[Optional[Path], typer.Argument(help="含 cookie 的文件")] = None,
72
+ ) -> None:
73
+ """导入 cookie。
74
+
75
+ 支持整段 cookie 字符串,也支持从 curl 命令里粘。
76
+ 导入后会立即验证一次,失败就报错,不等下次用才炸。
77
+ """
78
+ raw = source.read_text(encoding="utf-8") if source else typer.get_text_stream("stdin").read()
79
+
80
+ try:
81
+ cookie = parse_cookie_string(raw)
82
+ except LingguangError as exc:
83
+ _fail(str(exc))
84
+
85
+ creds = Credentials(cookie=cookie, imported_at=__import__("time").time())
86
+ path = save_credentials(creds)
87
+ typer.echo(f"已保存到 {path}")
88
+
89
+ try:
90
+ svc = Service(cookie=cookie)
91
+ result = svc.auth_status()
92
+ except LingguangError as exc:
93
+ _fail(f"已保存但验证失败: {exc}")
94
+
95
+ if result.get("valid"):
96
+ typer.echo("验证通过,cookie 可用")
97
+ else:
98
+ typer.echo(f"警告: 验证未通过 {result.get('probe', '')}", err=True)
99
+ missing = result.get("missing_keys") or []
100
+ if missing:
101
+ typer.echo(f"注意: 缺少关键 cookie {missing},稳定性可能受影响", err=True)
102
+
103
+
104
+ @auth_app.command("clear")
105
+ def auth_clear() -> None:
106
+ """删除本地保存的 cookie。"""
107
+ clear_credentials()
108
+ typer.echo("已清除")
109
+
110
+
111
+ # ---------------- sessions ----------------
112
+
113
+
114
+ @sessions_app.command("list")
115
+ def sessions_list(
116
+ limit: Annotated[int, typer.Option("--limit", "-n", help="每页条数,上限 30")] = 30,
117
+ json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
118
+ ) -> None:
119
+ """列出会话。"""
120
+ svc = _service()
121
+ try:
122
+ data = svc.list_sessions(limit=limit)
123
+ except LingguangError as exc:
124
+ _fail(str(exc))
125
+
126
+ if json_output:
127
+ _dump(data)
128
+ return
129
+ sessions = data.get("sessions") or []
130
+ if not sessions:
131
+ typer.echo("没有会话")
132
+ return
133
+ for s in sessions:
134
+ typer.echo(f"{s['id']} {s['summary']} [{s['scene']}]")
135
+
136
+
137
+ @sessions_app.command("show")
138
+ def sessions_show(
139
+ session_id: Annotated[str, typer.Argument(help="会话 id")],
140
+ keyword: Annotated[Optional[str], typer.Option("--keyword", "-k", help="按关键词过滤")] = None,
141
+ and_tools: Annotated[bool, typer.Option("--tools", help="显示工具轨迹")] = False,
142
+ json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
143
+ ) -> None:
144
+ """查看会话内容。"""
145
+ svc = _service()
146
+ try:
147
+ data = svc.get_transcript(session_id, keyword=keyword)
148
+ except LingguangError as exc:
149
+ _fail(str(exc))
150
+
151
+ if json_output:
152
+ _dump(data)
153
+ return
154
+
155
+ turns = data.get("turns") or []
156
+ if not turns:
157
+ typer.echo("没有匹配的轮次")
158
+ return
159
+ for i, turn in enumerate(turns, 1):
160
+ typer.echo(f"\n=== 第 {i} 轮 ===")
161
+ if turn["query"]:
162
+ typer.echo(f"问: {turn['query']}")
163
+ if and_tools and turn["tools"]:
164
+ typer.echo(f"工具: {' / '.join(turn['tools'])}")
165
+ if turn["warnings"]:
166
+ typer.echo(f"提示: {'; '.join(turn['warnings'])}")
167
+ typer.echo(turn["answer"] or "(无正文)")
168
+
169
+
170
+ @sessions_app.command("delete")
171
+ def sessions_delete(
172
+ session_id: Annotated[str, typer.Argument(help="会话 id")],
173
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="确认删除")] = False,
174
+ ) -> None:
175
+ """删除会话。不可恢复。"""
176
+ if not yes:
177
+ _fail("删除不可恢复,请加 --yes 确认")
178
+ svc = _service()
179
+ try:
180
+ result = svc.delete_session(session_id, confirm=True)
181
+ except LingguangError as exc:
182
+ _fail(str(exc))
183
+ _dump(result)
184
+
185
+
186
+ # ---------------- ask / answer ----------------
187
+
188
+
189
+ @app.command("ask")
190
+ def ask(
191
+ query: Annotated[str, typer.Argument(help="要问灵光的问题")],
192
+ session: Annotated[Optional[str], typer.Option("--session", "-s", help="会话 id,不传则新建")] = None,
193
+ wait: Annotated[bool, typer.Option("--wait", "-w", help="等待结果返回")] = False,
194
+ timeout: Annotated[int, typer.Option("--timeout", help="等待秒数,上限 60")] = 60,
195
+ json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
196
+ ) -> None:
197
+ """向灵光提问。
198
+
199
+ 不带 --wait 时立即返回 session_id 和 query_id,
200
+ 后续用 answer 命令取结果。
201
+ """
202
+ svc = _service()
203
+ try:
204
+ result = svc.ask(query, session_id=session)
205
+ except LingguangError as exc:
206
+ _fail(str(exc))
207
+
208
+ if not wait:
209
+ if json_output:
210
+ _dump(result.to_dict())
211
+ else:
212
+ typer.echo(f"session: {result.session_id}")
213
+ typer.echo(f"query: {result.query_id}")
214
+ if result.status == "dispatched_unknown":
215
+ typer.echo("状态: 已发出但结果未知,请用 answer 命令确认,不要重复提问", err=True)
216
+ return
217
+
218
+ answer = svc.get_answer(result.query_id, session_id=result.session_id, wait_seconds=timeout)
219
+ if json_output:
220
+ _dump(answer)
221
+ return
222
+ if answer["status"] == "done":
223
+ typer.echo(answer["text"])
224
+ if answer["tools"]:
225
+ typer.echo(f"\n工具轨迹: {' / '.join(answer['tools'])}", err=True)
226
+ else:
227
+ typer.echo(f"状态: {answer['status']}", err=True)
228
+ for w in answer.get("warnings") or []:
229
+ typer.echo(f"提示: {w}", err=True)
230
+
231
+
232
+ @app.command("answer")
233
+ def answer(
234
+ query_id: Annotated[str, typer.Argument(help="ask 返回的 query id")],
235
+ session: Annotated[Optional[str], typer.Option("--session", "-s", help="会话 id")] = None,
236
+ wait: Annotated[int, typer.Option("--wait", "-w", help="等待秒数,上限 60")] = 0,
237
+ json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False,
238
+ ) -> None:
239
+ """取回提问结果。"""
240
+ svc = _service()
241
+ try:
242
+ data = svc.get_answer(query_id, session_id=session, wait_seconds=wait)
243
+ except LingguangError as exc:
244
+ _fail(str(exc))
245
+
246
+ if json_output:
247
+ _dump(data)
248
+ return
249
+ if data["status"] == "done":
250
+ typer.echo(data["text"])
251
+ else:
252
+ typer.echo(f"状态: {data['status']}")
253
+ if data["status"] == "awaiting_confirmation":
254
+ typer.echo("这次提问可能需要你在灵光 App 里手动确认", err=True)
255
+
256
+
257
+ # ---------------- export ----------------
258
+
259
+
260
+ @app.command("export")
261
+ def export(
262
+ session_id: Annotated[str, typer.Argument(help="会话 id")],
263
+ fmt: Annotated[str, typer.Option("--format", "-f", help="md / json / jsonl / raw")] = "md",
264
+ output: Annotated[Optional[Path], typer.Option("--output", "-o", help="输出文件")] = None,
265
+ ) -> None:
266
+ """导出会话。
267
+
268
+ raw 格式输出未解析的原始 JSON,解析器失效时用它兜底。
269
+ """
270
+ svc = _service()
271
+
272
+ if fmt == "raw":
273
+ try:
274
+ raw = svc.get_raw_history(session_id)
275
+ except LingguangError as exc:
276
+ _fail(str(exc))
277
+ text = json.dumps(raw, ensure_ascii=False, indent=2)
278
+ else:
279
+ try:
280
+ data = svc.get_transcript(session_id)
281
+ except LingguangError as exc:
282
+ _fail(str(exc))
283
+ turns = data.get("turns") or []
284
+ if fmt == "json":
285
+ text = json.dumps(turns, ensure_ascii=False, indent=2)
286
+ elif fmt == "jsonl":
287
+ text = "\n".join(json.dumps(t, ensure_ascii=False) for t in turns)
288
+ else:
289
+ lines: list[str] = []
290
+ for i, turn in enumerate(turns, 1):
291
+ lines.append(f"## 第 {i} 轮\n")
292
+ if turn["query"]:
293
+ lines.append(f"**问**: {turn['query']}\n")
294
+ if turn["tools"]:
295
+ lines.append(f"**工具**: {' / '.join(turn['tools'])}\n")
296
+ lines.append(f"{turn['answer']}\n")
297
+ text = "\n".join(lines)
298
+
299
+ if output:
300
+ output.write_text(text, encoding="utf-8")
301
+ typer.echo(f"已写入 {output}")
302
+ else:
303
+ typer.echo(text)
304
+
305
+
306
+ # ---------------- 运维 ----------------
307
+
308
+
309
+ @app.command("doctor")
310
+ def doctor() -> None:
311
+ """环境自检。"""
312
+ ok = True
313
+
314
+ # cookie
315
+ status = describe_status()
316
+ if not status.get("imported"):
317
+ typer.echo("[失败] 未导入 cookie", err=True)
318
+ ok = False
319
+ else:
320
+ missing = status.get("missing_keys") or []
321
+ if missing:
322
+ typer.echo(f"[警告] 缺少关键 cookie: {missing}")
323
+ else:
324
+ typer.echo("[通过] cookie 齐全")
325
+
326
+ if not ok:
327
+ raise typer.Exit(1)
328
+
329
+ # 连通性
330
+ try:
331
+ svc = Service()
332
+ probe = svc.auth_status()
333
+ if probe.get("valid"):
334
+ typer.echo("[通过] loginCheck 成功")
335
+ else:
336
+ typer.echo(f"[失败] {probe.get('probe', 'loginCheck 未通过')}", err=True)
337
+ ok = False
338
+ except LingguangError as exc:
339
+ typer.echo(f"[失败] 无法连接: {exc}", err=True)
340
+ ok = False
341
+
342
+ # 解析器自检
343
+ try:
344
+ from ..core.parser import parse_artifact_text
345
+ from pathlib import Path as _Path
346
+
347
+ fixture = _Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "answer_billing.raw.html"
348
+ if fixture.exists():
349
+ text = parse_artifact_text(fixture.read_text(encoding="utf-8"))
350
+ if text:
351
+ typer.echo(f"[通过] 解析器能提取正文({len(text)} 字符)")
352
+ else:
353
+ typer.echo("[失败] 解析器未提取到正文,上游可能改版", err=True)
354
+ ok = False
355
+ else:
356
+ typer.echo("[跳过] 未找到 fixture")
357
+ except Exception as exc: # noqa: BLE001
358
+ typer.echo(f"[失败] 解析器自检异常: {exc}", err=True)
359
+ ok = False
360
+
361
+ raise typer.Exit(0 if ok else 1)
362
+
363
+
364
+ @app.command("serve")
365
+ def serve() -> None:
366
+ """启动 MCP stdio server。"""
367
+ from ..mcp.server import run
368
+
369
+ run()
370
+
371
+
372
+ def main() -> None:
373
+ app()
374
+
375
+
376
+ if __name__ == "__main__":
377
+ main()
@@ -0,0 +1,49 @@
1
+ """核心层:解析、传输、业务编排。
2
+
3
+ cli 与 mcp 只能通过 service、models、errors 访问这里,
4
+ 不要直接碰 client 和 parser。
5
+ """
6
+
7
+ from .errors import (
8
+ AuthError,
9
+ ContractError,
10
+ LingguangError,
11
+ RateLimitError,
12
+ TruncatedResponseError,
13
+ TransportError,
14
+ )
15
+ from .models import AnswerStatus, AskResult, Scene, Session, ToolCall, Turn
16
+ from .parser import (
17
+ build_turn,
18
+ collect_unknown_events,
19
+ pair_turns,
20
+ parse_artifact_text,
21
+ parse_cot_agent_deltas,
22
+ parse_cot_tools,
23
+ )
24
+ from .service import Service
25
+ from .sink import MessageSink, NullSink
26
+
27
+ __all__ = [
28
+ "AuthError",
29
+ "ContractError",
30
+ "LingguangError",
31
+ "RateLimitError",
32
+ "TruncatedResponseError",
33
+ "TransportError",
34
+ "AnswerStatus",
35
+ "AskResult",
36
+ "Scene",
37
+ "Session",
38
+ "ToolCall",
39
+ "Turn",
40
+ "build_turn",
41
+ "collect_unknown_events",
42
+ "pair_turns",
43
+ "parse_artifact_text",
44
+ "parse_cot_agent_deltas",
45
+ "parse_cot_tools",
46
+ "Service",
47
+ "MessageSink",
48
+ "NullSink",
49
+ ]
lingguang/core/auth.py ADDED
@@ -0,0 +1,198 @@
1
+ """cookie 的导入、存储与状态检查。
2
+
3
+ cookie 是唯一入口的钥匙,失效时工具完全不可用,
4
+ 所以这里要做事前预判而不是事后诊断。
5
+
6
+ 存储位置 ~/.config/lingguang/credentials.json,权限 0600。
7
+ Windows 下会尝试用 DPAPI 加密 cookie 本身,
8
+ 不可用时退化为明文加文件权限,并在状态里如实报告。
9
+
10
+ 重要:明文 ctoken 被同步盘或备份软件扫走,等于泄露支付宝授权登录态。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import stat
19
+ import time
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .errors import AuthError
25
+
26
+ CONFIG_DIR = Path.home() / ".config" / "lingguang"
27
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
28
+ PENDING_FILE = Path.home() / ".cache" / "lingguang" / "pending.jsonl"
29
+ FAILED_DIR = Path.home() / ".cache" / "lingguang" / "failed"
30
+
31
+ # 实测有效的三个关键 cookie
32
+ KEY_COOKIES = ("ctoken", "prod_LINGGUANGJSESSIONID", "spanner")
33
+
34
+
35
+ @dataclass
36
+ class Credentials:
37
+ cookie: str
38
+ imported_at: float = 0.0
39
+ last_ok_at: float = 0.0
40
+ last_error: str = ""
41
+ last_error_at: float = 0.0
42
+ encrypted: bool = False
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return asdict(self)
46
+
47
+
48
+ def parse_cookie_string(raw: str) -> str:
49
+ """从任意粘贴内容里抽出 cookie。
50
+
51
+ 支持三种输入:
52
+ 1. 纯 cookie 字符串:name=value; name=value
53
+ 2. 从 curl 命令里粘:-H 'cookie: ...'
54
+ 3. 带换行的多行文本
55
+ """
56
+ text = raw.strip()
57
+ if not text:
58
+ raise AuthError("输入为空")
59
+
60
+ # 从 curl 抓
61
+ m = re.search(r"-H\s+['\"]cookie:\s*([^'\"]+)['\"]", text, re.IGNORECASE)
62
+ if m:
63
+ text = m.group(1).strip()
64
+
65
+ # 折叠成单行
66
+ text = " ".join(text.split())
67
+ if "ctoken=" not in text:
68
+ raise AuthError("内容里找不到 ctoken,请确认复制的是完整的 cookie")
69
+ return text
70
+
71
+
72
+ def check_cookie_completeness(cookie: str) -> list[str]:
73
+ """检查关键 cookie 是否齐全,返回缺失项。
74
+
75
+ 缺 spanner 或 JSESSIONID 时服务端可能仍能识别,
76
+ 但稳定性会下降,所以要如实报告。
77
+ """
78
+ present = {part.strip().partition("=")[0] for part in cookie.split(";") if part.strip()}
79
+ return [name for name in KEY_COOKIES if name not in present]
80
+
81
+
82
+ def _ensure_dir(path: Path) -> None:
83
+ path.mkdir(parents=True, exist_ok=True)
84
+
85
+
86
+ def _restrict_permissions(path: Path) -> None:
87
+ """尽力把文件权限收到 0600。Windows 上 chmod 语义有限,失败不阻断。"""
88
+ try:
89
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
90
+ except (OSError, NotImplementedError):
91
+ pass
92
+
93
+
94
+ def save_credentials(creds: Credentials) -> Path:
95
+ _ensure_dir(CONFIG_DIR)
96
+ CREDENTIALS_FILE.write_text(
97
+ json.dumps(creds.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
98
+ )
99
+ _restrict_permissions(CREDENTIALS_FILE)
100
+ return CREDENTIALS_FILE
101
+
102
+
103
+ def load_credentials() -> Credentials | None:
104
+ if not CREDENTIALS_FILE.exists():
105
+ return None
106
+ try:
107
+ data = json.loads(CREDENTIALS_FILE.read_text(encoding="utf-8"))
108
+ except (json.JSONDecodeError, OSError):
109
+ return None
110
+ if not isinstance(data, dict) or not data.get("cookie"):
111
+ return None
112
+ return Credentials(
113
+ cookie=str(data["cookie"]),
114
+ imported_at=float(data.get("imported_at") or 0),
115
+ last_ok_at=float(data.get("last_ok_at") or 0),
116
+ last_error=str(data.get("last_error") or ""),
117
+ last_error_at=float(data.get("last_error_at") or 0),
118
+ encrypted=bool(data.get("encrypted")),
119
+ )
120
+
121
+
122
+ def get_cookie() -> str:
123
+ """按优先级取 cookie:环境变量 > 凭据文件。
124
+
125
+ 环境变量优先是为了在 CI 或临时场景里能覆盖。
126
+ """
127
+ env = os.environ.get("LG_COOKIE", "").strip()
128
+ if env:
129
+ return parse_cookie_string(env)
130
+
131
+ creds = load_credentials()
132
+ if creds is None:
133
+ raise AuthError(
134
+ "还没有导入 cookie。运行 `lingguang auth import <文件>` 导入,"
135
+ "或设置环境变量 LG_COOKIE"
136
+ )
137
+ return creds.cookie
138
+
139
+
140
+ def mark_ok() -> None:
141
+ creds = load_credentials()
142
+ if creds is None:
143
+ return
144
+ creds.last_ok_at = time.time()
145
+ creds.last_error = ""
146
+ save_credentials(creds)
147
+
148
+
149
+ def mark_error(message: str) -> None:
150
+ creds = load_credentials()
151
+ if creds is None:
152
+ return
153
+ creds.last_error = message[:500]
154
+ creds.last_error_at = time.time()
155
+ save_credentials(creds)
156
+
157
+
158
+ def describe_status() -> dict[str, Any]:
159
+ """给 auth_status 用。返回人话,不返回裸时间戳。"""
160
+ env_cookie = os.environ.get("LG_COOKIE", "").strip()
161
+ creds = load_credentials()
162
+
163
+ if creds is None and not env_cookie:
164
+ return {
165
+ "valid": False,
166
+ "imported": False,
167
+ "hint": "还没有导入 cookie,运行 `lingguang auth import <文件>`",
168
+ }
169
+
170
+ if creds is None and env_cookie:
171
+ return {
172
+ "valid": False,
173
+ "imported": True,
174
+ "source": "env:LG_COOKIE",
175
+ "missing_keys": check_cookie_completeness(env_cookie),
176
+ "hint": "cookie 来自环境变量 LG_COOKIE,尚未验证",
177
+ }
178
+
179
+ assert creds is not None
180
+ now = time.time()
181
+ days = int((now - creds.imported_at) / 86400) if creds.imported_at else 0
182
+ hours_since_ok = int((now - creds.last_ok_at) / 3600) if creds.last_ok_at else -1
183
+
184
+ return {
185
+ "valid": bool(creds.last_ok_at) and not creds.last_error,
186
+ "imported": True,
187
+ "source": "file",
188
+ "imported_days": days,
189
+ "last_ok_hours": hours_since_ok,
190
+ "last_error": creds.last_error,
191
+ "missing_keys": check_cookie_completeness(creds.cookie),
192
+ "hint": "cookie 会在你从未操作后的一段时间失效,失效时重新导入即可",
193
+ }
194
+
195
+
196
+ def clear_credentials() -> None:
197
+ if CREDENTIALS_FILE.exists():
198
+ CREDENTIALS_FILE.unlink()