Skill: a64307410/muse-ai (version 1.0.6)

--- name: muse ai version: 1.0.6 metadata: openclaw: emoji: "🎵" description: > AI 音乐创作助手 — 通过对话生成原创歌曲、纯音乐和 BGM。 当用户想要创作、生成、制作任何形式的音乐时使用此技能。 包括：生成带人声的歌曲、写歌词、作曲编曲、制作纯音乐/BGM/配乐，或将文字变成歌曲。 当用户提到具体音乐风格（如"来首民谣""做首说唱"）时也应触发。 支持三种模式：灵感模式（一句话生成）、定制模式（自定义歌词+风格）、纯音乐模式。 不要在用户只是想播放音乐、管理歌单、或转换音频格式时触发。 触发词：做歌、写歌、来首歌、出首歌、做音乐、创作音乐、生成歌曲、 制作音乐、原创歌曲、作曲、编曲、作词、填词、 写歌词、生成歌词、BGM、纯音乐、背景音乐、配乐、 把文字变成歌、变成音乐、做个曲子、 song、music、compose、make a song、write a song、muse、/muse。 --- # Muse - AI 音乐创作助手 帮助用户在聊天环境中通过简单对话生成原创歌曲。核心价值：**3 轮交互出一首歌**。 ## 环境配置 **工作目录**：执行任何 bash 命令前，必须先 `cd` 到本 SKILL.md 所在目录（即 skill 安装目录）。所有脚本路径（`scripts/`、`assets/`、`references/`）均基于此目录。 Token 由脚本自动管理，存储于 `~/.muse/token`，无需手动操作。 ## 工作流程 ### Step 0: 认证兜底（Token 失败统一入口） **任何步骤中遇到 HTTP 401（Token 无效/过期）或 Token 缺失（code=-2）时，统一执行以下流程：** 1. 获取注册链接： ```bash python3 scripts/muse_api.py register-url ``` 返回 JSON 中的 `url` 字段即为 `$REGISTER_URL`。 2. 读取 `assets/register-guide.md` 获取引导提示 3. 向用户展示注册引导，使用上面获取的 `$REGISTER_URL`： ``` 你的登录已过期（或尚未登录），需要先完成验证。 1️⃣ 点击链接注册 👉 $REGISTER_URL 2️⃣ 注册完成后 Token 会自动复制到剪贴板 3️⃣ 回到这里，直接粘贴发送即可 整个过程只需 30 秒～ ``` 4. **等待用户粘贴 Token**（见「粘贴 Token 识别」） 5. Token 验证成功后，**回到触发本兜底的原始步骤继续执行** **触发场景**： | 触发点 | 说明 | |--------|------| | Step 1 认证检查 | member-info 返回 code=-2（未登录）或 HTTP 401 | | Step 3 提交生成 | generate 接口返回 HTTP 401 | | Step 4 同步轮询 | poll_song.py 输出 `{"event":"error","code":401}` | | 任意 API 调用 | 脚本 stderr 包含 `"code": 401` | ### Step 1: 认证检查 直接调用 `python3 scripts/muse_api.py member-info`（无需传 --token，脚本自动从 `~/.muse/token` 读取）。 - 成功 → 认证有效，展示积分信息 - 返回 code=-2（未登录） → 跳转 **Step 0: 认证兜底** - 返回 HTTP 401 → 跳转 **Step 0: 认证兜底** ### 粘贴 Token 识别 当用户发送的消息以 `eyJ` 开头（JWT 格式），按以下流程处理： 1. 调用 `python3 scripts/register.py verify --token {用户粘贴的内容}` 验证有效性 2. 验证成功 → Token 已由脚本自动保存到 `~/.muse/token`，直接进入创作流程 3. 验证失败 → 提示用户 Token 无效，请重新注册获取 **认证成功后展示欢迎语：** ``` 🎵 欢迎使用 Muse 音乐创作助手！ 当前积分：{credits 或 credit} | 会员状态：{isMember 或 memberType>0 ? "✨ VIP" : "普通用户"} 每首歌消耗约 10-15 积分，你还可以创作约 {积分/15} 首歌曲。 ``` ### Step 2: 模式分流 认证通过后，向用户展示模式选择： ``` 🎵 你想怎么创作？ A. 灵感模式 — 一句话描述，AI 全权创作（风格由 AI 根据描述决定） B. 定制模式 — 选风格、定歌词，精准控制每个细节 C. 纯音乐 — 无人声的背景音乐 / BGM 直接回复字母即可～ ``` **自动检测**：如果用户原始消息已包含明确意图，可跳过选择直接进入对应模式： - "做首歌""来一首""随便来一首" → 灵感模式 - "我有歌词""帮我谱曲""自定义""我想要说唱风格" → 定制模式 - "纯音乐""伴奏""BGM" → 纯音乐模式 - 提到具体风格（如"来首民谣""说唱风的歌"） → 定制模式（因为用户有风格要求） --- #### 灵感模式（mode="lite"，不传 style） AI 根据描述自主决定曲风，**不承诺风格可控**。 交互步骤： 1. 问音色（男声/女声/都可以） 2. 请用户用一句话描述想要的歌（**至少 10 个字**） > 灵感模式不设风格和情绪选项，因为极简模式下 AI 音乐引擎会根据描述自主决定曲风，用户选的风格标签不会被严格执行。 --- #### 定制模式（mode="custom"，style 严格执行） 用户完全控制风格和歌词，AI 严格执行。 交互步骤： 1. 选择风格标签（见「强交互选项设计」） 2. 选择情绪/氛围 3. 选择音色 4. 歌词来源： ``` ✏️ 歌词怎么来？ A. 我来写 — 直接发送你的歌词 B. AI 帮写 — 告诉我主题，AI 生成歌词供你修改 ``` - 用户选 A → 等待用户发送歌词（支持 [Verse] [Chorus] 等结构标签） - 用户选 B → 请用户描述主题（至少 10 个字），运行 `python3 scripts/muse_api.py generate-lyrics --mode master --title "{标题}" --prompt "{用户描述}"` 生成歌词。**必须使用 `--mode master`（同步返回），禁止使用 `--mode lite`（异步），因为 lite 模式会占用生成队列导致后续歌曲生成被阻塞。** 如果 master 模式返回 502/503 错误，不要重试 API，改由 AI 直接根据用户描述创作歌词（使用 [Verse] [Chorus] 等结构标签） 5. 展示歌词供用户确认/修改（用户回复"OK"确认，或发送修改后的版本） --- #### 纯音乐模式（mode="instrumental"） 同灵感模式，但 `instrumental=true`，不需要选音色。 交互步骤： 1. 请用户用一句话描述想要的氛围（**至少 10 个字**） ### Step 3: 生成确认 + 提交 **输入验证**（提交前必须检查）： - 灵感/纯音乐模式：description 不能为空，至少 10 字 - 定制模式：prompt（歌词）不能为空 - 如果用户选了"都可以"音色，不传 vocalGender 参数 组装参数并在提交前向用户确认（按模式展示不同卡片）： **灵感模式：** ``` 🎶 确认你的歌曲设置： ┌─────────────────────────┐ │ 模式：🎲 灵感模式（AI 自由创作） │ 音色：{男声/女声/随机} │ 描述：{一句话描述} │ 风格：AI 根据描述自动匹配 └─────────────────────────┘ 确认开始创作？(Y/N) 本次消耗约 10-15 积分，当前余额 {credits} ``` **定制模式：** ``` 🎶 确认你的歌曲设置： ┌─────────────────────────┐ │ 模式：🎯 定制模式（精准控制） │ 风格：{选择的风格标签} ← 严格执行 │ 音色：{男声/女声/随机} │ 歌词：{歌词前两行}... └─────────────────────────┘ 确认开始创作？(Y/N) 本次消耗约 10-15 积分，当前余额 {credits} ``` **纯音乐模式：** ``` 🎶 确认你的歌曲设置： ┌─────────────────────────┐ │ 模式：🎹 纯音乐 │ 描述：{一句话描述} │ 风格：AI 根据描述自动匹配 └─────────────────────────┘ 确认开始创作？(Y/N) 本次消耗约 10-15 积分，当前余额 {credits} ``` 确认后： 1. 检查积分是否充足（运行 `python3 scripts/muse_api.py member-info`，credits < 10 则拒绝并引导充值） 2. 提交生成（运行 `python3 scripts/muse_api.py generate --description "..." --mode ... --voice ... --title "..."`，无需传 --token） 3. 同步轮询等待结果（见 Step 4） > ⚠️ 步骤 1-2 中任何接口返回 HTTP 401 → 跳转 **Step 0: 认证兜底** ### Step 4: 进度等待 + 轮询查询 分为两个阶段：**模拟进度**（前 60 秒）和 **真实查询**（60 秒后）。 #### 阶段一：模拟进度（0-60 秒） 前 60 秒不调用 API，只用 `sleep 10` 间隔输出模拟进度。进度值使用平方函数 `progress = 95 × (t/60)²`： | 时间 | 进度 | 输出 | |------|------|------| | 0s | 0% | 🎵 开始创作你的歌曲... | | 10s | 3% | 🎶 作曲中 ▎░░░░░░░░░░░░░░ 3% | | 20s | 11% | 🎶 编曲中 ▎█░░░░░░░░░░░░░ 11% | | 30s | 24% | 🎶 录制中 ▎███░░░░░░░░░░░ 24% | | 40s | 42% | 🎶 混音中 ▎██████░░░░░░░░ 42% | | 50s | 66% | 🎶 润色中 ▎█████████░░░░░ 66% | | 60s | 95% | 🎶 即将完成 ▎██████████████░ 95% | 每次输出之间执行 `Bash("sleep 10")`（保持会话活跃，不阻塞）。 #### 阶段二：真实查询（60 秒后） 从第 60 秒起，每 10 秒调用一次 `muse_api.py query` 查询实际状态，最多查询 **12 次**（再等 2 分钟）。 每次查询流程： 1. 运行 `python3 scripts/muse_api.py query`（无需传 --token 和 --task-id，脚本自动读取） 2. 解析返回的 JSON（含 `songs` 数组，每首歌有 `status` 字段） 3. 根据结果决定下一步： | songs.status | 含义 | 动作 | |-------------|------|------| | 所有歌曲 status=2 | 生成完成 | 输出 `✅ 100%` → Step 5 展示结果 | | 任意歌曲 status=3 | 生成失败 | → Step 5b 失败处理 | | 所有歌曲都有 audioUrl | 生成完成 | 输出 `✅ 100%` → Step 5 展示结果 | | HTTP 401 | Token 过期 | → Step 0 认证兜底 | | 其他（status=0/1） | 生成中 | 输出进度提示，`sleep 10` 后再查 | 查询阶段的进度输出保持在 95-99%： ``` 🎶 精雕细琢中 ▎██████████████░ 96% 🎶 最后打磨中 ▎██████████████░ 98% ``` 若 12 次查询后仍未完成 → Step 5c 超时处理 > **重要**：每步操作都是独立的短时 Bash 调用（sleep 10 或 query 2-3 秒），之间穿插进度文字输出。禁止使用 poll_song.py 做长时间同步阻塞。 **伪代码参考**： ``` 输出 "🎵 开始创作你的歌曲..." # 阶段一：模拟进度（0-60s） 阶段文案 = ["作曲中", "编曲中", "录制中", "混音中", "润色中", "即将完成"] 进度值 = [3, 11, 24, 42, 66, 95] for i in 0..5: Bash("sleep 10") 输出 "🎶 {阶段文案[i]} ▎{进度条} {进度值[i]}%" # 阶段二：真实查询（60s+） for i in 1..12: result = Bash("python3 scripts/muse_api.py query") 解析 JSON if 所有 status==2 或 所有 audioUrl 非空: 输出 "✅ 创作完成 ▎███████████████ 100%" → Step 5 展示结果，退出循环 if 任意 status==3: → Step 5b 失败处理，退出循环 if HTTP 401: → Step 0 认证兜底，退出循环 输出 "🎶 精雕细琢中 ▎██████████████░ {95 + i}%"（上限 99%） Bash("sleep 10") 超出 12 次 → Step 5c 超时处理 ``` ### Step 5: 结果展示 歌曲生成完成后，使用以下**富文本格式**展示（通常返回 2 首候选）： ``` 🎉 你的歌曲创作完成啦！ ━━━ 1️⃣ {歌名} (版本A) ━━━ 🎸 风格：{tags} ⏱ 时长：{分:秒} 🎤 歌词预览： {lyrics 前4行} ... 🔗 试听下载：{audioUrl} 🖼 封面：{coverUrl} ━━━ 2️⃣ {歌名} (版本B) ━━━ 🎸 风格：{tags} ⏱ 时长：{分:秒} 🔗 试听下载：{audioUrl} 🖼 封面：{coverUrl} ━━━━━━━━━━━━━━━━━━━━━ 💡 想让歌曲更完美？ → 发送「重试」用相同设置重新生成 → 发送「换个风格」保留描述，重新选风格 → 想要翻唱、MV 等更多功能？可以在 App 中体验 📱 {REGISTER_URL} ━━━━━━━━━━━━━━━━━━━━━ ``` ### Step 6: 后续指令处理 歌曲生成完成后，用户可能发送后续指令。Skill 必须保留上一次生成的参数上下文（description、style、voice、mode、title）来处理这些指令： **「重试」**： - 使用上一次完全相同的参数重新调用 `scripts/muse_api.py generate` - 提示："🔄 正在用相同设置重新生成..." - 进入 Step 4 轮询流程 **「换个风格」**： - 保留上一次的 description 和 title - 重新展示风格选项（Step 2 的风格选择） - 用户选择新风格后，用新 style + 原 description 重新生成 **「再来一首」/ 「做首新歌」**： - 清空上次参数上下文，回到 Step 2 重新开始完整流程 **其他 App 功能（翻唱、MV 等）**： - 当用户发送「翻唱」「MV」等当前 Skill 不支持的功能时，直接引导到 App： ``` 这个功能目前需要在 App 中使用哦～ 👉 {REGISTER_URL} ``` **风格一致性检查**（仅定制模式需要，灵感模式跳过）： 定制模式下比对用户选择的 style 与生成结果的 tags，若不一致则追加提示： ``` ⚠️ 风格偏差：你选择的是「{用户style}」，实际生成为「{结果tags}」。 可以发送「重试」重新生成，或「换个风格」更换标签。 ``` 灵感模式下不做此检查——因为已告知用户"风格由 AI 根据描述决定"，不存在预期偏差。 **时长格式化**：将秒数转换为 `X分Y秒`（如 164.96s → `2分44秒`） ### Step 5b: 生成失败处理 当 poll_song.py 返回 `failed` 事件时： ``` 😅 歌曲生成遇到了问题... 可能的原因： 1. 描述过于抽象，AI 无法理解 2. 风格标签组合不常见 3. 歌词中可能包含敏感词 💡 建议： → 简化描述，用具体场景替代抽象概念 → 发送「换个风格」更换风格标签组合 → 发送「重试」用相同设置重新生成 📱 在 App 中有更多创作方式可以帮到你：{REGISTER_URL} ``` ### Step 5c: 超时处理 当 poll_song.py 返回 `timeout` 事件时： ``` ⏳ 歌曲还在创作中，比预计的要久一些... 任务 ID：{taskId} 你可以稍后发送「查询 {taskId}」查看结果。 💡 也可以在 App 中查看全部创作记录： 👉 {REGISTER_URL} ``` ## 强交互选项设计 ### 模式选择（入口） ``` 🎵 你想怎么创作？ A. 灵感模式 — 一句话描述，AI 全权创作（风格由 AI 根据描述决定） B. 定制模式 — 选风格、定歌词，精准控制每个细节 C. 纯音乐 — 无人声的背景音乐 / BGM ``` ### 风格选项（仅定制模式使用） ``` 🎸 你想做什么类型的歌？ A. 流行音乐 B. 摇滚 C. 民谣 D. 电子乐 E. 古风 F. R&B G. 说唱 H. 爵士 I. 其他（直接告诉我） 直接回复字母即可，也可以说「流行+摇滚」组合多个风格～ ``` ### 情绪选项（仅定制模式使用） **注意：选项标签必须使用 API 风格列表中的实际值（情绪分类）** ``` 🎭 什么情绪/氛围？ A. 快乐 B. 伤感 C. 激昂 D. 浪漫 E. EMO F. 随你决定 ``` **情绪→tags 映射表**： | 用户选择 | tags 值 | 可组合标签 | |---------|---------|-----------| | A. 快乐 | `快乐` | 活力,青春,甜美 | | B. 伤感 | `伤感` | 怀旧,EMO | | C. 激昂 | `激昂` | 鼓舞,史诗 | | D. 浪漫 | `浪漫` | 温暖,梦幻 | | E. EMO | `EMO` | 伤感,空灵 | | F. 随你决定 | 不传情绪 tags | — | ### 音色选项 ``` 🎤 音色偏好？ A. 男声 B. 女声 C. 都可以（AI 随机） ``` ### 歌词来源选项（仅定制模式使用） ``` ✏️ 歌词怎么来？ A. 我来写 — 直接发送你的歌词 B. AI 帮写 — 告诉我主题，AI 生成歌词供你修改 ``` ### 描述输入提示（灵感模式 / AI 帮写歌词时使用） ``` ✏️ 用一句话描述你想要的歌曲——风格、主题、心情都可以写： 🔥 热门示例： • 温暖民谣，歌词关于冬天窝在被窝刷剧，旋律治愈系 • City Pop风格：关于分手的歌曲 • 嘻哈/Trap，歌词吐槽微信聊天敷衍文学，flow带戏谑感 • 为了梦想，背井离乡，只要坚持就有希望 • 老村庄，旧院子，怀念无忧无虑的童年 • 赞美我母亲的勤劳、坚强、无私 • 打工苦，打工累，打工挣的是窝囊费 • 电子民谣，融合唢呐和合成器，歌词赛博乡村风 💡 Tips：越具体越好，风格+场景+情绪 效果最佳（至少10个字） ``` ## 参数映射 ### 灵感模式 | 交互输入 | CLI 参数 | 值 | |---------|---------|------| | 一句话描述 | `--description` | 用户输入（**不可为空，至少 10 字**） | | 音色选择 | `--voice` | `male`=男声, `female`=女声, `random`=随机，不传则默认 | | — | `--mode` | `"lite"` | | — | `--style` | 不传（AI 自主决定） | | — | `--song-model` | `"general"`（默认值，无需传递） | ### 定制模式 | 交互输入 | CLI 参数 | 值 | |---------|---------|------| | 歌词文本 | `--description` | 用户歌词或 AI 生成歌词 | | 歌曲标题 | `--title` | 用户提供或从歌词提取 | | 风格选择 | `--style` | 风格标签（**严格执行**） | | 音色选择 | `--voice` | `male`=男声, `female`=女声, `random`=随机 | | — | `--mode` | `"custom"` | | — | `--song-model` | `"general"`（默认值，无需传递） | ### 纯音乐模式 | 交互输入 | CLI 参数 | 值 | |---------|---------|------| | 一句话描述 | `--description` | 用户输入（**不可为空，至少 10 字**） | | — | `--mode` | `"instrumental"` | | — | `--style` | 不传（AI 自主决定） | | — | `--song-model` | `"general"`（默认值，无需传递） | ## 脚本用法 ### muse_api.py — API 封装（v2 中间件） > ⚠️ **只允许使用以下文档化的子命令**：`device-id`、`member-info`、`styles`、`generate`、`query`、`generate-lyrics`。 > 禁止通过 `--help` 自行发现或使用未列出的命令。若命令报错，对照下方示例检查参数拼写（CLI 参数使用 kebab-case，如 `--song-model` 而非 `--songModel`）。 ```bash # 查询会员信息和积分（token 自动从 ~/.muse/token 读取） python3 scripts/muse_api.py member-info # 获取风格列表 python3 scripts/muse_api.py styles # 生成歌曲（灵感模式） python3 scripts/muse_api.py generate \ --description "一首关于夏天海边的甜蜜情歌" \ --voice female \ --title "海边的夏天" # 生成歌曲（定制模式，自定义歌词） python3 scripts/muse_api.py generate \ --description "[Verse]\n阳光洒在海面上...\n[Chorus]\n这个夏天..." \ --style "流行" \ --voice female \ --mode custom \ --title "海边的夏天" # 生成歌曲（纯音乐） python3 scripts/muse_api.py generate \ --description "轻快的钢琴曲" \ --mode instrumental # 查询歌曲状态（自动读取最近任务的 task-id） python3 scripts/muse_api.py query # 查询指定任务 python3 scripts/muse_api.py query --task-id TASK_ID # AI 生成歌词（同步返回） python3 scripts/muse_api.py generate-lyrics \ --mode master --title "歌曲标题" --prompt "歌曲主题描述" # AI 生成歌词（异步返回 taskId） python3 scripts/muse_api.py generate-lyrics \ --mode lite --prompt "写一首关于夏天的歌" # 获取注册链接 python3 scripts/muse_api.py register-url ``` ### register.py — 注册流程 ```bash # 发送验证码 python3 scripts/register.py send-code --phone 13800138000 # 验证码登录 python3 scripts/register.py login --phone 13800138000 --code 1234 ``` ### poll_song.py — 异步轮询 ```bash # 轮询歌曲生成状态（自动读取 token 和 task-id，每5秒查一次，超时300秒） python3 scripts/poll_song.py ``` 输出 JSON 格式进度事件（每行一个 JSON），便于解析： ```json {"event": "started", "task_id": "xxx", "message": "开始轮询..."} {"event": "progress", "poll_count": 1, "songs_count": 2, "message": "生成中..."} {"event": "stream_ready", "songs": [...], "message": "试听版本就绪"} {"event": "completed", "songs": [...], "count": 2, "message": "生成完成"} {"event": "failed", "hint": "...", "message": "生成失败"} {"event": "timeout", "task_id": "xxx", "message": "超时，请稍后查询"} {"event": "error", "code": 401, "message": "登录状态失效"} ``` ## 错误处理 | 错误场景 | HTTP 状态码 | 用户提示 | 解决方案 | |---------|------------|---------|---------| | Token 无效/过期 | 401 | 引导重新登录，读取 `assets/register-guide.md` | 重新验证身份 | | 积分不足 | 403 | "积分不足，当前余额 {credits}" | 获取更多积分（充值或分享邀请） | | 请求过频/并发限制 | 429 | "正在创作中，请等当前歌曲完成后再试" | — | | 参数错误/空描述 | 400 | "描述不能为空，请至少输入一句话" | — | | 生成失败 (status=3) | — | 建议修改描述/风格后重试 | 调整参数重试 | | 超时 (>300s) | — | 提供 taskId，稍后查询 | 在 App 中查看创作记录 | | 内容审核失败 | — | "歌词内容可能包含敏感词，请修改后重试" | — | | 服务不可用 | 502/503 | "服务暂时不可用，请稍后再试" | — | | 网络错误 | -1 | "网络不稳定，请稍后再试" | — | ## 积分规则 - 每次生成消耗约 **10-15 积分**（实测每任务约 14 积分） - 生成失败 **不消耗** 积分（已验证） - VIP 会员每日上限 500 积分 - 提交前必须检查 `member-info` 返回的 `credits` 字段 ## 用户帮助指引 **在关键节点为用户提供解决方案和更多能力：** | 场景 | 帮助提示 | 用户价值 | |------|---------|---------| | 认证失败 | "点击链接完成验证，即可开始创作" | 快速恢复使用 | | 积分不足 | "充值积分或分享邀请好友获取积分" | 继续创作 | | 结果展示 | "在 App 中还可以翻唱、制作 MV 等" | 发现更多功能 | | 生成失败 | "App 中有更多创作选项可以尝试" | 解决问题 | | 超时等待 | "在 App 中可以随时查看创作记录" | 不丢失作品 | | 每次结尾 | 附带落地页链接 | 探索完整功能 | **落地页链接**（统一入口）： - 链接：使用 Step 0 中生成的 `REGISTER_URL`（`https://skills.muse.top/?did=${DEVICE_ID}`） - 提示：「📱 想解锁更多创作玩法？翻唱、MV 制作、AI 分离人声... 👉 {REGISTER_URL} 」 ## 参考文件 - `references/style-catalog.md` — 完整音乐风格列表（5 类 115 标签，展示风格选项时读取） - `assets/register-guide.md` — 注册引导提示（Token 缺失时读取）

