@acedatacloud/skills 2026.621.3 → 2026.621.5

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.621.3",
3
+ "version": "2026.621.5",
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,75 @@
1
+ ---
2
+ name: cloudflare
3
+ description: Manage Cloudflare zones, DNS records, cache purge and Workers via the API v4. Use when the user mentions Cloudflare, a DNS record on a Cloudflare-hosted domain, purging / clearing the CDN cache, a zone's settings, WAF / firewall rules, or listing Workers.
4
+ when_to_use: |
5
+ Trigger when the user wants to list Cloudflare zones, read or change
6
+ DNS records, purge the cache, inspect Workers, or review firewall
7
+ rules. The connector stores a scoped Cloudflare API token; confirm
8
+ before any write (DNS create/update/delete, cache purge) and prefer
9
+ the smallest-blast-radius action.
10
+ connections: [cloudflare]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ Call the **Cloudflare API v4** with `curl + jq`. The user's **scoped** token is
19
+ in `$CLOUDFLARE_API_TOKEN` (optionally `$CLOUDFLARE_ACCOUNT_ID` /
20
+ `$CLOUDFLARE_ZONE_ID`); every call needs `Authorization: Bearer
21
+ $CLOUDFLARE_API_TOKEN`. Base URL: `https://api.cloudflare.com/client/v4`.
22
+
23
+ Every response has `{"success": bool, "errors": [...], "result": ...}`. On
24
+ `success:false` show `.errors` verbatim. `403`/`9109` means the token lacks the
25
+ permission for that resource → the user must re-mint the token with the right
26
+ scope (zone DNS edit, cache purge, etc.).
27
+
28
+ ```bash
29
+ AUTH=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN")
30
+ API="https://api.cloudflare.com/client/v4"
31
+ # Zones the token can see
32
+ curl -sS "${AUTH[@]}" "$API/zones" | jq '.result[] | {id, name, status, plan: .plan.name}'
33
+ ```
34
+
35
+ ## DNS records
36
+
37
+ ```bash
38
+ ZONE="${CLOUDFLARE_ZONE_ID:?set or pick from the zones list}"
39
+ # List (filter with ?type=A&name=foo.example.com)
40
+ curl -sS "${AUTH[@]}" "$API/zones/$ZONE/dns_records?per_page=100" \
41
+ | jq '.result[] | {id, type, name, content, proxied, ttl}'
42
+
43
+ # Create (confirm first)
44
+ curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \
45
+ -d '{"type":"CNAME","name":"www","content":"example.com","proxied":true}' \
46
+ "$API/zones/$ZONE/dns_records" | jq '.success, .result.id'
47
+
48
+ # Update PATCH /dns_records/{id} ; delete DELETE /dns_records/{id}
49
+ ```
50
+
51
+ ## Purge cache (confirm first)
52
+
53
+ ```bash
54
+ # Targeted purge by URL (preferred); use {"purge_everything":true} only if asked
55
+ curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \
56
+ -d '{"files":["https://example.com/path"]}' \
57
+ "$API/zones/$ZONE/purge_cache" | jq '.success'
58
+ ```
59
+
60
+ ## Workers & firewall
61
+
62
+ ```bash
63
+ ACCT="${CLOUDFLARE_ACCOUNT_ID:?needed for account-scoped resources}"
64
+ curl -sS "${AUTH[@]}" "$API/accounts/$ACCT/workers/scripts" | jq '.result[] | {id, modified_on}'
65
+ # Firewall / WAF custom rules live under /zones/$ZONE/firewall/rules and rulesets.
66
+ ```
67
+
68
+ ## Gotchas
69
+
70
+ - Insist on a **scoped API token**, never the legacy Global API Key (the connect
71
+ form's help says so) — a Global Key can do anything on the account.
72
+ - `proxied:true` = orange-cloud (CDN/WAF on); `false` = DNS-only. Changing it can
73
+ break TLS/origin expectations — confirm intent.
74
+ - Account-scoped calls (Workers, some analytics) need `$CLOUDFLARE_ACCOUNT_ID`;
75
+ zone-scoped calls need a zone id (pick from `/zones` if env unset).
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: cn-blog
3
+ description: Read and publish on Chinese content platforms with the user's own login cookies (BYOC) — list their published articles with vote/comment stats, inspect one article, and publish a new article. Use when the user mentions 知乎 / Zhihu, "我的知乎文章", reading their article stats (点赞/评论), or publishing/发文 to Zhihu.
4
+ when_to_use: |
5
+ Trigger for anything on the user's Zhihu (知乎) account driven by their own
6
+ login cookie: show who they are, list their published articles with
7
+ vote-up / comment counts, look at one article's stats, or publish a new
8
+ article. This acts as the user's real account, so writes are gated behind
9
+ an explicit confirmation.
10
+ connections: [zhihu]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ # cn-blog — Zhihu via your own cookies
19
+
20
+ Drives the user's **real** Zhihu account through the same web APIs the site's
21
+ own editor uses, authenticated by the login cookie they captured with the ACE
22
+ extension. No browser, no third-party deps — just `urllib`.
23
+
24
+ The connector injects the cookie jar as an env var:
25
+
26
+ - `ZHIHU_COOKIES` — a JSON array of `{name, value, domain, path, ...}` cookies.
27
+ **Secret — never echo or print it.** The CLI reads it for you.
28
+
29
+ ## CLI
30
+
31
+ The skill ships [`scripts/blog.py`](scripts/blog.py) — self-contained, stdlib only.
32
+
33
+ ```sh
34
+ BLOG=$SKILL_DIR/scripts/blog.py
35
+
36
+ # Read (run directly)
37
+ python3 $BLOG whoami # who is logged in
38
+ python3 $BLOG articles --limit 20 # my published articles + stats
39
+ python3 $BLOG article <article-id> # one article's details + stats
40
+ ```
41
+
42
+ ## Verify the connection first
43
+
44
+ ```sh
45
+ python3 $BLOG whoami
46
+ # → {"id": "...", "name": "崔庆才丨静觅", "url_token": "cui-qing-cai", ...}
47
+ ```
48
+
49
+ On a `401`/`403` the cookie is expired — tell the user to reconnect at
50
+ <https://auth.acedata.cloud/user/connections> (re-capture with the ACE
51
+ extension). Do **not** retry in a loop.
52
+
53
+ ## Reading recipes
54
+
55
+ | Goal | Command |
56
+ |---|---|
57
+ | Who am I | `python3 $BLOG whoami` |
58
+ | My latest articles + vote/comment counts | `python3 $BLOG articles --limit 20` |
59
+ | Next page | `python3 $BLOG articles --limit 20 --offset 20` |
60
+ | One article's stats | `python3 $BLOG article <id>` |
61
+
62
+ Stats come straight from Zhihu: `voteup_count` (赞同), `comment_count` (评论).
63
+ Zhihu does not expose per-article read counts on these endpoints.
64
+
65
+ ## Publishing — GATED (dry-run unless trailing `--confirm`)
66
+
67
+ `publish` writes to the user's real account. Without a trailing `--confirm` it
68
+ **dry-runs** (prints what it would do, changes nothing). `--confirm` is honored
69
+ **only as the last argument**, so a title/content containing "--confirm" can
70
+ never silently go live. Always show the dry-run to the user, get an explicit
71
+ "yes", then re-run with `--confirm` last.
72
+
73
+ ```sh
74
+ # Content is HTML. For Markdown, convert to HTML first.
75
+ python3 $BLOG publish --title "标题" --content-file article.html # dry-run
76
+ python3 $BLOG publish --title "标题" --content-file article.html --draft-only --confirm # save a private draft
77
+ python3 $BLOG publish --title "标题" --content-file article.html --confirm # PUBLIC, goes live
78
+ ```
79
+
80
+ - `--draft-only` stops after saving a private draft (safe — nothing public).
81
+ - Without `--draft-only`, the article is **published publicly** under the user's
82
+ name. Default to `--draft-only` unless the user clearly asked to go live.
83
+ - Images: only image URLs already reachable on the public web are kept as-is;
84
+ this CLI does not re-upload local images to Zhihu's CDN.
85
+
86
+ ## Gotchas — surface before the user is surprised
87
+
88
+ - **This is the user's real Zhihu account.** Confirm before any publish; reading
89
+ exposes their own private drafts.
90
+ - **Cookie expiry**: Zhihu cookies are short-lived. A `401`/`403` means
91
+ reconnect at auth.acedata.cloud/user/connections — never loop-retry.
92
+ - **ToS**: cookie automation is against most platforms' terms. This only ever
93
+ acts on the user's own account with their own captured cookie; the user owns
94
+ that risk. Never use it to scrape other people's content at scale.
95
+ - **Never print `ZHIHU_COOKIES`** — it is full account access.
96
+ - **Scope today**: Zhihu only. 掘金 / CSDN connectors exist in the vault and are
97
+ planned next; this skill will grow a `--platform` switch for them.
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ cn-blog — read & publish on Chinese content platforms with the user's own
4
+ login cookies (BYOC). Standard-library only (urllib), no third-party deps,
5
+ 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 named
8
+ ``<PLATFORM>_COOKIES`` (e.g. ``ZHIHU_COOKIES``) — a list of
9
+ ``{name, value, domain, path, ...}`` dicts captured by the ACE extension.
10
+
11
+ Read commands run directly. ``publish`` is GATED: without a trailing
12
+ ``--confirm`` it only dry-runs (prints what it would do, changes nothing).
13
+ ``--confirm`` is honored ONLY as the last argument, so a title/content that
14
+ merely contains "--confirm" can never silently go live.
15
+
16
+ Quick examples:
17
+ python3 $SKILL_DIR/scripts/blog.py whoami
18
+ python3 $SKILL_DIR/scripts/blog.py articles --limit 20
19
+ python3 $SKILL_DIR/scripts/blog.py article <article-id>
20
+ python3 $SKILL_DIR/scripts/blog.py drafts
21
+ python3 $SKILL_DIR/scripts/blog.py publish --title "T" --content-file a.html --draft-only --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/124.0.0.0 Safari/537.36"
38
+ )
39
+
40
+ # --confirm is only honored as the LAST token so a value that merely
41
+ # contains "--confirm" can never silently confirm a write.
42
+ _RAW = sys.argv[1:]
43
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
44
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
45
+
46
+
47
+ def out(obj) -> None:
48
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
49
+
50
+
51
+ def die(msg: str, code: int = 1) -> None:
52
+ out({"error": msg})
53
+ sys.exit(code)
54
+
55
+
56
+ # ── Cookie jar (from env) ───────────────────────────────────────────
57
+
58
+ def load_cookies(platform: str) -> list[dict]:
59
+ env = f"{platform.upper()}_COOKIES"
60
+ raw = os.environ.get(env)
61
+ if not raw:
62
+ die(
63
+ f"{env} is not set — connect the {platform} account at "
64
+ f"https://auth.acedata.cloud/user/connections, then retry."
65
+ )
66
+ try:
67
+ jar = json.loads(raw)
68
+ except json.JSONDecodeError as e:
69
+ die(f"{env} is not valid JSON: {e}")
70
+ if not isinstance(jar, list):
71
+ die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
72
+ return jar
73
+
74
+
75
+ def _domain_matches(host: str, domain: str) -> bool:
76
+ # Browser-style domain match: a cookie scoped to ".zhihu.com" / "zhihu.com"
77
+ # is sent to zhihu.com and any subdomain; a host-only cookie matches exactly.
78
+ d = domain.lstrip(".").lower()
79
+ h = host.lower()
80
+ return not d or h == d or h.endswith("." + d)
81
+
82
+
83
+ def cookie_header(jar: list[dict], url: str) -> str:
84
+ # Only send a cookie to a host inside its domain scope, so a jar is never
85
+ # replayed outside the platform it was captured for (defense in depth — all
86
+ # request URLs here are first-party, but this future-proofs multi-platform).
87
+ host = urllib.parse.urlsplit(url).hostname or ""
88
+ # A domainless cookie has no scope of its own; only send it to a host the
89
+ # jar's domain-scoped cookies already cover (i.e. this jar's own platform),
90
+ # never fail-open to an arbitrary host.
91
+ host_in_scope = any(
92
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
93
+ )
94
+ parts = []
95
+ for c in jar:
96
+ name, value = c.get("name"), c.get("value")
97
+ if not name or value is None:
98
+ continue
99
+ domain = c.get("domain")
100
+ if domain:
101
+ if not _domain_matches(host, str(domain)):
102
+ continue
103
+ elif not host_in_scope:
104
+ continue
105
+ parts.append(f"{name}={value}")
106
+ return "; ".join(parts)
107
+
108
+
109
+ # ── HTTP (stdlib urllib) ────────────────────────────────────────────
110
+
111
+ def request(method: str, url: str, jar: list[dict], *, headers=None, body=None):
112
+ hdrs = {
113
+ "User-Agent": UA,
114
+ "Accept": "application/json, text/plain, */*",
115
+ "Cookie": cookie_header(jar, url),
116
+ }
117
+ if headers:
118
+ hdrs.update(headers)
119
+ data = None
120
+ if body is not None:
121
+ data = json.dumps(body).encode("utf-8")
122
+ hdrs.setdefault("Content-Type", "application/json")
123
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
124
+ try:
125
+ with urllib.request.urlopen(req, timeout=30) as resp:
126
+ raw = resp.read()
127
+ if resp.headers.get("Content-Encoding") == "gzip":
128
+ raw = gzip.decompress(raw)
129
+ return resp.status, raw.decode("utf-8", "replace")
130
+ except urllib.error.HTTPError as e:
131
+ raw = e.read()
132
+ try:
133
+ if e.headers.get("Content-Encoding") == "gzip":
134
+ raw = gzip.decompress(raw)
135
+ except Exception:
136
+ pass
137
+ return e.code, raw.decode("utf-8", "replace")
138
+ except urllib.error.URLError as e:
139
+ die(f"network error reaching {url}: {e.reason}")
140
+
141
+
142
+ def get_json(url: str, jar: list[dict], **kw):
143
+ status, text = request("GET", url, jar, **kw)
144
+ if status == 401 or status == 403:
145
+ die(
146
+ f"auth failed ({status}) on {url} — the cookie is likely expired. "
147
+ f"Reconnect at https://auth.acedata.cloud/user/connections."
148
+ )
149
+ try:
150
+ return status, json.loads(text)
151
+ except json.JSONDecodeError:
152
+ die(f"non-JSON response ({status}) from {url}: {text[:300]}")
153
+
154
+
155
+ # ── Zhihu ───────────────────────────────────────────────────────────
156
+
157
+ ZH = {
158
+ "me": "https://www.zhihu.com/api/v4/me",
159
+ "articles": "https://www.zhihu.com/api/v4/members/{token}/articles",
160
+ "article": "https://www.zhihu.com/api/v4/articles/{id}",
161
+ "create_draft": "https://zhuanlan.zhihu.com/api/articles/drafts",
162
+ }
163
+ ZH_FETCH = {"x-requested-with": "fetch"}
164
+
165
+
166
+ def zh_me(jar):
167
+ _, data = get_json(ZH["me"], jar, headers=ZH_FETCH)
168
+ if not data.get("id"):
169
+ die(f"could not read Zhihu profile (cookie expired?): {str(data)[:300]}")
170
+ return data
171
+
172
+
173
+ def cmd_whoami(jar, _args):
174
+ me = zh_me(jar)
175
+ out({
176
+ "id": str(me.get("id", "")),
177
+ "name": me.get("name"),
178
+ "url_token": me.get("url_token"),
179
+ "headline": me.get("headline"),
180
+ "articles_count": me.get("articles_count"),
181
+ "voteup_count": me.get("voteup_count"),
182
+ })
183
+
184
+
185
+ # Zhihu omits stats unless asked via `include`. This pulls the counts the
186
+ # user actually cares about onto each article in the list/detail responses.
187
+ ZH_ARTICLE_INCLUDE = "data[*].comment_count,voteup_count,created,updated,title,url"
188
+
189
+
190
+ def _https(url):
191
+ if isinstance(url, str) and url.startswith("http://"):
192
+ return "https://" + url[len("http://"):]
193
+ return url
194
+
195
+
196
+ def _fmt_article(a: dict) -> dict:
197
+ aid = a.get("id")
198
+ # Prefer the canonical public reader URL built from the id — the API's own
199
+ # `url` field is inconsistent (zhuanlan in the list, api.zhihu.com in detail).
200
+ return {
201
+ "id": str(aid) if aid is not None else None,
202
+ "title": a.get("title"),
203
+ "url": (f"https://zhuanlan.zhihu.com/p/{aid}" if aid else _https(a.get("url"))),
204
+ "voteup_count": a.get("voteup_count"),
205
+ "comment_count": a.get("comment_count"),
206
+ "created": a.get("created"),
207
+ "updated": a.get("updated"),
208
+ }
209
+
210
+
211
+ def cmd_articles(jar, args):
212
+ me = zh_me(jar)
213
+ token = me.get("url_token")
214
+ if not token:
215
+ die("Zhihu profile has no url_token; cannot list articles.")
216
+ url = ZH["articles"].format(token=token)
217
+ q = urllib.parse.urlencode({
218
+ "include": ZH_ARTICLE_INCLUDE,
219
+ "limit": args.limit,
220
+ "offset": args.offset,
221
+ })
222
+ _, data = get_json(f"{url}?{q}", jar, headers=ZH_FETCH)
223
+ items = data.get("data", []) if isinstance(data, dict) else []
224
+ out({
225
+ "total": (data.get("paging") or {}).get("totals") if isinstance(data, dict) else None,
226
+ "count": len(items),
227
+ "articles": [_fmt_article(a) for a in items],
228
+ })
229
+
230
+
231
+ def cmd_article(jar, args):
232
+ base = ZH["article"].format(id=args.id)
233
+ q = urllib.parse.urlencode({"include": "comment_count,voteup_count"})
234
+ _, a = get_json(f"{base}?{q}", jar, headers=ZH_FETCH)
235
+ if not isinstance(a, dict) or not a.get("id"):
236
+ die(f"article {args.id} not found or not accessible: {str(a)[:300]}")
237
+ res = _fmt_article(a)
238
+ res["content_excerpt"] = (a.get("excerpt") or "")[:200]
239
+ out(res)
240
+
241
+
242
+ def cmd_publish(jar, args):
243
+ if not args.title:
244
+ die("--title is required")
245
+ if not args.content_file and args.content is None:
246
+ die("provide --content-file <path> or --content <html>")
247
+ content = args.content
248
+ if args.content_file:
249
+ try:
250
+ with open(args.content_file, encoding="utf-8") as f:
251
+ content = f.read()
252
+ except OSError as e:
253
+ die(f"cannot read --content-file: {e}")
254
+
255
+ if not CONFIRM:
256
+ out({
257
+ "dry_run": True,
258
+ "command": "publish",
259
+ "platform": "zhihu",
260
+ "title": args.title,
261
+ "draft_only": args.draft_only,
262
+ "content_bytes": len(content or ""),
263
+ "note": "re-run with --confirm as the LAST argument to actually write. "
264
+ "Without --draft-only this publishes a PUBLIC article on the user's real account.",
265
+ })
266
+ return
267
+
268
+ # 1. create empty draft
269
+ status, text = request(
270
+ "POST", ZH["create_draft"], jar, headers=ZH_FETCH,
271
+ body={"title": args.title, "content": "", "delta_time": 0},
272
+ )
273
+ try:
274
+ created = json.loads(text)
275
+ except json.JSONDecodeError:
276
+ die(f"create-draft returned non-JSON ({status}): {text[:300]}")
277
+ draft_id = created.get("id")
278
+ if not draft_id:
279
+ die(f"create-draft failed ({status}): {str(created)[:300]}")
280
+
281
+ # 2. set draft content
282
+ status, text = request(
283
+ "PATCH", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/draft", jar,
284
+ headers=ZH_FETCH, body={"title": args.title, "content": content},
285
+ )
286
+ if status >= 400:
287
+ die(f"update-draft failed ({status}) for {draft_id}: {text[:300]}")
288
+
289
+ if args.draft_only:
290
+ out({
291
+ "ok": True,
292
+ "draft_only": True,
293
+ "draft_id": str(draft_id),
294
+ "edit_url": f"https://zhuanlan.zhihu.com/write?draftId={draft_id}",
295
+ })
296
+ return
297
+
298
+ # 3. publish (go live)
299
+ status, text = request(
300
+ "PUT", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/publish", jar,
301
+ headers=ZH_FETCH, body={},
302
+ )
303
+ if status >= 400:
304
+ die(f"publish failed ({status}) for {draft_id}; it remains a draft: {text[:300]}")
305
+ out({
306
+ "ok": True,
307
+ "published": True,
308
+ "article_id": str(draft_id),
309
+ "url": f"https://zhuanlan.zhihu.com/p/{draft_id}",
310
+ })
311
+
312
+
313
+ COMMANDS = {
314
+ "whoami": cmd_whoami,
315
+ "articles": cmd_articles,
316
+ "article": cmd_article,
317
+ "publish": cmd_publish,
318
+ }
319
+
320
+
321
+ def main() -> None:
322
+ p = argparse.ArgumentParser(prog="blog.py", description="cn-blog cookie CLI")
323
+ p.add_argument("--platform", default="zhihu", choices=["zhihu"],
324
+ help="content platform (only zhihu is implemented today)")
325
+ sub = p.add_subparsers(dest="command", required=True)
326
+
327
+ sub.add_parser("whoami", help="show the logged-in account")
328
+
329
+ sp = sub.add_parser("articles", help="list the user's published articles + stats")
330
+ sp.add_argument("--limit", type=int, default=20)
331
+ sp.add_argument("--offset", type=int, default=0)
332
+
333
+ sp = sub.add_parser("article", help="one article's details + stats")
334
+ sp.add_argument("id")
335
+
336
+ sp = sub.add_parser("publish", help="create/publish an article (GATED by trailing --confirm)")
337
+ sp.add_argument("--title")
338
+ sp.add_argument("--content", help="HTML content inline")
339
+ sp.add_argument("--content-file", help="path to an HTML file")
340
+ sp.add_argument("--draft-only", action="store_true",
341
+ help="create a draft only; do NOT go public")
342
+
343
+ args = p.parse_args(ARGV)
344
+ jar = load_cookies(args.platform)
345
+ COMMANDS[args.command](jar, args)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: figma
3
+ description: Read Figma design files, nodes, rendered images and comments via the Figma REST API. Use when the user mentions Figma, a figma.com file link, implementing a design as code, extracting design tokens / colors / spacing, or summarizing comments on a design.
4
+ when_to_use: |
5
+ Trigger when the user shares a Figma file URL or wants to read a
6
+ design — get the document tree / a specific frame, render a node to
7
+ PNG/SVG to "see" it, extract styles / tokens, or read comments.
8
+ Read-only. The file key comes from the Figma URL the user pastes.
9
+ connections: [figma]
10
+ allowed_tools: [Bash]
11
+ license: Apache-2.0
12
+ metadata:
13
+ author: acedatacloud
14
+ version: "1.0"
15
+ ---
16
+
17
+ Read **Figma** via `curl + jq`. The user's OAuth bearer token is in
18
+ `$FIGMA_TOKEN`; every call needs `Authorization: Bearer $FIGMA_TOKEN`. Base URL:
19
+ `https://api.figma.com/v1`.
20
+
21
+ Failures are `{"status":<code>,"err":"..."}` — show `err` verbatim. `403` means
22
+ the token lacks the scope or the file isn't shared with the user. `404` = bad
23
+ file key.
24
+
25
+ The **file key** is the `figma.com/file/<KEY>/...` or `figma.com/design/<KEY>/...`
26
+ segment of a pasted URL. A **node id** is in `?node-id=1-23` (Figma shows `1:23`;
27
+ the API also accepts `1:23`).
28
+
29
+ ```bash
30
+ F="https://api.figma.com/v1"; AUTH=(-H "Authorization: Bearer $FIGMA_TOKEN")
31
+ # Who am I (account card)
32
+ curl -sS "${AUTH[@]}" "$F/me" | jq '{handle, email}'
33
+ # File document tree (name + top-level frames). Big files: prefer /nodes below.
34
+ curl -sS "${AUTH[@]}" "$F/files/FILE_KEY?depth=2" \
35
+ | jq '{name, pages: [.document.children[] | {name, frames: [.children[]?.name]}]}'
36
+ ```
37
+
38
+ ## Read specific nodes & render images
39
+
40
+ ```bash
41
+ KEY="FILE_KEY"
42
+ # Just the nodes you care about (faster than the whole file)
43
+ curl -sS "${AUTH[@]}" "$F/files/$KEY/nodes?ids=1:23,1:45" \
44
+ | jq '.nodes | to_entries[] | {id: .key, name: .value.document.name, type: .value.document.type}'
45
+
46
+ # Render nodes to images — returns temporary CDN URLs (this is the "see it" tool)
47
+ curl -sS "${AUTH[@]}" "$F/images/$KEY?ids=1:23&format=png&scale=2" \
48
+ | jq '.images' # { "1:23": "https://...png" }
49
+ ```
50
+
51
+ For design-to-code, render the frame to PNG (to view) and read its node JSON
52
+ (layout/fills/typography) to extract exact colors, spacing and text.
53
+
54
+ ## Comments & projects
55
+
56
+ ```bash
57
+ curl -sS "${AUTH[@]}" "$F/files/FILE_KEY/comments" \
58
+ | jq '.comments[] | {user: .user.handle, at: .created_at, message}'
59
+ # Team projects → files (needs a team id from the Figma URL /team/<id>/...)
60
+ curl -sS "${AUTH[@]}" "$F/teams/TEAM_ID/projects" | jq '.projects'
61
+ ```
62
+
63
+ ## Gotchas
64
+
65
+ - Node ids: Figma URLs use `1-23` (dash); the API wants `1:23` (colon). Convert.
66
+ - `/images` URLs are **temporary** — download/use them promptly, don't store.
67
+ - `depth=` limits tree traversal; omit it only for small files or you'll pull
68
+ megabytes of node JSON.
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: google-ads
3
+ description: Query Google Ads campaigns, ad groups, keywords and spend via the Google Ads API (GAQL searchStream). Use when the user mentions Google Ads, ad campaigns, ad spend / cost, impressions / clicks / conversions on ads, or campaign performance.
4
+ when_to_use: |
5
+ Trigger when the user wants Google Ads reporting — list accessible
6
+ customers, campaign / ad-group / keyword performance, spend and
7
+ conversions. Read via GAQL. Needs the OAuth token PLUS a platform
8
+ developer token and a login-customer-id; if those env vars are
9
+ absent the connector isn't fully provisioned yet — tell the user.
10
+ connections: [google/ads]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ Query the **Google Ads API** via `curl + jq`. Three credentials are needed:
19
+
20
+ - `$GOOGLE_ADS_TOKEN` — the user's OAuth bearer (`adwords` scope) →
21
+ `Authorization: Bearer $GOOGLE_ADS_TOKEN`
22
+ - `$GOOGLE_ADS_DEVELOPER_TOKEN` — the platform's developer token (injected
23
+ server-side) → header `developer-token: $GOOGLE_ADS_DEVELOPER_TOKEN`
24
+ - `login-customer-id` — the manager (MCC) id under which calls are made; use the
25
+ target customer id, or `$GOOGLE_ADS_LOGIN_CUSTOMER_ID` if set (digits only, no
26
+ dashes).
27
+
28
+ > **API version:** the base is `https://googleads.googleapis.com/<vNN>`. Google
29
+ > ships a new `vNN` every ~4 months and retires old ones — set `VER` to the
30
+ > **current** supported version (check developers.google.com/google-ads/api
31
+ > release notes); the example uses `v18`.
32
+
33
+ If `$GOOGLE_ADS_DEVELOPER_TOKEN` is empty, the connector isn't fully provisioned —
34
+ say so rather than calling the API (it would 401/DEVELOPER_TOKEN_NOT_APPROVED).
35
+
36
+ ```bash
37
+ VER="v18"; BASE="https://googleads.googleapis.com/$VER"
38
+ AUTH=(-H "Authorization: Bearer $GOOGLE_ADS_TOKEN" -H "developer-token: $GOOGLE_ADS_DEVELOPER_TOKEN")
39
+ # Customers the OAuth user can access (ids are returned as customers/<id>)
40
+ curl -sS "${AUTH[@]}" "$BASE/customers:listAccessibleCustomers" | jq '.resourceNames'
41
+ ```
42
+
43
+ ## Report with GAQL (searchStream)
44
+
45
+ ```bash
46
+ CID="1234567890" # target customer id, digits only
47
+ curl -sS "${AUTH[@]}" -H "login-customer-id: ${GOOGLE_ADS_LOGIN_CUSTOMER_ID:-$CID}" \
48
+ -H "Content-Type: application/json" -d '{
49
+ "query":"SELECT campaign.name, metrics.cost_micros, metrics.clicks, metrics.conversions FROM campaign WHERE segments.date DURING LAST_30_DAYS ORDER BY metrics.cost_micros DESC"
50
+ }' "$BASE/customers/$CID/googleAds:searchStream" \
51
+ | jq '.[].results[]? | {campaign: .campaign.name, cost_usd: (.metrics.costMicros|tonumber/1e6), clicks: .metrics.clicks, conv: .metrics.conversions}'
52
+ ```
53
+
54
+ GAQL resources: `campaign`, `ad_group`, `ad_group_criterion` (keywords),
55
+ `customer`. Cost is `metrics.cost_micros` (÷ 1,000,000 = account currency).
56
+
57
+ ## Gotchas
58
+
59
+ - **Three headers, not one.** Missing `developer-token` or `login-customer-id`
60
+ is the #1 cause of 401/403 here.
61
+ - Customer ids are **digits only** in URLs/headers (strip the dashes from
62
+ `123-456-7890`).
63
+ - `searchStream` returns an array of chunks each with `.results[]` — flatten with
64
+ `.[].results[]?`.
65
+ - Cost is in **micros** of the account currency; divide by 1e6.