@acedatacloud/skills 2026.726.0 → 2026.726.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.726.0",
3
+ "version": "2026.726.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",
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: oschina
3
+ description: Read the connected 开源中国 / OSChina (my.oschina.net) account and create Markdown blog drafts with the user's own login cookies (BYOC). Use when the user mentions 开源中国 / OSChina, wants to save a 开源中国 draft, or asks who their connected 开源中国 account is.
4
+ when_to_use: |
5
+ Trigger for the user's 开源中国 (my.oschina.net) account driven by their own
6
+ login cookie: show the connected account, or turn Markdown into a 开源中国
7
+ blog draft. 开源中国 exposes no publish API, so this skill stops at a draft
8
+ and hands the user the editor URL. Writes are gated behind explicit
9
+ confirmation.
10
+ connections: [oschina]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ # oschina — read & draft on 开源中国 via your own cookies
19
+
20
+ Drives the user's **real** 开源中国 account through the same
21
+ `apiv1.oschina.net` endpoints the site uses, authenticated by the login cookie
22
+ they captured with the ACE extension. No browser, no third-party deps — just
23
+ `urllib`.
24
+
25
+ The connector injects the cookie jar as a JSON env var `$OSCHINA_COOKIES`.
26
+ Never print it.
27
+
28
+ ```bash
29
+ python3 "$SKILL_DIR/scripts/oschina.py" whoami
30
+ ```
31
+
32
+ If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
33
+ this skill's directory explicitly before running the commands below.
34
+
35
+ ## Important: draft only
36
+
37
+ **开源中国 has no public publish API.** Every open-source client for this
38
+ platform (including the upstream adapter this skill is modelled on) stops at
39
+ draft creation. This skill does the same: it saves the Markdown as a draft and
40
+ returns the editor URL. Tell the user plainly that they must open that URL and
41
+ click publish themselves — do not claim the article was published.
42
+
43
+ ## Verify the connection first
44
+
45
+ ```bash
46
+ python3 "$SKILL_DIR/scripts/oschina.py" whoami
47
+ ```
48
+
49
+ If this fails with an auth error, the cookie has expired. Ask the user to
50
+ reconnect at `https://auth.acedata.cloud/user/connections` rather than retrying.
51
+
52
+ ## Create a draft — GATED
53
+
54
+ Prepare the complete Markdown in a file. The first call is always a dry run and
55
+ does not write anything.
56
+
57
+ ```bash
58
+ # Dry run — shows exactly what would be written.
59
+ python3 "$SKILL_DIR/scripts/oschina.py" draft \
60
+ --title "标题" --content-file /tmp/article.md
61
+
62
+ # Actually create the draft after the user confirms.
63
+ python3 "$SKILL_DIR/scripts/oschina.py" draft \
64
+ --title "标题" --content-file /tmp/article.md --confirm
65
+ ```
66
+
67
+ `--confirm` is valid only as the final argument. Show the title and full
68
+ content to the user before writing.
69
+
70
+ ## Gotchas
71
+
72
+ - Content is sent as Markdown (`contentType: 1`). Do not pre-render it to HTML.
73
+ - Images referenced by external URL are not re-hosted. If the source host
74
+ blocks hotlinking they will not render; mention this when the article has
75
+ images.
76
+ - `--catalog` takes a 开源中国 catalog id; the default `0` uses the account's
77
+ default catalog. If the API rejects the catalog, ask the user for the right
78
+ id rather than guessing.
79
+ - Do not retry a timed-out write automatically — the outcome may be unknown and
80
+ a retry can create a duplicate draft.
81
+
82
+ ## Record the output
83
+
84
+ This skill only produces drafts, so do **not** call `publish_artifact`. Report
85
+ the returned `draft_id` and `edit_url` to the user instead.
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ oschina — read & publish on 开源中国 (my.oschina.net) with the user's own login
4
+ cookies (BYOC). Standard-library only (urllib), no third-party deps, so it runs
5
+ in the bare sandbox without an image change.
6
+
7
+ The connector injects the user's cookie jar as a JSON env var ``OSCHINA_COOKIES``
8
+ — a list of ``{name, value, domain, ...}`` dicts captured by the ACE extension.
9
+
10
+ Read commands run directly. ``publish`` is GATED: without a trailing
11
+ ``--confirm`` it only dry-runs. ``--confirm`` is honored ONLY as the last
12
+ argument, so a title/content that merely contains "--confirm" can never silently
13
+ go live.
14
+
15
+ NOTE: 开源中国 exposes no public "publish" API — only draft creation. This CLI
16
+ creates a draft and returns its editor URL; the user finishes publishing in the
17
+ 开源中国 editor.
18
+
19
+ Examples:
20
+ python3 oschina.py whoami
21
+ python3 oschina.py draft --title T --content-file a.md --confirm
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import gzip
28
+ import json
29
+ import os
30
+ import sys
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+
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
+ )
39
+ PLATFORM = "oschina"
40
+ API = "https://apiv1.oschina.net/oschinapi"
41
+ ORIGIN = "https://my.oschina.net"
42
+ MAX_CONTENT_BYTES = 10 * 1024 * 1024
43
+
44
+ _RAW = sys.argv[1:]
45
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
46
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
47
+
48
+
49
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
50
+ """Refuse redirects outright.
51
+
52
+ add_unredirected_header only protects the Cookie; urllib copies every other
53
+ header onto the redirected request, so a 30x to a foreign host could hand
54
+ over any auth header we add later.
55
+ """
56
+
57
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
58
+ return None
59
+
60
+
61
+ _OPENER = urllib.request.build_opener(_NoRedirect())
62
+
63
+
64
+ def out(obj) -> None:
65
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
66
+
67
+
68
+ def die(msg: str, code: int = 1) -> None:
69
+ out({"error": msg})
70
+ sys.exit(code)
71
+
72
+
73
+ # ── Cookie jar (shared pattern across the cookie-BYOC skills) ────────
74
+
75
+ def load_cookies() -> list:
76
+ env = f"{PLATFORM.upper()}_COOKIES"
77
+ raw = os.environ.get(env)
78
+ if not raw:
79
+ die(f"{env} is not set — connect 开源中国 at "
80
+ f"https://auth.acedata.cloud/user/connections, then retry.")
81
+ try:
82
+ jar = json.loads(raw)
83
+ except json.JSONDecodeError as e:
84
+ die(f"{env} is not valid JSON: {e}")
85
+ if not isinstance(jar, list):
86
+ die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
87
+ return jar
88
+
89
+
90
+ def _domain_matches(host: str, domain: str) -> bool:
91
+ d = domain.lstrip(".").lower()
92
+ h = host.lower()
93
+ return not d or h == d or h.endswith("." + d)
94
+
95
+
96
+ def cookie_header(jar: list, url: str) -> str:
97
+ host = urllib.parse.urlsplit(url).hostname or ""
98
+ host_in_scope = any(
99
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
100
+ )
101
+ parts = []
102
+ for c in jar:
103
+ name, value = c.get("name"), c.get("value")
104
+ if not name or value is None:
105
+ continue
106
+ domain = c.get("domain")
107
+ if domain:
108
+ if not _domain_matches(host, str(domain)):
109
+ continue
110
+ elif not host_in_scope:
111
+ continue
112
+ parts.append(f"{name}={value}")
113
+ return "; ".join(parts)
114
+
115
+
116
+ def request(method: str, url: str, jar: list, *, headers=None, body=None, write: bool = False):
117
+ hdrs = {
118
+ "User-Agent": UA,
119
+ "Accept": "*/*",
120
+ "Accept-Language": "zh-CN,zh;q=0.9",
121
+ "Origin": ORIGIN,
122
+ "Referer": f"{ORIGIN}/",
123
+ }
124
+ if headers:
125
+ hdrs.update(headers)
126
+ data = None
127
+ if body is not None:
128
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
129
+ hdrs.setdefault("Content-Type", "application/json")
130
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
131
+ # Unredirected → the cookie is not re-sent if the API 30x-redirects to a
132
+ # different host (e.g. a login page), so the jar never leaks off-site.
133
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
134
+ try:
135
+ with _OPENER.open(req, timeout=30) as resp:
136
+ raw = resp.read()
137
+ if resp.headers.get("Content-Encoding") == "gzip":
138
+ raw = gzip.decompress(raw)
139
+ return resp.status, raw.decode("utf-8", "replace")
140
+ except urllib.error.HTTPError as e:
141
+ if e.code in (301, 302, 303, 307, 308):
142
+ die(f"开源中国 redirected {method} {url} — not followed, so no "
143
+ f"credential left oschina.net. You are most likely logged out; "
144
+ f"reconnect at https://auth.acedata.cloud/user/connections."
145
+ + (" This was a WRITE: its outcome is UNKNOWN — check your "
146
+ "drafts before retrying." if write else ""))
147
+ raw = e.read()
148
+ try:
149
+ if e.headers.get("Content-Encoding") == "gzip":
150
+ raw = gzip.decompress(raw)
151
+ except Exception:
152
+ pass
153
+ return e.code, raw.decode("utf-8", "replace")
154
+ except urllib.error.URLError as e:
155
+ if write:
156
+ die(f"开源中国 write {method} {url} did not return a result "
157
+ f"({e.reason}); the outcome is UNKNOWN. Check your 开源中国 "
158
+ f"drafts before retrying so you do not create a duplicate.")
159
+ die(f"network error reaching {url}: {e.reason}")
160
+
161
+
162
+ def api_call(method: str, path: str, jar: list, *, body=None, write: bool = False):
163
+ """Unwrap the {success, message, code, result} envelope, dying on failure.
164
+ 开源中国 returns HTTP 200 even for logical errors."""
165
+ url = f"{API}{path}"
166
+ status, text = request(method, url, jar, body=body, write=write)
167
+ try:
168
+ env = json.loads(text)
169
+ except json.JSONDecodeError:
170
+ if write:
171
+ die(f"开源中国 write to {path} returned a non-JSON response ({status}); "
172
+ f"the outcome is UNKNOWN. Check your drafts before retrying. "
173
+ f"Body: {text[:200]}")
174
+ die(f"non-JSON response ({status}) from {path}: {text[:300]}")
175
+ if not isinstance(env, dict):
176
+ die(f"unexpected response from {path}: {text[:300]}")
177
+ if not env.get("success"):
178
+ msg = str(env.get("message") or "")
179
+ code = env.get("code")
180
+ if code in (40001, 401, 403) or "未登录" in msg or "登录" in msg:
181
+ die(f"auth failed (code={code}: {msg}) — cookie likely expired. "
182
+ f"Reconnect at https://auth.acedata.cloud/user/connections.")
183
+ die(f"开源中国 API error on {path} (code={code}): {msg}")
184
+ return env.get("result")
185
+
186
+
187
+ # ── commands ────────────────────────────────────────────────────────
188
+
189
+ def os_me(jar) -> dict:
190
+ me = api_call("GET", "/user/myDetails", jar)
191
+ if not isinstance(me, dict) or not me.get("userId"):
192
+ die("could not read 开源中国 profile (cookie expired?)")
193
+ return me
194
+
195
+
196
+ def cmd_whoami(jar, _args):
197
+ me = os_me(jar)
198
+ vo = me.get("userVo") or {}
199
+ uid = me.get("userId")
200
+ out({
201
+ "user_id": str(uid),
202
+ "name": vo.get("name"),
203
+ "url": f"{ORIGIN}/u/{uid}",
204
+ "avatar": vo.get("portraitUrl"),
205
+ })
206
+
207
+
208
+ def read_content(args) -> str:
209
+ content = args.content
210
+ if args.content_file:
211
+ try:
212
+ with open(args.content_file, encoding="utf-8") as f:
213
+ content = f.read()
214
+ except OSError as e:
215
+ die(f"cannot read --content-file: {e}")
216
+ if content is None:
217
+ die("provide --content-file <path.md> or --content <markdown>")
218
+ if len(content.encode("utf-8")) > MAX_CONTENT_BYTES:
219
+ die("content exceeds the 10 MiB safety limit")
220
+ return content
221
+
222
+
223
+ def cmd_draft(jar, args):
224
+ if not args.title:
225
+ die("--title is required")
226
+ content = read_content(args)
227
+
228
+ if not CONFIRM:
229
+ out({
230
+ "dry_run": True, "command": "draft", "platform": PLATFORM,
231
+ "title": args.title, "catalog": args.catalog,
232
+ "private": not args.public,
233
+ "content_characters": len(content),
234
+ "note": "开源中国 content is Markdown. Re-run with --confirm as the LAST "
235
+ "argument to actually create the draft. 开源中国 exposes no "
236
+ "publish API — finish publishing in the 开源中国 editor.",
237
+ })
238
+ return
239
+
240
+ me = os_me(jar)
241
+ uid = me.get("userId")
242
+ result = api_call("POST", "/api/draft/save_draft", jar, write=True, body={
243
+ "title": args.title,
244
+ "user": int(uid),
245
+ "content": content,
246
+ "contentType": 1, # 1 = Markdown, 2 = HTML
247
+ "catalog": args.catalog,
248
+ "originUrl": "",
249
+ "privacy": not args.public,
250
+ "disableComment": False,
251
+ })
252
+ draft_id = result.get("id") if isinstance(result, dict) else None
253
+ if not draft_id:
254
+ die(f"draft creation failed: {str(result)[:300]}")
255
+ out({
256
+ "ok": True,
257
+ "draft_only": True,
258
+ "draft_id": str(draft_id),
259
+ "edit_url": f"{ORIGIN}/u/{uid}/blog/write/draft/{draft_id}",
260
+ "note": "Draft saved. 开源中国 has no publish API — open edit_url to publish.",
261
+ })
262
+
263
+
264
+ COMMANDS = {
265
+ "whoami": cmd_whoami,
266
+ "draft": cmd_draft,
267
+ }
268
+
269
+
270
+ def main() -> None:
271
+ p = argparse.ArgumentParser(prog="oschina.py", description="开源中国 cookie CLI")
272
+ sub = p.add_subparsers(dest="command", required=True)
273
+ sub.add_parser("whoami", help="show the logged-in account")
274
+ sp = sub.add_parser("draft", help="create a draft article (GATED by trailing --confirm)")
275
+ sp.add_argument("--title")
276
+ sp.add_argument("--content", help="Markdown content inline")
277
+ sp.add_argument("--content-file", help="path to a Markdown file")
278
+ sp.add_argument("--catalog", type=int, default=0, help="开源中国 catalog id (0 = default)")
279
+ sp.add_argument("--public", action="store_true",
280
+ help="mark the draft non-private; it still needs publishing in the editor")
281
+ args = p.parse_args(ARGV)
282
+ jar = load_cookies()
283
+ COMMANDS[args.command](jar, args)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -65,10 +65,15 @@ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -
65
65
  [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
66
66
 
67
67
  python3 "$R" search --query "suno api" --time week --limit 10
68
- python3 "$R" search --query "image generation api" --subreddit SideProject --sort new
68
+ python3 "$R" search --query "image generation api" --subreddit SideProject
69
69
  python3 "$R" subreddit-info --subreddit SideProject
70
70
  ```
71
71
 
72
+ `--sort` defaults to `relevance`. **Reddit's `new` sort ignores keyword relevance**
73
+ — it returns recent posts that often do not contain the query terms at all. Use
74
+ `--sort new` only to scan a specific subreddit's recent activity, never to find
75
+ threads by keyword.
76
+
72
77
  ## Reply to a thread — GATED
73
78
 
74
79
  `--parent` accepts a fullname (`t3_…` post, `t1_…` comment) or a reddit.com
@@ -474,7 +474,7 @@ class RedditClient:
474
474
  return {"ok": True, "posted": True, "id": post.get("id"), "name": post.get("name"), "url": post_url}
475
475
 
476
476
 
477
- def search(self, *, query: str, subreddit: str = "", sort: str = "new", limit: int = 10, time_filter: str = "week") -> list[dict]:
477
+ def search(self, *, query: str, subreddit: str = "", sort: str = "relevance", limit: int = 10, time_filter: str = "month") -> list[dict]:
478
478
  suffix = ".json" if self.mode == "cookie" else ""
479
479
  path = f"/r/{subreddit}/search{suffix}" if subreddit else f"/search{suffix}"
480
480
  params = {"q": query, "sort": sort, "limit": limit, "t": time_filter, "raw_json": 1, "type": "link"}
@@ -677,8 +677,8 @@ def build_parser() -> argparse.ArgumentParser:
677
677
  search = commands.add_parser("search", help="search public posts to find threads worth replying to")
678
678
  search.add_argument("--query", "-q", required=True)
679
679
  search.add_argument("--subreddit", "-r", default="")
680
- search.add_argument("--sort", choices=["new", "relevance", "top", "comments"], default="new")
681
- search.add_argument("--time", dest="time_filter", choices=["hour", "day", "week", "month", "year", "all"], default="week")
680
+ search.add_argument("--sort", choices=["relevance", "new", "top", "comments"], default="relevance")
681
+ search.add_argument("--time", dest="time_filter", choices=["hour", "day", "week", "month", "year", "all"], default="month")
682
682
  search.add_argument("--limit", type=positive_limit, default=10)
683
683
 
684
684
  about = commands.add_parser("subreddit-info", help="read a subreddit's rules and posting requirements")
@@ -512,6 +512,20 @@ class RedditSkillTests(unittest.TestCase):
512
512
  self.assertEqual("https://www.reddit.com/r/SunoAI/comments/p1/looking/", formatted["url"])
513
513
  self.assertEqual(500, len(formatted["selftext_excerpt"]))
514
514
 
515
+ def test_search_defaults_to_relevance_because_new_ignores_keywords(self):
516
+ """Reddit's `new` sort returns recent posts that need not match the query."""
517
+ client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
518
+ with patch.object(client, "request", return_value={"data": {"children": []}}) as request:
519
+ client.search(query="suno api")
520
+ self.assertEqual("relevance", request.call_args.kwargs["query"]["sort"])
521
+
522
+ stream = io.StringIO()
523
+ with patch.dict(os.environ, {"REDDIT_COOKIES": COOKIE_JAR}, clear=True), patch.object(
524
+ reddit.RedditClient, "search", return_value=[]
525
+ ) as search, redirect_stdout(stream):
526
+ reddit.main(["search", "--query", "suno api"])
527
+ self.assertEqual("relevance", search.call_args.kwargs["sort"])
528
+
515
529
  def test_search_is_a_read_and_rejects_malformed_shape(self):
516
530
  client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
517
531
  stream = io.StringIO()
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: segmentfault
3
+ description: Read the connected SegmentFault 思否 (segmentfault.com) account and create Markdown article drafts with the user's own login cookies (BYOC). Use when the user mentions SegmentFault / 思否, wants to save a 思否 draft, or asks who their connected 思否 account is.
4
+ when_to_use: |
5
+ Trigger for the user's SegmentFault 思否 account driven by their own login
6
+ cookie: show the connected account, or turn Markdown into a 思否 article
7
+ draft. The write API creates a draft, so this skill stops there and hands the
8
+ user the editor URL. Writes are gated behind explicit confirmation.
9
+ connections: [segmentfault]
10
+ allowed_tools: [Bash]
11
+ license: Apache-2.0
12
+ metadata:
13
+ author: acedatacloud
14
+ version: "1.0"
15
+ ---
16
+
17
+ # segmentfault — read & draft on 思否 via your own cookies
18
+
19
+ Drives the user's **real** SegmentFault account through the same
20
+ `segmentfault.com/gateway` endpoints the site uses, authenticated by the login
21
+ cookie they captured with the ACE extension. No browser, no third-party deps —
22
+ just `urllib`.
23
+
24
+ The connector injects the cookie jar as a JSON env var `$SEGMENTFAULT_COOKIES`.
25
+ Never print it.
26
+
27
+ ```bash
28
+ python3 "$SKILL_DIR/scripts/segmentfault.py" whoami
29
+ ```
30
+
31
+ If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
32
+ this skill's directory explicitly before running the commands below.
33
+
34
+ ## Important: draft only
35
+
36
+ SegmentFault's write endpoint (`/gateway/draft`) creates a **draft**. This skill
37
+ returns the draft's editor URL and does not publish. Tell the user plainly that
38
+ they must open that URL and publish themselves — do not claim the article went
39
+ live.
40
+
41
+ ## Verify the connection first
42
+
43
+ ```bash
44
+ python3 "$SKILL_DIR/scripts/segmentfault.py" whoami
45
+ ```
46
+
47
+ If this fails with an auth error, the cookie has expired. Ask the user to
48
+ reconnect at `https://auth.acedata.cloud/user/connections` rather than retrying.
49
+
50
+ ## Create a draft — GATED
51
+
52
+ Prepare the complete Markdown in a file. The first call is always a dry run and
53
+ does not write anything.
54
+
55
+ ```bash
56
+ # Dry run — shows exactly what would be written.
57
+ python3 "$SKILL_DIR/scripts/segmentfault.py" draft \
58
+ --title "标题" --content-file /tmp/article.md --tags "python,api"
59
+
60
+ # Actually create the draft after the user confirms.
61
+ python3 "$SKILL_DIR/scripts/segmentfault.py" draft \
62
+ --title "标题" --content-file /tmp/article.md --tags "python,api" --confirm
63
+ ```
64
+
65
+ `--confirm` is valid only as the final argument. Show the title, tags and full
66
+ content to the user before writing.
67
+
68
+ ## Gotchas
69
+
70
+ - The write API needs a per-session token scraped from the `/write` page. If
71
+ the CLI reports it could not find that token, the cookie is stale — reconnect
72
+ rather than retrying.
73
+ - A restricted account (禁言 / 锁定) is reported as an explicit error. Do not
74
+ retry; tell the user their account is restricted.
75
+ - Content is sent as Markdown. Do not pre-render it to HTML.
76
+ - Images referenced by external URL are not re-hosted. If the source host
77
+ blocks hotlinking they will not render; mention this when the article has
78
+ images.
79
+ - Do not retry a timed-out write automatically — the outcome may be unknown and
80
+ a retry can create a duplicate draft.
81
+
82
+ ## Record the output
83
+
84
+ This skill only produces drafts, so do **not** call `publish_artifact`. Report
85
+ the returned `draft_id` and `edit_url` to the user instead.
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ segmentfault — read & publish on SegmentFault 思否 (segmentfault.com) with the
4
+ user's own login cookies (BYOC). Standard-library only (urllib), no third-party
5
+ deps, so it runs in the bare sandbox without an image change.
6
+
7
+ The connector injects the user's cookie jar as a JSON env var
8
+ ``SEGMENTFAULT_COOKIES`` — a list of ``{name, value, domain, ...}`` dicts
9
+ captured by the ACE extension.
10
+
11
+ Read commands run directly. ``draft`` is GATED: without a trailing ``--confirm``
12
+ it only dry-runs. ``--confirm`` is honored ONLY as the last argument, so a
13
+ title/content that merely contains "--confirm" can never silently go live.
14
+
15
+ NOTE: SegmentFault's write API creates a DRAFT. This CLI returns the draft's
16
+ editor URL; the user finishes publishing in the SegmentFault editor.
17
+
18
+ Examples:
19
+ python3 segmentfault.py whoami
20
+ python3 segmentfault.py draft --title T --content-file a.md --confirm
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import gzip
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+
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
+ )
39
+ PLATFORM = "segmentfault"
40
+ BASE = "https://segmentfault.com"
41
+ MAX_CONTENT_BYTES = 10 * 1024 * 1024
42
+
43
+ # Bounded so a hostile/huge page cannot cause catastrophic backtracking.
44
+ _TOKEN_RE = re.compile(r'serverData"\s*:\s*\{\s*"Token"\s*:\s*"([^"]{1,500})"')
45
+
46
+ _RAW = sys.argv[1:]
47
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
48
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
49
+
50
+
51
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
52
+ """Refuse redirects outright.
53
+
54
+ add_unredirected_header only protects the Cookie; urllib still copies every
55
+ other header (including the per-session ``token``) onto the redirected
56
+ request, so a 30x to an attacker host would hand over the write token.
57
+ """
58
+
59
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
60
+ return None
61
+
62
+
63
+ _OPENER = urllib.request.build_opener(_NoRedirect())
64
+
65
+
66
+ def out(obj) -> None:
67
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
68
+
69
+
70
+ def die(msg: str, code: int = 1) -> None:
71
+ out({"error": msg})
72
+ sys.exit(code)
73
+
74
+
75
+ # ── Cookie jar (shared pattern across the cookie-BYOC skills) ────────
76
+
77
+ def load_cookies() -> list:
78
+ env = f"{PLATFORM.upper()}_COOKIES"
79
+ raw = os.environ.get(env)
80
+ if not raw:
81
+ die(f"{env} is not set — connect SegmentFault at "
82
+ f"https://auth.acedata.cloud/user/connections, then retry.")
83
+ try:
84
+ jar = json.loads(raw)
85
+ except json.JSONDecodeError as e:
86
+ die(f"{env} is not valid JSON: {e}")
87
+ if not isinstance(jar, list):
88
+ die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
89
+ return jar
90
+
91
+
92
+ def _domain_matches(host: str, domain: str) -> bool:
93
+ d = domain.lstrip(".").lower()
94
+ h = host.lower()
95
+ return not d or h == d or h.endswith("." + d)
96
+
97
+
98
+ def cookie_header(jar: list, url: str) -> str:
99
+ host = urllib.parse.urlsplit(url).hostname or ""
100
+ host_in_scope = any(
101
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
102
+ )
103
+ parts = []
104
+ for c in jar:
105
+ name, value = c.get("name"), c.get("value")
106
+ if not name or value is None:
107
+ continue
108
+ domain = c.get("domain")
109
+ if domain:
110
+ if not _domain_matches(host, str(domain)):
111
+ continue
112
+ elif not host_in_scope:
113
+ continue
114
+ parts.append(f"{name}={value}")
115
+ return "; ".join(parts)
116
+
117
+
118
+ def request(method: str, url: str, jar: list, *, headers=None, body=None, write: bool = False):
119
+ hdrs = {
120
+ "User-Agent": UA,
121
+ "Accept": "*/*",
122
+ "Accept-Language": "zh-CN,zh;q=0.9",
123
+ "Origin": BASE,
124
+ "Referer": f"{BASE}/",
125
+ }
126
+ if headers:
127
+ hdrs.update(headers)
128
+ data = None
129
+ if body is not None:
130
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
131
+ hdrs.setdefault("Content-Type", "application/json")
132
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
133
+ # Unredirected → the cookie is not re-sent if the API 30x-redirects to a
134
+ # different host (e.g. a login page), so the jar never leaks off-site.
135
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
136
+ try:
137
+ with _OPENER.open(req, timeout=30) as resp:
138
+ raw = resp.read()
139
+ if resp.headers.get("Content-Encoding") == "gzip":
140
+ raw = gzip.decompress(raw)
141
+ return resp.status, raw.decode("utf-8", "replace")
142
+ except urllib.error.HTTPError as e:
143
+ if e.code in (301, 302, 303, 307, 308):
144
+ die(f"SegmentFault redirected {method} {url} — not followed, so no "
145
+ f"credential left segmentfault.com. You are most likely logged "
146
+ f"out; reconnect at https://auth.acedata.cloud/user/connections."
147
+ + (" This was a WRITE: its outcome is UNKNOWN — check your "
148
+ "drafts before retrying." if write else ""))
149
+ raw = e.read()
150
+ try:
151
+ if e.headers.get("Content-Encoding") == "gzip":
152
+ raw = gzip.decompress(raw)
153
+ except Exception:
154
+ pass
155
+ return e.code, raw.decode("utf-8", "replace")
156
+ except urllib.error.URLError as e:
157
+ if write:
158
+ die(f"SegmentFault write {method} {url} did not return a result "
159
+ f"({e.reason}); the outcome is UNKNOWN. Check your SegmentFault "
160
+ f"drafts before retrying so you do not create a duplicate.")
161
+ die(f"network error reaching {url}: {e.reason}")
162
+
163
+
164
+ def _auth_died(status: int, text: str) -> None:
165
+ if status in (401, 403) or text.strip('"') == "Unauthorized":
166
+ die("auth failed — cookie expired or invalid. Reconnect at "
167
+ "https://auth.acedata.cloud/user/connections.")
168
+
169
+
170
+ def session_token(jar: list) -> str:
171
+ """The write API needs a per-session token embedded in the /write page."""
172
+ status, html = request("GET", f"{BASE}/write", jar)
173
+ _auth_died(status, html)
174
+ m = _TOKEN_RE.search(html)
175
+ if not m:
176
+ die("could not find the SegmentFault session token on /write — you are "
177
+ "probably logged out. Reconnect at "
178
+ "https://auth.acedata.cloud/user/connections.")
179
+ return m.group(1)
180
+
181
+
182
+ # ── commands ────────────────────────────────────────────────────────
183
+
184
+ def cmd_whoami(jar, _args):
185
+ status, html = request("GET", f"{BASE}/user/settings", jar)
186
+ _auth_died(status, html)
187
+ if status != 200:
188
+ die(f"unexpected status {status} reading the profile")
189
+ name = re.search(r'name="name"[^>]{0,300}?value="([^"]{1,200})"', html)
190
+ avatar = re.search(
191
+ r'src="(https://avatar-static\.segmentfault\.com/[^"]{1,300})"', html
192
+ )
193
+ if not name and "登录" in html:
194
+ die("not logged in — cookie expired. Reconnect at "
195
+ "https://auth.acedata.cloud/user/connections.")
196
+ out({
197
+ "platform": PLATFORM,
198
+ "name": name.group(1) if name else None,
199
+ "avatar": avatar.group(1) if avatar else None,
200
+ "settings_url": f"{BASE}/user/settings",
201
+ })
202
+
203
+
204
+ def read_content(args) -> str:
205
+ content = args.content
206
+ if args.content_file:
207
+ try:
208
+ with open(args.content_file, encoding="utf-8") as f:
209
+ content = f.read()
210
+ except OSError as e:
211
+ die(f"cannot read --content-file: {e}")
212
+ if content is None:
213
+ die("provide --content-file <path.md> or --content <markdown>")
214
+ if len(content.encode("utf-8")) > MAX_CONTENT_BYTES:
215
+ die("content exceeds the 10 MiB safety limit")
216
+ return content
217
+
218
+
219
+ def _extract_draft_id(text: str):
220
+ """SegmentFault answers either [0, {...}] / [1, "error"] or a bare object."""
221
+ try:
222
+ res = json.loads(text)
223
+ except json.JSONDecodeError:
224
+ return None, f"non-JSON response: {text[:200]}"
225
+ if isinstance(res, list):
226
+ if res and res[0] == 1:
227
+ return None, str(res[1] if len(res) > 1 else "unknown error")
228
+ if len(res) > 1 and isinstance(res[1], dict) and res[1].get("id"):
229
+ return res[1]["id"], None
230
+ return None, f"unexpected list response: {text[:200]}"
231
+ if isinstance(res, dict):
232
+ if res.get("id"):
233
+ return res["id"], None
234
+ msg = res.get("message") or res.get("msg") or res.get("error") or res.get("errMsg")
235
+ return None, str(msg or text[:200])
236
+ return None, f"unexpected response: {text[:200]}"
237
+
238
+
239
+ def cmd_draft(jar, args):
240
+ if not args.title:
241
+ die("--title is required")
242
+ content = read_content(args)
243
+ tags = [t.strip() for t in (args.tags or "").split(",") if t.strip()]
244
+
245
+ if not CONFIRM:
246
+ out({
247
+ "dry_run": True, "command": "draft", "platform": PLATFORM,
248
+ "title": args.title, "tags": tags,
249
+ "content_characters": len(content),
250
+ "note": "SegmentFault content is Markdown. Re-run with --confirm as "
251
+ "the LAST argument to actually create the draft. The write "
252
+ "API creates a DRAFT — finish publishing in the SegmentFault "
253
+ "editor.",
254
+ })
255
+ return
256
+
257
+ token = session_token(jar)
258
+ status, text = request("POST", f"{BASE}/gateway/draft", jar,
259
+ headers={"token": token}, write=True,
260
+ body={"title": args.title, "tags": tags,
261
+ "text": content, "object_id": "",
262
+ "type": "article"})
263
+ _auth_died(status, text)
264
+ for marker in ("禁言", "锁定"):
265
+ if marker in text:
266
+ die(f"SegmentFault rejected the write ({marker}); the account is "
267
+ f"restricted.")
268
+ draft_id, err = _extract_draft_id(text)
269
+ if not draft_id:
270
+ die(f"draft creation failed (status {status}): {err}")
271
+ out({
272
+ "ok": True,
273
+ "draft_only": True,
274
+ "draft_id": str(draft_id),
275
+ "edit_url": f"{BASE}/write?draftId={draft_id}",
276
+ "note": "Draft saved. Open edit_url to review and publish.",
277
+ })
278
+
279
+
280
+ COMMANDS = {
281
+ "whoami": cmd_whoami,
282
+ "draft": cmd_draft,
283
+ }
284
+
285
+
286
+ def main() -> None:
287
+ p = argparse.ArgumentParser(prog="segmentfault.py",
288
+ description="SegmentFault 思否 cookie CLI")
289
+ sub = p.add_subparsers(dest="command", required=True)
290
+ sub.add_parser("whoami", help="show the logged-in account")
291
+ sp = sub.add_parser("draft", help="create a draft article (GATED by trailing --confirm)")
292
+ sp.add_argument("--title")
293
+ sp.add_argument("--content", help="Markdown content inline")
294
+ sp.add_argument("--content-file", help="path to a Markdown file")
295
+ sp.add_argument("--tags", help="comma-separated tag names")
296
+ args = p.parse_args(ARGV)
297
+ jar = load_cookies()
298
+ COMMANDS[args.command](jar, args)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
@@ -0,0 +1,104 @@
1
+ ---
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.
4
+ when_to_use: |
5
+ Trigger for 语雀 / Yuque document management: verify the connected account,
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.
9
+ connections: [yuque]
10
+ allowed_tools: [Bash]
11
+ license: Apache-2.0
12
+ metadata:
13
+ author: acedatacloud
14
+ version: "1.0"
15
+ ---
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.
21
+
22
+ ```bash
23
+ python3 "$SKILL_DIR/scripts/yuque.py" whoami
24
+ ```
25
+
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.
28
+
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.
33
+
34
+ ## Read
35
+
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.
39
+
40
+ ```bash
41
+ # Verify the token and see the account.
42
+ python3 "$SKILL_DIR/scripts/yuque.py" whoami
43
+
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 germey/blog --limit 20
47
+ python3 "$SKILL_DIR/scripts/yuque.py" doc germey/blog DOC_ID
48
+ ```
49
+
50
+ ## Create and update
51
+
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
+
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 germey/blog \
59
+ --title "标题" --content-file /tmp/article.md
60
+
61
+ # Create it privately after the user confirms.
62
+ python3 "$SKILL_DIR/scripts/yuque.py" create germey/blog \
63
+ --title "标题" --content-file /tmp/article.md --confirm
64
+
65
+ # Public publishing additionally requires --public.
66
+ python3 "$SKILL_DIR/scripts/yuque.py" create germey/blog \
67
+ --title "标题" --content-file /tmp/article.md --public --confirm
68
+
69
+ python3 "$SKILL_DIR/scripts/yuque.py" update germey/blog DOC_ID \
70
+ --title "新标题" --content-file /tmp/article.md --public --confirm
71
+ ```
72
+
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.
80
+
81
+ ## Delete
82
+
83
+ ```bash
84
+ python3 "$SKILL_DIR/scripts/yuque.py" delete germey/blog DOC_ID --confirm
85
+ ```
86
+
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.
90
+
91
+ ## Gotchas
92
+
93
+ - 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 语雀
95
+ 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.
99
+
100
+ ## Record the output
101
+
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.
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ """Read and write Yuque (语雀) documents through its official open API."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import socket
11
+ import sys
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+
16
+ API_BASE = "https://www.yuque.com/api/v2"
17
+ GATED_COMMANDS = {"create", "update", "delete"}
18
+ MAX_CONTENT_BYTES = 10 * 1024 * 1024
19
+ MAX_ITEMS = 100
20
+
21
+
22
+ def output(value) -> None:
23
+ print(json.dumps(value, ensure_ascii=False, indent=2, default=str))
24
+
25
+
26
+ def die(message: str, code: int = 1) -> None:
27
+ output({"error": message})
28
+ raise SystemExit(code)
29
+
30
+
31
+ def split_confirmation(argv: list[str]) -> tuple[list[str], bool]:
32
+ confirmed = bool(argv) and argv[-1] == "--confirm"
33
+ return (argv[:-1] if confirmed else list(argv), confirmed)
34
+
35
+
36
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
38
+ return None
39
+
40
+
41
+ class YuqueClient:
42
+ def __init__(self, token: str, opener=None) -> None:
43
+ self._token = token
44
+ self._opener = opener or urllib.request.build_opener(NoRedirectHandler())
45
+
46
+ @classmethod
47
+ def from_environment(cls):
48
+ 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
+ },
68
+ )
69
+ try:
70
+ with self._opener.open(request, timeout=30) as response:
71
+ raw = response.read()
72
+ except urllib.error.HTTPError as error:
73
+ 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
+ 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."
79
+ )
80
+ 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):
84
+ if write:
85
+ 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."
88
+ )
89
+ die(f"Network error while calling Yuque {method} {path}.")
90
+ if not expect_json or not raw:
91
+ return None
92
+ try:
93
+ payload = json.loads(raw)
94
+ except json.JSONDecodeError:
95
+ die(f"Yuque returned invalid JSON for {method} {path}.")
96
+ if not isinstance(payload, dict) or "data" not in payload:
97
+ die(f"Yuque returned an unexpected envelope for {method} {path}.")
98
+ return payload["data"]
99
+
100
+ def user(self) -> dict:
101
+ value = self.request("GET", "/user")
102
+ if not isinstance(value, dict):
103
+ die("Yuque returned malformed user data.")
104
+ return value
105
+
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
112
+
113
+ def docs(self, repo: str) -> list[dict]:
114
+ quoted = urllib.parse.quote(str(repo), safe="")
115
+ value = self.request("GET", f"/repos/{quoted}/docs")
116
+ if not isinstance(value, list):
117
+ die("Yuque returned malformed document data.")
118
+ return value
119
+
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}")
124
+ if not isinstance(value, dict):
125
+ die("Yuque returned malformed document data.")
126
+ return value
127
+
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)
131
+ if not isinstance(value, dict) or not value.get("id"):
132
+ die("Yuque did not return a valid created document.")
133
+ return value
134
+
135
+ def update_doc(self, repo: str, doc_id: str, body: dict) -> dict:
136
+ repo_q = urllib.parse.quote(str(repo), safe="")
137
+ doc_q = urllib.parse.quote(str(doc_id), safe="")
138
+ value = self.request("PUT", f"/repos/{repo_q}/docs/{doc_q}", body=body, write=True)
139
+ if not isinstance(value, dict) or not value.get("id"):
140
+ die("Yuque did not return a valid updated document.")
141
+ return value
142
+
143
+ def delete_doc(self, repo: str, doc_id: str) -> None:
144
+ repo_q = urllib.parse.quote(str(repo), safe="")
145
+ doc_q = urllib.parse.quote(str(doc_id), safe="")
146
+ self.request("DELETE", f"/repos/{repo_q}/docs/{doc_q}", write=True, expect_json=False)
147
+
148
+
149
+ def read_content(args) -> str:
150
+ if args.content_file:
151
+ path = pathlib.Path(args.content_file)
152
+ try:
153
+ if path.stat().st_size > MAX_CONTENT_BYTES:
154
+ die("Content file exceeds the 10 MiB safety limit.")
155
+ return path.read_text(encoding="utf-8")
156
+ except OSError as error:
157
+ die(f"Cannot read --content-file: {error}")
158
+ if args.content is not None:
159
+ if len(args.content.encode("utf-8")) > MAX_CONTENT_BYTES:
160
+ die("Content exceeds the 10 MiB safety limit.")
161
+ return args.content
162
+ die("Provide --content-file <path.md> or --content <markdown>.")
163
+
164
+
165
+ def format_repo(repo: dict) -> dict:
166
+ return {
167
+ "repo_id": repo.get("id"),
168
+ "namespace": repo.get("namespace"),
169
+ "name": repo.get("name"),
170
+ "slug": repo.get("slug"),
171
+ "type": repo.get("type"),
172
+ "public": repo.get("public"),
173
+ "items_count": repo.get("items_count"),
174
+ }
175
+
176
+
177
+ def format_doc(doc: dict, repo: str | None = None) -> dict:
178
+ namespace = repo or (doc.get("book") or {}).get("namespace")
179
+ slug = doc.get("slug")
180
+ return {
181
+ "doc_id": doc.get("id"),
182
+ "title": doc.get("title"),
183
+ "slug": slug,
184
+ "public": doc.get("public"),
185
+ "status": doc.get("status"),
186
+ "url": f"https://www.yuque.com/{namespace}/{slug}" if namespace and slug else None,
187
+ "word_count": doc.get("word_count"),
188
+ "created_at": doc.get("created_at"),
189
+ "updated_at": doc.get("updated_at"),
190
+ }
191
+
192
+
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
+ def build_parser() -> argparse.ArgumentParser:
208
+ parser = argparse.ArgumentParser(prog="yuque.py", description="Yuque open API CLI")
209
+ sub = parser.add_subparsers(dest="command", required=True)
210
+ sub.add_parser("whoami", help="show the token's account")
211
+
212
+ 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")
214
+
215
+ docs = sub.add_parser("docs", help="list documents in a knowledge base")
216
+ docs.add_argument("repo", help="repo namespace (user/book) or numeric repo id")
217
+ docs.add_argument("--limit", type=int, default=20)
218
+
219
+ one = sub.add_parser("doc", help="read one document")
220
+ one.add_argument("repo")
221
+ one.add_argument("doc_id", help="document id or slug")
222
+
223
+ for command in ("create", "update"):
224
+ write = sub.add_parser(command, help=f"{command} a document (GATED by trailing --confirm)")
225
+ write.add_argument("repo")
226
+ if command == "update":
227
+ write.add_argument("doc_id")
228
+ write.add_argument("--title", required=True)
229
+ write.add_argument("--content")
230
+ write.add_argument("--content-file")
231
+ write.add_argument("--slug")
232
+ write.add_argument(
233
+ "--public",
234
+ action="store_true",
235
+ help="publish publicly; omit to keep the document private",
236
+ )
237
+
238
+ delete = sub.add_parser("delete", help="delete a document (GATED by trailing --confirm)")
239
+ delete.add_argument("repo")
240
+ delete.add_argument("doc_id")
241
+ return parser
242
+
243
+
244
+ def dry_run(args, content: str | None = None) -> None:
245
+ value = {"dry_run": True, "command": args.command, "platform": "yuque", "repo": args.repo}
246
+ if args.command in {"create", "update"}:
247
+ value.update(
248
+ {
249
+ "doc_id": getattr(args, "doc_id", None),
250
+ "title": args.title,
251
+ "visibility": "public" if args.public else "private",
252
+ "slug": args.slug,
253
+ "content_characters": len(content or ""),
254
+ }
255
+ )
256
+ else:
257
+ value["doc_id"] = args.doc_id
258
+ value["note"] = "Re-run with --confirm as the final argument to write to Yuque."
259
+ output(value)
260
+
261
+
262
+ def main(argv: list[str] | None = None) -> None:
263
+ raw = list(sys.argv[1:] if argv is None else argv)
264
+ clean_argv, confirmed = split_confirmation(raw)
265
+ args = build_parser().parse_args(clean_argv)
266
+ content = read_content(args) if args.command in {"create", "update"} else None
267
+ if args.command in GATED_COMMANDS and not confirmed:
268
+ dry_run(args, content)
269
+ return
270
+
271
+ client = YuqueClient.from_environment()
272
+ if args.command == "whoami":
273
+ user = client.user()
274
+ output(
275
+ {
276
+ "user_id": user.get("id"),
277
+ "login": user.get("login"),
278
+ "name": user.get("name"),
279
+ "url": f"https://www.yuque.com/{user.get('login')}" if user.get("login") else None,
280
+ "books_count": user.get("books_count"),
281
+ "public_books_count": user.get("public_books_count"),
282
+ }
283
+ )
284
+ 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)]})
289
+ elif args.command == "docs":
290
+ if not 1 <= args.limit <= MAX_ITEMS:
291
+ die(f"--limit must be between 1 and {MAX_ITEMS}.")
292
+ items = client.docs(args.repo)[: args.limit]
293
+ output({"count": len(items), "docs": [format_doc(item, args.repo) for item in items]})
294
+ elif args.command == "doc":
295
+ doc = client.doc(args.repo, args.doc_id)
296
+ result = format_doc(doc, args.repo)
297
+ result["body"] = doc.get("body")
298
+ output(result)
299
+ 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)})
302
+ 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)})
305
+ elif args.command == "delete":
306
+ client.delete_doc(args.repo, args.doc_id)
307
+ output({"ok": True, "repo": args.repo, "doc_id": args.doc_id, "deleted": True})
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()