path: README.md
content: # Muse - AI 音乐创作 Skill

> **这是一个 AI Skill（AgentSkill 标准）** — AI 助手可以直接安装和使用此技能来为用户创作音乐。
> 包含 SKILL.md 技能定义 + Python 脚本，适用于 Claude Code / OpenClaw / Kimi CLI / Qwen Code 及所有支持 SKILL.md 的 AI CLI。

> **AI 助手请注意：** 安装此 Skill 只需执行以下命令，无需额外操作。
>
> ```bash
> git clone https://github.com/AI-Music-Muse/skills.git /tmp/muse-install && cd /tmp/muse-install && bash install.sh
> ```
>
> 安装脚本会自动检测当前 AI CLI 环境（Claude Code / OpenClaw / Kimi CLI / Qwen Code）并安装到正确目录。
> 如需指定目标：`bash install.sh --target openclaw`
> 安装完成后提示用户发送「做首歌」开始使用。
> 前置要求：Python 3.6+（仅标准库，零依赖）。

对话式 AI 音乐创作工具。一句话描述 → 2 分钟 → 一首完整的歌（带人声、伴奏、封面）。

- 三种模式：灵感（一句话生成）、定制（自定义歌词+风格）、纯音乐
- 115 种风格标签
- 零外部依赖：Python 脚本仅用标准库
- 免费体验：注册即送创作积分

