@hmanlab/mmx-claude 0.4.3

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/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # `@hl-plugins/mmx-claude`
2
+
3
+ Claude Code adapter for the seven MiniMax multimodal tools. Pairs with
4
+ [`@hl-plugins/mmx`](../plugin-mmx/README.md) (the OpenCode plugin) — same
5
+ `mmx-cli` binary, same auth, two install records.
6
+
7
+ > Status: **Plan v1** — implementation below is tracked phase by phase. Update
8
+ > the checkboxes as each item lands. Once all boxes in a phase are checked,
9
+ > that phase is done and the next one can start.
10
+
11
+ ## What this package is
12
+
13
+ A new sibling of `@hl-plugins/mmx` that ships the same seven tools to
14
+ **Claude Code** instead of OpenCode, by exposing them through a **Model
15
+ Context Protocol (MCP) server** that Claude Code launches at startup.
16
+
17
+ | Runtime | Package | Transport |
18
+ |---|---|---|
19
+ | OpenCode | `@hl-plugins/mmx` | `tool()` from `@opencode-ai/plugin`, runs as `.ts` |
20
+ | Claude Code | `@hl-plugins/mmx-claude` *(this package)* | MCP server, bundled `.js` |
21
+
22
+ The two packages share `mmx-cli` state and the MiniMax API key — only the
23
+ delivery mechanism differs.
24
+
25
+ ## Phase index
26
+
27
+ - [Phase A — CLI contract + paths](#phase-a--cli-contract--paths)
28
+ - [Phase B — New package + MCP server](#phase-b--new-package--mcp-server)
29
+ - [Phase C — Install / uninstall / status + docs](#phase-c--install--uninstall--status--docs)
30
+
31
+ Each phase is independently shippable and demoable. Stopping after any
32
+ phase leaves the tree green; only the matching acceptance criteria need to
33
+ hold at that point.
34
+
35
+ ---
36
+
37
+ ## Phase A — CLI contract + paths
38
+
39
+ **Scope.** Extend the `hl-plugins` manifest type with the two new Claude
40
+ fields, add the Claude-side path helpers, ship zero install behavior yet.
41
+ Every later phase depends on these types and helpers.
42
+
43
+ ### Acceptance criteria
44
+
45
+ - [ ] `packages/cli/src/lib/registry.ts` exports `claudeMcp?: string` and
46
+ `claudeSkill?: string` on `PluginManifest.hlPlugins`
47
+ - [ ] `packages/cli/src/lib/paths.ts` exports:
48
+ - [ ] `claudeConfigDir()` — returns the Claude Code config directory
49
+ per platform (`~/.claude/` on macOS/Linux, `%APPDATA%\Claude\`
50
+ on Windows)
51
+ - [ ] `claudeConfigFile()` — returns the path to `~/.claude.json`
52
+ - [ ] `hlPluginsDataDir()` — returns `~/.local/share/hl-plugins/`
53
+ (XDG-style; platform-correct on Windows)
54
+ - [ ] Existing `hl-plugins list` still works and shows the existing
55
+ `mmx` plugin unchanged
56
+ - [ ] `npm run typecheck` passes
57
+ - [ ] New unit tests under `packages/cli/test/lib/paths.test.ts` cover
58
+ `claudeConfigDir`, `claudeConfigFile`, `hlPluginsDataDir` for
59
+ macOS / Linux / Windows path conventions
60
+
61
+ ### Files touched
62
+
63
+ ```
64
+ M packages/cli/src/lib/registry.ts
65
+ M packages/cli/src/lib/paths.ts
66
+ A packages/cli/test/lib/paths.test.ts
67
+ ```
68
+
69
+ ### Demo
70
+
71
+ ```bash
72
+ npm run typecheck
73
+ node packages/cli/bin/hl-plugins.js list
74
+ # expected: shows @hl-plugins/mmx, no mmx-claude yet
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Phase B — New package + MCP server
80
+
81
+ **Scope.** Create `packages/plugin-mmx-claude/` from scratch. The MCP server
82
+ source registers the seven mmx tools backed by `src/lib.ts`'s `runMmx`. The
83
+ package's `package.json` declares the full `hl-plugins` contract. A
84
+ `bun build` script bundles the server into `dist/mmx-mcp-server.js`.
85
+
86
+ ### Acceptance criteria
87
+
88
+ #### Package scaffolding
89
+
90
+ - [ ] `packages/plugin-mmx-claude/package.json` exists with `private: true`,
91
+ name `@hl-plugins/mmx-claude`, and a full `hl-plugins` contract:
92
+ - [ ] `opencodePlugin` / `opencodeSkill` omitted (not an OpenCode plugin)
93
+ - [ ] `claudeMcp: "./dist/mmx-mcp-server.js"`
94
+ - [ ] `claudeSkill: "./claude/skill/mmx/SKILL.md"`
95
+ - [ ] `requires` includes `mmx-cli` and `bun`
96
+ - [ ] `auth` block carried over from `@hl-plugins/mmx`
97
+ - [ ] `packages/plugin-mmx-claude/tsconfig.json` extends the base config
98
+ - [ ] `packages/plugin-mmx-claude/bunfig.toml` is present
99
+ - [ ] Workspace `package.json` lists the new package under `workspaces`
100
+
101
+ #### Source files
102
+
103
+ - [ ] `src/lib.ts` exports `runMmx`, `resolveOutDir`, and the
104
+ suspicious-path detector (~50 lines, `Bun.spawn`)
105
+ - [ ] `claude/mcp/mmx-mcp-server.ts` registers the seven tools via the
106
+ `@modelcontextprotocol/sdk` Server class:
107
+ - [ ] `mmx_image`
108
+ - [ ] `mmx_speech`
109
+ - [ ] `mmx_video`
110
+ - [ ] `mmx_music`
111
+ - [ ] `mmx_search`
112
+ - [ ] `mmx_vision`
113
+ - [ ] `mmx_quota`
114
+ - [ ] `claude/skill/mmx/SKILL.md` mirrors the OpenCode skill's content,
115
+ edited for Claude Code context (no `@opencode-ai/plugin` references)
116
+
117
+ #### Build
118
+
119
+ - [ ] `bun run --filter @hl-plugins/mmx-claude build` produces
120
+ `packages/plugin-mmx-claude/dist/mmx-mcp-server.js`
121
+ - [ ] `dist/` is listed in `.gitignore`
122
+ - [ ] `npm run typecheck` passes across the workspace
123
+
124
+ #### Tests
125
+
126
+ - [ ] `test/lib.test.ts` covers:
127
+ - [ ] Suspicious-path rejection (HOME, ~/Desktop, /tmp, `.`, `..`)
128
+ - [ ] `MMX_OUTPUT_DIR` env-var override wins over the default
129
+ - [ ] Default `~/Desktop/mmx-output/` resolution
130
+ - [ ] `test/mcp-smoke.test.ts` spawns the bundle over stdio, sends a
131
+ JSON-RPC `tools/list` request, and asserts all seven tool names
132
+ appear in the response
133
+
134
+ ### Files touched
135
+
136
+ ```
137
+ A packages/plugin-mmx-claude/package.json
138
+ A packages/plugin-mmx-claude/tsconfig.json
139
+ A packages/plugin-mmx-claude/bunfig.toml
140
+ A packages/plugin-mmx-claude/src/lib.ts
141
+ A packages/plugin-mmx-claude/claude/mcp/mmx-mcp-server.ts
142
+ A packages/plugin-mmx-claude/claude/skill/mmx/SKILL.md
143
+ A packages/plugin-mmx-claude/test/lib.test.ts
144
+ A packages/plugin-mmx-claude/test/mcp-smoke.test.ts
145
+ M package.json # add @modelcontextprotocol/sdk devDep
146
+ M packages/plugin-mmx-claude/.gitignore # OR root .gitignore (dist)
147
+ ```
148
+
149
+ ### Demo
150
+
151
+ ```bash
152
+ bun run --filter @hl-plugins/mmx-claude build
153
+ node packages/plugin-mmx-claude/test/mcp-smoke.test.ts
154
+ # expected: stdout lists mmx_image, mmx_speech, mmx_video, mmx_music,
155
+ # mmx_search, mmx_vision, mmx_quota
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Phase C — Install / uninstall / status + docs
161
+
162
+ **Scope.** Wire the new contract fields into the install flow. Add
163
+ `addMcpServer` / `removeMcpServer` helpers. Update status to report
164
+ Claude-side state. Add the install hint for Claude Code systems. Update
165
+ `README.md`, `docs/architecture.md`, and `docs/adding-a-plugin.md`.
166
+
167
+ ### Acceptance criteria
168
+
169
+ #### Helpers
170
+
171
+ - [ ] `packages/cli/src/lib/config.ts` exports `addMcpServer(name, spec)`
172
+ and `removeMcpServer(name)` — defensive `~/.claude.json` parsing
173
+ with a clear error if the file is unrecognizable
174
+ - [ ] `packages/cli/src/commands/install.ts` extended:
175
+ - [ ] Copies the `claudeMcp` bundle to
176
+ `~/.local/share/hl-plugins/<plugin>/<file>` and updates the
177
+ bundle's on-disk path in the MCP spec before merging
178
+ - [ ] Copies the `claudeSkill` markdown to
179
+ `~/.claude/skills/<plugin>/SKILL.md`
180
+ - [ ] Calls `addMcpServer` to merge the spec into `~/.claude.json`
181
+ - [ ] All three steps are idempotent (same merge semantics as the
182
+ OpenCode path)
183
+ - [ ] `packages/cli/src/commands/uninstall.ts` extended:
184
+ - [ ] Removes the bundle from `~/.local/share/hl-plugins/<plugin>/`
185
+ - [ ] Removes the skill from `~/.claude/skills/<plugin>/`
186
+ - [ ] Calls `removeMcpServer` to drop the entry from `~/.claude.json`
187
+ - [ ] `packages/cli/src/commands/status.ts` extended to report all five
188
+ Claude-side install points per plugin as present/missing
189
+ - [ ] Install hint: when `hl-plugins install mmx` runs on a system with
190
+ `~/.claude/` present but no `~/.opencode/`, prints a hint about
191
+ `mmx-claude` and proceeds with the OpenCode install
192
+
193
+ #### Documentation
194
+
195
+ - [ ] `README.md` — split the plugin table row into two
196
+ (OpenCode + Claude Code), add a "Choosing the right package"
197
+ one-liner
198
+ - [ ] `docs/architecture.md` — document `claudeMcp` + `claudeSkill` in
199
+ the contract section; extend the install-flow diagram with a
200
+ Claude Code branch
201
+ - [ ] `docs/adding-a-plugin.md` — note the four contract fields
202
+ (`opencodePlugin`, `opencodeSkill`, `claudeMcp`, `claudeSkill`) and
203
+ that a plugin can declare any subset; add a Claude Code example
204
+
205
+ #### End-to-end smoke
206
+
207
+ - [ ] `hl-plugins install mmx-claude` on a clean Claude Code system:
208
+ - [ ] Auto-installs Bun if missing (via the `requires` entry)
209
+ - [ ] Auto-installs `mmx-cli` if missing
210
+ - [ ] Prompts for API key (or reads `MMX_API_KEY`)
211
+ - [ ] Copies the MCP bundle to
212
+ `~/.local/share/hl-plugins/mmx-claude/mmx-mcp-server.js`
213
+ - [ ] Copies the skill MD to `~/.claude/skills/mmx-claude/SKILL.md`
214
+ - [ ] Merges `mcpServers.mmx-claude` into `~/.claude.json`
215
+ - [ ] Runs `mmx quota` as the post-install smoke test
216
+ - [ ] `hl-plugins uninstall mmx-claude` reverses every step above
217
+ - [ ] `hl-plugins status mmx-claude` reports all five install points
218
+ - [ ] `hl-plugins install mmx` on a system with `~/.claude/` but no
219
+ `~/.opencode/` prints the hint and proceeds
220
+ - [ ] Re-running `hl-plugins install mmx-claude` is a no-op (idempotent)
221
+ - [ ] `npm run typecheck` + `npm run build` both green
222
+
223
+ ### Files touched
224
+
225
+ ```
226
+ M packages/cli/src/lib/config.ts
227
+ M packages/cli/src/commands/install.ts
228
+ M packages/cli/src/commands/uninstall.ts
229
+ M packages/cli/src/commands/status.ts
230
+ M README.md
231
+ M docs/architecture.md
232
+ M docs/adding-a-plugin.md
233
+ ```
234
+
235
+ ### Demo
236
+
237
+ ```bash
238
+ node packages/cli/bin/hl-plugins.js install mmx-claude
239
+ # expected: Bun auto-installs (if missing) -> mmx-cli auto-installs (if
240
+ # missing) -> API key prompt -> bundle copied -> skill copied
241
+ # -> ~/.claude.json merged -> mmx quota smoke test -> green
242
+ # checkmarks per step
243
+
244
+ node packages/cli/bin/hl-plugins.js status mmx-claude
245
+ # expected: five green checks (bundle, skill, mcpServers entry,
246
+ # auth present, mmx quota responds)
247
+
248
+ node packages/cli/bin/hl-plugins.js uninstall mmx-claude
249
+ # expected: every install step reversed, exit 0
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Cross-phase checklist
255
+
256
+ These items are not tied to one phase; they belong to the whole feature
257
+ and should land before merge.
258
+
259
+ - [ ] `packages/plugin-mmx` source is **untouched** — zero edits, the
260
+ OpenCode plugin stays as-is
261
+ - [ ] Both packages stay `private: true` until the install flow is
262
+ battle-tested on real Claude Code installs
263
+ - [ ] `~/Desktop/mmx-output/` is the default output directory in both
264
+ packages; `MMX_OUTPUT_DIR` overrides it in both
265
+ - [ ] Same suspicious-path rules apply in both packages (no writes to
266
+ HOME, ~/Desktop, /tmp, or `.`)
267
+ - [ ] No secrets committed; `MMX_API_KEY` is read from env, never echoed
268
+ - [ ] All `.ts` strict-mode clean; `npm run typecheck` green across the
269
+ workspace
270
+ - [ ] No npm publish from the agent — the human maintainer runs
271
+ `npm run publish:cli`
272
+
273
+ ## Out of scope (deferred)
274
+
275
+ - Slash commands (`/mmx-image ...`) — could ship in a v2 alongside the
276
+ MCP server for explicit user-driven invocation
277
+ - Auto-update of the MCP bundle — `hl-plugins update` will need a
278
+ Claude-side mirror; tracked separately
279
+ - Cross-agent plugin state sync — the two agents share `mmx-cli` state,
280
+ nothing else
281
+ - Publishing either package to npm — both stay `private: true` until the
282
+ install flow is battle-tested
@@ -0,0 +1,335 @@
1
+ // mmx-mcp-server.ts
2
+ //
3
+ // MCP server that exposes the seven mmx tools to Claude Code. Loaded
4
+ // at startup by Claude Code's mcpServers config; communicates over
5
+ // stdio via JSON-RPC.
6
+ //
7
+ // Built with: bun build ./claude/mcp/mmx-mcp-server.ts --target=bun
8
+ // --outfile=./dist/mmx-mcp-server.js
9
+ //
10
+ // All generated files default to ~/Desktop/mmx-output/. The user can
11
+ // override the default directory permanently via $MMX_OUTPUT_DIR; the
12
+ // LLM should never pass out_dir / out_path unless the user explicitly
13
+ // asked.
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
+ // Import from `zod/v3` so the type identity matches the SDK's internal
18
+ // zod-compat. Without this, the SDK's AnySchema = z3.ZodTypeAny | z4.$ZodType
19
+ // rejects the zod `ZodString` we hand in (TS sees two distinct ZodString
20
+ // types because they were loaded from different module specifiers).
21
+ import { z } from "zod/v3"
22
+ import { dirname } from "node:path"
23
+ import {
24
+ DEFAULT_OUT_DIR,
25
+ ensureDir,
26
+ resolveFilePath,
27
+ resolveOutDir,
28
+ runMmx,
29
+ warnSuspiciousOutDir,
30
+ } from "../../src/lib.js"
31
+
32
+ const server = new McpServer({
33
+ name: "mmx-claude",
34
+ version: "0.1.0",
35
+ })
36
+
37
+ // Claude Code does not have a worktree concept the way OpenCode does.
38
+ // process.cwd() is the closest analogue — it's the directory Claude was
39
+ // launched from, which is what relative paths in tool args would resolve
40
+ // against. The OpenCode plugin uses ctx.worktree; we use cwd() here.
41
+ const worktree = process.cwd()
42
+
43
+ function textResult(text: string) {
44
+ return { content: [{ type: "text" as const, text }] }
45
+ }
46
+
47
+ // ─── mmx_image ──────────────────────────────────────────────────────────
48
+ server.registerTool(
49
+ "mmx_image",
50
+ {
51
+ description:
52
+ "Generate one or more images from a text prompt using MiniMax's image-01 model via mmx-cli. Use this whenever the user asks for an image, illustration, logo, artwork, photo, or any visual asset. Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4, 21:9. Pass seed for reproducible results. Returns the saved file path.",
53
+ inputSchema: {
54
+ prompt: z
55
+ .string()
56
+ .describe(
57
+ "Detailed text description of the image. Be specific about subject, style, lighting, composition, mood.",
58
+ ),
59
+ aspect_ratio: z
60
+ .string()
61
+ .optional()
62
+ .describe("Aspect ratio. One of: 1:1, 16:9, 9:16, 4:3, 3:4, 21:9. Default 1:1."),
63
+ n: z.number().optional().describe("Number of images to generate. Default 1, max 4."),
64
+ seed: z.number().optional().describe("Random seed for reproducible generation."),
65
+ out_dir: z
66
+ .string()
67
+ .optional()
68
+ .describe(
69
+ "Override save directory. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/ (or $MMX_OUTPUT_DIR if set). Suspicious paths fall back to the default with a warning.",
70
+ ),
71
+ optimize_prompt: z.boolean().optional().describe("Auto-optimize the prompt for better quality."),
72
+ filename_prefix: z
73
+ .string()
74
+ .optional()
75
+ .describe(
76
+ "Filename prefix. Defaults to a unique per-call value (image-<timestamp>) so back-to-back calls don't overwrite each other. Pass an explicit value for predictable sequential naming.",
77
+ ),
78
+ },
79
+ },
80
+ async (args) => {
81
+ const requestedDir = resolveOutDir(args.out_dir, worktree)
82
+ const outDir = requestedDir === "" || requestedDir === "." ? DEFAULT_OUT_DIR : requestedDir
83
+ ensureDir(outDir)
84
+ const filenamePrefix = args.filename_prefix ?? `image-${Date.now()}`
85
+ const cliArgs = ["image", "generate", "--prompt", args.prompt]
86
+ if (args.aspect_ratio) cliArgs.push("--aspect-ratio", args.aspect_ratio)
87
+ if (args.n) cliArgs.push("--n", String(args.n))
88
+ if (args.seed != null) cliArgs.push("--seed", String(args.seed))
89
+ if (args.optimize_prompt) cliArgs.push("--prompt-optimizer")
90
+ cliArgs.push("--out-dir", outDir, "--out-prefix", filenamePrefix, "--non-interactive")
91
+ const { stdout, stderr, exitCode } = await runMmx(cliArgs)
92
+ if (exitCode !== 0) {
93
+ return textResult(`mmx image generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`)
94
+ }
95
+ let msg = `Image generation complete.\n\n${stdout.trim()}\n\nSaved to: ${outDir}\nFilename prefix: ${filenamePrefix}`
96
+ if (outDir !== requestedDir && args.out_dir) {
97
+ msg += `\n\n${warnSuspiciousOutDir(args.out_dir, outDir)}`
98
+ }
99
+ return textResult(msg)
100
+ },
101
+ )
102
+
103
+ // ─── mmx_speech ─────────────────────────────────────────────────────────
104
+ server.registerTool(
105
+ "mmx_speech",
106
+ {
107
+ description:
108
+ "Synthesize speech from text using MiniMax's speech-2.8-hd model. Use when the user wants a voiceover, narration, audio file, TTS, or to read text aloud. Saves an MP3 and returns the path. 40+ languages. Default voice: English_expressive_narrator.",
109
+ inputSchema: {
110
+ text: z.string().describe("The text to speak. Up to 10,000 characters."),
111
+ voice: z.string().optional().describe("Voice ID. Default English_expressive_narrator."),
112
+ speed: z.number().optional().describe("Speech speed multiplier. Default 1.0."),
113
+ out_path: z
114
+ .string()
115
+ .optional()
116
+ .describe(
117
+ "Override output .mp3 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/speech-<timestamp>.mp3 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
118
+ ),
119
+ },
120
+ },
121
+ async (args) => {
122
+ const {
123
+ filePath: outPath,
124
+ wasSuspicious,
125
+ originalArg,
126
+ } = resolveFilePath(args.out_path, worktree, `speech-${Date.now()}.mp3`)
127
+ ensureDir(dirname(outPath))
128
+ const cliArgs = ["speech", "synthesize", "--text", args.text]
129
+ if (args.voice) cliArgs.push("--voice", args.voice)
130
+ if (args.speed != null) cliArgs.push("--speed", String(args.speed))
131
+ cliArgs.push("--out", outPath, "--non-interactive")
132
+ const { stdout, stderr, exitCode } = await runMmx(cliArgs)
133
+ if (exitCode !== 0) {
134
+ return textResult(
135
+ `mmx speech synthesize failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`,
136
+ )
137
+ }
138
+ let msg = `Speech synthesized.\n\nSaved to: ${outPath}`
139
+ if (wasSuspicious && originalArg) {
140
+ msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
141
+ }
142
+ return textResult(msg)
143
+ },
144
+ )
145
+
146
+ // ─── mmx_video ──────────────────────────────────────────────────────────
147
+ server.registerTool(
148
+ "mmx_video",
149
+ {
150
+ description:
151
+ "Generate a short video from a text prompt using MiniMax's Hailuo-2.3 model. Use when the user wants a video clip, animation, or motion graphic. Generation can take 1-3 minutes. Returns the output MP4 file path.",
152
+ inputSchema: {
153
+ prompt: z
154
+ .string()
155
+ .describe("Detailed description of the video scene, including camera movement and action."),
156
+ model: z
157
+ .string()
158
+ .optional()
159
+ .describe(
160
+ "Model ID. Default MiniMax-Hailuo-2.3. Use MiniMax-Hailuo-2.3-Fast for quicker lower-quality results.",
161
+ ),
162
+ out_path: z
163
+ .string()
164
+ .optional()
165
+ .describe(
166
+ "Override output .mp4 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/video-<timestamp>.mp4 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
167
+ ),
168
+ },
169
+ },
170
+ async (args) => {
171
+ const {
172
+ filePath: outPath,
173
+ wasSuspicious,
174
+ originalArg,
175
+ } = resolveFilePath(args.out_path, worktree, `video-${Date.now()}.mp4`)
176
+ ensureDir(dirname(outPath))
177
+ const cliArgs = ["video", "generate", "--prompt", args.prompt]
178
+ if (args.model) cliArgs.push("--model", args.model)
179
+ cliArgs.push("--download", outPath, "--non-interactive")
180
+ const { stdout, stderr, exitCode } = await runMmx(cliArgs)
181
+ if (exitCode !== 0) {
182
+ return textResult(`mmx video generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`)
183
+ }
184
+ let msg = `Video generation complete.\n\nSaved to: ${outPath}`
185
+ if (wasSuspicious && originalArg) {
186
+ msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
187
+ }
188
+ return textResult(msg)
189
+ },
190
+ )
191
+
192
+ // ─── mmx_music ──────────────────────────────────────────────────────────
193
+ server.registerTool(
194
+ "mmx_music",
195
+ {
196
+ description:
197
+ "Generate a song or instrumental music from a style prompt using MiniMax's music-2.6 model. Use when the user wants background music, a theme song, a jingle, or instrumental music. Either supply lyrics or set instrumental=true.",
198
+ inputSchema: {
199
+ prompt: z
200
+ .string()
201
+ .describe(
202
+ "Style description: genre, mood, instruments, tempo. E.g. 'cinematic orchestral, building tension'.",
203
+ ),
204
+ lyrics: z
205
+ .string()
206
+ .optional()
207
+ .describe("Song lyrics with structure tags like [Verse], [Chorus]. Omit for instrumental."),
208
+ instrumental: z.boolean().optional().describe("If true, generate instrumental music with no vocals."),
209
+ vocals: z.string().optional().describe("Vocal style hint, e.g. 'warm male baritone'."),
210
+ bpm: z.number().optional().describe("Exact tempo in BPM."),
211
+ out_path: z
212
+ .string()
213
+ .optional()
214
+ .describe(
215
+ "Override output .mp3 path. Leave unset unless the user explicitly asked for a different save location in this conversation. Default: ~/Desktop/mmx-output/music-<timestamp>.mp3 (or $MMX_OUTPUT_DIR if set). Suspicious parent directories fall back to the default with a warning.",
216
+ ),
217
+ },
218
+ },
219
+ async (args) => {
220
+ const {
221
+ filePath: outPath,
222
+ wasSuspicious,
223
+ originalArg,
224
+ } = resolveFilePath(args.out_path, worktree, `music-${Date.now()}.mp3`)
225
+ ensureDir(dirname(outPath))
226
+ const cliArgs = ["music", "generate", "--prompt", args.prompt]
227
+ if (args.lyrics) cliArgs.push("--lyrics", args.lyrics)
228
+ if (args.instrumental) cliArgs.push("--instrumental")
229
+ if (args.vocals) cliArgs.push("--vocals", args.vocals)
230
+ if (args.bpm != null) cliArgs.push("--bpm", String(args.bpm))
231
+ cliArgs.push("--out", outPath, "--non-interactive")
232
+ const { stdout, stderr, exitCode } = await runMmx(cliArgs)
233
+ if (exitCode !== 0) {
234
+ return textResult(`mmx music generate failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`)
235
+ }
236
+ let msg = `Music generation complete.\n\nSaved to: ${outPath}`
237
+ if (wasSuspicious && originalArg) {
238
+ msg += `\n\n${warnSuspiciousOutDir(originalArg, outPath)}`
239
+ }
240
+ return textResult(msg)
241
+ },
242
+ )
243
+
244
+ // ─── mmx_search ─────────────────────────────────────────────────────────
245
+ server.registerTool(
246
+ "mmx_search",
247
+ {
248
+ description:
249
+ "Search the web using MiniMax's search API. Use when the user wants current information, news, facts, or anything time-sensitive. Returns a textual summary of search results.",
250
+ inputSchema: {
251
+ query: z.string().describe("The search query."),
252
+ },
253
+ },
254
+ async (args) => {
255
+ const { stdout, stderr, exitCode } = await runMmx([
256
+ "search",
257
+ "query",
258
+ "--q",
259
+ args.query,
260
+ "--output",
261
+ "json",
262
+ "--non-interactive",
263
+ ])
264
+ if (exitCode !== 0) {
265
+ return textResult(`mmx search failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`)
266
+ }
267
+ return textResult(stdout.trim() || "(no results)")
268
+ },
269
+ )
270
+
271
+ // ─── mmx_vision ─────────────────────────────────────────────────────────
272
+ server.registerTool(
273
+ "mmx_vision",
274
+ {
275
+ description:
276
+ "Describe or analyze an image using MiniMax's vision model. Pass a local file path or URL. Returns a textual description. Useful when the user uploads an image and wants analysis, OCR, or a description.",
277
+ inputSchema: {
278
+ image: z.string().describe("Local file path or URL of the image."),
279
+ prompt: z
280
+ .string()
281
+ .optional()
282
+ .describe("Custom question about the image. Default 'Describe the image.'"),
283
+ },
284
+ },
285
+ async (args) => {
286
+ const cliArgs = ["vision", "describe", "--image", args.image]
287
+ if (args.prompt) cliArgs.push("--prompt", args.prompt)
288
+ cliArgs.push("--non-interactive")
289
+ const { stdout, stderr, exitCode } = await runMmx(cliArgs)
290
+ if (exitCode !== 0) {
291
+ return textResult(
292
+ `mmx vision describe failed (exit ${exitCode}):\n${stderr || stdout || "(no output)"}`,
293
+ )
294
+ }
295
+ return textResult(stdout.trim() || "(no description)")
296
+ },
297
+ )
298
+
299
+ // ─── mmx_quota ──────────────────────────────────────────────────────────
300
+ server.registerTool(
301
+ "mmx_quota",
302
+ {
303
+ description:
304
+ "Show current Token Plan usage and remaining quota (5-hour rolling and weekly windows). Use when the user asks about quota, usage, limits, or how many calls they have left.",
305
+ },
306
+ async () => {
307
+ let { stdout, stderr, exitCode } = await runMmx(["quota"])
308
+ if (exitCode !== 0) {
309
+ const fallback = await runMmx(["quota", "show", "--non-interactive"])
310
+ if (fallback.exitCode === 0) {
311
+ stdout = fallback.stdout
312
+ stderr = fallback.stderr
313
+ exitCode = fallback.exitCode
314
+ } else {
315
+ return textResult(`mmx quota failed (exit ${exitCode}):\n${stderr || "(no stderr)"}`)
316
+ }
317
+ }
318
+ const raw = stdout.trim()
319
+ let data: any
320
+ try {
321
+ data = JSON.parse(raw)
322
+ } catch {
323
+ return textResult(raw || "(no quota info)")
324
+ }
325
+ const rows = (data.model_remains ?? []).map((m: any) => {
326
+ const reset = new Date(m.end_time).toUTCString().replace(/^[^,]+,\s*/, "")
327
+ return `| ${m.model_name} | ${m.current_interval_remaining_percent}% left | ${m.current_weekly_remaining_percent}% left | resets ${reset} |`
328
+ })
329
+ const header = "| Model | 5h window | Weekly | Next reset |\n|---|---|---|---|"
330
+ return textResult(`Quota — Token Plan\n\n${header}\n${rows.join("\n")}\n`)
331
+ },
332
+ )
333
+
334
+ const transport = new StdioServerTransport()
335
+ await server.connect(transport)
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: mmx
3
+ description: Use when the user wants to generate images, video, music, speech, run web search, analyze images, or check Token Plan quota. Wraps the official mmx-cli so multimodal MiniMax capabilities can be invoked directly from inside a Claude Code session without leaving the chat. Front-load keywords: image, picture, illustration, logo, artwork, generate, mmx, MiniMax, video, music, song, voiceover, TTS, speech, search, vision, quota, usage.
4
+ ---
5
+
6
+ # mmx — MiniMax multimodal tools
7
+
8
+ The `mmx-claude` MCP server exposes seven tools that wrap the official MiniMax CLI. They are auto-registered when the user runs `hl-plugins install mmx-claude` and Claude Code restarts.
9
+
10
+ | Tool | What it does |
11
+ | ----------------- | ---------------------------------------------------- |
12
+ | `mmx_image` | Text → image (image-01 model) |
13
+ | `mmx_speech` | Text → MP3 voiceover (speech-2.8-hd) |
14
+ | `mmx_video` | Text → MP4 short video (Hailuo-2.3) |
15
+ | `mmx_music` | Text → song / instrumental (music-2.6) |
16
+ | `mmx_search` | Web search via MiniMax search API |
17
+ | `mmx_vision` | Image → text description / OCR / Q&A |
18
+ | `mmx_quota` | Show Token Plan usage and remaining quota |
19
+
20
+ These are the same seven tools available to OpenCode users via `@hl-plugins/mmx`. Same `mmx-cli` binary, same auth state, same MiniMax API key.
21
+
22
+ ## Setup (one-time, on the machine)
23
+
24
+ 1. Install the CLI: `npm install -g mmx-cli`
25
+ 2. Install Bun (the MCP server runs on it): `curl -fsSL https://bun.sh/install | bash`
26
+ 3. Authenticate with the user's API key: `mmx auth login --api-key sk-xxxxx`
27
+ The key is stored locally — never paste it into chat.
28
+ 4. If calls return 401, the region auto-detect failed. Set it manually:
29
+ - Overseas: `mmx config set --key region --value global`
30
+ - Mainland China: `mmx config set --key region --value cn`
31
+ 5. Confirm with: `mmx quota` and `mmx auth status`
32
+
33
+ The user runs `hl-plugins install mmx-claude` once and the CLI handles steps 1–4 automatically (auto-installing Bun and `mmx-cli` if missing).
34
+
35
+ ## Output location
36
+
37
+ All generated files save to `~/Desktop/mmx-output/` by default — regardless of which repo or directory Claude Code was launched from. This is intentional; do not override it on your own.
38
+
39
+ **Never pass `out_dir` / `out_path` unless the user explicitly asked for a different save location in this conversation.** Passing these args based on your own inference (e.g. "save to the current repo") is wrong and will be silently corrected to the default with a warning in the tool output.
40
+
41
+ Two ways the user can legitimately override:
42
+
43
+ 1. **Per-call:** tell you explicitly in chat ("save this to my Pictures folder"), and you pass the right `out_dir` / `out_path`.
44
+ 2. **Permanent:** set `MMX_OUTPUT_DIR=/some/path` in the user's shell rc (`~/.zshrc`, `~/.bashrc`) before launching Claude Code. This changes the default for all mmx tools.
45
+
46
+ Suspicious paths (`$HOME`, `~/Desktop`, `/tmp`, `.`) passed via `out_dir` / `out_path` are always rejected and fall back to `~/Desktop/mmx-output/` — even when the user explicitly asks for them. (Users wanting those locations should use `MMX_OUTPUT_DIR` for the legitimate case.)
47
+
48
+ `mmx_image` defaults to a unique filename prefix per call (`image-<timestamp>`) so back-to-back calls don't overwrite each other. Pass an explicit `filename_prefix` when you want predictable sequential naming (e.g. `logo_001.jpg`, `logo_002.jpg`).
49
+
50
+ ## Common patterns
51
+
52
+ - **Cover image for a slide / social post** — call `mmx_image` with a detailed style prompt and `aspect_ratio: "16:9"` or `"1:1"`.
53
+ - **Voiceover for a video** — `mmx_speech` with the script as `text`. Override voice ID if the default English narrator doesn't fit.
54
+ - **Background music** — `mmx_music` with `instrumental: true` and a style prompt like "calm ambient pad, lo-fi beat, 70 bpm".
55
+ - **Verify what's left in the quota** — `mmx_quota` before starting a long batch.
56
+ - **Describe a screenshot the user dropped in** — `mmx_vision` with the local path.
57
+
58
+ ## Tips
59
+
60
+ - Image prompts should be **specific and detailed**: subject, style, lighting, composition, mood. Vague prompts produce vague results.
61
+ - For reproducible images, pass `seed`. Same prompt + same seed = same image.
62
+ - Video and music calls block for 1–3 minutes — call them only when the user actually wants the output.
63
+ - If `mmx_image` returns a failed exit code, surface the stderr verbatim — that's where mmx-cli writes quota errors, region errors, and validation issues.
64
+
65
+ ## When NOT to use these tools
66
+
67
+ - For diagrams, charts, tables, or anything textual — render with HTML/JSX instead.
68
+ - For tiny UI icons — too heavy; use SVG.
69
+ - If the user is on the free tier or hasn't authenticated, point them at the setup section above before retrying.