@acedatacloud/skills 2026.728.1 → 2026.728.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.728.1",
3
+ "version": "2026.728.2",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -1,104 +1,125 @@
1
1
  ---
2
2
  name: yuque
3
- description: Read and write Yuque (语雀) documents with the user's own personal access token through the official open API at https://www.yuque.com/api/v2. Use when the user wants to publish Markdown to 语雀, list their knowledge bases (知识库), read or update a 语雀 document, or delete one.
3
+ description: Read and write Yuque (语雀) documents on the user's own account — list knowledge bases (知识库), read a document, publish Markdown, and update or delete one. Works with either the user's browser login (free) or a personal access token. Use when the user wants to publish Markdown to 语雀, list their 语雀 knowledge bases, or read a 语雀 document.
4
4
  when_to_use: |
5
5
  Trigger for 语雀 / Yuque document management: verify the connected account,
6
6
  list knowledge bases or the documents inside one, read a document, create a
7
- Markdown document, update an existing one, or delete one. Writes and
8
- destructive actions require explicit confirmation.
7
+ Markdown document, and (token connections only) update or delete one.
8
+ Writes and destructive actions require explicit confirmation.
9
9
  connections: [yuque]
10
10
  allowed_tools: [Bash]
11
11
  license: Apache-2.0
12
12
  metadata:
13
13
  author: acedatacloud
14
- version: "1.0"
14
+ version: "2.0"
15
15
  ---
16
16
 
17
- Use the bundled standard-library CLI. The connector injects the user's 语雀
18
- personal access token as `$YUQUE_TOKEN`. Never print it. The CLI uses the
19
- official open API at `https://www.yuque.com/api/v2` with the documented
20
- `X-Auth-Token` header.
17
+ # 语雀 — two connection modes
21
18
 
22
- ```bash
23
- python3 "$SKILL_DIR/scripts/yuque.py" whoami
24
- ```
19
+ The connector injects exactly one credential, and the CLI picks its mode from it:
25
20
 
26
- If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
27
- this skill's directory explicitly before running the commands below.
21
+ - **`$YUQUE_COOKIES`** — the user's own browser login jar, captured by the ACE
22
+ extension. **Free.** Drives 语雀's internal web API.
23
+ - **`$YUQUE_TOKEN`** — a 语雀 personal access token for the official open API.
24
+ **Requires a paid 语雀超级会员.**
28
25
 
29
- If authentication fails, ask the user to create a token at
30
- `https://www.yuque.com/settings/tokens` and reconnect at
31
- `https://auth.acedata.cloud/user/connections`. Do not ask for their account
32
- password or Cookie.
26
+ Both are **secret — never echo, print, log or return them.** Every command
27
+ reports the active mode back as `auth_mode`; read that instead of guessing.
33
28
 
34
- ## Read
29
+ | Command | cookie | token |
30
+ |---|---|---|
31
+ | `whoami`, `repos`, `docs`, `doc` | ✅ | ✅ |
32
+ | `create` | ✅ | ✅ |
33
+ | `update`, `delete` | ❌ refused with a clear error | ✅ |
34
+
35
+ If the user asks to edit or delete on a cookie connection, tell them that needs
36
+ a personal token (created at `https://www.yuque.com/settings/tokens`, 超级会员
37
+ required) and offer to create a new document instead. Do not attempt a
38
+ workaround.
35
39
 
36
- A `repo` is a 语雀 knowledge base, addressed either by its `namespace`
37
- (`user/book`, as shown in the document URL) or by its numeric id. Always run
38
- `repos` first — never guess a namespace.
40
+ ## Script resolution
39
41
 