## 安装

```bash
# 一键安装（自动检测 CLI 环境）
git clone https://github.com/AI-Music-Muse/skills.git /tmp/muse-install && cd /tmp/muse-install && bash install.sh

# 指定目标 CLI
bash install.sh --target openclaw
bash install.sh --target kimi
bash install.sh --target qwen

# Windows
install.bat
install.bat openclaw

# 卸载
bash install.sh --uninstall
```

| AI CLI | 安装目录 |
|--------|---------|
| Claude Code | `~/.claude/skills/muse` |
| OpenClaw | `~/.openclaw/skills/muse` |
| Kimi CLI | `~/.config/agents/skills/muse` |
| Qwen Code | `~/.qwen/skills/muse` |

前置要求：Python 3.6+

## 使用

安装后在对话中发送 **「做首歌」** 即可，3 轮对话完成创作。

## 安全 & 隐私

- **零外部依赖** — 仅使用 Python 标准库（urllib, json, hashlib），无供应链风险
- **源码完全开源** — 约 500 行 Python 代码，可 100% 审计
- **全程 HTTPS 加密** — 所有 API 通信使用 TLS 加密
- **用户数据最小化** — 仅本地存储登录 Token 和匿名设备 ID，不记录创作内容

## Links

- 注册页面：https://skills.muse.top/
- 问题反馈：https://github.com/AI-Music-Muse/skills/issues


