@acedatacloud/skills 2026.725.0 → 2026.726.1

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.725.0",
3
+ "version": "2026.726.1",
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()
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: reddit
3
- description: Submit posts (link or text) to subreddits and read your Reddit identity / submissions using either official OAuth or your own Reddit login cookies. Use when the user mentions Reddit, posting to a subreddit, submitting a link or self-post, or checking their Reddit profile / submissions.
3
+ description: Search subreddits, read rules, reply to threads, and submit posts (link or text) to Reddit using either official OAuth or your own Reddit login cookies. Use when the user mentions Reddit, posting to a subreddit, replying or commenting on a thread, submitting a link or self-post, or checking their Reddit profile / submissions.
4
4
  when_to_use: |
5
- Trigger when the user wants to submit a post to a subreddit (link or
6
- self/text post), or read their own Reddit identity and submissions.
7
- Posting to a subreddit is public and subject to that subreddit's rules
8
- / karma requirements — confirm the target subreddit, title and body
9
- before submitting.
5
+ Trigger when the user wants to find relevant Reddit threads, reply to a
6
+ post or comment, submit a post to a subreddit (link or self/text post),
7
+ or read their own Reddit identity and submissions. Commenting and posting
8
+ are public and subject to that subreddit's rules / karma requirements —
9
+ confirm the target and the exact text before writing.
10
10
  connections: [reddit]
11
11
  allowed_tools: [Bash, publish_artifact]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "2.0"
15
+ version: "2.1"
16
16
  connection_method_preferences:
17
17
  reddit/reddit: [oauth, cookie]
18
18
  ---
@@ -54,6 +54,35 @@ python3 "$R" whoami
54
54
  python3 "$R" submissions --limit 10
55
55
  ```
56
56
 
57
+ ## Find threads and check the rules
58
+
59
+ `search` returns each hit's `fullname` (`t3_…`), which is what `comment --parent`
60
+ takes. `subreddit-info` returns the subreddit's rules — **read them before
61
+ writing anything there.**
62
+
63
+ ```sh
64
+ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
65
+ [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
66
+
67
+ python3 "$R" search --query "suno api" --time week --limit 10
68
+ python3 "$R" search --query "image generation api" --subreddit SideProject --sort new
69
+ python3 "$R" subreddit-info --subreddit SideProject
70
+ ```
71
+
72
+ ## Reply to a thread — GATED
73
+
74
+ `--parent` accepts a fullname (`t3_…` post, `t1_…` comment) or a reddit.com
75
+ permalink. **Show the target thread and the exact reply text, then obtain
76
+ explicit confirmation.** Without a trailing `--confirm` this is a dry run.
77
+
78
+ ```sh
79
+ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -path '*/skills/*/reddit/scripts/reddit.py' -print -quit 2>/dev/null)
80
+ [ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
81
+
82
+ python3 "$R" comment --parent t3_1r9yrtn --body-file reply.md
83
+ python3 "$R" comment --parent t3_1r9yrtn --body-file reply.md --confirm
84
+ ```
85
+
57
86
  ## Submit a post — GATED
58
87
 
59
88
  Posting is public. **Always show the subreddit, final title and final body/URL,
@@ -79,25 +108,47 @@ contains the text `--confirm` can never trigger a write.
79
108
  ## Safety and failure handling
80
109
 
81
110
  - Never print `REDDIT_COOKIES`, `REDDIT_TOKEN`, `reddit_session` or the modhash.
82
- - Do not vote, send private messages, evade bans, automate engagement, or
83
- cross-post identical content. This skill intentionally exposes none of those
84
- operations.
85
- - Follow each subreddit's rules. Account age, karma and flair requirements can
86
- reject a post; report the rejection without exposing Reddit's raw authenticated
87
- response, which may contain reflected credential material.
111
+ - Do not vote, send private messages, evade bans, or cross-post identical
112
+ content. This skill intentionally exposes none of those operations.
113
+ - **Never post the same reply text to more than one thread.** Reddit treats
114
+ repeated identical comments as spam and actions the account, not the comment.
115
+ Write each reply against the specific thread it answers.
116
+ - Follow each subreddit's rules — run `subreddit-info` first. Account age, karma
117
+ and flair requirements can reject a post or comment; report the rejection
118
+ without exposing Reddit's raw authenticated response, which may contain
119
+ reflected credential material.
88
120
  - Do not retry a write automatically. A timeout may occur after Reddit accepted
89
121
  it, and replaying could create a duplicate.
90
- - Respect rate limits and never bulk-submit. Use `r/test` only for a deliberate
91
- end-to-end validation.
122
+ - Respect rate limits and never bulk-submit or bulk-comment. Use `r/test` only
123
+ for a deliberate end-to-end validation.
92
124
  - Cookie mode drives Reddit's first-party web JSON endpoints and may drift when
93
125
  Reddit changes its site. Report unexpected HTML or route errors as upstream
94
126
  drift instead of guessing another private endpoint.
95
127
 
128
+ ## Self-promotion — read this before marketing anything
129
+
130
+ Reddit bans accounts for promotion patterns, not for individual links. When the
131
+ user's goal is promoting their own product:
132
+
133
+ - **Answer the question first.** A reply must stand on its own as useful even if
134
+ the product link were removed. If it does not, do not send it.
135
+ - **Disclose the affiliation** ("I build X") — undisclosed promotion is the
136
+ fastest route to a ban.
137
+ - **One link at most, at the end**, and never in a post title.
138
+ - **Do not blanket-reply.** A handful of genuinely relevant threads beats volume,
139
+ and volume is exactly what spam detection keys on.
140
+ - If a subreddit has a dedicated self-promo / "Showoff" thread, use it — that is
141
+ the sanctioned channel.
142
+ - A low-karma or new account will have posts auto-removed by AutoModerator in
143
+ most active subreddits. Check `whoami` karma and warn the user rather than
144
+ burning the account on a post that will be silently filtered.
145
+
96
146
 
97
147
  ## Record the output
98
148
 
99
- After you successfully publish and obtain the live result URL, call the built-in
100
- `publish_artifact` tool ONCE so the user can track this deliverable in **My Outputs**:
149
+ After you successfully publish a post **or a comment** and obtain the live result
150
+ URL, call the built-in `publish_artifact` tool ONCE so the user can track this
151
+ deliverable in **My Outputs**:
101
152
 
102
153
  ```
103
154
  publish_artifact(kind="message", channel="reddit", title="<title>", url="<the REAL returned URL>", status="delivered")