40
- ```bash
41
- # Verify the token and see the account.
42
- python3 "$SKILL_DIR/scripts/yuque.py" whoami
42
+ Bash calls do not share shell variables. Resolve the helper inside **every**
43
+ fenced Bash invocation before using it:
43
44
 
44
- # List knowledge bases, then the documents in one.
45
- python3 "$SKILL_DIR/scripts/yuque.py" repos
46
- python3 "$SKILL_DIR/scripts/yuque.py" docs REPO_NAMESPACE --limit 20
47
- python3 "$SKILL_DIR/scripts/yuque.py" doc REPO_NAMESPACE DOC_ID
45
+ ```sh
46
+ Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
47
+ [ -f "$Y" ] || { echo "yuque script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
48
+ python3 "$Y" whoami
48
49
  ```
49
50
 
50
- ## Create and update
51
+ On an auth error, ask the user to reconnect at
52
+ <https://auth.acedata.cloud/user/connections>. Never ask for their password,
53
+ and never ask them to paste a Cookie into the chat.
51
54
 
52
- Prepare the complete Markdown in a file. 语雀 has no separate draft state — a
53
- document is either private or public — so the CLI creates **private** documents
54
- by default and only publishes publicly with an explicit `--public`.
55
+ ## Read
56
+
57
+ A knowledge base (`repo`) is addressed by its `user/book` namespace or its
58
+ numeric id. **Always run `repos` first — never guess a namespace or an id.**
59
+ Pass back exactly the `repo_id` that `repos` printed; on a cookie connection a
60
+ `user/book` namespace is resolved by matching the account's own knowledge
61
+ bases, so it fails for a base the account does not own, and an ambiguous slug
62
+ is refused rather than guessed.
63
+
64
+ ```sh
65
+ Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
66
+ python3 "$Y" repos
67
+ python3 "$Y" docs REPO_ID --limit 20
68
+ python3 "$Y" doc REPO_ID DOC_ID
69
+ ```
55
70
 
56
- ```bash
57
- # First call is always a dry run and does not load credentials or call the API.
58
- python3 "$SKILL_DIR/scripts/yuque.py" create REPO_NAMESPACE \
59
- --title "标题" --content-file /tmp/article.md
71
+ ## Create
72
+
73
+ Prepare the complete Markdown in a file. 语雀 has no draft state — a document is
74
+ either private or public — so the CLI creates **private** documents by default
75
+ and only publishes publicly with an explicit `--public`.
76
+
77
+ ```sh
78
+ Y="${SKILL_DIR:-}/scripts/yuque.py"; [ -f "$Y" ] || Y=$(find /tmp -maxdepth 8 -path '*/skills/*/yuque/scripts/yuque.py' -print -quit 2>/dev/null)
79
+
80
+ # The first call is always a dry run: it loads no credentials and calls no API.
81
+ python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md
60
82
 
61
83
  # Create it privately after the user confirms.
62
- python3 "$SKILL_DIR/scripts/yuque.py" create REPO_NAMESPACE \
63
- --title "标题" --content-file /tmp/article.md --confirm
84
+ python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md --confirm
64
85
 
65
86
  # Public publishing additionally requires --public.
66
- python3 "$SKILL_DIR/scripts/yuque.py" create REPO_NAMESPACE \
67
- --title "标题" --content-file /tmp/article.md --public --confirm
68
-
69
- python3 "$SKILL_DIR/scripts/yuque.py" update REPO_NAMESPACE DOC_ID \
70
- --title "新标题" --content-file /tmp/article.md --public --confirm
87
+ python3 "$Y" create REPO_ID --title "标题" --content-file /tmp/article.md --public --confirm
71
88
  ```
72
89
 
73
- `--confirm` is valid only as the final argument. Always show the target
74
- knowledge base, title, visibility and full content to the user before a public
75
- publish. Default to a private document unless the user explicitly asks to
76
- publish publicly.
77
-
78
- Note that `update` rewrites the whole document body. Read the current document
79
- first if the user only wants part of it changed.
90
+ `--confirm` is honored **only as the final argument**. Before a public publish,
91
+ always show the user the target knowledge base, the title, the visibility and
92
+ the full content. Default to private unless they explicitly ask to publish
93
+ publicly.
80
94
 
81
- ## Delete
95
+ ## Update and delete (token connections only)
82
96
 
83
- ```bash
84
- python3 "$SKILL_DIR/scripts/yuque.py" delete REPO_NAMESPACE DOC_ID --confirm
97
+ ```sh
98
+ python3 "$Y" update REPO_NAMESPACE DOC_ID --title "新标题" --content-file /tmp/a.md --confirm
99
+ python3 "$Y" delete REPO_NAMESPACE DOC_ID --confirm
85
100
  ```
86
101
 
87
- Use the real returned `doc_id` and URL. Do not retry a timed-out write
88
- automatically because its outcome may be unknown; list the documents first so
89
- you do not create a duplicate.
102
+ `update` rewrites the whole document body — read the current document first if
103
+ the user only wants part of it changed.
90
104
 
91
105
  ## Gotchas
92
106
 
107
+ - Use the real returned `doc_id` and `url`; never invent either. A cookie
108
+ connection cannot always resolve a public URL, in which case `url` is `null`
109
+ — report the `doc_id` instead of guessing a link.
110
+ - If a write times out its outcome is **unknown** — run `docs` to check before
111
+ retrying, or you will create a duplicate.
93
112
  - Images referenced by external URL are not re-hosted; 语雀 renders them from
94
- the original host. If the source blocks hotlinking, upload the images to 语雀
113
+ the original host. If that host blocks hotlinking, upload the images in 语雀
95
114
  manually first and reference the returned URLs.
96
- - 语雀's own terms state the open API is for normal reading and writing of 语雀
97
- content; abnormal automated behaviour can get the account blocked. Keep the
98
- volume human-scale.
115
+ - 语雀's terms allow the API for normal reading and writing of 语雀 content;
116
+ abnormal automated behaviour can get the account blocked. Keep the volume
117
+ human-scale and never batch-publish.
99
118
 
100
119
  ## Record the output
101
120
 
102
- After a confirmed public publish returns a real URL, call `publish_artifact`
103
- once with `kind="article"`, `channel="yuque"`, the title, returned URL, and
104
- `status="delivered"`. Do not record private documents or failed/unknown writes.
121
+ After a confirmed **public** publish, if the response carries a non-null `url`,
122
+ call `publish_artifact` once with `kind="article"`, `channel="yuque"`, the
123
+ title, that URL, and `status="delivered"`. If `url` is `null`, **do not invent
124
+ one** — report the `doc_id` to the user and skip `publish_artifact`. Do not
125
+ record private documents or failed/unknown writes.
@@ -1,9 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Read and write Yuque (语雀) documents through its official open API."""
2
+ """Read and write Yuque (语雀) documents.
3
+
4
+ Two authentication modes, picked automatically from the environment:
5
+
6
+ * ``YUQUE_TOKEN`` — the official open API (``/api/v2`` + ``X-Auth-Token``).
7
+ Requires a paid 语雀超级会员.
8
+ * ``YUQUE_COOKIES`` — the user's own browser login jar (BYOC) driving the
9
+ internal web API (``/api/*``). Free, no membership.
10
+
11
+ Standard-library only (urllib), no third-party deps, so it runs in the bare
12
+ sandbox without an image change.
13
+
14
+ Writes are GATED: without a trailing ``--confirm`` they only dry-run.
15
+ ``--confirm`` is honored ONLY as the last argument, so a title or body that
16
+ merely contains "--confirm" can never silently write.
17
+ """
3
18
 
4
19
  from __future__ import annotations
5
20
 
6
21
  import argparse
22
+ import gzip
7
23
  import json
8
24
  import os
9
25
  import pathlib
@@ -13,11 +29,21 @@ import urllib.error
13
29
  import urllib.parse
14
30
  import urllib.request
15
31
 
16
- API_BASE = "https://www.yuque.com/api/v2"
32
+ ORIGIN = "https://www.yuque.com"
33
+ API_BASE = f"{ORIGIN}/api/v2"
34
+ WEB_API = f"{ORIGIN}/api"
35
+ UA = (
36
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
37
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
38
+ )
17
39
  GATED_COMMANDS = {"create", "update", "delete"}
40
+ # Commands the internal web API cannot serve safely; see SKILL.md.
41
+ TOKEN_ONLY_COMMANDS = {"update", "delete"}
18
42
  MAX_CONTENT_BYTES = 10 * 1024 * 1024
19
43
  MAX_ITEMS = 100
20
44
 
45
+ RECONNECT = "https://auth.acedata.cloud/user/connections"
46
+
21
47
 
22
48
  def output(value) -> None:
23
49
  print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
@@ -34,110 +60,280 @@ def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
34
60
 
35
61
 
36
62
  class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
63
+ """Refuse redirects outright.
64
+
65
+ ``add_unredirected_header`` protects only the Cookie header — urllib copies
66
+ every *other* header onto a redirected request, so ``x-csrf-token`` would
67
+ follow a 30x to an arbitrary host. Refusing redirects closes that leak.
68
+ """
69
+
37
70
  def redirect_request(self, req, fp, code, msg, headers, newurl):
38
71
  return None
39
72
 
40
73
 
74
+ # ── Cookie jar (shared pattern across the cookie-BYOC skills) ──────────────
75
+
76
+
77
+ def load_cookies(raw: str) -> list:
78
+ try:
79
+ jar = json.loads(raw)
80
+ except json.JSONDecodeError as error:
81
+ die(f"YUQUE_COOKIES is not valid JSON: {error}")
82
+ if not isinstance(jar, list):
83
+ die(f"YUQUE_COOKIES must be a JSON list of cookies, got {type(jar).__name__}")
84
+ return jar
85
+
86
+
87
+ def _domain_matches(host: str, domain: str) -> bool:
88
+ d = domain.lstrip(".").lower()
89
+ h = host.lower()
90
+ return not d or h == d or h.endswith("." + d)
91
+
92
+
93
+ def cookie_header(jar: list, url: str) -> str:
94
+ host = urllib.parse.urlsplit(url).hostname or ""
95
+ host_in_scope = any(
96
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
97
+ )
98
+ parts = []
99
+ for c in jar:
100
+ name, value = c.get("name"), c.get("value")
101
+ if not name or value is None:
102
+ continue
103
+ domain = c.get("domain")
104
+ if domain:
105
+ if not _domain_matches(host, str(domain)):
106
+ continue
107
+ elif not host_in_scope:
108
+ continue
109
+ parts.append(f"{name}={value}")
110
+ return "; ".join(parts)
111
+
112
+
113
+ def csrf_token(jar: list) -> str:
114
+ """Yuque's internal API takes its CSRF token from the yuque_ctoken cookie."""
115
+ for c in jar:
116
+ if c.get("name") == "yuque_ctoken" and c.get("value"):
117
+ return str(c["value"])
118
+ die(
119
+ "The 语雀 cookie jar has no yuque_ctoken cookie, so no write can be "
120
+ f"signed. Log in at {ORIGIN} and reconnect at {RECONNECT}."
121
+ )
122
+ return "" # unreachable; die() raises
123
+
124
+
41
125
  class YuqueClient:
42
- def __init__(self, token: str, opener=None) -> None:
126
+ """One client, two modes. ``mode`` drives the base URL and the auth headers."""
127
+
128
+ def __init__(self, mode: str, *, token: str = "", cookies: list | None = None, opener=None) -> None:
129
+ self.mode = mode
43
130
  self._token = token
131
+ self._cookies = cookies or []
132
+ self._csrf = csrf_token(self._cookies) if mode == "cookie" else ""
133
+ self._books_cache: list[dict] | None = None
134
+ self._book_meta: dict[int, dict] = {}
44
135
  self._opener = opener or urllib.request.build_opener(NoRedirectHandler())
45
136
 
46
137
  @classmethod
47
138
  def from_environment(cls):
48
139
  token = os.environ.get("YUQUE_TOKEN", "").strip()
49
- if not token:
50
- die(
51
- "YUQUE_TOKEN is not set. Reconnect 语雀 at "
52
- "https://auth.acedata.cloud/user/connections."
53
- )
54
- return cls(token)
55
-
56
- def request(self, method: str, path: str, *, body=None, write: bool = False, expect_json: bool = True):
57
- encoded = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
58
- request = urllib.request.Request(
59
- f"{API_BASE}{path}",
60
- data=encoded,
61
- method=method,
62
- headers={
63
- "Accept": "application/json",
64
- "X-Auth-Token": self._token,
65
- "Content-Type": "application/json",
66
- "User-Agent": "AceDataCloud-Yuque-Skill/1.0",
67
- },
140
+ if token:
141
+ if "\r" in token or "\n" in token:
142
+ die("YUQUE_TOKEN contains invalid characters.")
143
+ return cls("token", token=token)
144
+ raw = os.environ.get("YUQUE_COOKIES", "").strip()
145
+ if raw:
146
+ return cls("cookie", cookies=load_cookies(raw))
147
+ die(
148
+ "No 语雀 credential is available. Connect 语雀 with the browser "
149
+ f"extension (free) or a personal token at {RECONNECT}."
68
150
  )
151
+
152
+ # -- transport ---------------------------------------------------------
153
+
154
+ def _open(self, request: urllib.request.Request, *, what: str, write: bool):
69
155
  try:
70
156
  with self._opener.open(request, timeout=30) as response:
71
157
  raw = response.read()
158
+ if response.headers.get("Content-Encoding") == "gzip":
159
+ raw = gzip.decompress(raw)
160
+ return raw
72
161
  except urllib.error.HTTPError as error:
73
162
  if error.code in {301, 302, 303, 307, 308}:
74
- die(f"Yuque API redirected {method} {path}; credentials were not forwarded.")
75
- if error.code in {401, 403}:
76
163
  die(
77
- f"Yuque API HTTP {error.code} for {method} {path}. The token is invalid or "
78
- "lacks scope. Reconnect with a token from https://www.yuque.com/settings/tokens."
164
+ f"语雀 redirected {what} — not followed, so no credential left "
165
+ f"yuque.com. You are most likely logged out; reconnect at {RECONNECT}."
166
+ + (
167
+ " This was a WRITE: its outcome is UNKNOWN — list the "
168
+ "documents before retrying so you do not create a duplicate."
169
+ if write
170
+ else ""
171
+ )
172
+ )
173
+ if error.code in {401, 403}:
174
+ hint = (
175
+ "The token is invalid or lacks scope. Note the 语雀 open API "
176
+ "requires a paid 语雀超级会员."
177
+ if self.mode == "token"
178
+ else "The login session expired or the request was rejected by 语雀's "
179
+ "CSRF check."
79
180
  )
181
+ die(f"语雀 HTTP {error.code} for {what}. {hint} Reconnect at {RECONNECT}.")
80
182
  if error.code == 404:
81
- die(f"Yuque API 404 for {method} {path}; the repo or document does not exist.")
82
- die(f"Yuque API HTTP {error.code} for {method} {path}.")
83
- except (urllib.error.URLError, OSError, socket.timeout):
183
+ die(f"语雀 404 for {what}; the knowledge base or document does not exist.")
184
+ die(f"语雀 HTTP {error.code} for {what}.")
185
+ except (urllib.error.URLError, OSError, socket.timeout) as error:
84
186
  if write:
85
187
  die(
86
- f"Yuque write {method} {path} did not return a result; outcome is unknown. "
87
- "List the documents before retrying so you do not create a duplicate."
188
+ f"语雀 write {what} did not return a result ({error}); the outcome "
189
+ "is UNKNOWN. List the documents before retrying so you do not "
190
+ "create a duplicate."
88
191
  )
89
- die(f"Network error while calling Yuque {method} {path}.")
192
+ die(f"Network error while calling 语雀 {what}.")
193
+
194
+ def request(self, method: str, path: str, *, body=None, write: bool = False, expect_json: bool = True):
195
+ """Call the API of whichever mode is active and return the ``data`` payload."""
196
+ what = f"{method} {path}"
197
+ if self.mode == "token":
198
+ url = f"{API_BASE}{path}"
199
+ headers = {
200
+ "Accept": "application/json",
201
+ "X-Auth-Token": self._token,
202
+ "Content-Type": "application/json",
203
+ "User-Agent": "AceDataCloud-Yuque-Skill/2.0",
204
+ }
205
+ else:
206
+ url = f"{WEB_API}{path}"
207
+ # Yuque's CSRF layer rejects writes without Origin/Referer — the
208
+ # upstream WechatSync adapter injects both for every /api/ call.
209
+ headers = {
210
+ "Accept": "application/json",
211
+ "Accept-Language": "zh-CN,zh;q=0.9",
212
+ "Content-Type": "application/json",
213
+ "User-Agent": UA,
214
+ "Origin": ORIGIN,
215
+ "Referer": f"{ORIGIN}/dashboard",
216
+ "x-csrf-token": self._csrf,
217
+ "x-requested-with": "XMLHttpRequest",
218
+ }
219
+
220
+ encoded = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
221
+ request = urllib.request.Request(url, data=encoded, method=method, headers=headers)
222
+ if self.mode == "cookie":
223
+ request.add_unredirected_header("Cookie", cookie_header(self._cookies, url))
224
+
225
+ raw = self._open(request, what=what, write=write)
90
226
  if not expect_json or not raw:
91
227
  return None
92
228
  try:
93
229
  payload = json.loads(raw)
94
230
  except json.JSONDecodeError:
95
- die(f"Yuque returned invalid JSON for {method} {path}.")
231
+ die(f"语雀 returned invalid JSON for {what}.")
96
232
  if not isinstance(payload, dict) or "data" not in payload:
97
- die(f"Yuque returned an unexpected envelope for {method} {path}.")
233
+ die(f"语雀 returned an unexpected envelope for {what}.")
98
234
  return payload["data"]
99
235
 
236
+ # -- reads -------------------------------------------------------------
237
+
100
238
  def user(self) -> dict:
101
- value = self.request("GET", "/user")
239
+ path = "/user" if self.mode == "token" else "/mine"
240
+ value = self.request("GET", path)
102
241
  if not isinstance(value, dict):
103
- die("Yuque returned malformed user data.")
242
+ die("语雀 returned malformed user data.")
104
243
  return value
105
244
 
106
- def repos(self, login: str) -> list[dict]:
107
- quoted = urllib.parse.quote(str(login), safe="")
108
- value = self.request("GET", f"/users/{quoted}/repos")
109
- if not isinstance(value, list):
110
- die("Yuque returned malformed repo data.")
111
- return value
245
+ def repos(self, login: str | None = None) -> list[dict]:
246
+ if self.mode == "token":
247
+ quoted = urllib.parse.quote(str(login), safe="")
248
+ value = self.request("GET", f"/users/{quoted}/repos")
249
+ if not isinstance(value, list):
250
+ die("语雀 returned malformed repo data.")
251
+ return value
252
+ # Internal API: books are nested under the "common used" payload and
253
+ # carry the book id as target_id.
254
+ value = self.request("GET", "/mine/common_used")
255
+ books = (value or {}).get("books") if isinstance(value, dict) else None
256
+ if not isinstance(books, list):
257
+ die("语雀 returned malformed knowledge-base data.")
258
+ return books
112
259
 
113
260
  def docs(self, repo: str) -> list[dict]:
114
- quoted = urllib.parse.quote(str(repo), safe="")
115
- value = self.request("GET", f"/repos/{quoted}/docs")
261
+ if self.mode == "token":
262
+ quoted = urllib.parse.quote(str(repo), safe="")
263
+ value = self.request("GET", f"/repos/{quoted}/docs")
264
+ else:
265
+ value = self.request("GET", f"/books/{self._book_id(repo)}/docs")
116
266
  if not isinstance(value, list):
117
- die("Yuque returned malformed document data.")
267
+ die("语雀 returned malformed document data.")
118
268
  return value
119
269
 
120
- def doc(self, repo: str, doc_id: str) -> dict:
121
- repo_q = urllib.parse.quote(str(repo), safe="")
122
- doc_q = urllib.parse.quote(str(doc_id), safe="")
123
- value = self.request("GET", f"/repos/{repo_q}/docs/{doc_q}")
270
+ def doc(self, repo: str, doc_id: str) -> tuple[dict, str | None]:
271
+ book_id = None
272
+ if self.mode == "token":
273
+ repo_q = urllib.parse.quote(str(repo), safe="")
274
+ doc_q = urllib.parse.quote(str(doc_id), safe="")
275
+ value = self.request("GET", f"/repos/{repo_q}/docs/{doc_q}")
276
+ else:
277
+ book_id = self._book_id(repo)
278
+ query = urllib.parse.urlencode({"book_id": book_id, "mode": "markdown"})
279
+ doc_q = urllib.parse.quote(str(doc_id), safe="")
280
+ value = self.request("GET", f"/docs/{doc_q}?{query}")
124
281
  if not isinstance(value, dict):
125
- die("Yuque returned malformed document data.")
126
- return value
282
+ die("语雀 returned malformed document data.")
283
+ return value, self.doc_url(book_id, value)
284
+
285
+ # -- writes ------------------------------------------------------------
127
286
 
128
- def create_doc(self, repo: str, body: dict) -> dict:
129
- quoted = urllib.parse.quote(str(repo), safe="")
130
- value = self.request("POST", f"/repos/{quoted}/docs", body=body, write=True)
287
+ def create_doc(self, repo: str, args, content: str) -> tuple[dict, str | None]:
288
+ book_id = None
289
+ if self.mode == "token":
290
+ quoted = urllib.parse.quote(str(repo), safe="")
291
+ body = {
292
+ "title": args.title,
293
+ "body": content,
294
+ "format": "markdown",
295
+ "public": 1 if args.public else 0,
296
+ }
297
+ if args.slug:
298
+ body["slug"] = args.slug
299
+ value = self.request("POST", f"/repos/{quoted}/docs", body=body, write=True)
300
+ else:
301
+ # The internal API accepts markdown directly, so we skip the extra
302
+ # /api/docs/convert round trip, and insert_to_catalog puts the new
303
+ # doc in the book's 目录 (the plain create leaves it unlisted).
304
+ book_id = self._book_id(repo)
305
+ body = {
306
+ "book_id": book_id,
307
+ "title": args.title,
308
+ "format": "markdown",
309
+ "body": content,
310
+ "body_draft": content,
311
+ "public": 1 if args.public else 0,
312
+ "insert_to_catalog": True,
313
+ "action": "appendByDocs",
314
+ "target_uuid": "",
315
+ }
316
+ if args.slug:
317
+ body["slug"] = args.slug
318
+ value = self.request("POST", "/docs", body=body, write=True)
131
319
  if not isinstance(value, dict) or not value.get("id"):
132
- die("Yuque did not return a valid created document.")
133
- return value
320
+ die("语雀 did not return a valid created document.")
321
+ return value, self.doc_url(book_id, value)
134
322
 
135
- def update_doc(self, repo: str, doc_id: str, body: dict) -> dict:
323
+ def update_doc(self, repo: str, doc_id: str, args, content: str) -> dict:
136
324
  repo_q = urllib.parse.quote(str(repo), safe="")
137
325
  doc_q = urllib.parse.quote(str(doc_id), safe="")
326
+ body = {
327
+ "title": args.title,
328
+ "body": content,
329
+ "format": "markdown",
330
+ "public": 1 if args.public else 0,
331
+ }
332
+ if args.slug:
333
+ body["slug"] = args.slug
138
334
  value = self.request("PUT", f"/repos/{repo_q}/docs/{doc_q}", body=body, write=True)
139
335
  if not isinstance(value, dict) or not value.get("id"):
140
- die("Yuque did not return a valid updated document.")
336
+ die("语雀 did not return a valid updated document.")
141
337
  return value
142
338
 
143
339
  def delete_doc(self, repo: str, doc_id: str) -> None:
@@ -145,6 +341,73 @@ class YuqueClient:
145
341
  doc_q = urllib.parse.quote(str(doc_id), safe="")
146
342
  self.request("DELETE", f"/repos/{repo_q}/docs/{doc_q}", write=True, expect_json=False)
147
343
 
344
+ # -- helpers -----------------------------------------------------------
345
+
346
+ def _book_id(self, repo: str) -> int:
347
+ """Resolve a repo reference to a numeric book id for the internal API.
348
+
349
+ The internal endpoints are addressed by numeric book id only, so a
350
+ ``user/book`` namespace has to be looked up against the account's own
351
+ knowledge bases first.
352
+ """
353
+ text = str(repo).strip()
354
+ # isdigit() is Unicode-aware but int() is not, and non-ASCII digits
355
+ # would otherwise either crash or resolve to a different number.
356
+ if text.isascii() and text.isdigit():
357
+ return int(text)
358
+ wanted = text.rsplit("/", 1)[-1].lower()
359
+ if not wanted:
360
+ die("Empty knowledge base reference; run `repos` and pass a repo_id.")
361
+ matches = []
362
+ for book in self._books():
363
+ slug = str(book.get("slug") or "").lower()
364
+ name = str(book.get("name") or "").lower()
365
+ if wanted in {slug, name}:
366
+ raw = book.get("target_id") or book.get("id")
367
+ try:
368
+ matches.append((int(raw), book))
369
+ except (TypeError, ValueError):
370
+ continue
371
+ if len(matches) > 1:
372
+ die(
373
+ f"{repo!r} matches {len(matches)} knowledge bases "
374
+ f"({', '.join(str(m[0]) for m in matches)}). Pass the numeric "
375
+ "repo_id from `repos` instead — writing to the wrong base is silent."
376
+ )
377
+ if matches:
378
+ self._book_meta[matches[0][0]] = matches[0][1]
379
+ return matches[0][0]
380
+ die(
381
+ f"Could not resolve knowledge base {repo!r} to a book id. Run "
382
+ "`repos` and pass the numeric repo_id shown there."
383
+ )
384
+ return 0 # unreachable; die() raises
385
+
386
+ def _books(self) -> list[dict]:
387
+ """Knowledge bases, fetched once — a doc loop must not re-query per item."""
388
+ if self._books_cache is None:
389
+ self._books_cache = self.repos()
390
+ return self._books_cache
391
+
392
+ def doc_url(self, book_id, doc: dict) -> str | None:
393
+ """Public URL for a doc, using the book namespace when we know it."""
394
+ slug = doc.get("slug")
395
+ book = (doc.get("book") or {}) if isinstance(doc.get("book"), dict) else {}
396
+ namespace = book.get("namespace")
397
+ if not namespace and self.mode == "cookie":
398
+ try:
399
+ meta = self._book_meta.get(int(book_id)) or {}
400
+ except (TypeError, ValueError):
401
+ meta = {}
402
+ user = (meta.get("user") or {}) if isinstance(meta.get("user"), dict) else {}
403
+ login = user.get("login")
404
+ book_slug = meta.get("slug")
405
+ if login and book_slug:
406
+ namespace = f"{login}/{book_slug}"
407
+ if namespace and slug:
408
+ return f"{ORIGIN}/{namespace}/{slug}"
409
+ return None
410
+
148
411
 
149
412
  def read_content(args) -> str:
150
413
  if args.content_file:
@@ -163,9 +426,17 @@ def read_content(args) -> str:
163
426
 
164
427
 
165
428
  def format_repo(repo: dict) -> dict:
429
+ # The internal API nests books under a "common used" record whose own `id`
430
+ # is NOT the book id — the book id is target_id. Prefer it, or `repos`
431
+ # would print an id that no other endpoint accepts.
432
+ repo_id = repo.get("target_id") or repo.get("id")
433
+ user = repo.get("user") if isinstance(repo.get("user"), dict) else {}
434
+ namespace = repo.get("namespace")
435
+ if not namespace and user.get("login") and repo.get("slug"):
436
+ namespace = f"{user['login']}/{repo['slug']}"
166
437
  return {
167
- "repo_id": repo.get("id"),
168
- "namespace": repo.get("namespace"),
438
+ "repo_id": repo_id,
439
+ "namespace": namespace,
169
440
  "name": repo.get("name"),
170
441
  "slug": repo.get("slug"),
171
442
  "type": repo.get("type"),
@@ -174,43 +445,31 @@ def format_repo(repo: dict) -> dict:
174
445
  }
175
446
 
176
447
 
177
- def format_doc(doc: dict, repo: str | None = None) -> dict:
448
+ def format_doc(doc: dict, repo: str | None = None, url: str | None = None) -> dict:
178
449
  namespace = repo or (doc.get("book") or {}).get("namespace")
179
450
  slug = doc.get("slug")
451
+ if url is None and namespace and slug and not str(namespace).isdigit():
452
+ url = f"{ORIGIN}/{namespace}/{slug}"
180
453
  return {
181
454
  "doc_id": doc.get("id"),
182
455
  "title": doc.get("title"),
183
456
  "slug": slug,
184
457
  "public": doc.get("public"),
185
458
  "status": doc.get("status"),
186
- "url": f"https://www.yuque.com/{namespace}/{slug}" if namespace and slug else None,
459
+ "url": url,
187
460
  "word_count": doc.get("word_count"),
188
461
  "created_at": doc.get("created_at"),
189
462
  "updated_at": doc.get("updated_at"),
190
463
  }
191
464
 
192
465
 
193
- def build_body(args, content: str) -> dict:
194
- # public: 0 = private, 1 = public. Yuque has no separate "draft" state, so a
195
- # private doc is the closest equivalent and is the default.
196
- body = {
197
- "title": args.title,
198
- "body": content,
199
- "format": "markdown",
200
- "public": 1 if args.public else 0,
201
- }
202
- if args.slug:
203
- body["slug"] = args.slug
204
- return body
205
-
206
-
207
466
  def build_parser() -> argparse.ArgumentParser:
208
- parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque open API CLI")
467
+ parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque CLI (token or cookie)")
209
468
  sub = parser.add_subparsers(dest="command", required=True)
210
- sub.add_parser("whoami", help="show the token's account")
469
+ sub.add_parser("whoami", help="show the connected account and the active auth mode")
211
470
 
212
471
  repos = sub.add_parser("repos", help="list the account's knowledge bases")
213
- repos.add_argument("--login", help="user login; defaults to the token's own account")
472
+ repos.add_argument("--login", help="user login; token mode only, defaults to the own account")
214
473
 
215
474
  docs = sub.add_parser("docs", help="list documents in a knowledge base")
216
475
  docs.add_argument("repo", help="repo namespace (user/book) or numeric repo id")
@@ -255,7 +514,7 @@ def dry_run(args, content: str | None = None) -> None:
255
514
  )
256
515
  else:
257
516
  value["doc_id"] = args.doc_id
258
- value["note"] = "Re-run with --confirm as the final argument to write to Yuque."
517
+ value["note"] = "Re-run with --confirm as the final argument to write to 语雀."
259
518
  output(value)
260
519
 
261
520
 
@@ -269,42 +528,56 @@ def main(argv: list[str] | None = None) -> None:
269
528
  return
270
529
 
271
530
  client = YuqueClient.from_environment()
531
+ if client.mode == "cookie" and args.command in TOKEN_ONLY_COMMANDS:
532
+ die(
533
+ f"`{args.command}` is only available when 语雀 is connected with a "
534
+ "personal token (the open API). This connection uses login cookies. "
535
+ "Create a new document instead, or edit the existing one in 语雀."
536
+ )
537
+
538
+ mode = {"auth_mode": client.mode}
272
539
  if args.command == "whoami":
273
540
  user = client.user()
541
+ login = user.get("login")
274
542
  output(
275
543
  {
544
+ **mode,
276
545
  "user_id": user.get("id"),
277
- "login": user.get("login"),
546
+ "login": login,
278
547
  "name": user.get("name"),
279
- "url": f"https://www.yuque.com/{user.get('login')}" if user.get("login") else None,
548
+ "url": f"{ORIGIN}/{login}" if login else None,
280
549
  "books_count": user.get("books_count"),
281
550
  "public_books_count": user.get("public_books_count"),
282
551
  }
283
552
  )
284
553
  elif args.command == "repos":
285
- login = args.login or client.user().get("login")
286
- if not login:
287
- die("Could not resolve the account login; pass --login explicitly.")
288
- output({"repos": [format_repo(item) for item in client.repos(login)]})
554
+ if client.mode == "token":
555
+ login = args.login or client.user().get("login")
556
+ if not login:
557
+ die("Could not resolve the account login; pass --login explicitly.")
558
+ items = client.repos(login)
559
+ else:
560
+ items = client.repos()
561
+ output({**mode, "repos": [format_repo(item) for item in items]})
289
562
  elif args.command == "docs":
290
563
  if not 1 <= args.limit <= MAX_ITEMS:
291
564
  die(f"--limit must be between 1 and {MAX_ITEMS}.")
292
565
  items = client.docs(args.repo)[: args.limit]
293
- output({"count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
566
+ output({**mode, "count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
294
567
  elif args.command == "doc":
295
- doc = client.doc(args.repo, args.doc_id)
296
- result = format_doc(doc, args.repo)
568
+ doc, url = client.doc(args.repo, args.doc_id)
569
+ result = format_doc(doc, args.repo, url)
297
570
  result["body"] = doc.get("body")
298
- output(result)
571
+ output({**mode, **result})
299
572
  elif args.command == "create":
300
- result = client.create_doc(args.repo, build_body(args, content or ""))
301
- output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
573
+ result, url = client.create_doc(args.repo, args, content or "")
574
+ output({**mode, "ok": True, **format_doc(result, args.repo, url), "public": bool(args.public)})
302
575
  elif args.command == "update":
303
- result = client.update_doc(args.repo, args.doc_id, build_body(args, content or ""))
304
- output({"ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
576
+ result = client.update_doc(args.repo, args.doc_id, args, content or "")
577
+ output({**mode, "ok": True, **format_doc(result, args.repo), "public": bool(args.public)})
305
578
  elif args.command == "delete":
306
579
  client.delete_doc(args.repo, args.doc_id)
307
- output({"ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
580
+ output({**mode, "ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
308
581
 
309
582
 
310
583
  if __name__ == "__main__":
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import importlib.util
5
+ import io
6
+ import json
7
+ import pathlib
8
+ import sys
9
+ import unittest
10
+ import unittest.mock
11
+ import urllib.error
12
+
13
+ SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "yuque.py"
14
+ SPEC = importlib.util.spec_from_file_location("yuque_skill_script", SCRIPT)
15
+ yuque = importlib.util.module_from_spec(SPEC)
16
+ assert SPEC.loader is not None
17
+ sys.modules[SPEC.name] = yuque
18
+ SPEC.loader.exec_module(yuque)
19
+
20
+
21
+ COOKIES = [
22
+ {"name": "yuque_ctoken", "value": "CTOKEN", "domain": ".yuque.com"},
23
+ {"name": "_yuque_session", "value": "SESSION", "domain": ".yuque.com"},
24
+ ]
25
+
26
+
27
+ def run(argv, env):
28
+ """Run main() with a patched environ and captured stdout."""
29
+ buffer = io.StringIO()
30
+ code = 0
31
+ with unittest.mock.patch.dict(yuque.os.environ, env, clear=True):
32
+ with contextlib.redirect_stdout(buffer):
33
+ try:
34
+ yuque.main(argv)
35
+ except SystemExit as exc: # die() path
36
+ code = exc.code
37
+ return code, json.loads(buffer.getvalue())
38
+
39
+
40
+ class ModeSelection(unittest.TestCase):
41
+ def test_token_wins_when_both_credentials_are_present(self):
42
+ with unittest.mock.patch.dict(
43
+ yuque.os.environ,
44
+ {"YUQUE_TOKEN": "t", "YUQUE_COOKIES": json.dumps(COOKIES)},
45
+ clear=True,
46
+ ):
47
+ self.assertEqual(yuque.YuqueClient.from_environment().mode, "token")
48
+
49
+ def test_cookie_is_the_fallback(self):
50
+ with unittest.mock.patch.dict(
51
+ yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
52
+ ):
53
+ self.assertEqual(yuque.YuqueClient.from_environment().mode, "cookie")
54
+
55
+ def test_no_credential_dies_with_a_reconnect_hint(self):
56
+ code, payload = run(["whoami"], {})
57
+ self.assertEqual(code, 1)
58
+ self.assertIn("connections", payload["error"])
59
+
60
+ def test_cookie_jar_without_ctoken_is_rejected(self):
61
+ jar = [c for c in COOKIES if c["name"] != "yuque_ctoken"]
62
+ code, payload = run(["whoami"], {"YUQUE_COOKIES": json.dumps(jar)})
63
+ self.assertEqual(code, 1)
64
+ self.assertIn("yuque_ctoken", payload["error"])
65
+
66
+
67
+ class CookieScoping(unittest.TestCase):
68
+ def test_jar_is_not_sent_off_domain(self):
69
+ self.assertEqual(yuque.cookie_header(COOKIES, "https://evil.example/x"), "")
70
+
71
+ def test_jar_is_sent_to_yuque(self):
72
+ header = yuque.cookie_header(COOKIES, "https://www.yuque.com/api/mine")
73
+ self.assertIn("_yuque_session=SESSION", header)
74
+
75
+ def test_domainless_cookie_is_not_leaked_to_an_unrelated_host(self):
76
+ jar = COOKIES + [{"name": "loose", "value": "L"}]
77
+ self.assertEqual(yuque.cookie_header(jar, "https://evil.example/x"), "")
78
+
79
+ def test_a_lookalike_suffix_host_is_not_matched(self):
80
+ self.assertEqual(yuque.cookie_header(COOKIES, "https://notyuque.com/api"), "")
81
+
82
+
83
+ class WriteGating(unittest.TestCase):
84
+ def test_create_without_confirm_is_a_dry_run(self):
85
+ code, payload = run(
86
+ ["create", "1", "--title", "T", "--content", "body"],
87
+ {"YUQUE_COOKIES": json.dumps(COOKIES)},
88
+ )
89
+ self.assertEqual(code, 0)
90
+ self.assertTrue(payload["dry_run"])
91
+
92
+ def test_confirm_is_only_honored_as_the_last_argument(self):
93
+ # A real --confirm argv element that is NOT last must not confirm.
94
+ argv = ["create", "1", "--confirm", "--title", "T", "--content", "b"]
95
+ self.assertEqual(yuque.split_confirmation(argv), (argv, False))
96
+
97
+ def test_confirm_as_the_last_argument_does_confirm(self):
98
+ clean, confirmed = yuque.split_confirmation(["create", "1", "--confirm"])
99
+ self.assertTrue(confirmed)
100
+ self.assertEqual(clean, ["create", "1"])
101
+
102
+ def test_a_body_containing_the_flag_is_not_a_confirmation(self):
103
+ code, payload = run(
104
+ ["create", "1", "--title", "T", "--content", "please --confirm this"],
105
+ {"YUQUE_COOKIES": json.dumps(COOKIES)},
106
+ )
107
+ self.assertEqual(code, 0)
108
+ self.assertTrue(payload["dry_run"])
109
+
110
+ def test_create_defaults_to_private(self):
111
+ code, payload = run(
112
+ ["create", "1", "--title", "T", "--content", "b"],
113
+ {"YUQUE_COOKIES": json.dumps(COOKIES)},
114
+ )
115
+ self.assertEqual(payload["visibility"], "private")
116
+
117
+
118
+ class TokenOnlyCommands(unittest.TestCase):
119
+ def test_update_is_refused_on_a_cookie_connection(self):
120
+ code, payload = run(
121
+ ["update", "1", "9", "--title", "T", "--content", "b", "--confirm"],
122
+ {"YUQUE_COOKIES": json.dumps(COOKIES)},
123
+ )
124
+ self.assertEqual(code, 1)
125
+ self.assertIn("personal token", payload["error"])
126
+
127
+ def test_delete_is_refused_on_a_cookie_connection(self):
128
+ code, payload = run(
129
+ ["delete", "1", "9", "--confirm"], {"YUQUE_COOKIES": json.dumps(COOKIES)}
130
+ )
131
+ self.assertEqual(code, 1)
132
+ self.assertIn("personal token", payload["error"])
133
+
134
+
135
+ class RequestShape(unittest.TestCase):
136
+ """Capture the outgoing request instead of calling 语雀."""
137
+
138
+ def _capture(self, mode_env, method, path, **kwargs):
139
+ seen = {}
140
+
141
+ class Opener:
142
+ def open(self, request, timeout=None):
143
+ seen["request"] = request
144
+ raise urllib.error.HTTPError(
145
+ request.full_url, 401, "Unauthorized", {}, None
146
+ )
147
+
148
+ with unittest.mock.patch.dict(yuque.os.environ, mode_env, clear=True):
149
+ client = yuque.YuqueClient.from_environment()
150
+ client._opener = Opener()
151
+ with contextlib.redirect_stdout(io.StringIO()):
152
+ with self.assertRaises(SystemExit):
153
+ client.request(method, path, **kwargs)
154
+ return seen["request"]
155
+
156
+ def test_cookie_mode_sends_csrf_origin_and_referer(self):
157
+ request = self._capture({"YUQUE_COOKIES": json.dumps(COOKIES)}, "GET", "/mine")
158
+ self.assertEqual(request.full_url, "https://www.yuque.com/api/mine")
159
+ self.assertEqual(request.get_header("X-csrf-token"), "CTOKEN")
160
+ # 语雀's CSRF layer rejects writes without these two.
161
+ self.assertEqual(request.get_header("Origin"), "https://www.yuque.com")
162
+ self.assertTrue(request.get_header("Referer"))
163
+
164
+ def test_cookie_is_unredirected_so_it_is_dropped_on_a_30x(self):
165
+ request = self._capture({"YUQUE_COOKIES": json.dumps(COOKIES)}, "GET", "/mine")
166
+ self.assertIn("Cookie", request.unredirected_hdrs)
167
+ self.assertNotIn("Cookie", request.headers)
168
+
169
+ def test_token_mode_uses_the_open_api_and_auth_header(self):
170
+ request = self._capture({"YUQUE_TOKEN": "TK"}, "GET", "/user")
171
+ self.assertEqual(request.full_url, "https://www.yuque.com/api/v2/user")
172
+ self.assertEqual(request.get_header("X-auth-token"), "TK")
173
+ self.assertIsNone(request.get_header("X-csrf-token"))
174
+
175
+ def test_cookie_create_sends_markdown_without_a_convert_round_trip(self):
176
+ request = self._capture(
177
+ {"YUQUE_COOKIES": json.dumps(COOKIES)},
178
+ "POST",
179
+ "/docs",
180
+ body={"book_id": 1, "format": "markdown", "body": "# hi"},
181
+ write=True,
182
+ )
183
+ payload = json.loads(request.data)
184
+ self.assertEqual(payload["format"], "markdown")
185
+
186
+
187
+ class RedirectHandling(unittest.TestCase):
188
+ def test_redirects_are_refused(self):
189
+ handler = yuque.NoRedirectHandler()
190
+ self.assertIsNone(
191
+ handler.redirect_request(None, None, 302, "Found", {}, "https://evil.example")
192
+ )
193
+
194
+ def test_the_no_redirect_handler_is_actually_installed(self):
195
+ # Cookie is unredirected, but x-csrf-token is NOT — urllib copies every
196
+ # other header onto a redirected request, so only refusing the redirect
197
+ # keeps the CSRF token off a foreign host.
198
+ with unittest.mock.patch.dict(
199
+ yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
200
+ ):
201
+ client = yuque.YuqueClient.from_environment()
202
+ self.assertTrue(
203
+ any(isinstance(h, yuque.NoRedirectHandler) for h in client._opener.handlers),
204
+ "default opener must install NoRedirectHandler",
205
+ )
206
+
207
+ def test_a_30x_on_a_write_reports_an_unknown_outcome(self):
208
+ class Opener:
209
+ def open(self, request, timeout=None):
210
+ raise urllib.error.HTTPError(
211
+ request.full_url, 302, "Found", {}, None
212
+ )
213
+
214
+ with unittest.mock.patch.dict(
215
+ yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
216
+ ):
217
+ client = yuque.YuqueClient.from_environment()
218
+ client._opener = Opener()
219
+ buffer = io.StringIO()
220
+ with contextlib.redirect_stdout(buffer):
221
+ with self.assertRaises(SystemExit):
222
+ client.request("POST", "/docs", body={}, write=True)
223
+ error = json.loads(buffer.getvalue())["error"]
224
+ self.assertIn("UNKNOWN", error)
225
+
226
+
227
+ class BookIdResolution(unittest.TestCase):
228
+ """`_book_id` turns a repo reference into the numeric id the internal API needs."""
229
+
230
+ def _client(self, books):
231
+ with unittest.mock.patch.dict(
232
+ yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
233
+ ):
234
+ client = yuque.YuqueClient.from_environment()
235
+ client._books_cache = books
236
+ return client
237
+
238
+ def _expect_die(self, fn, *args):
239
+ buffer = io.StringIO()
240
+ with contextlib.redirect_stdout(buffer):
241
+ with self.assertRaises(SystemExit):
242
+ fn(*args)
243
+ return json.loads(buffer.getvalue())["error"]
244
+
245
+ def test_non_ascii_digits_do_not_crash(self):
246
+ # str.isdigit() is Unicode-aware but int() is not; a traceback would
247
+ # break the JSON-only output contract.
248
+ error = self._expect_die(self._client([])._book_id, "²")
249
+ self.assertIn("Could not resolve", error)
250
+
251
+ def test_a_malformed_book_id_is_skipped_not_fatal(self):
252
+ client = self._client(
253
+ [{"slug": "bad", "target_id": "not-a-number"}, {"slug": "blog", "target_id": 42}]
254
+ )
255
+ self.assertEqual(client._book_id("alice/blog"), 42)
256
+
257
+ def test_an_ambiguous_slug_is_refused_rather_than_guessed(self):
258
+ # Writing to the wrong knowledge base would be a silent wrong result.
259
+ client = self._client(
260
+ [{"slug": "blog", "target_id": 111}, {"slug": "blog", "target_id": 222}]
261
+ )
262
+ error = self._expect_die(client._book_id, "alice/blog")
263
+ self.assertIn("matches 2", error)
264
+
265
+ def test_books_are_fetched_once_per_run(self):
266
+ client = self._client(None)
267
+ calls = []
268
+
269
+ def fake_repos(login=None):
270
+ calls.append(1)
271
+ return [{"slug": "blog", "target_id": 7}]
272
+
273
+ client.repos = fake_repos
274
+ client._book_id("a/blog")
275
+ client._book_id("a/blog")
276
+ self.assertEqual(len(calls), 1)
277
+
278
+
279
+ class OutputShape(unittest.TestCase):
280
+ def test_repos_reports_target_id_as_the_repo_id(self):
281
+ # The common-used record's own `id` is not the book id.
282
+ formatted = yuque.format_repo({"id": 55555555, "target_id": 12345, "slug": "blog"})
283
+ self.assertEqual(formatted["repo_id"], 12345)
284
+
285
+ def test_repos_derives_a_namespace_from_the_owner_login(self):
286
+ formatted = yuque.format_repo(
287
+ {"target_id": 1, "slug": "blog", "user": {"login": "alice"}}
288
+ )
289
+ self.assertEqual(formatted["namespace"], "alice/blog")
290
+
291
+ def test_cookie_mode_create_returns_a_real_url(self):
292
+ # SKILL.md requires a real URL for publish_artifact; a null would
293
+ # invite the model to invent one.
294
+ client = self._cookie_client()
295
+ client._book_meta[9] = {"slug": "blog", "user": {"login": "alice"}}
296
+ url = client.doc_url(9, {"id": 1, "slug": "abc123"})
297
+ self.assertEqual(url, "https://www.yuque.com/alice/blog/abc123")
298
+
299
+ def test_doc_url_is_none_rather_than_wrong_when_the_book_is_unknown(self):
300
+ client = self._cookie_client()
301
+ self.assertIsNone(client.doc_url(9, {"id": 1, "slug": "abc123"}))
302
+
303
+ def _cookie_client(self):
304
+ with unittest.mock.patch.dict(
305
+ yuque.os.environ, {"YUQUE_COOKIES": json.dumps(COOKIES)}, clear=True
306
+ ):
307
+ return yuque.YuqueClient.from_environment()
308
+
309
+
310
+ if __name__ == "__main__":
311
+ unittest.main()