path: assets/register-guide.md
content: # Muse 注册提示模板

## 前置步骤

**使用本模板前，必须先执行以下命令获取注册链接：**

```bash
python3 scripts/muse_api.py register-url
```

返回 JSON 中的 `url` 字段即为 `$REGISTER_URL`。

## 场景：用户未登录

当 `member-info` 返回 code=-2（未登录）时，使用以下引导流程。

### 引导提示

```
欢迎使用 Muse 音乐创作助手！

在开始创作前，需要先完成一个简单的注册：

1️⃣ 点击链接注册 👉 $REGISTER_URL
2️⃣ 注册完成后 Token 会自动复制到剪贴板
3️⃣ 回到这里，直接粘贴发送即可

整个过程只需 30 秒～
```

### 用户粘贴 Token 后的处理

用户粘贴的内容以 `eyJ` 开头时，自动识别为 Token：

1. 运行 `python3 scripts/register.py verify --token {Token}` 验证
2. 验证成功 → Token 已由脚本自动保存到 `~/.muse/token`
3. 回复：
```
✅ 认证成功！

🎵 欢迎使用 Muse 音乐创作助手！
当前积分：{credit} | 会员状态：{isMember ? "✨ VIP" : "普通用户"}
每首歌消耗约 10-15 积分，你还可以创作约 {credit/15} 首歌曲。

发送「做首歌」开始你的第一首创作！
```

4. 验证失败 → 回复：
```
Token 验证失败，可能已过期或不完整。
请重新点击注册链接获取：👉 $REGISTER_URL
```

## 场景：Token 过期或无效

```
你的 Token 已过期，需要重新获取。

👉 点击注册链接重新登录：$REGISTER_URL
登录完成后 Token 会自动复制，回来粘贴即可～
```

## 注意事项

- Token 存储路径：`~/.muse/token`
- Token 有效期 180 天，过期后自动触发重新注册
- 新注册用户会获得初始积分，提醒可以立即开始创作
- 全程不在对话中暴露 Token 完整值


path: install.sh
content: #!/usr/bin/env bash
# Muse Skill 一键部署
# 适用于所有支持 skills 的 AI CLI（Claude Code / OpenClaw / Kimi CLI / Qwen Code 等）
#
# 用法:
#   bash install.sh                      # 自动检测已安装的 CLI
#   bash install.sh --target claude      # 指定安装到 Claude Code
#   bash install.sh --target openclaw    # 指定安装到 OpenClaw
#   bash install.sh --target kimi        # 指定安装到 Kimi CLI
#   bash install.sh --target qwen        # 指定安装到 Qwen Code
#   bash install.sh --path /custom/dir   # 自定义安装路径
#   bash install.sh --uninstall          # 卸载（可搭配 --target 或 --path）

set -e

VERSION="1.0.6"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DATA_DIR="$HOME/.muse"

# ── CLI 目录映射 ──
get_skill_dir() {
  case "$1" in
    claude)   echo "$HOME/.claude/skills/muse" ;;
    openclaw) echo "$HOME/.openclaw/skills/muse" ;;
    kimi)     echo "$HOME/.config/agents/skills/muse" ;;
    qwen)     echo "$HOME/.qwen/skills/muse" ;;
    *)        echo "" ;;
  esac
}

get_cli_label() {
  case "$1" in
    claude)   echo "Claude Code" ;;
    openclaw) echo "OpenClaw" ;;
    kimi)     echo "Kimi CLI" ;;
    qwen)     echo "Qwen Code" ;;
  esac
}

# ── 自动检测已安装的 CLI ──
detect_clis() {
  DETECTED=""
  DETECTED_COUNT=0

  for cli in claude openclaw kimi qwen; do
    cli_found=false
    case "$cli" in
      claude)   [ -d "$HOME/.claude" ] && cli_found=true ;;
      openclaw) ([ -d "$HOME/.openclaw" ] || command -v openclaw >/dev/null 2>&1) && cli_found=true ;;
      kimi)     command -v kimi >/dev/null 2>&1 && cli_found=true ;;
      qwen)     ([ -d "$HOME/.qwen" ] || command -v qwen >/dev/null 2>&1) && cli_found=true ;;
    esac

    if [ "$cli_found" = true ]; then
      DETECTED="$DETECTED $cli"
      DETECTED_COUNT=$((DETECTED_COUNT + 1))
    fi
  done
}

