yuque-cli 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.
yuque_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """yuque-cli: 企业语雀命令行客户端。"""
2
+
3
+ __version__ = "0.1.0"
yuque_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
yuque_cli/appdata.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from urllib.parse import unquote
6
+
7
+ # 匹配 window.appData = JSON.parse(decodeURIComponent("...")) 中被 percent-encode 的串。
8
+ # 编码后的内容不含裸引号(" 会变成 %22),故 [^"]* 足以圈定整段。
9
+ _APP_DATA_RE = re.compile(
10
+ r'window\.appData\s*=\s*JSON\.parse\(\s*decodeURIComponent\(\s*"([^"]*)"\s*\)\s*\)'
11
+ )
12
+
13
+
14
+ class AppDataError(ValueError):
15
+ """页面 HTML 中找不到或无法解析 window.appData。"""
16
+
17
+
18
+ def parse_app_data(html: str) -> dict:
19
+ m = _APP_DATA_RE.search(html)
20
+ if not m:
21
+ raise AppDataError("页面中未找到 window.appData(可能未登录或页面结构变化)")
22
+ try:
23
+ return json.loads(unquote(m.group(1)))
24
+ except (ValueError, json.JSONDecodeError) as exc:
25
+ raise AppDataError(f"window.appData 解码失败:{exc}") from exc
26
+
27
+
28
+ def book_id(app_data: dict) -> int:
29
+ try:
30
+ return int(app_data["book"]["id"])
31
+ except (KeyError, TypeError, ValueError) as exc:
32
+ raise AppDataError("appData 中缺少 book.id") from exc
33
+
34
+
35
+ def doc_id(app_data: dict) -> int:
36
+ try:
37
+ return int(app_data["doc"]["id"])
38
+ except (KeyError, TypeError, ValueError) as exc:
39
+ raise AppDataError("appData 中缺少 doc.id(该页面可能不是文档页)") from exc
yuque_cli/auth.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Callable, Optional
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+
12
+ from .session import SESSION_COOKIE, parse_cookie_string
13
+
14
+ CDP_PORT = 9222
15
+ # 本地回环不应走代理;这些 host 在 CDP 连接时强制直连。
16
+ _NO_PROXY_HOSTS = ["127.0.0.1", "localhost", "::1"]
17
+ _WS_TIMEOUT = 5.0
18
+
19
+
20
+ class CdpUnavailable(Exception):
21
+ """无法通过 Chrome 远程调试(CDP)抓到目标域名的会话 cookie。"""
22
+
23
+
24
+ # -- 浏览器级 WebSocket 端点发现 -------------------------------------------
25
+
26
+
27
+ def default_devtools_port_files() -> list[Path]:
28
+ home = Path.home()
29
+ candidates = [
30
+ home / "Library/Application Support/Google/Chrome/DevToolsActivePort",
31
+ home / "Library/Application Support/Chromium/DevToolsActivePort",
32
+ home / ".config/google-chrome/DevToolsActivePort",
33
+ home / ".config/chromium/DevToolsActivePort",
34
+ ]
35
+ local_appdata = os.environ.get("LOCALAPPDATA")
36
+ if local_appdata:
37
+ candidates.append(
38
+ Path(local_appdata) / "Google/Chrome/User Data/DevToolsActivePort"
39
+ )
40
+ return candidates
41
+
42
+
43
+ def parse_devtools_active_port(text: str) -> tuple[int, str]:
44
+ lines = text.splitlines()
45
+ if not lines or not lines[0].strip().isdigit():
46
+ raise CdpUnavailable("DevToolsActivePort 内容异常(缺少端口号)")
47
+ port = int(lines[0].strip())
48
+ ws_path = lines[1].strip() if len(lines) > 1 else ""
49
+ return port, ws_path
50
+
51
+
52
+ def _ws_url_from_devtools_file(*, verbose: bool = False) -> Optional[str]:
53
+ """从 Chrome 的 DevToolsActivePort 文件取浏览器级 WS 地址。
54
+
55
+ Chrome 136+ 在默认 profile 下禁用了 /json HTTP 接口(返回 404),但该文件仍记录
56
+ 端口与浏览器级 WS 路径,故这是最可靠的发现方式。
57
+ """
58
+ for path in default_devtools_port_files():
59
+ try:
60
+ if not path.is_file():
61
+ continue
62
+ port, ws_path = parse_devtools_active_port(path.read_text())
63
+ if ws_path:
64
+ return f"ws://127.0.0.1:{port}{ws_path}"
65
+ except Exception as exc:
66
+ if verbose:
67
+ print(f"读取 {path} 失败:{exc!r}", file=sys.stderr)
68
+ return None
69
+
70
+
71
+ def _ws_url_from_http(port: int, *, verbose: bool = False) -> Optional[str]:
72
+ """退而求其次:经 /json/version 取 WS 地址(部分配置可用)。
73
+
74
+ 用 trust_env=False 绕开 *_proxy 环境变量——本地回环不该走代理。
75
+ """
76
+ url = f"http://127.0.0.1:{port}/json/version"
77
+ try:
78
+ resp = httpx.get(url, timeout=1.0, trust_env=False)
79
+ resp.raise_for_status()
80
+ return resp.json().get("webSocketDebuggerUrl")
81
+ except Exception as exc:
82
+ if verbose:
83
+ print(f"CDP HTTP 探测失败({url}):{exc!r}", file=sys.stderr)
84
+ return None
85
+
86
+
87
+ def cdp_websocket_url(port: int = CDP_PORT, *, verbose: bool = False) -> Optional[str]:
88
+ """发现本地 Chrome 的浏览器级 WebSocket 调试地址;未启用远程调试则 None。"""
89
+ return _ws_url_from_devtools_file(verbose=verbose) or _ws_url_from_http(
90
+ port, verbose=verbose
91
+ )
92
+
93
+
94
+ # -- cookie 过滤 ------------------------------------------------------------
95
+
96
+
97
+ def _domain_matches(cookie_domain: str, host: str) -> bool:
98
+ d = cookie_domain.lstrip(".")
99
+ return host == d or host.endswith("." + d)
100
+
101
+
102
+ def cookies_for_host(cdp_cookies: list[dict], host: str) -> dict[str, str]:
103
+ out: dict[str, str] = {}
104
+ for cookie in cdp_cookies:
105
+ if _domain_matches(str(cookie.get("domain", "")), host):
106
+ out[cookie["name"]] = cookie["value"]
107
+ return out
108
+
109
+
110
+ # -- WebSocket 取 cookie ----------------------------------------------------
111
+
112
+
113
+ def _storage_get_cookies(ws_url: str) -> list[dict]:
114
+ from websocket import create_connection
115
+
116
+ # suppress_origin:Chrome 111+ 默认拒绝带 Origin 的 WS(除非 --remote-allow-origins);
117
+ # 不发 Origin 头即可连上,免去用户重启 Chrome 加该 flag。
118
+ ws = create_connection(
119
+ ws_url,
120
+ timeout=_WS_TIMEOUT,
121
+ http_no_proxy=_NO_PROXY_HOSTS,
122
+ suppress_origin=True,
123
+ )
124
+ try:
125
+ ws.send(json.dumps({"id": 1, "method": "Storage.getCookies"}))
126
+ for _ in range(50):
127
+ data = json.loads(ws.recv())
128
+ if data.get("id") == 1:
129
+ return data.get("result", {}).get("cookies", [])
130
+ raise CdpUnavailable("CDP 未在预期内返回 cookie")
131
+ finally:
132
+ ws.close()
133
+
134
+
135
+ def cookies_via_cdp(base_url: str, ws_url: str) -> dict[str, str]:
136
+ """经浏览器级 WS 调 Storage.getCookies,过滤出目标域名 cookie 并校验登录态。"""
137
+ host = urlsplit(base_url).hostname or base_url
138
+ try:
139
+ raw = _storage_get_cookies(ws_url)
140
+ except CdpUnavailable:
141
+ raise
142
+ except Exception as exc: # WS 连接/收发失败、端口已死等
143
+ raise CdpUnavailable(f"CDP 抓取失败:{exc}") from exc
144
+ cookies = cookies_for_host(raw, host)
145
+ if SESSION_COOKIE not in cookies:
146
+ raise CdpUnavailable(
147
+ f"CDP 未取到 {host} 的 {SESSION_COOKIE}(请确认已在该 Chrome 登录该域名)"
148
+ )
149
+ return cookies
150
+
151
+
152
+ def cookies_via_manual(prompt_fn: Callable[[], str]) -> dict[str, str]:
153
+ return parse_cookie_string(prompt_fn())
yuque_cli/cli.py ADDED
@@ -0,0 +1,355 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from typing import Annotated, Any, Callable, Optional
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from . import __version__, auth, config, output, session
12
+ from .appdata import AppDataError
13
+ from .appdata import book_id as appdata_book_id
14
+ from .appdata import doc_id as appdata_doc_id
15
+ from .client import Client
16
+ from .errors import AuthExpired, YuqueError
17
+ from .inputs import BodyInputError, resolve_body
18
+ from .session import SessionError
19
+ from .urls import UrlParseError, parse_book_ref, parse_doc_ref
20
+
21
+
22
+ @dataclass
23
+ class AppState:
24
+ json: bool = False
25
+ yes: bool = False
26
+ verbose: bool = False
27
+
28
+
29
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="企业语雀命令行客户端")
30
+ auth_app = typer.Typer(no_args_is_help=True, help="登录态管理")
31
+ book_app = typer.Typer(no_args_is_help=True, help="知识库(book)")
32
+ doc_app = typer.Typer(no_args_is_help=True, help="文档(doc)")
33
+ comment_app = typer.Typer(no_args_is_help=True, help="评论(comment)")
34
+ app.add_typer(auth_app, name="auth")
35
+ app.add_typer(book_app, name="book")
36
+ app.add_typer(doc_app, name="doc")
37
+ app.add_typer(comment_app, name="comment")
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ typer.echo(__version__)
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def main(
48
+ ctx: typer.Context,
49
+ json_out: Annotated[bool, typer.Option("--json", help="输出原始 JSON")] = False,
50
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="跳过所有确认")] = False,
51
+ verbose: Annotated[
52
+ bool, typer.Option("--verbose", "-v", help="打印内部 HTTP 请求")
53
+ ] = False,
54
+ version: Annotated[
55
+ Optional[bool],
56
+ typer.Option(
57
+ "--version", "-V", callback=_version_callback, is_eager=True, help="显示版本"
58
+ ),
59
+ ] = None,
60
+ ) -> None:
61
+ ctx.obj = AppState(json=json_out, yes=yes, verbose=verbose)
62
+
63
+
64
+ # -- 公共辅助 ---------------------------------------------------------------
65
+
66
+
67
+ @contextmanager
68
+ def cli_errors():
69
+ try:
70
+ yield
71
+ except AuthExpired as exc:
72
+ typer.secho(str(exc), fg="red", err=True)
73
+ raise typer.Exit(2)
74
+ except (YuqueError, SessionError, UrlParseError, AppDataError, BodyInputError) as exc:
75
+ typer.secho(str(exc), fg="red", err=True)
76
+ raise typer.Exit(1)
77
+
78
+
79
+ def build_client(state: AppState) -> Client:
80
+ sess = session.load(config.session_file())
81
+ hooks: dict[str, list[Callable[..., Any]]] = {}
82
+ if state.verbose:
83
+
84
+ def _log(request: httpx.Request) -> None:
85
+ typer.secho(f"→ {request.method} {request.url}", fg="bright_black", err=True)
86
+
87
+ hook: Callable[..., Any] = _log
88
+ hooks["request"] = [hook]
89
+ # trust_env=False:默认直连,绕开 *_proxy 环境变量(如本地 Clash 的 socks5 代理)——
90
+ # 避免请求被本地代理拦截,也免去 SOCKS 需 socksio 的崩溃。如需经代理访问,移除此参数并装 httpx[socks]。
91
+ http = httpx.Client(timeout=30.0, event_hooks=hooks, trust_env=False)
92
+ return Client(http, sess, base_url=config.base_url())
93
+
94
+
95
+ def _emit(state: AppState, data, human: str) -> None:
96
+ typer.echo(output.dumps(data) if state.json else human)
97
+
98
+
99
+ def _confirm(state: AppState, message: str) -> None:
100
+ if state.yes:
101
+ return
102
+ if not sys.stdin.isatty():
103
+ raise YuqueError("需要确认但当前为非交互环境;如确定请加 --yes")
104
+ if not typer.confirm(message):
105
+ raise typer.Abort()
106
+
107
+
108
+ def _read_body(body: Optional[str], file: Optional[str]) -> Optional[str]:
109
+ return resolve_body(
110
+ body, file, stdin=sys.stdin, stdin_isatty=sys.stdin.isatty()
111
+ )
112
+
113
+
114
+ # -- auth -------------------------------------------------------------------
115
+
116
+
117
+ def _prompt_cookie() -> str:
118
+ typer.echo(
119
+ "请粘贴目标域名的完整 cookie 串"
120
+ "(浏览器 DevTools → Application → Cookies,或 document.cookie):",
121
+ err=True,
122
+ )
123
+ return typer.prompt("cookie")
124
+
125
+
126
+ _CDP_HINT = (
127
+ f"未检测到 Chrome 远程调试(DevToolsActivePort / 127.0.0.1:{auth.CDP_PORT})。\n"
128
+ "如需自动化登录:完全退出 Chrome,再带远程调试启动并登录目标语雀站点,例如 macOS:\n"
129
+ f' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" '
130
+ f"--remote-debugging-port={auth.CDP_PORT}\n"
131
+ "随后重试 yuque auth login。现在可手动粘贴 cookie 完成登录。"
132
+ )
133
+
134
+
135
+ @auth_app.command("login")
136
+ def auth_login(
137
+ ctx: typer.Context,
138
+ manual: Annotated[
139
+ bool, typer.Option("--manual", help="跳过 CDP,直接手动粘贴 cookie")
140
+ ] = False,
141
+ ) -> None:
142
+ """登录:默认探测本地 CDP 自动获取登录态;未启用或失败时回退手动粘贴。"""
143
+ with cli_errors():
144
+ state: AppState = ctx.obj
145
+ base = config.base_url()
146
+ ws_url = None if manual else auth.cdp_websocket_url(verbose=state.verbose)
147
+ if ws_url is None:
148
+ if not manual:
149
+ typer.secho(_CDP_HINT, fg="yellow", err=True)
150
+ cookies = auth.cookies_via_manual(_prompt_cookie)
151
+ else:
152
+ try:
153
+ cookies = auth.cookies_via_cdp(base, ws_url)
154
+ typer.secho(
155
+ f"已通过 CDP 从浏览器获取 {config.host()} 登录态", fg="green", err=True
156
+ )
157
+ except auth.CdpUnavailable as exc:
158
+ typer.secho(f"CDP 已连接但获取失败:{exc}", fg="yellow", err=True)
159
+ cookies = auth.cookies_via_manual(_prompt_cookie)
160
+ sess = session.Session(cookies)
161
+ sess.require_session()
162
+ session.save(sess, config.session_file())
163
+ typer.echo(f"已登录,会话保存在 {config.session_file()}")
164
+
165
+
166
+ @auth_app.command("status")
167
+ def auth_status(ctx: typer.Context) -> None:
168
+ """显示当前登录身份(GET /api/mine)。"""
169
+ state: AppState = ctx.obj
170
+ with cli_errors():
171
+ user = build_client(state).mine()
172
+ _emit(state, user, output.render_user(user))
173
+
174
+
175
+ @auth_app.command("logout")
176
+ def auth_logout(ctx: typer.Context) -> None:
177
+ """清除本地保存的会话。"""
178
+ path = config.session_file()
179
+ if path.exists():
180
+ path.unlink()
181
+ typer.echo("已退出登录(已清除本地会话)")
182
+ else:
183
+ typer.echo("当前未登录")
184
+
185
+
186
+ # -- book -------------------------------------------------------------------
187
+
188
+
189
+ @book_app.command("list")
190
+ def book_list(ctx: typer.Context) -> None:
191
+ """列出我的知识库(GET /api/mine/books)。"""
192
+ state: AppState = ctx.obj
193
+ with cli_errors():
194
+ books = build_client(state).mine_books()
195
+ _emit(state, books, output.render_books(books))
196
+
197
+
198
+ # -- doc --------------------------------------------------------------------
199
+
200
+ UrlArg = Annotated[str, typer.Argument(help="目标 URL 或去 host 的路径形态")]
201
+ BodyOpt = Annotated[Optional[str], typer.Option("--body", "-b", help="内联正文")]
202
+ FileOpt = Annotated[
203
+ Optional[str], typer.Option("--file", "-F", help="从文件读正文(- 表示 stdin)")
204
+ ]
205
+ PublicOpt = Annotated[
206
+ Optional[bool],
207
+ typer.Option("--public/--private", help="可见性;不给则继承知识库默认/保持原值"),
208
+ ]
209
+
210
+
211
+ @doc_app.command("list")
212
+ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
213
+ """列出知识库下的文档。"""
214
+ state: AppState = ctx.obj
215
+ with cli_errors():
216
+ ref = parse_book_ref(url)
217
+ client = build_client(state)
218
+ book_id = client.resolve_book_id(ref.login, ref.book)
219
+ docs = client.book_docs(book_id)
220
+ _emit(state, docs, output.render_docs(docs))
221
+
222
+
223
+ @doc_app.command("get")
224
+ def doc_get(ctx: typer.Context, url: UrlArg) -> None:
225
+ """取文档正文:默认干净 Markdown;--json 出详情(含 Lake)。"""
226
+ state: AppState = ctx.obj
227
+ with cli_errors():
228
+ ref = parse_doc_ref(url)
229
+ client = build_client(state)
230
+ if state.json:
231
+ app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
232
+ detail = client.doc_detail(ref.slug, appdata_book_id(app_data))
233
+ typer.echo(output.dumps(detail))
234
+ else:
235
+ typer.echo(client.doc_markdown(ref.login, ref.book, ref.slug))
236
+
237
+
238
+ @doc_app.command("create")
239
+ def doc_create(
240
+ ctx: typer.Context,
241
+ url: UrlArg,
242
+ title: Annotated[str, typer.Option("--title", "-t", help="文档标题")],
243
+ slug: Annotated[Optional[str], typer.Option("--slug", help="文档 slug(省略则自动生成)")] = None,
244
+ public: PublicOpt = None,
245
+ body: BodyOpt = None,
246
+ file: FileOpt = None,
247
+ ) -> None:
248
+ """在知识库中创建文档。"""
249
+ state: AppState = ctx.obj
250
+ with cli_errors():
251
+ ref = parse_book_ref(url)
252
+ text = _read_body(body, file)
253
+ client = build_client(state)
254
+ book_id = client.resolve_book_id(ref.login, ref.book)
255
+ pub = None if public is None else (1 if public else 0)
256
+ created = client.create_doc(
257
+ book_id=book_id, title=title, body=text, slug=slug, public=pub
258
+ )
259
+ url_out = f"{config.base_url()}/{ref.login}/{ref.book}/{created.get('slug', '')}"
260
+ _emit(state, created, f"已创建:{url_out}")
261
+
262
+
263
+ @doc_app.command("update")
264
+ def doc_update(
265
+ ctx: typer.Context,
266
+ url: UrlArg,
267
+ title: Annotated[Optional[str], typer.Option("--title", "-t", help="新标题")] = None,
268
+ public: PublicOpt = None,
269
+ body: BodyOpt = None,
270
+ file: FileOpt = None,
271
+ ) -> None:
272
+ """更新文档(先取后合并:未给的字段保持原值)。"""
273
+ state: AppState = ctx.obj
274
+ with cli_errors():
275
+ if title is None and public is None and body is None and file is None:
276
+ raise YuqueError(
277
+ "没有要更新的字段(至少给 --title / 正文 / --public|--private 之一)"
278
+ )
279
+ ref = parse_doc_ref(url)
280
+ client = build_client(state)
281
+ app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
282
+ doc_id = appdata_doc_id(app_data)
283
+ meta = app_data.get("doc", {})
284
+ new_title = title if title is not None else meta.get("title", "")
285
+ if body is not None or file is not None:
286
+ new_body = _read_body(body, file) or ""
287
+ else:
288
+ new_body = client.doc_markdown(ref.login, ref.book, ref.slug)
289
+ new_public = (1 if public else 0) if public is not None else meta.get("public")
290
+ updated = client.update_doc(
291
+ doc_id=doc_id, title=new_title, body=new_body, public=new_public
292
+ )
293
+ url_out = f"{config.base_url()}/{ref.login}/{ref.book}/{ref.slug}"
294
+ _emit(state, updated, f"已更新:{url_out}")
295
+
296
+
297
+ @doc_app.command("delete")
298
+ def doc_delete(ctx: typer.Context, url: UrlArg) -> None:
299
+ """删除文档(软删,可在回收站恢复)。"""
300
+ state: AppState = ctx.obj
301
+ with cli_errors():
302
+ ref = parse_doc_ref(url)
303
+ client = build_client(state)
304
+ app_data = client.doc_page_app_data(ref.login, ref.book, ref.slug)
305
+ doc_id = appdata_doc_id(app_data)
306
+ title = app_data.get("doc", {}).get("title", ref.slug)
307
+ _confirm(state, f"确认删除文档「{title}」?(软删,可在回收站恢复)")
308
+ result = client.delete_doc(doc_id)
309
+ _emit(state, result, "已删除(可在回收站恢复)")
310
+
311
+
312
+ # -- comment ----------------------------------------------------------------
313
+
314
+
315
+ @comment_app.command("list")
316
+ def comment_list(ctx: typer.Context, url: UrlArg) -> None:
317
+ """列出文档的评论。"""
318
+ state: AppState = ctx.obj
319
+ with cli_errors():
320
+ ref = parse_doc_ref(url)
321
+ client = build_client(state)
322
+ _, doc_id = client.resolve_doc_ids(ref.login, ref.book, ref.slug)
323
+ items = client.comments(doc_id)
324
+ _emit(state, items, output.render_comments(items))
325
+
326
+
327
+ @comment_app.command("add")
328
+ def comment_add(
329
+ ctx: typer.Context, url: UrlArg, body: BodyOpt = None, file: FileOpt = None
330
+ ) -> None:
331
+ """给文档添加评论。"""
332
+ state: AppState = ctx.obj
333
+ with cli_errors():
334
+ ref = parse_doc_ref(url)
335
+ text = _read_body(body, file)
336
+ if not text:
337
+ raise YuqueError("评论正文不能为空(用 --body 或 --file/-)")
338
+ client = build_client(state)
339
+ _, doc_id = client.resolve_doc_ids(ref.login, ref.book, ref.slug)
340
+ created = client.create_comment(doc_id=doc_id, body=text)
341
+ _emit(state, created, f"已评论(id={created.get('id', '')})")
342
+
343
+
344
+ @comment_app.command("delete")
345
+ def comment_delete(
346
+ ctx: typer.Context,
347
+ comment_id: Annotated[int, typer.Argument(help="评论 id(从 comment list 获取)")],
348
+ ) -> None:
349
+ """删除一条评论。"""
350
+ state: AppState = ctx.obj
351
+ with cli_errors():
352
+ client = build_client(state)
353
+ _confirm(state, f"确认删除评论 {comment_id}?")
354
+ result = client.delete_comment(comment_id)
355
+ _emit(state, result, "已删除评论")
yuque_cli/client.py ADDED
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from . import appdata
8
+ from .errors import ApiError, AuthExpired
9
+ from .session import Session
10
+
11
+ USER_AGENT = (
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
13
+ "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
14
+ )
15
+
16
+
17
+ class Client:
18
+ """语雀内部 web API 客户端。注入 httpx.Client 与 Session 以便测试。"""
19
+
20
+ def __init__(
21
+ self,
22
+ http: httpx.Client,
23
+ session: Session,
24
+ *,
25
+ base_url: str,
26
+ user_agent: str = USER_AGENT,
27
+ ) -> None:
28
+ self.http = http
29
+ self.session = session
30
+ self.base_url = base_url.rstrip("/")
31
+ self.user_agent = user_agent
32
+
33
+ # -- 底层请求 -----------------------------------------------------------
34
+
35
+ def _headers(self, *, write: bool, referer: str | None) -> dict[str, str]:
36
+ headers = {
37
+ "Cookie": self.session.cookie_header,
38
+ "User-Agent": self.user_agent,
39
+ "Referer": referer or f"{self.base_url}/",
40
+ "x-requested-with": "XMLHttpRequest",
41
+ }
42
+ if write:
43
+ token = self.session.csrf_token
44
+ if not token:
45
+ raise ApiError("写操作缺少 CSRF token(yuque_ctoken);请重新登录")
46
+ headers["Content-Type"] = "application/json"
47
+ headers["x-csrf-token"] = token
48
+ return headers
49
+
50
+ def _request(
51
+ self,
52
+ method: str,
53
+ url: str,
54
+ *,
55
+ params: dict[str, Any] | None = None,
56
+ json_body: Any | None = None,
57
+ write: bool = False,
58
+ referer: str | None = None,
59
+ ) -> httpx.Response:
60
+ resp = self.http.request(
61
+ method,
62
+ url,
63
+ params=params,
64
+ json=json_body,
65
+ headers=self._headers(write=write, referer=referer),
66
+ )
67
+ if resp.status_code == 401:
68
+ raise AuthExpired("登录态已失效(401);请重新运行 yuque auth login")
69
+ if resp.status_code >= 400:
70
+ raise ApiError(
71
+ f"请求失败 {resp.status_code} {method} {url}:{resp.text[:200]}",
72
+ status_code=resp.status_code,
73
+ )
74
+ return resp
75
+
76
+ @staticmethod
77
+ def _data(resp: httpx.Response) -> Any:
78
+ payload = resp.json()
79
+ if isinstance(payload, dict) and "data" in payload:
80
+ return payload["data"]
81
+ return payload
82
+
83
+ # -- 用户 / 知识库 ------------------------------------------------------
84
+
85
+ def mine(self) -> dict:
86
+ return self._data(self._request("GET", f"{self.base_url}/api/mine"))
87
+
88
+ def mine_books(self) -> list[dict]:
89
+ return self._data(self._request("GET", f"{self.base_url}/api/mine/books"))
90
+
91
+ def book_docs(self, book_id: int) -> list[dict]:
92
+ return self._data(
93
+ self._request("GET", f"{self.base_url}/api/books/{book_id}/docs")
94
+ )
95
+
96
+ # -- 文档 ---------------------------------------------------------------
97
+
98
+ def doc_markdown(self, login: str, book: str, slug: str) -> str:
99
+ url = f"{self.base_url}/{login}/{book}/{slug}/markdown"
100
+ return self._request(
101
+ "GET",
102
+ url,
103
+ params={"plain": "true", "linebreak": "false", "anchor": "false"},
104
+ referer=f"{self.base_url}/{login}/{book}/{slug}",
105
+ ).text
106
+
107
+ def doc_detail(self, slug_or_id: str | int, book_id: int) -> dict:
108
+ return self._data(
109
+ self._request(
110
+ "GET",
111
+ f"{self.base_url}/api/docs/{slug_or_id}",
112
+ params={"book_id": book_id},
113
+ )
114
+ )
115
+
116
+ def create_doc(
117
+ self,
118
+ *,
119
+ book_id: int,
120
+ title: str,
121
+ body: str | None,
122
+ slug: str | None = None,
123
+ public: int | None = None,
124
+ ) -> dict:
125
+ payload: dict[str, Any] = {
126
+ "book_id": book_id,
127
+ "title": title,
128
+ "format": "markdown",
129
+ "body": body or "",
130
+ }
131
+ if slug is not None:
132
+ payload["slug"] = slug
133
+ if public is not None:
134
+ payload["public"] = public
135
+ return self._data(
136
+ self._request("POST", f"{self.base_url}/api/docs", json_body=payload, write=True)
137
+ )
138
+
139
+ def update_doc(
140
+ self,
141
+ *,
142
+ doc_id: int,
143
+ title: str,
144
+ body: str,
145
+ public: int | None = None,
146
+ ) -> dict:
147
+ payload: dict[str, Any] = {"title": title, "body": body, "format": "markdown"}
148
+ if public is not None:
149
+ payload["public"] = public
150
+ return self._data(
151
+ self._request(
152
+ "PUT", f"{self.base_url}/api/docs/{doc_id}", json_body=payload, write=True
153
+ )
154
+ )
155
+
156
+ def delete_doc(self, doc_id: int) -> dict:
157
+ return self._data(
158
+ self._request("DELETE", f"{self.base_url}/api/docs/{doc_id}", write=True)
159
+ )
160
+
161
+ # -- 评论 ---------------------------------------------------------------
162
+
163
+ def comments(self, doc_id: int) -> list[dict]:
164
+ return self._data(
165
+ self._request(
166
+ "GET",
167
+ f"{self.base_url}/api/comments",
168
+ params={"commentable_id": doc_id, "commentable_type": "Doc"},
169
+ )
170
+ )
171
+
172
+ def create_comment(self, *, doc_id: int, body: str) -> dict:
173
+ payload = {"commentable_id": doc_id, "commentable_type": "Doc", "body": body}
174
+ return self._data(
175
+ self._request(
176
+ "POST", f"{self.base_url}/api/comments", json_body=payload, write=True
177
+ )
178
+ )
179
+
180
+ def delete_comment(self, comment_id: int) -> dict:
181
+ return self._data(
182
+ self._request(
183
+ "DELETE", f"{self.base_url}/api/comments/{comment_id}", write=True
184
+ )
185
+ )
186
+
187
+ # -- URL → id 解析(抓 appData) ----------------------------------------
188
+
189
+ def doc_page_app_data(self, login: str, book: str, slug: str) -> dict:
190
+ html = self._request("GET", f"{self.base_url}/{login}/{book}/{slug}").text
191
+ return appdata.parse_app_data(html)
192
+
193
+ def resolve_book_id(self, login: str, book: str) -> int:
194
+ html = self._request("GET", f"{self.base_url}/{login}/{book}").text
195
+ return appdata.book_id(appdata.parse_app_data(html))
196
+
197
+ def resolve_doc_ids(self, login: str, book: str, slug: str) -> tuple[int, int]:
198
+ data = self.doc_page_app_data(login, book, slug)
199
+ return appdata.book_id(data), appdata.doc_id(data)
yuque_cli/config.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ DEFAULT_HOST = "www.yuque.com"
7
+
8
+
9
+ def host() -> str:
10
+ return os.environ.get("YUQUE_HOST") or DEFAULT_HOST
11
+
12
+
13
+ def base_url() -> str:
14
+ return f"https://{host()}"
15
+
16
+
17
+ def config_dir() -> Path:
18
+ override = os.environ.get("YUQUE_CONFIG_DIR")
19
+ if override:
20
+ return Path(override)
21
+ xdg = os.environ.get("XDG_CONFIG_HOME")
22
+ base = Path(xdg) if xdg else Path.home() / ".config"
23
+ return base / "yuque-cli"
24
+
25
+
26
+ def session_file() -> Path:
27
+ return config_dir() / "session.json"
28
+
29
+
30
+ def no_color() -> bool:
31
+ return bool(os.environ.get("NO_COLOR"))
yuque_cli/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class YuqueError(Exception):
5
+ """yuque-cli 的运行期错误基类。"""
6
+
7
+
8
+ class AuthExpired(YuqueError):
9
+ """登录态失效(HTTP 401)。"""
10
+
11
+
12
+ class ApiError(YuqueError):
13
+ """内部接口返回非预期状态码。"""
14
+
15
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
16
+ super().__init__(message)
17
+ self.status_code = status_code
yuque_cli/inputs.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TextIO
5
+
6
+
7
+ class BodyInputError(ValueError):
8
+ """正文输入来源无效(互斥冲突或文件不可读)。"""
9
+
10
+
11
+ def resolve_body(
12
+ body: str | None,
13
+ file: str | None,
14
+ *,
15
+ stdin: TextIO,
16
+ stdin_isatty: bool,
17
+ ) -> str | None:
18
+ """按 --body > --file(-=stdin) > 管道 stdin 的优先级解析正文。
19
+
20
+ 在 TTY 下且未给任何来源时返回 None(由调用方决定是否必填)。
21
+ """
22
+ if body is not None and file is not None:
23
+ raise BodyInputError("--body 与 --file 互斥,只能给其一")
24
+ if body is not None:
25
+ return body
26
+ if file is not None:
27
+ if file == "-":
28
+ return stdin.read()
29
+ try:
30
+ return Path(file).read_text(encoding="utf-8")
31
+ except OSError as exc:
32
+ raise BodyInputError(f"无法读取文件 {file}:{exc}") from exc
33
+ if not stdin_isatty:
34
+ return stdin.read()
35
+ return None
yuque_cli/output.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def dumps(data: Any) -> str:
8
+ return json.dumps(data, ensure_ascii=False, indent=2)
9
+
10
+
11
+ def table(headers: list[str], rows: list[list[str]]) -> str:
12
+ widths = [len(h) for h in headers]
13
+ for row in rows:
14
+ for i, cell in enumerate(row):
15
+ widths[i] = max(widths[i], len(cell))
16
+ lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
17
+ for row in rows:
18
+ lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(headers))).rstrip())
19
+ return "\n".join(lines)
20
+
21
+
22
+ def _first_line(text: str, limit: int = 60) -> str:
23
+ line = (text or "").splitlines()[0] if text else ""
24
+ return line if len(line) <= limit else line[: limit - 1] + "…"
25
+
26
+
27
+ def render_user(u: dict) -> str:
28
+ fields = ["login", "name", "id", "email", "organization_login"]
29
+ return "\n".join(f"{k}: {u[k]}" for k in fields if k in u)
30
+
31
+
32
+ def render_books(books: list[dict]) -> str:
33
+ rows = [
34
+ [
35
+ str(b.get("name", "")),
36
+ str(b.get("slug", "")),
37
+ str(b.get("items_count", "")),
38
+ "yes" if b.get("public") else "no",
39
+ str(b.get("id", "")),
40
+ ]
41
+ for b in books
42
+ ]
43
+ return table(["NAME", "SLUG", "DOCS", "PUBLIC", "ID"], rows)
44
+
45
+
46
+ def render_docs(docs: list[dict]) -> str:
47
+ rows = [
48
+ [str(d.get("title", "")), str(d.get("slug", "")), str(d.get("id", ""))]
49
+ for d in docs
50
+ ]
51
+ return table(["TITLE", "SLUG", "ID"], rows)
52
+
53
+
54
+ def render_comments(comments: list[dict]) -> str:
55
+ rows = [
56
+ [
57
+ str(c.get("id", "")),
58
+ str((c.get("user") or {}).get("name", "")),
59
+ _first_line(c.get("body", "")),
60
+ ]
61
+ for c in comments
62
+ ]
63
+ return table(["ID", "AUTHOR", "BODY"], rows)
yuque_cli/session.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ SESSION_COOKIE = "_yuque_session"
8
+ CSRF_COOKIE = "yuque_ctoken"
9
+
10
+
11
+ class SessionError(ValueError):
12
+ """会话缺失或无效(未登录、cookie 不含登录态等)。"""
13
+
14
+
15
+ def parse_cookie_string(raw: str) -> dict[str, str]:
16
+ out: dict[str, str] = {}
17
+ for part in raw.split(";"):
18
+ part = part.strip()
19
+ if not part or "=" not in part:
20
+ continue
21
+ key, value = part.split("=", 1)
22
+ key = key.strip()
23
+ if key:
24
+ out[key] = value.strip()
25
+ return out
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Session:
30
+ cookies: dict[str, str]
31
+
32
+ @property
33
+ def cookie_header(self) -> str:
34
+ return "; ".join(f"{k}={v}" for k, v in self.cookies.items())
35
+
36
+ @property
37
+ def csrf_token(self) -> str | None:
38
+ return self.cookies.get(CSRF_COOKIE)
39
+
40
+ def require_session(self) -> None:
41
+ if SESSION_COOKIE not in self.cookies:
42
+ raise SessionError(
43
+ f"cookie 中缺少 {SESSION_COOKIE}(登录态);请重新运行 yuque auth login"
44
+ )
45
+
46
+
47
+ def from_cookie_string(raw: str) -> Session:
48
+ session = Session(parse_cookie_string(raw))
49
+ session.require_session()
50
+ return session
51
+
52
+
53
+ def save(session: Session, path: Path) -> None:
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ path.write_text(
56
+ json.dumps({"cookies": session.cookies}, ensure_ascii=False),
57
+ encoding="utf-8",
58
+ )
59
+ path.chmod(0o600)
60
+
61
+
62
+ def load(path: Path) -> Session:
63
+ if not path.exists():
64
+ raise SessionError("尚未登录;请先运行 yuque auth login")
65
+ try:
66
+ data = json.loads(path.read_text(encoding="utf-8"))
67
+ return Session(dict(data["cookies"]))
68
+ except (ValueError, KeyError, TypeError) as exc:
69
+ raise SessionError(f"会话文件损坏:{path}({exc})") from exc
yuque_cli/urls.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from urllib.parse import urlsplit
5
+
6
+
7
+ class UrlParseError(ValueError):
8
+ """输入无法解析成知识库(book)或文档(doc)地址。"""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BookRef:
13
+ login: str
14
+ book: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DocRef:
19
+ login: str
20
+ book: str
21
+ slug: str
22
+
23
+ @property
24
+ def book_ref(self) -> BookRef:
25
+ return BookRef(self.login, self.book)
26
+
27
+
28
+ def _segments(raw: str) -> list[str]:
29
+ s = raw.strip()
30
+ if not s:
31
+ return []
32
+ s = s.split("#", 1)[0].split("?", 1)[0]
33
+ if "://" in s:
34
+ path = urlsplit(s).path
35
+ else:
36
+ # 无 scheme 时可能是 "host/seg/..." 也可能是纯 "seg/seg..."。
37
+ # 语雀 login 不含点,故首段含 "." 即视为 host 并剥掉。
38
+ parts = s.split("/")
39
+ if "." in parts[0]:
40
+ parts = parts[1:]
41
+ path = "/".join(parts)
42
+ return [seg for seg in path.split("/") if seg]
43
+
44
+
45
+ def parse_doc_ref(raw: str) -> DocRef:
46
+ segs = _segments(raw)
47
+ if len(segs) != 3:
48
+ raise UrlParseError(
49
+ f"文档地址应形如 {{login}}/{{book}}/{{slug}}(三段),得到:{raw!r}"
50
+ )
51
+ login, book, slug = segs
52
+ return DocRef(login=login, book=book, slug=slug)
53
+
54
+
55
+ def parse_book_ref(raw: str) -> BookRef:
56
+ segs = _segments(raw)
57
+ if len(segs) < 2:
58
+ raise UrlParseError(
59
+ f"知识库地址应形如 {{login}}/{{book}}(两段),得到:{raw!r}"
60
+ )
61
+ if len(segs) > 3:
62
+ raise UrlParseError(f"地址段数过多,无法识别为知识库:{raw!r}")
63
+ return BookRef(login=segs[0], book=segs[1])
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: yuque-cli
3
+ Version: 0.1.0
4
+ Summary: 针对企业语雀实例的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: websocket-client>=1.9.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # yuque-cli
12
+
13
+ 面向语雀(含企业版)的通用命令行客户端:以浏览器 cookie 认证,驱动语雀内部 web 接口,提供文档(doc)与评论(comment)的增删改查。默认连接 `www.yuque.com`,企业实例用环境变量 `YUQUE_HOST` 指定。
14
+
15
+ > 术语(知识库/文档/评论/login/namespace/slug)见 [CONTEXT.md](./CONTEXT.md);关键设计取舍见 [docs/adr](./docs/adr)。
16
+
17
+ ## 特性
18
+
19
+ - **复用浏览器登录态**:默认探测本地 Chrome 远程调试(CDP)自动抓取 cookie,无需手动复制;失败可回退手动粘贴。
20
+ - **干净的 Markdown 输出**:`doc get` 默认输出纯 Markdown,便于管道与落盘;`--json` 输出含 Lake 正文的完整详情。
21
+ - **先取后合并的更新**:`doc update` 只改你指定的字段,其余保持原值。
22
+ - **脚本友好**:全局 `--json` 输出原始 JSON,`-y` 跳过确认,正文支持 `--body`/`--file`/管道 stdin。
23
+
24
+ ## 安装
25
+
26
+ 需要 Python ≥ 3.10 与 [uv](https://docs.astral.sh/uv/)。
27
+
28
+ ```bash
29
+ uv sync # 安装依赖
30
+ uv run yuque --help # 在项目内运行
31
+ ```
32
+
33
+ 或作为工具安装,得到全局 `yuque` 命令:
34
+
35
+ ```bash
36
+ uv tool install .
37
+ yuque --help
38
+ ```
39
+
40
+ ## 快速开始
41
+
42
+ ```bash
43
+ yuque auth login # 登录(自动 CDP,失败回退手动粘贴)
44
+ yuque auth status # 查看当前身份
45
+ yuque book list # 列出我的知识库
46
+ yuque doc list <login>/<book> # 列出知识库下的文档
47
+ yuque doc get <login>/<book>/<slug> # 取文档正文(Markdown)
48
+ ```
49
+
50
+ `<login>/<book>/<slug>` 既可写成去掉 host 的路径形态,也可直接粘贴完整浏览器 URL(见 [URL 形态](#url-形态))。
51
+
52
+ ## 认证
53
+
54
+ ### 自动(CDP)
55
+
56
+ `yuque auth login` 默认尝试从**你当前的 Chrome**(已登录目标语雀站点)直接抓取 cookie。前提是 Chrome 以远程调试端口启动:
57
+
58
+ ```bash
59
+ # 完全退出 Chrome 后,带远程调试重新启动(默认 profile 即可),再登录你的语雀站点
60
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
61
+ ```
62
+
63
+ 随后 `yuque auth login` 会自动连接、抓取目标站点的全部 cookie(含 `_yuque_session` 与 CSRF `yuque_ctoken`)并保存会话。
64
+
65
+ ### 手动
66
+
67
+ 未检测到远程调试、或自动抓取失败时,会提示粘贴 cookie;也可直接 `yuque auth login --manual` 跳过 CDP。cookie 取自浏览器 DevTools → Application → Cookies,或控制台 `document.cookie`。
68
+
69
+ ### 会话与退出
70
+
71
+ - 会话保存在 `~/.config/yuque-cli/session.json`(权限 `0600`)。
72
+ - `yuque auth logout` 清除本地会话。
73
+
74
+ ### 代理说明
75
+
76
+ 对语雀的 API 请求**默认直连,不读取 `*_proxy` 环境变量**(`trust_env=False`),避免请求被本地代理(如 Clash)拦截,也免去 SOCKS 代理触发 `socksio` 缺失的报错。如果你的网络必须经代理才能访问目标实例,需要移除该参数并引入 `httpx[socks]` 依赖。
77
+
78
+ ## 命令参考
79
+
80
+ ### 全局选项
81
+
82
+ 置于子命令之前,如 `yuque --json book list`。
83
+
84
+ | 选项 | 说明 |
85
+ | --- | --- |
86
+ | `--json` | 输出原始 JSON(便于脚本解析) |
87
+ | `-y`, `--yes` | 跳过所有确认(删除等危险操作) |
88
+ | `-v`, `--verbose` | 打印内部 HTTP 请求到 stderr |
89
+ | `-V`, `--version` | 显示版本 |
90
+
91
+ ### auth — 登录态管理
92
+
93
+ | 命令 | 说明 |
94
+ | --- | --- |
95
+ | `auth login [--manual]` | 登录;默认探测 CDP 自动获取,`--manual` 直接手动粘贴 |
96
+ | `auth status` | 显示当前登录身份(`GET /api/mine`) |
97
+ | `auth logout` | 清除本地会话 |
98
+
99
+ ### book — 知识库
100
+
101
+ | 命令 | 说明 |
102
+ | --- | --- |
103
+ | `book list` | 列出我的知识库(`GET /api/mine/books`) |
104
+
105
+ ### doc — 文档
106
+
107
+ | 命令 | 说明 |
108
+ | --- | --- |
109
+ | `doc list <login>/<book>` | 列出知识库下的文档 |
110
+ | `doc get <login>/<book>/<slug>` | 取正文;默认干净 Markdown,`--json` 出详情(含 Lake) |
111
+ | `doc create <login>/<book> -t <标题> [选项]` | 在知识库中创建文档 |
112
+ | `doc update <login>/<book>/<slug> [选项]` | 更新文档(先取后合并,未给的字段保持原值) |
113
+ | `doc delete <login>/<book>/<slug>` | 删除文档(软删,可在回收站恢复) |
114
+
115
+ `doc create` 选项:`-t/--title`(必填)、`--slug`(省略则自动生成)、`--public/--private`、`-b/--body`、`-F/--file`。
116
+
117
+ `doc update` 选项:`-t/--title`、`--public/--private`、`-b/--body`、`-F/--file`;至少给其一,否则报错。
118
+
119
+ 可见性 `--public/--private`:不给则创建时继承知识库默认、更新时保持原值。
120
+
121
+ ```bash
122
+ # 创建(内联正文)
123
+ yuque doc create your-team/handbook -t "上手指南" --body "# 标题\n正文"
124
+
125
+ # 从文件创建,并设为私有
126
+ yuque doc create your-team/handbook -t "周报" -F report.md --private
127
+
128
+ # 仅改标题(正文与可见性保持不变)
129
+ yuque doc update your-team/handbook/getting-started -t "新标题"
130
+
131
+ # 删除(非交互环境需 -y)
132
+ yuque -y doc delete your-team/handbook/old-draft
133
+ ```
134
+
135
+ ### comment — 评论
136
+
137
+ | 命令 | 说明 |
138
+ | --- | --- |
139
+ | `comment list <login>/<book>/<slug>` | 列出文档评论 |
140
+ | `comment add <login>/<book>/<slug> [-b/--body \| -F/--file]` | 添加评论(正文不能为空) |
141
+ | `comment delete <comment_id>` | 删除一条评论(id 取自 `comment list`) |
142
+
143
+ ## URL 形态
144
+
145
+ 凡接受文档/知识库地址的命令,既可用**完整浏览器 URL**,也可用**去掉 host 的路径形态**,二者等价:
146
+
147
+ ```
148
+ https://<host>/<login>/<book>/<slug> ←→ <login>/<book>/<slug> (文档)
149
+ https://<host>/<login>/<book> ←→ <login>/<book> (知识库)
150
+ ```
151
+
152
+ 数字 id 不属于用户语汇,仅内部解析时使用。
153
+
154
+ ## 正文输入
155
+
156
+ `doc create` / `comment add` 的正文按以下优先级取值(`--body` 与 `--file` 互斥):
157
+
158
+ 1. `-b/--body`(内联字符串)
159
+ 2. `-F/--file`(从文件读;`-F -` 表示读 stdin)
160
+ 3. 管道 stdin(直接 `... | yuque doc create ...`)
161
+
162
+ ```bash
163
+ echo "# 来自管道的正文" | yuque doc create your-team/handbook -t "标题"
164
+ ```
165
+
166
+ `doc update` 略有不同:只有显式给 `-b/--body` 或 `-F/--file`(含 `-F -` 读 stdin)才会改正文;**裸管道不会被读取**——不给正文来源即表示保持原正文不变。
167
+
168
+ ## 环境变量
169
+
170
+ | 变量 | 作用 | 默认 |
171
+ | --- | --- | --- |
172
+ | `YUQUE_HOST` | 目标语雀 host | `www.yuque.com` |
173
+ | `YUQUE_CONFIG_DIR` | 配置/会话目录 | `$XDG_CONFIG_HOME/yuque-cli` 或 `~/.config/yuque-cli` |
174
+ | `XDG_CONFIG_HOME` | 配置根目录 | `~/.config` |
175
+ | `NO_COLOR` | 设置后禁用彩色输出 | — |
176
+
177
+ ## 开发
178
+
179
+ 本项目遵循 TDD,并用 [mise](https://mise.jdx.dev/) 管理工具链(锁定 uv)与常用任务。
180
+
181
+ ```bash
182
+ mise run deps # 安装依赖(uv sync)
183
+ mise run test # 运行测试(pytest)
184
+ mise run lint # ruff check && ruff format
185
+ mise run check # ty 类型检查
186
+ mise run cli -- --help # 运行 CLI(-- 之后为 yuque 的参数)
187
+ ```
188
+
189
+ - 源码:`src/yuque_cli/`;测试:`test/`。
190
+ - 设计文档:术语表 [CONTEXT.md](./CONTEXT.md)、决策记录 [docs/adr](./docs/adr)、内部接口速查 [docs/internal-api.md](./docs/internal-api.md)。
@@ -0,0 +1,16 @@
1
+ yuque_cli/__init__.py,sha256=E7Y2P7b8OYtteFYpw4lSixsqfYPPORhyw3S2sV0O7fA,74
2
+ yuque_cli/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
3
+ yuque_cli/appdata.py,sha256=OCpcFbn1_tpLa8xP9knKx724tWu93BedIijqaGBOJM4,1328
4
+ yuque_cli/auth.py,sha256=PGp_y_M-fmATK447w3NyJ2HL6jOkkcUxsdQLXJ-wKjw,5393
5
+ yuque_cli/cli.py,sha256=Y5QGMY2sEGZ00DhUKUaVSUcw0Svp8R_UNrGhvA_tdkc,13007
6
+ yuque_cli/client.py,sha256=LSvSUAtcThzlZ_YSrHjIe5PUQt9ndj5tAGLKYSO5gk0,6534
7
+ yuque_cli/config.py,sha256=yoeFuC26KjaxGwDr3TrTCQvtqimIZ9NRXvhNmJm0yj0,633
8
+ yuque_cli/errors.py,sha256=P0PJ6ZrdxQUStDVEE9drygFzTGm76CoTlI7rYJptoU0,425
9
+ yuque_cli/inputs.py,sha256=YhyRxZLuVZ17v1ZIYruBSh9-1ayY35brdpfdH0a5Aro,1004
10
+ yuque_cli/output.py,sha256=rsPjt_JGZMONcSFEysSE8tFdqBiAD-q26ti31RLps30,1828
11
+ yuque_cli/session.py,sha256=Aw2Xgbs6Oh3a4zvRb1PMzDONor1-CA3LtOayMk64qgo,1921
12
+ yuque_cli/urls.py,sha256=iiMW2nI_k4ycJxOt_DlV0_Q7XHx94nZfnY1ParjH9UM,1678
13
+ yuque_cli-0.1.0.dist-info/METADATA,sha256=1Kt63WVNkneeJF2TYIx4SXzgC7Fu317Qqm88870wUs0,7340
14
+ yuque_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ yuque_cli-0.1.0.dist-info/entry_points.txt,sha256=wAmlUjwsHdCdBpdv0baZSO1v_vVbpe4940swfNb7zIM,44
16
+ yuque_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yuque = yuque_cli.cli:app