@acedatacloud/skills 2026.712.1 → 2026.713.0

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/AGENTS.md CHANGED
@@ -24,6 +24,7 @@ Skills are located in the `skills/` directory (also mirrored to `.agents/skills/
24
24
  - **hailuo-video** — Generate videos with Hailuo / MiniMax
25
25
  - **seedance-video** — Generate dance/motion videos with ByteDance Seedance
26
26
  - **wan-video** — Generate videos with Alibaba Wan
27
+ - **maestro-video** — Produce complete videos from a brief with scripting, media, voiceover, editing, captions, and multilingual variants
27
28
 
28
29
  ### AI Chat & Tools
29
30
  - **ai-chat** — Unified LLM gateway — GPT, Claude, Gemini, Kimi, Grok (50+ models)
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  Compatible with **30+ AI coding agents** via the [agentskills.io](https://agentskills.io/) open standard: Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, Cursor, Roo Code, Goose, and more.
14
14
 
15
- ## Available Skills (30)
15
+ ## Available Skills
16
16
 
17
17
  ### AI Music & Audio
18
18
 
@@ -40,6 +40,7 @@ Compatible with **30+ AI coding agents** via the [agentskills.io](https://agents
40
40
  | [kling-video](skills/kling-video/) | Generate videos with Kuaishou Kling (motion control) |
41
41
  | [hailuo-video](skills/hailuo-video/) | Generate videos with Hailuo / MiniMax |
42
42
  | [seedance-video](skills/seedance-video/) | Generate dance/motion videos with ByteDance Seedance |
43
+ | [maestro-video](skills/maestro-video/) | Produce complete videos from a brief with script, media, voiceover, captions, and editing |
43
44
 
44
45
  ### AI Chat & Tools
45
46
 
@@ -208,6 +209,7 @@ Skills provide **knowledge** (when to use, parameters, gotchas). MCP servers pro
208
209
  | veo-video | [mcp-veo](https://pypi.org/project/mcp-veo/) | `pip install mcp-veo` | `https://veo.mcp.acedata.cloud/mcp` |
209
210
  | seedream-image | [mcp-seedream](https://pypi.org/project/mcp-seedream/) | `pip install mcp-seedream` | `https://seedream.mcp.acedata.cloud/mcp` |
210
211
  | seedance-video | [mcp-seedance](https://pypi.org/project/mcp-seedance/) | `pip install mcp-seedance` | `https://seedance.mcp.acedata.cloud/mcp` |
212
+ | maestro-video | [mcp-maestro](https://pypi.org/project/mcp-maestro/) | `pip install mcp-maestro` | `https://maestro.mcp.acedata.cloud/mcp` |
211
213
  | nano-banana-image | [mcp-nano-banana](https://pypi.org/project/mcp-nano-banana/) | `pip install mcp-nano-banana` | `https://nano-banana.mcp.acedata.cloud/mcp` |
212
214
  | short-url | [mcp-shorturl](https://pypi.org/project/mcp-shorturl/) | `pip install mcp-shorturl` | `https://short-url.mcp.acedata.cloud/mcp` |
213
215
  | wan-video | [mcp-wan](https://pypi.org/project/mcp-wan/) | `pip install mcp-wan` | `https://wan.mcp.acedata.cloud/mcp` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.712.1",
3
+ "version": "2026.713.0",
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",
@@ -15,6 +15,7 @@ Each AceDataCloud service has a corresponding MCP server that provides tool-use
15
15
  | veo-video | `pip install mcp-veo` | `https://veo.mcp.acedata.cloud/mcp` |
16
16
  | seedream-image | `pip install mcp-seedream` | `https://seedream.mcp.acedata.cloud/mcp` |
17
17
  | seedance-video | `pip install mcp-seedance` | `https://seedance.mcp.acedata.cloud/mcp` |
18
+ | maestro-video | `pip install mcp-maestro` | `https://maestro.mcp.acedata.cloud/mcp` |
18
19
  | nano-banana-image | `pip install mcp-nano-banana` | `https://nano-banana.mcp.acedata.cloud/mcp` |
19
20
  | short-url | `pip install mcp-shorturl` | `https://short-url.mcp.acedata.cloud/mcp` |
20
21
  | wan-video | `pip install mcp-wan` | `https://wan.mcp.acedata.cloud/mcp` |
@@ -1,171 +1,119 @@
1
1
  ---
2
2
  name: hashnode
3
- description: Publish, update and read blog posts on Hashnode via its GraphQL API. Use when the user mentions Hashnode, publishing a blog post to Hashnode, cross-posting an article to their Hashnode blog, saving a Hashnode draft, updating a published Hashnode post, or listing their Hashnode publications and posts.
3
+ description: Publish, draft and read blog posts on the user's Hashnode blog using their own login cookies (BYOC) — no Hashnode Pro plan required. Use when the user mentions Hashnode, publishing/cross-posting an article to their Hashnode blog, saving a Hashnode draft, or listing their Hashnode publications.
4
4
  when_to_use: |
5
- Trigger when the user wants to publish a Markdown article to their
6
- Hashnode blog, save it as a draft, update a previously published
7
- post, or list / inspect their own Hashnode publications and posts.
8
- The connector stores a Hashnode Personal Access Token with full
9
- account access — confirm before publishing publicly (you can save a
10
- draft first with the createDraft mutation).
5
+ Trigger when the user wants to publish a Markdown article to their Hashnode
6
+ blog, save it as a private draft, or list their Hashnode publications. This
7
+ drives the user's REAL blog, so publishing is gated behind an explicit
8
+ confirmation (save a draft first if unsure).
11
9
  connections: [hashnode]
12
- allowed_tools: [Bash]
10
+ allowed_tools: [Bash, publish_artifact]
13
11
  license: Apache-2.0
14
12
  metadata:
15
13
  author: acedatacloud
16
- version: "1.0"
14
+ version: "2.0"
17
15
  ---
18
16
 
19
- Call the **Hashnode GraphQL API** with `curl + jq`. The user's Personal Access
20
- Token is in `$HASHNODE_TOKEN`. Single endpoint: `https://gql-beta.hashnode.com`
21
- (always `POST` a JSON body `{query, variables}`).
17
+ # hashnode publish to your Hashnode blog via your own cookies (no Pro)
22
18
 
23
- > The old `https://gql.hashnode.com` host is **deprecated** it now 301-redirects
24
- > to a changelog page and returns HTML, not JSON. Always use `gql-beta.hashnode.com`.
19
+ Hashnode moved its **public GraphQL API behind a paid Pro plan** (2026-05-13), so
20
+ a Personal Access Token can no longer publish for free. This skill instead drives
21
+ the **same first-party REST API the Hashnode web editor uses**
22
+ (`https://hashnode.com/api/...`), authenticated by the user's login **session
23
+ cookie** — which is free and needs no Pro plan.
25
24
 
26
- Every request needs these headers:
25
+ The connector injects the cookie jar as `HASHNODE_COOKIES` (a JSON array; the
26
+ session cookie is `hashnode-session`). **Secret — full account access. Never echo
27
+ or print it.**
27
28
 
28
- ```
29
- Authorization: Bearer $HASHNODE_TOKEN
30
- Content-Type: application/json
31
- ```
32
-
33
- GraphQL always returns HTTP `200`; real failures live in the JSON `errors`
34
- array — always inspect it and show it verbatim. Two error cases matter:
35
-
36
- - `Unauthorized` / `not authenticated` → the token is invalid → the user must
37
- re-connect the Hashnode connector.
38
- - `extensions.code == "FORBIDDEN"` with a message like *"Publication does not have
39
- an active Pro plan"* → this is **not** a token problem. As of 2026-05-13 Hashnode
40
- moved its API behind a paid plan: **every write mutation** (`publishPost`,
41
- `createDraft`, `updatePost`) **and publication-scoped reads require the target
42
- publication to be on [Hashnode Pro](https://hashnode.com/pro)**. Do NOT tell the
43
- user to reconnect and do NOT retry in a loop — tell them the publication owner
44
- must upgrade to Hashnode Pro (blog dashboard → Billing → Upgrade to Pro), after
45
- which the API works immediately. Public reads (posts/feeds/users/tags) stay free.
46
-
47
- Helper — send a query/mutation (`$1` = query string, `$2` = variables JSON):
48
-
49
- ```bash
50
- gql() {
51
- jq -n --arg q "$1" --argjson v "${2:-null}" '{query:$q, variables:$v}' \
52
- | curl -sS -X POST https://gql-beta.hashnode.com \
53
- -H "Authorization: Bearer $HASHNODE_TOKEN" \
54
- -H "Content-Type: application/json" \
55
- -d @-
56
- }
57
- ```
29
+ > E2E-verified 2026-07-12 on a real connected blog: list publications, create +
30
+ > save draft (title / subtitle / cover / tags / Markdown), publish, and delete —
31
+ > all via cookie auth, no Pro.
58
32
 
59
- ## Always start by confirming the token and finding the publication id
33
+ ## Setup anchor on our own script
60
34
 
61
- `publishPost` / `createDraft` need a `publicationId`. Fetch the account and its
62
- publications first (a user can have several blogs):
63
-
64
- ```bash
65
- gql 'query { me { id username publications(first: 10) { edges { node { id title url } } } } }' \
66
- | jq '{me: .data.me.username, publications: [.data.me.publications.edges[].node | {id, title, url}], errors: .errors}'
35
+ ```sh
36
+ # $SKILL_DIR can point at another skill loaded this turn — anchor on our script.
37
+ H="$SKILL_DIR/scripts/hashnode.py"; [ -f "$H" ] || H=$(find /tmp -maxdepth 8 -path '*/skills/*/hashnode/scripts/hashnode.py' 2>/dev/null | head -1)
38
+ [ -f "$H" ] || { echo "hashnode script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
39
+ python3 "$H" publications # list the user's blogs (verifies the cookie)
67
40
  ```
68
41
 
69
- Pick the target blog's `id` (that is the `publicationId`). If the user has
70
- exactly one publication, use it; otherwise ask which blog to post to.
71
-
72
- ## Publish a post
42
+ On an auth error the cookie is expired have the user reconnect at
43
+ <https://auth.acedata.cloud/user/connections>. Do **not** loop-retry.
73
44
 
74
- **Confirm with the user before publishing publicly.** If they are not sure, save
75
- a draft first (see below). `tags` is required — supply 1–5 tags, each as
76
- `{name, slug}` (slug lowercase, no spaces).
45
+ ## ⚠️ Non-Pro blogs are automoderated write EDITORIAL, not ads
77
46
 
78
- ```bash
79
- PUB_ID="PUBLICATION_ID" # from the me query above
80
- TITLE="My title"
81
- BODY_MD="$(cat article.md)" # full Markdown body
47
+ This is the single most important rule. A **non-Pro** publication is subject to
48
+ Hashnode **automoderation**: an overtly promotional post (hypey language,
49
+ "free credits!!", the *same* CTA link repeated 3–4 times) is **auto-removed
50
+ within seconds** the publish call still returns success, but the live URL then
51
+ 404s. (Pro's one benefit here is "protection from automoderation removal".)
82
52
 
83
- VARS=$(jq -n --arg p "$PUB_ID" --arg t "$TITLE" --arg b "$BODY_MD" '{
84
- input: {
85
- publicationId: $p,
86
- title: $t,
87
- contentMarkdown: $b,
88
- tags: [{name:"AI", slug:"ai"}, {name:"Web Development", slug:"web-development"}]
89
- }
90
- }')
53
+ To stay live, write a genuinely useful **how-to / guide** that happens to feature
54
+ the product:
91
55
 
92
- gql 'mutation PublishPost($input: PublishPostInput!) {
93
- publishPost(input: $input) { post { id slug url title } }
94
- }' "$VARS" | jq '{post: .data.publishPost.post, errors: .errors}'
95
- ```
56
+ - Educational framing and a concrete title (e.g. *"… (Beginner's Guide)"*).
57
+ - At most a couple of **tasteful** links, not the same CTA repeated.
58
+ - No "limited-time!! grab now!!" marketing voice.
96
59
 
97
- Useful optional `input` fields:
60
+ `publish` re-fetches the live URL after publishing and returns a `warning` if the
61
+ post was moderated away — if you see that, rewrite it editorially and republish.
98
62
 
99
- - `subtitle` — post subtitle.
100
- - `slug` — custom URL slug (otherwise derived from the title).
101
- - `canonicalUrl` / `originalArticleURL` — when cross-posting, point SEO back to
102
- the original source. Set this whenever the same article also lives elsewhere.
103
- - `coverImageOptions: { coverImageURL: "https://..." }` — cover image.
104
- - `publishedAt` — ISO-8601 timestamp to backdate; `disableComments` etc. live
105
- under `settings`.
63
+ ## Read
106
64
 
107
- ## Save a draft (safe, non-public)
108
-
109
- Use this when the user has not explicitly approved public publishing:
65
+ ```sh
66
+ python3 "$H" publications # {publications:[{id,title,url}]}
67
+ ```
110
68
 
111
- ```bash
112
- VARS=$(jq -n --arg p "$PUB_ID" --arg t "$TITLE" --arg b "$BODY_MD" '{
113
- input: { publicationId: $p, title: $t, contentMarkdown: $b }
114
- }')
69
+ ## Save a private draft (safe, non-public)
115
70
 
116
- gql 'mutation CreateDraft($input: CreateDraftInput!) {
117
- createDraft(input: $input) { draft { id slug title } }
118
- }' "$VARS" | jq '{draft: .data.createDraft.draft, errors: .errors}'
71
+ ```sh
72
+ python3 "$H" draft \
73
+ --title "My Title" \
74
+ --subtitle "Optional subtitle" \
75
+ --content-file article.md \
76
+ --cover "https://cdn.example.com/cover.jpg" \
77
+ --tags ai,apis
78
+ # → {draft_id, editor_url}. Nothing is public yet.
119
79
  ```
120
80
 
121
- ## Update a published post
122
-
123
- `updatePost` needs the post `id` (from `publishPost`, or the list query below):
81
+ - `--content-file` is the Markdown body (use a file for long posts; `--content`
82
+ for a short inline string). Inline **images** and **links** are just Markdown.
83
+ - `--cover` sets the header/cover image (also used as the OG image).
84
+ - `--tags` are comma-separated slugs (`ai,apis,web-development`); they're resolved
85
+ to real Hashnode tag ids automatically (up to 5).
86
+ - `--publication <id>` is only needed if the account has more than one blog.
124
87
 
125
- ```bash
126
- VARS=$(jq -n --arg id "POST_ID" --arg b "$(cat article.md)" '{
127
- input: { id: $id, contentMarkdown: $b }
128
- }')
88
+ ## Publish — GATED (dry-run unless trailing `--confirm`)
129
89
 
130
- gql 'mutation UpdatePost($input: UpdatePostInput!) {
131
- updatePost(input: $input) { post { id url title } }
132
- }' "$VARS" | jq '{post: .data.updatePost.post, errors: .errors}'
90
+ ```sh
91
+ python3 "$H" publish --title "My Title" --content-file article.md --cover "https://…" --tags ai,apis # dry-run
92
+ python3 "$H" publish --title "My Title" --content-file article.md --cover "https://…" --tags ai,apis --confirm # LIVE
93
+ # → {ok, url} (or {ok:false, warning:"…automoderation removed…"} — rewrite editorially)
133
94
  ```
134
95
 
135
- ## List / inspect my posts
96
+ A confirmed `publish` is **immediately public** on the user's real blog. Always
97
+ show the final title + body, get an explicit "yes", then run with `--confirm` as
98
+ the **last** argument.
136
99
 
137
- ```bash
138
- gql 'query { me { posts(pageSize: 20, page: 1) { nodes { id title slug url views reactionCount responseCount publishedAt } } } }' \
139
- | jq '[.data.me.posts.nodes[] | {id, title, url, views, reactions: .reactionCount, responses: .responseCount}]'
140
- ```
141
-
142
- Single post with full content + stats:
100
+ ## Delete a draft or post — GATED
143
101
 
144
- ```bash
145
- gql 'query GetPost($id: ObjectId!) { post(id: $id) { title url views reactionCount responseCount content { markdown } } }' \
146
- "$(jq -n --arg id "POST_ID" '{id:$id}')" | jq '.data.post | {title, url, views, reactions: .reactionCount}'
102
+ ```sh
103
+ python3 "$H" delete --id <draftOrPostId> # dry-run
104
+ python3 "$H" delete --id <draftOrPostId> --confirm # delete
147
105
  ```
148
106
 
149
107
  ## Gotchas
150
108
 
151
- - **Endpoint is `https://gql-beta.hashnode.com`** the old `gql.hashnode.com`
152
- host is deprecated (301 HTML changelog page, never valid GraphQL JSON).
153
- - **`Authorization: Bearer $HASHNODE_TOKEN`** send the token with the `Bearer`
154
- prefix (this is what Hashnode's own official skill uses).
155
- - **Pro plan required for writes** since 2026-05-13, `publishPost` / `createDraft`
156
- / `updatePost` and publication-scoped reads only work if the target publication
157
- has an active **Hashnode Pro** plan; otherwise the call returns a `FORBIDDEN`
158
- *"does not have an active Pro plan"* error. That is a billing state on the user's
159
- blog, not something the skill or a reconnect can fix — surface it and stop.
160
- - **`publicationId` is mandatory** for publishing/drafting — never guess it, read
161
- it from the `me` query.
162
- - **`tags` is required on `publishPost`** — supply at least one `{name, slug}`;
163
- slug must be lowercase with hyphens (e.g. `machine-learning`).
164
- - **`errors` on HTTP 200** — GraphQL reports failures in the `errors` array, not
165
- via status codes; always surface them.
166
- - **Idempotency** — re-running `publishPost` creates a *new* post each time; to
167
- change an existing one use `updatePost` with its `id`.
168
-
109
+ - **No Pro, no PAT.** This uses the cookie-authed `hashnode.com/api/*` editor API,
110
+ not `gql-beta.hashnode.com` (which requires a paid Pro plan).
111
+ - **Automoderation** (above) is the main failure mode on free blogs — keep it
112
+ editorial.
113
+ - **This is the user's real blog.** Confirm before any publish/delete.
114
+ - **Never print `HASHNODE_COOKIES`** it is full account access.
115
+ - **First-party internal API** it can change when Hashnode updates the editor;
116
+ if a call starts returning HTML/404 unexpectedly, report it as upstream drift.
169
117
 
170
118
  ## Record the output
171
119
 
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ hashnode — read & publish on Hashnode with the user's own login cookies (BYOC).
4
+ Standard-library only (urllib), no third-party deps.
5
+
6
+ Hashnode moved its **public GraphQL API behind a paid Pro plan** (2026-05-13), so
7
+ this skill does NOT use ``gql-beta.hashnode.com`` / a Personal Access Token.
8
+ Instead it drives the **same first-party REST endpoints the web editor uses**
9
+ (``https://hashnode.com/api/...``), authenticated by the login session cookie —
10
+ which is free and needs no Pro plan.
11
+
12
+ The connector injects the cookie jar as a JSON env var ``HASHNODE_COOKIES`` (a
13
+ JSON list of cookie dicts; the session cookie is ``hashnode-session``). It is
14
+ full account access — NEVER echo or print it.
15
+
16
+ ⚠️ Non-Pro publications are subject to Hashnode **automoderation**: overtly
17
+ promotional posts (hypey language, repeated identical CTA links, "free credits!!")
18
+ are auto-removed within seconds of publishing, so the post 404s even though the
19
+ publish call returned success. Write **editorial / how-to** content (a helpful
20
+ guide that happens to feature the product, with at most a couple of tasteful
21
+ links) and it stays live. ``publish`` re-fetches the live URL and warns if the
22
+ post was moderated away.
23
+
24
+ Read commands run directly. ``publish`` / ``delete`` are GATED by a trailing
25
+ ``--confirm`` (honored only as the LAST arg). ``draft`` saves a private draft.
26
+
27
+ Examples:
28
+ python3 hashnode.py publications
29
+ python3 hashnode.py draft --title T --content-file a.md --cover https://x/y.jpg --tags ai,apis
30
+ python3 hashnode.py publish --title T --content-file a.md --cover https://x/y.jpg --tags ai,apis --confirm
31
+ python3 hashnode.py delete --id <draftOrPostId> --confirm
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import gzip
38
+ import json
39
+ import os
40
+ import sys
41
+ import time
42
+ import urllib.error
43
+ import urllib.parse
44
+ import urllib.request
45
+
46
+ UA = (
47
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
48
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
49
+ )
50
+ BASE = "https://hashnode.com"
51
+
52
+ _RAW = sys.argv[1:]
53
+ # --confirm is honored ONLY as the last token, so body text containing
54
+ # "--confirm" can never silently trigger a write.
55
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
56
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
57
+
58
+ # State-changing commands — dry-run unless the invocation ends with --confirm.
59
+ GATED = {"publish", "delete"}
60
+
61
+
62
+ def out(obj) -> None:
63
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
64
+
65
+
66
+ def die(msg: str, code: int = 1) -> None:
67
+ out({"error": msg})
68
+ sys.exit(code)
69
+
70
+
71
+ # ── Cookie jar ──────────────────────────────────────────────────────
72
+
73
+ def load_cookies() -> list:
74
+ raw = os.environ.get("HASHNODE_COOKIES")
75
+ if not raw:
76
+ die("HASHNODE_COOKIES is not set — connect Hashnode at "
77
+ "https://auth.acedata.cloud/user/connections, then retry.")
78
+ try:
79
+ jar = json.loads(raw)
80
+ except json.JSONDecodeError as e:
81
+ die(f"HASHNODE_COOKIES is not valid JSON: {e}")
82
+ if not isinstance(jar, list):
83
+ die(f"HASHNODE_COOKIES must be a JSON list of cookies, got {type(jar).__name__}")
84
+ if not any(c.get("name") == "hashnode-session" for c in jar):
85
+ die("HASHNODE_COOKIES is missing the 'hashnode-session' cookie — re-capture "
86
+ "on hashnode.com with the ACE extension, then reconnect at "
87
+ "https://auth.acedata.cloud/user/connections.")
88
+ return jar
89
+
90
+
91
+ def _domain_matches(host: str, domain: str) -> bool:
92
+ d = domain.lstrip(".").lower()
93
+ h = host.lower()
94
+ return not d or h == d or h.endswith("." + d)
95
+
96
+
97
+ def cookie_header(jar: list, url: str) -> str:
98
+ host = urllib.parse.urlsplit(url).hostname or ""
99
+ host_in_scope = any(
100
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
101
+ )
102
+ parts = []
103
+ for c in jar:
104
+ name, value = c.get("name"), c.get("value")
105
+ if not name or value is None:
106
+ continue
107
+ domain = c.get("domain")
108
+ if domain:
109
+ if not _domain_matches(host, str(domain)):
110
+ continue
111
+ elif not host_in_scope:
112
+ # A domain-less cookie only rides along when at least one scoped
113
+ # cookie already matched this host (never leak it to a stray host).
114
+ continue
115
+ parts.append(f"{name}={value}")
116
+ return "; ".join(parts)
117
+
118
+
119
+ # ── HTTP ────────────────────────────────────────────────────────────
120
+
121
+ def request(method, path, jar, *, body=None):
122
+ url = BASE + path
123
+ hdrs = {
124
+ "User-Agent": UA,
125
+ "Accept": "application/json",
126
+ "Origin": BASE,
127
+ "Referer": BASE + "/",
128
+ "X-Requested-With": "XMLHttpRequest",
129
+ }
130
+ data = None
131
+ if body is not None:
132
+ data = json.dumps(body).encode("utf-8")
133
+ hdrs["Content-Type"] = "application/json"
134
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
135
+ # Unredirected → the cookie is not re-sent if the API 30x-redirects to a
136
+ # different host, so the jar never leaks off-site.
137
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
138
+ try:
139
+ with urllib.request.urlopen(req, timeout=30) as resp:
140
+ raw = resp.read()
141
+ if resp.headers.get("Content-Encoding") == "gzip":
142
+ raw = gzip.decompress(raw)
143
+ return resp.status, raw.decode("utf-8", "replace")
144
+ except urllib.error.HTTPError as e:
145
+ raw = e.read()
146
+ try:
147
+ if e.headers.get("Content-Encoding") == "gzip":
148
+ raw = gzip.decompress(raw)
149
+ except Exception:
150
+ pass
151
+ return e.code, raw.decode("utf-8", "replace")
152
+ except urllib.error.URLError as e:
153
+ die(f"network error reaching {url}: {e.reason}")
154
+
155
+
156
+ def api(method, path, jar, *, body=None, _retried=False):
157
+ status, text = request(method, path, jar, body=body)
158
+ # Hashnode sits behind Cloudflare, which intermittently 403/429s an
159
+ # otherwise valid session; retry idempotent GETs once. Never replay a
160
+ # write (POST/PUT/DELETE) — it could duplicate a publish.
161
+ if status in (403, 429) and method == "GET" and not _retried:
162
+ time.sleep(1.5)
163
+ return api(method, path, jar, body=body, _retried=True)
164
+ if status in (401, 403):
165
+ die(f"auth failed ({status}) on {path} — cookie likely expired. "
166
+ f"Reconnect at https://auth.acedata.cloud/user/connections.")
167
+ # A hashnode.com/api path that 404s with an HTML body means the route/id is
168
+ # wrong; a JSON {success:false} is a real API error.
169
+ stripped = text.lstrip()
170
+ if stripped.startswith("<"):
171
+ die(f"unexpected non-JSON ({status}) from {path} — the route or id is "
172
+ f"likely wrong.")
173
+ try:
174
+ d = json.loads(text)
175
+ except json.JSONDecodeError:
176
+ die(f"non-JSON response ({status}) from {path}: {text[:300]}")
177
+ if isinstance(d, dict) and d.get("success") is False:
178
+ die(f"Hashnode API error ({status}) on {path}: {d.get('error')}")
179
+ return d
180
+
181
+
182
+ # ── helpers ─────────────────────────────────────────────────────────
183
+
184
+ def resolve_publication(jar, wanted: str | None) -> dict:
185
+ d = api("GET", "/api/publications", jar)
186
+ pubs = d.get("publications") or []
187
+ if not pubs:
188
+ die("no publications on this account — create a blog on hashnode.com first.")
189
+ if wanted:
190
+ for p in pubs:
191
+ if wanted in (p.get("_id"), p.get("title"), p.get("displayTitle")):
192
+ return p
193
+ die(f"publication {wanted!r} not found; have: "
194
+ + ", ".join(f'{p.get("title")}({p.get("_id")})' for p in pubs))
195
+ if len(pubs) > 1:
196
+ die("multiple publications — pass --publication <id>. Have: "
197
+ + ", ".join(f'{p.get("title")}({p.get("_id")})' for p in pubs))
198
+ return pubs[0]
199
+
200
+
201
+ def resolve_tags(jar, slugs_csv: str | None) -> list:
202
+ if not slugs_csv:
203
+ return []
204
+ tags = []
205
+ for slug in [s.strip() for s in slugs_csv.split(",") if s.strip()]:
206
+ d = api("GET", f"/api/tags/search?q={urllib.parse.quote(slug)}", jar)
207
+ cands = d.get("tags") or []
208
+ pick = next((t for t in cands if t.get("slug") == slug), None) \
209
+ or (max(cands, key=lambda t: t.get("numPosts", 0)) if cands else None)
210
+ if pick:
211
+ tags.append({"_id": pick["_id"], "name": pick["name"], "slug": pick["slug"]})
212
+ return tags[:5]
213
+
214
+
215
+ def read_content(args) -> str:
216
+ if args.content_file:
217
+ if not os.path.isfile(args.content_file):
218
+ die(f"content file not found: {args.content_file}")
219
+ with open(args.content_file, encoding="utf-8") as fh:
220
+ return fh.read()
221
+ if args.content:
222
+ return args.content
223
+ die("provide --content-file or --content")
224
+
225
+
226
+ def build_save(args, pub_id, jar) -> dict:
227
+ body = {
228
+ "title": (args.title or "").strip(),
229
+ "contentMarkdown": read_content(args),
230
+ "publicationId": pub_id,
231
+ }
232
+ if not body["title"]:
233
+ die("provide --title")
234
+ if args.subtitle:
235
+ body["subtitle"] = args.subtitle
236
+ if args.cover:
237
+ body["coverImage"] = args.cover
238
+ body["ogImage"] = args.cover
239
+ if args.slug:
240
+ body["slug"] = args.slug
241
+ tags = resolve_tags(jar, args.tags)
242
+ if tags:
243
+ body["tags"] = tags
244
+ return body
245
+
246
+
247
+ def create_and_save(args, jar) -> tuple[str, dict]:
248
+ pub = resolve_publication(jar, args.publication)
249
+ d = api("POST", "/api/drafts", jar, body={"publicationId": pub["_id"]})
250
+ did = d.get("draftId")
251
+ if not did:
252
+ die(f"draft creation returned no draftId: {d}")
253
+ save = build_save(args, pub["_id"], jar)
254
+ api("PUT", f"/api/drafts/{did}", jar, body=save)
255
+ return did, pub
256
+
257
+
258
+ # ── commands ────────────────────────────────────────────────────────
259
+
260
+ def cmd_publications(jar, _args):
261
+ d = api("GET", "/api/publications", jar)
262
+ out({"publications": [
263
+ {"id": p.get("_id"), "title": p.get("title"),
264
+ "url": p.get("url") or p.get("domain")} for p in d.get("publications") or []]})
265
+
266
+
267
+ def cmd_draft(jar, args):
268
+ did, pub = create_and_save(args, jar)
269
+ out({"ok": True, "draft_id": did, "publication": pub.get("title"),
270
+ "editor_url": f"https://hashnode.com/draft/{did}",
271
+ "note": "Private draft saved. Review it, then run `publish ... --confirm` to go live."})
272
+
273
+
274
+ def cmd_publish(jar, args):
275
+ did, pub = create_and_save(args, jar)
276
+ d = api("POST", f"/api/drafts/{did}/publish", jar, body={})
277
+ post = d.get("post") or {}
278
+ url = post.get("url")
279
+ result = {"ok": True, "posted": True, "id": post.get("id"),
280
+ "title": post.get("title"), "url": url, "publication": pub.get("title")}
281
+ # Verify the post is actually live — non-Pro blogs auto-moderate promo posts.
282
+ if url:
283
+ time.sleep(4)
284
+ try:
285
+ code = _url_status(url)
286
+ if code == 404:
287
+ result["ok"] = False
288
+ result["warning"] = (
289
+ "The publish call succeeded but the live URL returns 404 — "
290
+ "Hashnode automoderation removed the post (common for overtly "
291
+ "promotional content on a non-Pro blog). Rewrite it in an "
292
+ "editorial / how-to style with fewer repeated CTA links and "
293
+ "republish.")
294
+ except Exception:
295
+ pass
296
+ out(result)
297
+
298
+
299
+ def _url_status(url: str) -> int:
300
+ req = urllib.request.Request(url, headers={"User-Agent": UA}, method="GET")
301
+ try:
302
+ with urllib.request.urlopen(req, timeout=20) as r:
303
+ return r.status
304
+ except urllib.error.HTTPError as e:
305
+ return e.code
306
+ except Exception:
307
+ return 0
308
+
309
+
310
+ def cmd_delete(jar, args):
311
+ d = api("DELETE", f"/api/drafts/{args.id}", jar)
312
+ out({"ok": True, "deleted": True, "id": d.get("deletedDraftId") or args.id})
313
+
314
+
315
+ COMMANDS = {
316
+ "publications": cmd_publications,
317
+ "draft": cmd_draft,
318
+ "publish": cmd_publish,
319
+ "delete": cmd_delete,
320
+ }
321
+
322
+
323
+ def gated_dry_run(args) -> None:
324
+ out({"dry_run": True, "command": args.command,
325
+ "title": getattr(args, "title", None), "id": getattr(args, "id", None),
326
+ "note": "Re-run with --confirm as the LAST argument to actually run this "
327
+ "on the user's REAL Hashnode blog."})
328
+
329
+
330
+ def build_parser() -> argparse.ArgumentParser:
331
+ p = argparse.ArgumentParser(description="Hashnode via login cookies (no Pro).")
332
+ sub = p.add_subparsers(dest="command", required=True)
333
+
334
+ sub.add_parser("publications", help="list my blogs (publications)")
335
+
336
+ def add_write_args(sp):
337
+ sp.add_argument("--title", required=True)
338
+ sp.add_argument("--subtitle")
339
+ sp.add_argument("--content-file", dest="content_file", help="path to a Markdown file")
340
+ sp.add_argument("--content", help="inline Markdown (use --content-file for长文)")
341
+ sp.add_argument("--cover", help="cover image URL (also used as OG image)")
342
+ sp.add_argument("--tags", help="comma-separated tag slugs, e.g. ai,apis")
343
+ sp.add_argument("--slug", help="custom URL slug (optional)")
344
+ sp.add_argument("--publication", help="publication id (needed only if >1 blog)")
345
+
346
+ add_write_args(sub.add_parser("draft", help="save a PRIVATE draft (safe, non-public)"))
347
+ add_write_args(sub.add_parser("publish", help="publish a post (GATED by trailing --confirm)"))
348
+
349
+ sp = sub.add_parser("delete", help="delete a draft or post (GATED by trailing --confirm)")
350
+ sp.add_argument("--id", required=True)
351
+ return p
352
+
353
+
354
+ def main() -> None:
355
+ args = build_parser().parse_args(ARGV)
356
+ if args.command in GATED and not CONFIRM:
357
+ gated_dry_run(args)
358
+ return
359
+ jar = load_cookies()
360
+ COMMANDS[args.command](jar, args)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()
@@ -0,0 +1,271 @@
1
+ ---
2
+ name: maestro-video
3
+ description: "Produce complete AI videos with Maestro via AceDataCloud API. Use when: Maestro, article-to-video, prompt-to-video, turn a brief or reference media into a finished captioned video, generate scripts/visuals/voiceover/music/editing in one workflow, create multilingual video variants, or remix/edit/extend a previous Maestro video. Covers task creation, progress polling, history, and final output retrieval."
4
+ license: Apache-2.0
5
+ metadata:
6
+ author: acedatacloud
7
+ version: "1.0"
8
+ compatibility: Requires ACEDATACLOUD_API_TOKEN in .env file (see _shared/authentication.md). Optionally pair with mcp-maestro for tool-use.
9
+ ---
10
+
11
+ # Maestro End-to-End Video Production
12
+
13
+ Use Maestro when the user wants a **finished video**, not only a generated clip. A headless AI director turns one natural-language brief into a script, visual assets, voiceover, music, edit, captions, quality checks, and rendered video variants.
14
+
15
+ > **Setup:** See [authentication](../_shared/authentication.md) for token setup.
16
+
17
+ ## Choose Maestro When
18
+
19
+ - The user wants an article, idea, product brief, or campaign turned into a complete video.
20
+ - The workflow needs scripting, visuals, narration, captions, and editing handled together.
21
+ - The user supplies product images, a logo, portrait, source footage, or reference audio.
22
+ - One visual production must be rendered in multiple languages.
23
+ - A completed Maestro video needs to be remixed, edited, or extended.
24
+
25
+ Use a model-specific video API such as Seedance, Kling, or Veo when the user only needs a short generated shot and wants direct model controls. Maestro may use multiple media services internally and is optimized for the finished production.
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ curl -X POST https://api.acedata.cloud/maestro/videos \
31
+ -H "Authorization: Bearer $ACEDATACLOUD_API_TOKEN" \
32
+ -H "Content-Type: application/json" \
33
+ -d '{
34
+ "prompt": "Create a 30-second beginner-friendly video explaining vector databases. End with one memorable takeaway.",
35
+ "aspect": "16:9",
36
+ "duration": 30,
37
+ "quality": "standard",
38
+ "scenario": "narrated",
39
+ "langs": ["en"]
40
+ }'
41
+ ```
42
+
43
+ The response contains a `task_id`. Maestro is asynchronous, so query the task until it reaches `succeeded` or `failed`:
44
+
45
+ ```bash
46
+ curl -X POST https://api.acedata.cloud/maestro/tasks \
47
+ -H "Authorization: Bearer $ACEDATACLOUD_API_TOKEN" \
48
+ -H "Content-Type: application/json" \
49
+ -d '{"id": "<task_id>", "action": "retrieve"}'
50
+ ```
51
+
52
+ Do not invent output URLs or report completion while the task is still running.
53
+
54
+ ## Core Workflow
55
+
56
+ 1. Translate the user's goal into a concrete production brief.
57
+ 2. Call `POST /maestro/videos` and preserve the returned `task_id` and `trace_id`.
58
+ 3. Poll `POST /maestro/tasks` at a reasonable interval.
59
+ 4. Continue through nonterminal states; stop only at `succeeded` or `failed`.
60
+ 5. On success, return every item in `response.data.variants`, including its language and `output_url`.
61
+ 6. If requested, iterate by creating a new task with `action`, `ref_task_id`, and a change-focused prompt.
62
+
63
+ Polling and history queries are free; the video task is settled after production based on delivered output. Avoid submitting duplicate creation requests while an existing task is still running.
64
+
65
+ ## Creation Parameters
66
+
67
+ | Parameter | Type | Default | Description |
68
+ |---|---|---|---|
69
+ | `prompt` | string | required | Natural-language production brief: topic, audience, content, tone, and desired result |
70
+ | `action` | string | `generate` | `generate`, `remix`, `edit`, or `extend` |
71
+ | `ref_task_id` | string | - | Required for `remix`, `edit`, and `extend` |
72
+ | `file_urls` | string[] | - | Public image, video, or audio references, up to the limits enforced by the API |
73
+ | `langs` | string[] | `["zh-cn"]` | Output language codes; each creates a localized rendered variant |
74
+ | `aspect` | string | `9:16` | `9:16`, `16:9`, or `1:1` |
75
+ | `duration` | integer | `30` | Target duration from 1 to 600 seconds |
76
+ | `quality` | string | `standard` | `draft`, `standard`, or `premium` |
77
+ | `scenario` | string | `auto` | `auto`, `narrated`, `drama`, `avatar`, `motion`, or `slideshow` |
78
+ | `style` | string | `auto` | Preset or freeform visual direction |
79
+ | `voice` | string | `auto` | Cross-lingual voice preset or a 32-hex-character Fish reference ID |
80
+ | `callback_url` | string | - | Optional webhook called on success or failure |
81
+
82
+ ### Quality
83
+
84
+ - `draft`: fast rough cut for validating direction.
85
+ - `standard`: balanced default for normal production.
86
+ - `premium`: richer, more polished production with a higher cost and longer turnaround.
87
+
88
+ ### Scenarios
89
+
90
+ - `auto`: let the director choose from the brief.
91
+ - `narrated`: multi-scene explainer, documentary, brand, history, or product video with voiceover.
92
+ - `drama`: acted short drama with characters and dialogue.
93
+ - `avatar`: talking-head or digital-human video; normally provide a portrait in `file_urls`.
94
+ - `motion`: kinetic typography, data, logo, or abstract motion graphics.
95
+ - `slideshow`: presentation deck, pitch, or slide-led video.
96
+
97
+ ### Styles
98
+
99
+ Named presets include `cinematic`, `glass`, `luxury`, `swiss`, `modern`, `editorial`, `warm`, `vibrant`, `neon`, `mono`, `pastel`, `bold`, `industrial`, `futuristic`, and `retro`. The API also accepts a freeform style hint.
100
+
101
+ ### Voices
102
+
103
+ Available presets include:
104
+
105
+ - Female: `warm-female`, `bright-female`, `anchor-female`, `clean-female`
106
+ - Male: `calm-male`, `deep-male`, `documentary-male`, `energetic-male`, `storyteller-male`
107
+ - Automatic: `auto`
108
+
109
+ Voice controls timbre rather than language. The same preset speaks the language selected in `langs`.
110
+
111
+ ## Reference Media
112
+
113
+ Pass public URLs in `file_urls`:
114
+
115
+ ```json
116
+ {
117
+ "prompt": "Create a product launch video that clearly shows the camera body and logo. Use the supplied audio as the tone reference.",
118
+ "file_urls": [
119
+ "https://example.com/product.jpg",
120
+ "https://example.com/logo.png",
121
+ "https://example.com/reference.mp3"
122
+ ],
123
+ "scenario": "narrated",
124
+ "style": "editorial",
125
+ "aspect": "16:9"
126
+ }
127
+ ```
128
+
129
+ Reference URLs must be reachable by the service. Do not pass local file paths. Upload local assets first, then use their public URLs.
130
+
131
+ ## Multilingual Variants
132
+
133
+ Use one request to reuse the production across languages:
134
+
135
+ ```json
136
+ {
137
+ "prompt": "A concise product walkthrough for first-time customers",
138
+ "langs": ["en", "de", "ja"],
139
+ "voice": "warm-female",
140
+ "duration": 45
141
+ }
142
+ ```
143
+
144
+ The first language is primary. A successful task normally returns one item per delivered language in `response.data.variants`. Do not assume every requested language succeeded; inspect the actual variants.
145
+
146
+ ## Iterate on a Previous Video
147
+
148
+ All iteration actions require `ref_task_id`.
149
+
150
+ ### Remix
151
+
152
+ Use `remix` for a new creative interpretation that keeps the previous task as context:
153
+
154
+ ```json
155
+ {
156
+ "action": "remix",
157
+ "ref_task_id": "previous-task-id",
158
+ "prompt": "Rework this as a faster social cut with a stronger opening hook and neon styling.",
159
+ "aspect": "9:16"
160
+ }
161
+ ```
162
+
163
+ ### Edit
164
+
165
+ Use `edit` for targeted revisions:
166
+
167
+ ```json
168
+ {
169
+ "action": "edit",
170
+ "ref_task_id": "previous-task-id",
171
+ "prompt": "Keep the structure and visuals. Replace the final call to action and use a calmer narrator."
172
+ }
173
+ ```
174
+
175
+ ### Extend
176
+
177
+ Use `extend` to continue or lengthen the production:
178
+
179
+ ```json
180
+ {
181
+ "action": "extend",
182
+ "ref_task_id": "previous-task-id",
183
+ "prompt": "Add a 15-second customer example before the conclusion.",
184
+ "duration": 60
185
+ }
186
+ ```
187
+
188
+ Iteration creates a new task. Continue polling the new `task_id`; do not overwrite or confuse it with the source task.
189
+
190
+ ## Task Response
191
+
192
+ A task response exposes top-level progress for user feedback:
193
+
194
+ ```json
195
+ {
196
+ "id": "task-id",
197
+ "status": "producing",
198
+ "progress": {
199
+ "percent": 52,
200
+ "stage": "visuals",
201
+ "message": "Generating scene assets",
202
+ "activity": "Creating scene 4"
203
+ },
204
+ "response": null
205
+ }
206
+ ```
207
+
208
+ On success, inspect the delivered variants:
209
+
210
+ ```json
211
+ {
212
+ "status": "succeeded",
213
+ "response": {
214
+ "success": true,
215
+ "data": {
216
+ "variants": [
217
+ {
218
+ "lang": "en",
219
+ "output_url": "https://cdn.example/video.mp4",
220
+ "captions_url": "https://cdn.example/captions.vtt",
221
+ "cover_url": "https://cdn.example/cover.jpg",
222
+ "duration": 31.2,
223
+ "qc_score": 0.96
224
+ }
225
+ ]
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ Treat the fields above as a shape guide. Return fields that are actually present; never fabricate missing captions, covers, durations, or QC scores.
232
+
233
+ ## List Recent Tasks
234
+
235
+ The live task endpoint also supports account history:
236
+
237
+ ```bash
238
+ curl -X POST https://api.acedata.cloud/maestro/tasks \
239
+ -H "Authorization: Bearer $ACEDATACLOUD_API_TOKEN" \
240
+ -H "Content-Type: application/json" \
241
+ -d '{"action": "retrieve_batch", "limit": 20}'
242
+ ```
243
+
244
+ Optional Unix timestamp filters are `created_at_min` and `created_at_max`. The authenticated identity determines which tasks are returned; do not supply or trust a body-level user ID.
245
+
246
+ ## Prompting Guidance
247
+
248
+ A useful brief answers:
249
+
250
+ - What is the subject and desired outcome?
251
+ - Who is the audience?
252
+ - What facts, products, people, or scenes must appear?
253
+ - What should the viewer feel or do afterward?
254
+ - Which platform determines aspect ratio and pacing?
255
+ - Are there exact brand, compliance, or wording constraints?
256
+
257
+ Prefer a concrete production brief over a list of low-level model instructions. Maestro is responsible for choosing and orchestrating media tools.
258
+
259
+ ## Gotchas
260
+
261
+ - This API is asynchronous. Always preserve and poll the returned `task_id`.
262
+ - Terminal states are `succeeded` and `failed`; other status names may evolve as the production pipeline changes.
263
+ - `remix`, `edit`, and `extend` fail without `ref_task_id`.
264
+ - `file_urls` must be public URLs, not local paths.
265
+ - `avatar` usually needs a usable portrait reference.
266
+ - Requested duration is a target; report the actual delivered duration from the result.
267
+ - Multilingual requests may return fewer variants than requested if one output fails.
268
+ - Do not resubmit the same brief merely because a long-running poll has not finished.
269
+ - Do not expose the API token in logs, output, source files, or examples.
270
+
271
+ > **MCP:** `pip install mcp-maestro` | Hosted: `https://maestro.mcp.acedata.cloud/mcp` | See [all MCP servers](../_shared/mcp-servers.md)