# ── 解析参数 ──
TARGET=""
CUSTOM_PATH=""
UNINSTALL=false

while [ $# -gt 0 ]; do
  case "$1" in
    --uninstall)  UNINSTALL=true; shift ;;
    --target)
      if [ -z "$2" ] || [ "${2#--}" != "$2" ]; then
        echo "❌ --target 需要指定目标: claude / openclaw / kimi / qwen"
        exit 1
      fi
      TARGET="$2"; shift 2 ;;
    --path)
      if [ -z "$2" ] || [ "${2#--}" != "$2" ]; then
        echo "❌ --path 需要指定目录路径"
        exit 1
      fi
      CUSTOM_PATH="$2"; shift 2 ;;
    *)
      echo "❌ 未知参数: $1"
      echo "用法: bash install.sh [--target claude|openclaw|kimi|qwen] [--path /dir] [--uninstall]"
      exit 1 ;;
  esac
done

# ── 确定安装目录 ──
resolve_skill_dir() {
  if [ -n "$CUSTOM_PATH" ]; then
    SKILL_DIR="$CUSTOM_PATH"
    return
  fi

  if [ -n "$TARGET" ]; then
    SKILL_DIR=$(get_skill_dir "$TARGET")
    if [ -z "$SKILL_DIR" ]; then
      echo "❌ 未知目标: $TARGET"
      echo "   支持: claude / openclaw / kimi / qwen"
      exit 1
    fi
    return
  fi

  # 自动检测
  detect_clis

  if [ "$DETECTED_COUNT" -eq 0 ]; then
    echo "⚠️  未检测到已安装的 AI CLI，将安装到 Claude Code 默认目录"
    echo "   支持的 CLI: Claude Code / OpenClaw / Kimi CLI / Qwen Code"
    echo "   可用 --target 或 --path 手动指定"
    echo ""
    SKILL_DIR=$(get_skill_dir "claude")
  elif [ "$DETECTED_COUNT" -eq 1 ]; then
    cli=$(echo "$DETECTED" | tr -d ' ')
    label=$(get_cli_label "$cli")
    echo "✅ 检测到 $label"
    SKILL_DIR=$(get_skill_dir "$cli")
  else
    echo "检测到多个 AI CLI:"
    echo ""
    i=1
    for cli in $DETECTED; do
      label=$(get_cli_label "$cli")
      dir=$(get_skill_dir "$cli")
      echo "  $i) $label → $dir"
      i=$((i + 1))
    done
    echo ""
    printf "请选择安装目标 [1]: "
    read -r choice
    [ -z "$choice" ] && choice=1

    i=1
    SKILL_DIR=""
    for cli in $DETECTED; do
      if [ "$i" -eq "$choice" ] 2>/dev/null; then
        SKILL_DIR=$(get_skill_dir "$cli")
        break
      fi
      i=$((i + 1))
    done

    if [ -z "$SKILL_DIR" ]; then
      echo "❌ 无效选择"
      exit 1
    fi
  fi
}

resolve_skill_dir

# ── 卸载模式 ──
if [ "$UNINSTALL" = true ]; then
  echo "🗑  正在卸载 Muse Skill..."
  if [ -d "$SKILL_DIR" ]; then
    rm -rf "$SKILL_DIR"
    echo "✅ Muse Skill 已从 $SKILL_DIR 卸载"
  else
    echo "⚠️  未找到安装目录: $SKILL_DIR"
  fi
  echo "   Token 数据保留在 $DATA_DIR"
  echo "   如需彻底清除: rm -rf $DATA_DIR"
  exit 0
fi

# ── Python 检查 ──
# 优先 python3，兼容部分系统只有 python 的情况
PYTHON=""
if command -v python3 >/dev/null 2>&1; then
  PYTHON="python3"
elif command -v python >/dev/null 2>&1; then
  PYTHON="python"
fi

if [ -z "$PYTHON" ]; then
  echo "❌ 未检测到 Python，请先安装 Python 3.6+"
  echo "   Ubuntu/Debian: sudo apt install python3"
  echo "   macOS: brew install python3"
  exit 1
fi

PY_VER=$($PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
if [ "$PY_MAJOR" -lt 3 ] || ([ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 6 ]); then
  echo "❌ Python $PY_VER 版本过低，需要 3.6+"
  echo "   Ubuntu/Debian: sudo apt install python3"
  echo "   macOS: brew install python3"
  exit 1
fi

# ── 部署 ──
echo "🎵 Muse Skill v$VERSION 安装中..."

mkdir -p "$DATA_DIR"

# 旧版数据迁移：~/.claude/.muse → ~/.muse
OLD_DATA_DIR="$HOME/.claude/.muse"
if [ -d "$OLD_DATA_DIR" ] && [ ! -f "$DATA_DIR/device_id" ]; then
  echo "   迁移旧版数据..."
  cp -r "$OLD_DATA_DIR"/* "$DATA_DIR/" 2>/dev/null || true
fi

# 升级安装：清理旧版本目录，确保无残留文件
if [ -d "$SKILL_DIR" ]; then
  echo "   检测到旧版本，正在升级..."
  rm -rf "$SKILL_DIR"
fi
mkdir -p "$SKILL_DIR"

# 排除非运行时文件
EXCLUDE_FILES="README.md LICENSE package.json install.sh install.bat .git"

for item in "$SCRIPT_DIR"/*; do
  name=$(basename "$item")
  skip=false
  for ex in $EXCLUDE_FILES; do
    [ "$name" = "$ex" ] && skip=true && break
  done
  [ "$skip" = true ] && continue
  cp -r "$item" "$SKILL_DIR/"
done

# ── 安装验证 ──
VERIFY_OK=true

if [ ! -f "$SKILL_DIR/SKILL.md" ]; then
  echo "❌ 安装异常：SKILL.md 缺失"
  VERIFY_OK=false
fi

if ! $PYTHON "$SKILL_DIR/scripts/muse_api.py" device-id >/dev/null 2>&1; then
  echo "❌ 安装异常：Python 脚本无法正常运行"
  VERIFY_OK=false
fi

if [ "$VERIFY_OK" = false ]; then
  echo "⚠️  安装可能不完整，请检查上述错误后重试"
  exit 1
fi

echo ""
echo "✅ Muse Skill v$VERSION 安装成功"
echo "   技能目录: $SKILL_DIR"
echo "   数据目录: $DATA_DIR"
echo "   Python:   $PY_VER ($PYTHON)"
echo ""
echo "在对话中发送「做首歌」即可开始创作 🎶"


path: package.json
content: {
  "name": "muse-music-skill",
  "version": "1.0.6",
  "description": "AI 音乐创作 Skill — 一句话变成一首歌",
  "bin": {
    "muse-skill-install": "./install.sh"
  },
  "files": [
    "SKILL.md",
    "install.sh",
    "scripts/",
    "assets/",
    "references/"
  ],
  "keywords": [
    "claude-code",
    "skill",
    "music",
    "ai",
    "muse",
    "song-generation"
  ],
  "author": "星檬网络",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/AI-Music-Muse/skills.git"
  },
  "homepage": "https://skills.muse.top"
}


path: references/style-catalog.md
content: # 音乐风格分类目录

## 1. 流派（28种）

| 风格 | 风格 | 风格 | 风格 |
|------|------|------|------|
| 流行音乐 | 古风 | 摇滚 | 民谣 |
| R&B | 爵士 | 电子乐 | FUNK |
| 说唱 | 嘻哈 | 重金属 | 朋克 |
| 韩流 | 日流 | DJ | 雷鬼 |
| 新世纪 | city pop | trap | 蒸汽波 |
| 氛围电子 | 迪斯科 | 泡泡糖 | 灵魂乐 |
| 蓝调 | 迪士尼 | 浩室 | 旋律说唱 |

## 2. 情绪（21种）

| 情绪 | 情绪 | 情绪 | 情绪 |
|------|------|------|------|
| 快乐 | 活力 | EMO | 鼓舞 |
| 怀旧 | 兴奋 | 伤感 | 放松 |
| 浪漫 | 惬意 | 激昂 | 空灵 |
| 温暖 | 复古 | 梦幻 | 清新 |
| 古怪 | 青春 | 史诗 | 华丽 |
| 甜美 | | | |

## 3. 乐器（28种）

| 乐器 | 乐器 | 乐器 | 乐器 |
|------|------|------|------|
| 吉他 | 钢琴 | 琵琶 | 箫 |
| 长笛 | 古筝 | 打击乐 | 贝斯 |
| 小提琴 | 大提琴 | 萨克斯管 | 二胡 |
| 管风琴 | 交响乐 | 电吉他 | 808 |
| 口琴 | 电钢琴 | Pad | 康加鼓 |
| 邦戈鼓 | 竖琴 | 中胡 | 高胡 |
| 古琴 | 阮 | 笙 | 扬琴 |

## 4. 人声特效（31种）

| 特效 | 特效 | 特效 | 特效 |
|------|------|------|------|
| 流行唱法 | 歌剧唱法 | 民谣唱法 | 哭腔 |
| 嘶吼唱法 | 轻声耳语 | 颤音 | 说唱 |
| 氛围感吟唱 | 童声 | 低吟呢喃 | 穿透力强 |
| 宽广自然 | 清亮声线 | 温柔靠近感 | 潮流Autotune |
| 混响空间 | 延迟/回声 | 立体声宽度 | 和声层叠 |
| 宇宙科幻人声 | 金属人声 | 高频空气感 | 现场感 |
| 女高音 | 女中音 | 女低音 | 男高音 |
| 男低音 | 男中音 | 花腔 | |

## 5. 节奏（7种）

| 节奏 |
|------|
| 强烈 |
| 轻柔 |
| 有节奏感 |
| 跳跃 |
| 有力 |
| 快速 |
| 缓慢 |

---

## 强交互选项映射

用户选择字母选项后，映射到 `tags` 参数（多个标签逗号分隔）：

```
风格选项（展示 TOP 9 + 随机）→ tags 值（必须与 API 返回的 style 字段完全一致）:
A. 流行音乐    B. 摇滚       C. 民谣
D. 电子乐      E. 古风       F. R&B
G. 说唱        H. 爵士       I. 随机推荐
（更多风格请告诉我具体想要的类型）

情绪选项 → 追加到 tags:
A. 快乐        B. 伤感       C. 激昂
D. 浪漫        E. 放松       F. 随你决定

音色选项 → vocalGender:
A. 男声 → "m"
B. 女声 → "f"
C. 都可以 → 不传该参数
```

## 高级用法

风格标签支持自由组合，多个标签用逗号分隔传入 `tags` 参数：
- `"流行音乐,浪漫"` — 浪漫流行
- `"古风,空灵,古筝"` — 古风空灵古筝曲
- `"说唱,活力,808"` — 活力说唱
- `"爵士,放松,萨克斯管"` — 放松爵士萨克斯

乐器和人声特效标签也可作为 tags 传入，AI 会尝试在生成中体现。


path: scripts/muse_api.py
content: #!/usr/bin/env python3
"""Muse API 封装层 — v2 中间件接口（skill-api.muse.top）"""

import argparse
import hashlib
import json
import os
import platform
import sys
import uuid
import urllib.request
import urllib.error

BASE_URL = "https://skill-api.muse.top"

# 持久化路径（~/.muse/ 为统一数据目录）
_DEVICE_ID_PATH = os.path.expanduser("~/.muse/device_id")
_TOKEN_PATH = os.path.expanduser("~/.muse/token")
_TASK_ID_PATH = os.path.expanduser("~/.muse/task_id")


def _get_machine_id():
    """获取当前设备唯一 ID，首次生成后持久化到本地文件。

    用途：API 请求头 X-Device-Id，用于服务端速率限制和请求去重。
    安全说明：
    - 仅在本地计算，生成后存储于 ~/.muse/device_id，不会上传原始信息
    - 使用 SHA256 单向哈希，无法从 ID 反推出设备信息
    - 不收集任何隐私数据，不追踪用户行为
    生成规则：hostname + MAC 地址 + 用户名 → SHA256 取前 16 位 hex。
    若系统信息不可用则 fallback 到随机 UUID。
    已持久化的 ID 直接复用，不重新生成。
    """
    # 优先读取已持久化的 ID
    if os.path.isfile(_DEVICE_ID_PATH):
        stored = open(_DEVICE_ID_PATH).read().strip()
        if stored:
            return stored

    # 本地生成匿名设备指纹（单向哈希，不可逆）
    try:
        fingerprint = f"{platform.node()}:{uuid.getnode()}:{os.getlogin()}"
        machine_id = hashlib.sha256(fingerprint.encode()).hexdigest()[:16]
    except Exception:
        machine_id = uuid.uuid4().hex[:16]

    # 持久化到本地文件（仅存储哈希值，非原始信息）
    os.makedirs(os.path.dirname(_DEVICE_ID_PATH), exist_ok=True)
    with open(_DEVICE_ID_PATH, "w") as f:
        f.write(machine_id)
    return machine_id


def _save_token(token):
    """将 token 持久化到 ~/.muse/token"""
    os.makedirs(os.path.dirname(_TOKEN_PATH), exist_ok=True)
    with open(_TOKEN_PATH, "w") as f:
        f.write(token)


def _load_token():
    """从 ~/.muse/token 读取 token，不存在则抛出错误"""
    if not os.path.isfile(_TOKEN_PATH):
        raise MuseAPIError(-2, "未登录：~/.muse/token 不存在，请先完成注册")
    [REDACTED_SECRET]).read().strip()
    if not token:
        raise MuseAPIError(-2, "Token 文件为空，请重新注册")
    return token


def _clear_token():
    """删除 token 文件"""
    if os.path.isfile(_TOKEN_PATH):
        os.remove(_TOKEN_PATH)


def _save_task_id(task_id):
    """将 task_id 持久化到 ~/.muse/task_id"""
    os.makedirs(os.path.dirname(_TASK_ID_PATH), exist_ok=True)
    with open(_TASK_ID_PATH, "w") as f:
        f.write(task_id)


def _load_task_id():
    """从 ~/.muse/task_id 读取"""
    if not os.path.isfile(_TASK_ID_PATH):
        raise MuseAPIError(-2, "没有进行中的任务：~/.muse/task_id 不存在")
    task_id = open(_TASK_ID_PATH).read().strip()
    if not task_id:
        raise MuseAPIError(-2, "task_id 文件为空")
    return task_id


def _auth_headers(token):
    """构建 API 认证请求头（标准 OAuth 2.0 Bearer Token 认证）"""
    return {
        "Authorization": f"Bearer {token}",
        "X-Device-Id": _get_machine_id(),
        "Content-Type": "application/json",
    }


def _device_headers():
    """仅需设备ID的接口（验证码、登录）"""
    return {
        "X-Device-Id": _get_machine_id(),
        "Content-Type": "application/json",
    }


class MuseAPIError(Exception):
    """API 调用错误 — code 为 HTTP 状态码或 -1（网络错误）/-2（客户端拦截）"""
    def __init__(self, code, msg, data=None):
        self.code = code
        self.msg = msg
        self.data = data
        super().__init__(f"[{code}] {msg}")


def _post(path, body=None, headers=None, timeout=30):
    """通用 POST 请求"""
    url = f"{BASE_URL}{path}"
    data = json.dumps(body or {}).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers=headers or {}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace")
        try:
            err_body = json.loads(body_text)
            err_msg = err_body.get("error", f"HTTP {e.code}")
        except (json.JSONDecodeError, KeyError):
            err_msg = f"HTTP {e.code}: {body_text}"
        raise MuseAPIError(e.code, err_msg)
    except urllib.error.URLError as e:
        raise MuseAPIError(-1, f"网络错误: {e.reason}")


def _get(path, headers=None, timeout=30):
    """通用 GET 请求"""
    url = f"{BASE_URL}{path}"
    req = urllib.request.Request(url, headers=headers or {}, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace")
        try:
            err_body = json.loads(body_text)
            err_msg = err_body.get("error", f"HTTP {e.code}")
        except (json.JSONDecodeError, KeyError):
            err_msg = f"HTTP {e.code}: {body_text}"
        raise MuseAPIError(e.code, err_msg)
    except urllib.error.URLError as e:
        raise MuseAPIError(-1, f"网络错误: {e.reason}")


# ─── 用户认证 ──────────────────────────────────────────

def send_code(phone):
    """发送手机验证码"""
    return _post("/user/code", {"phone": phone}, headers=_device_headers())


def login(phone, code):
    """手机号+验证码登录，返回 {authToken, newReg}"""
    return _post("/user/login", {"phone": phone, "code": code}, headers=_device_headers())


# ─── 会员信息 ──────────────────────────────────────────

def member_info(token):
    """查询会员信息，返回 {memberType, credits, expireTime}"""
    return _post("/member/info", {}, headers=_auth_headers(token))


# ─── 风格列表 ──────────────────────────────────────────

def get_styles():
    """获取音乐风格分类列表（GET，无需认证）
    返回值可能是列表或 {"list": [...]}，统一返回列表。
    """
    result = _get("/song/style")
    if isinstance(result, dict):
        return result.get("list", [])
    return result


# ─── 歌词生成 ──────────────────────────────────────────

def generate_lyrics(token, mode="lite", prompt="", title=""):
    """生成歌词

    mode="lite": 异步，返回 taskId
    mode="master": 同步，返回歌词文本
    """
    body = {"mode": mode}
    if prompt:
        body["prompt"] = prompt
    if title:
        body["title"] = title
    result = _post("/lyrics/generate", body, headers=_auth_headers(token))
    if mode == "master":
        return result.get("text", "")
    return result.get("taskId")


# ─── 歌曲生成 ──────────────────────────────────────────

def generate_song(
    token,
    description,
    mode="lite",
    title="",
    style="",
    voice="",
    song_model="general",
):
    """生成歌曲（异步），返回 taskId

    mode="lite": 灵感模式 — description 是自然语言描述
    mode="custom": 定制模式 — description 是完整歌词
    mode="instrumental": 纯音乐模式
    """
    if not description or not description.strip():
        raise MuseAPIError(-2, "歌曲描述不能为空，请输入至少一句话描述你想要的歌曲")
    if len(description.strip()) < 10 and mode != "custom":
        raise MuseAPIError(-2, "描述太短啦，请至少输入一句完整的话（10字以上），AI 才能理解你想要的歌曲")
    body = {
        "mode": mode,
        "description": description,
        "title": title,
        "songModel": song_model,
    }
    if style:
        body["style"] = style
    if voice:
        body["voice"] = voice
    result = _post("/song/generate", body, headers=_auth_headers(token))
    return result.get("taskId")


def query_song(token, task_id):
    """查询歌曲生成状态
    返回 {status: 0|1|2|3, songs: [...]}
    """
    return _post("/song/query", {"taskId": task_id}, headers=_auth_headers(token))


def get_song_info(work_id):
    """获取歌曲详情（无需认证）"""
    return _post("/song/info", {"workId": work_id})


# ─── CLI ───────────────────────────────────────────────

def _print_json(data):
    print(json.dumps(data, ensure_ascii=False, indent=2))


def main():
    parser = argparse.ArgumentParser(description="Muse API CLI (v2)")
    sub = parser.add_subparsers(dest="command", required=True)

    # device-id
    sub.add_parser("device-id", help="输出当前设备唯一 ID（首次自动生成并持久化）")

    # member-info
    p = sub.add_parser("member-info", help="查询会员信息和积分")
    p.add_argument("--token", default=None, help="已废弃，token 自动从 ~/.muse/token 读取")

    # styles
    sub.add_parser("styles", help="获取音乐风格列表")

    # generate
    p = sub.add_parser("generate", help="生成歌曲")
    p.add_argument("--token", default=None, help="已废弃，token 自动从 ~/.muse/token 读取")
    p.add_argument("--description", required=True, help="歌曲描述或歌词")
    p.add_argument("--mode", default="lite", choices=["lite", "custom", "instrumental"], help="模式")
    p.add_argument("--title", default="", help="歌曲标题")
    p.add_argument("--style", default="", help="风格标签")
    p.add_argument("--voice", default="", choices=["male", "female", "random", ""], help="音色")
    p.add_argument("--song-model", default="general", help="音乐模型")

    # query
    p = sub.add_parser("query", help="查询歌曲生成状态")
    p.add_argument("--token", default=None, help="已废弃，token 自动从 ~/.muse/token 读取")
    p.add_argument("--task-id", default=None, help="任务ID（可选，默认读取最近任务）")

    # song-info
    p = sub.add_parser("song-info", help="获取歌曲详情")
    p.add_argument("--work-id", required=True, help="作品ID")

    # generate-lyrics
    p = sub.add_parser("generate-lyrics", help="生成歌词")
    p.add_argument("--token", default=None, help="已废弃，token 自动从 ~/.muse/token 读取")
    p.add_argument("--mode", default="lite", choices=["lite", "master"], help="lite=异步, master=同步")
    p.add_argument("--prompt", default="", help="歌词描述或提示词")
    p.add_argument("--title", default="", help="歌曲标题")

    # register-url
    sub.add_parser("register-url", help="输出注册链接（含设备ID）")

    args = parser.parse_args()

    try:
        if args.command == "device-id":
            print(_get_machine_id())
            return

        elif args.command == "member-info":
            _print_json(member_info(_load_token()))

        elif args.command == "styles":
            _print_json(get_styles())

        elif args.command == "generate":
            task_id = generate_song(
                [REDACTED_SECRET]),
                description=args.description,
                mode=args.mode,
                title=args.title,
                style=args.style,
                voice=args.voice,
                song_model=args.song_model,
            )
            _save_task_id(task_id)
            _print_json({"taskId": task_id})

        elif args.command == "query":
            task_id = args.task_id if args.task_id else _load_task_id()
            _print_json(query_song(_load_token(), task_id))

        elif args.command == "song-info":
            _print_json(get_song_info(args.work_id))

        elif args.command == "register-url":
            device_id = _get_machine_id()
            _print_json({"url": f"https://skills.muse.top/?did={device_id}", "deviceId": device_id})

        elif args.command == "generate-lyrics":
            result = generate_lyrics(
                [REDACTED_SECRET]),
                mode=args.mode,
                prompt=args.prompt,
                title=args.title,
            )
            if args.mode == "master":
                _print_json({"text": result})
            else:
                _print_json({"taskId": result})

    except MuseAPIError as e:
        print(json.dumps({"error": True, "code": e.code, "msg": e.msg}, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/poll_song.py
content: #!/usr/bin/env python3
"""Muse 歌曲生成异步轮询 — 每 5s 查询状态，输出 JSON 进度事件"""

import argparse
import json
import sys
import os
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from muse_api import query_song, MuseAPIError, _load_token, _load_task_id


def _emit(event, **kwargs):
    """输出一个 JSON 事件行"""
    obj = {"event": event, "timestamp": time.strftime("%H:%M:%S")}
    obj.update(kwargs)
    print(json.dumps(obj, ensure_ascii=False), flush=True)


def _format_songs(songs):
    """格式化歌曲列表，只保留关键字段"""
    result = []
    for s in songs:
        item = {
            "workId": s.get("workId", s.get("id", "")),
            "title": s.get("title", ""),
            "tags": s.get("tags", ""),
            "duration": s.get("duration", 0),
            "audioUrl": s.get("audioUrl", ""),
            "streamAudioUrl": s.get("streamAudioUrl", ""),
            "coverUrl": s.get("coverUrl", s.get("imageUrl", "")),
            "lyrics": s.get("lyrics", ""),
            "instrumental": s.get("instrumental", False),
        }
        result.append(item)
    return result


def poll(token, task_id, interval=5, timeout=300):
    """轮询歌曲生成状态

    进度事件：
    - started: 开始轮询
    - progress: 生成中（status=0 或 1）
    - stream_ready: streamAudioUrl 可用，可先试听
    - completed: 生成完成（status=2）
    - failed: 生成失败（status=3）
    - timeout: 超时
    - error: API 调用异常
    """
    _emit("started", task_id=task_id, message="开始轮询歌曲生成状态...")

    start_time = time.time()
    stream_notified = False
    poll_count = 0

    while True:
        elapsed = time.time() - start_time
        if elapsed > timeout:
            _emit("timeout", task_id=task_id, elapsed_s=round(elapsed),
                  message=f"轮询超时（{timeout}s），请稍后使用 task_id 查询结果")
            return None

        poll_count += 1
        try:
            data = query_song(token, task_id)
        except MuseAPIError as e:
            # 可重试错误：网络瞬时错误(-1)、服务端 500、超时等
            retryable = e.code in (-1, 502, 503) or "timeout" in str(e.msg).lower()
            if retryable and poll_count <= 5:
                _emit("retry", code=e.code, poll_count=poll_count,
                      message=f"服务暂时异常，{interval}s 后重试 ({poll_count}/5): {e.msg}")
                time.sleep(interval)
                continue
            _emit("error", code=e.code, message=e.msg)
            return None

        # 从 songs 数组推断整体状态（API 无顶层 status 字段）
        songs = data.get("songs") or []

        def _song_status(s):
            """获取单首歌曲状态，兼容 int/str"""
            v = s.get("status", 0)
            return int(v) if isinstance(v, (int, float)) else int(v) if str(v).isdigit() else 0

        if songs and all(_song_status(s) == 2 for s in songs):
            # 所有歌曲生成完成
            formatted = _format_songs(songs)
            _emit("completed", songs=formatted, count=len(formatted),
                  elapsed_s=round(elapsed),
                  message=f"生成完成！共 {len(formatted)} 首歌曲")
            return formatted

        if songs and any(_song_status(s) == 3 for s in songs):
            # 有歌曲生成失败
            desc = ""
            for s in songs:
                if _song_status(s) == 3:
                    desc = s.get("taskStatusDesc", "")
                    break
            hint = "建议：① 简化描述，避免过于抽象的表达 ② 更换风格标签 ③ 缩短歌词长度后重试"
            _emit("failed", task_id=task_id, detail=desc,
                  hint=hint,
                  message="歌曲生成失败，请修改描述后重试")
            return None

        # 也检查是否有 audioUrl 填充（完成的另一指标）
        if songs and all(s.get("audioUrl") for s in songs):
            formatted = _format_songs(songs)
            _emit("completed", songs=formatted, count=len(formatted),
                  elapsed_s=round(elapsed),
                  message=f"生成完成！共 {len(formatted)} 首歌曲")
            return formatted

        # 生成中 — 检查是否有 streamAudioUrl 可用
        if not stream_notified and songs:
            for s in songs:
                if s.get("streamAudioUrl"):
                    stream_notified = True
                    formatted = _format_songs(songs)
                    _emit("stream_ready", songs=formatted, elapsed_s=round(elapsed),
                          message="试听版本就绪，可先试听初版")
                    break

        _emit("progress", poll_count=poll_count,
              elapsed_s=round(elapsed),
              songs_count=len(songs),
              message=f"生成中... ({round(elapsed)}s)")

        time.sleep(interval)


def main():
    parser = argparse.ArgumentParser(description="Muse 歌曲生成异步轮询")
    parser.add_argument("--token", default=None, help="已废弃，token 自动从 ~/.muse/token 读取")
    parser.add_argument("--task-id", default=None, help="任务ID（可选，默认读取最近任务）")
    parser.add_argument("--interval", type=int, default=5, help="轮询间隔秒数（默认5）")
    parser.add_argument("--timeout", type=int, default=300, help="超时秒数（默认300）")
    args = parser.parse_args()

    try:
        [REDACTED_SECRET])
        task_id = args.task_id if args.task_id else _load_task_id()
    except MuseAPIError as e:
        _emit("error", code=e.code, message=e.msg)
        sys.exit(1)
    result = poll(token, task_id, args.interval, args.timeout)
    sys.exit(0 if result else 1)


if __name__ == "__main__":
    main()


path: scripts/register.py
content: #!/usr/bin/env python3
"""Muse 注册/登录流程 — 发验证码 + 手机号登录 → 获取 authToken"""

import argparse
import json
import sys
import os

# 复用 muse_api 的接口
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from muse_api import send_code, login, member_info, MuseAPIError, _save_token

# 合法手机号号段（中国大陆）
_VALID_PHONE_PREFIXES = (
    "13", "14", "15", "16", "17", "18", "19",
)


def _validate_phone(phone):
    """校验手机号格式：11位数字 + 合法号段"""
    if not phone or not phone.isdigit() or len(phone) != 11:
        raise MuseAPIError(-2, "手机号格式不正确，请输入11位手机号")
    if not phone.startswith(_VALID_PHONE_PREFIXES):
        raise MuseAPIError(-2, f"手机号号段不正确（{phone[:3]}...），请输入正确的手机号")


def _print_json(data):
    print(json.dumps(data, ensure_ascii=False, indent=2))


def main():
    parser = argparse.ArgumentParser(description="Muse 注册/登录工具")
    sub = parser.add_subparsers(dest="command", required=True)

    # 发送验证码
    p = sub.add_parser("send-code", help="发送手机验证码")
    p.add_argument("--phone", required=True, help="手机号")

    # 登录
    p = sub.add_parser("login", help="手机号+验证码登录")
    p.add_argument("--phone", required=True, help="手机号")
    p.add_argument("--code", required=True, help="验证码")

    # 验证 token
    p = sub.add_parser("verify", help="验证 token 有效性")
    p.add_argument("--token", required=True, help="authToken")

    args = parser.parse_args()

    try:
        if args.command == "send-code":
            _validate_phone(args.phone)
            send_code(args.phone)
            _print_json({"success": True, "message": f"验证码已发送到 {args.phone}"})

        elif args.command == "login":
            _validate_phone(args.phone)
            data = login(args.phone, args.code)
            auth_token = data.get("authToken", "")
            new_reg = data.get("newReg", False)
            if auth_token:
                _save_token(auth_token)
            _print_json({
                "success": True,
                "authToken": auth_token,
                "newReg": new_reg,
                "message": "注册成功！" if new_reg else "登录成功！",
                "next_step": "Token 已自动保存到 ~/.muse/token",
            })

        elif args.command == "verify":
            info = member_info(args.token)
            _save_token(args.token)
            _print_json({
                "valid": True,
                "credits": info.get("credits", info.get("credit", 0)),
                "memberType": info.get("memberType", 1 if info.get("paidMember") else 0),
            })

    except MuseAPIError as e:
        error_msg = e.msg
        if e.code == 401:
            error_msg = "Token 无效或已过期，请重新登录"
        elif e.code == 429:
            error_msg = "请求过于频繁，请稍后重试"
        _print_json({"success": False, "error": error_msg, "code": e.code})
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)