jjs_cli 0.1.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/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # jjs_cli
2
+
3
+ Node.js CLI for the `case_storage` Streamable HTTP MCP service. It follows the same install shape as Bailian-style CLIs: install with npm, then use `jjs_cli` or the short command `jjs`.
4
+
5
+ ## Install
6
+
7
+ After publishing to npm:
8
+
9
+ ```bash
10
+ npm install -g jjs_cli
11
+ jjs_cli --help
12
+ jjs --help
13
+ ```
14
+
15
+ From this repository before publishing:
16
+
17
+ ```bash
18
+ npm install -g ./cli/jjs_cli
19
+ jjs_cli --help
20
+ jjs --help
21
+ ```
22
+
23
+ Requires Node.js `>=22.12.0` and npm.
24
+
25
+ ## Auth
26
+
27
+ ```bash
28
+ jjs_cli auth login --url "https://<HOST>/mcp" --api-key "<SERVER_API_KEY>"
29
+ jjs_cli auth status --json
30
+ jjs_cli auth logout
31
+ ```
32
+
33
+ The key is sent as `X-Server-API-Key`. CLI output masks the key. Local config is stored under the user config directory as `jjs_cli/config.json`, or a custom path from `CASE_STORAGE_CLI_CONFIG`.
34
+
35
+ Environment overrides:
36
+
37
+ ```bash
38
+ CASE_STORAGE_MCP_URL="https://<HOST>/mcp"
39
+ CASE_STORAGE_SERVER_API_KEY="<SERVER_API_KEY>"
40
+ ```
41
+
42
+ ## Commands
43
+
44
+ ```bash
45
+ jjs_cli tools
46
+ jjs_cli schema create_image
47
+ jjs_cli call get_user_info --json
48
+ jjs_cli image create --prompt "产品宣传图" --aspect-ratio 3:4 --image-size 2K
49
+ jjs_cli video seedance --prompt "产品缓慢旋转" --image "https://example.com/ref.jpg"
50
+ jjs_cli task get --task-id "<TASK_ID>" --task-type image
51
+ jjs_cli voice public
52
+ jjs_cli voice synthesize --content "你好" --speaker-id "<SPEAKER_ID>"
53
+ jjs_cli douhot search --keyword "热点" --page 1
54
+ ```
55
+
56
+ The installed `jjs` command is equivalent to `jjs_cli`:
57
+
58
+ ```bash
59
+ jjs tools
60
+ jjs image create --prompt "产品宣传图" --json
61
+ ```
62
+
63
+ Generic calls accept:
64
+
65
+ ```bash
66
+ jjs_cli call create_image \
67
+ --arg prompt="产品宣传图" \
68
+ --json-arg image_urls='["https://example.com/ref.jpg"]' \
69
+ --json
70
+ ```
71
+
72
+ ## Test
73
+
74
+ ```bash
75
+ npm test
76
+ npm pack --dry-run
77
+ ```
78
+
79
+ The automated tests use a fake local MCP server and do not require a real backend or key.
package/bin/jjs.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ const code = await main(process.argv.slice(2));
5
+ process.exitCode = code;
6
+
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "jjs_cli",
3
+ "version": "0.1.0",
4
+ "description": "Node.js CLI for case_storage Streamable HTTP MCP tools.",
5
+ "type": "module",
6
+ "bin": {
7
+ "jjs_cli": "bin/jjs.js",
8
+ "jjs": "bin/jjs.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "engines": {
14
+ "node": ">=22.12.0"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "src/",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "jjs",
23
+ "mcp",
24
+ "cli"
25
+ ],
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "registry": "https://registry.npmjs.org/"
30
+ }
31
+ }
package/src/args.js ADDED
@@ -0,0 +1,156 @@
1
+ export function parseGlobalOptions(argv) {
2
+ const options = { json: false };
3
+ const rest = [];
4
+
5
+ for (let i = 0; i < argv.length; i += 1) {
6
+ const token = argv[i];
7
+ if (token === '--json') {
8
+ options.json = true;
9
+ continue;
10
+ }
11
+ if (token === '--output') {
12
+ const value = requireValue(argv, i, token);
13
+ i += 1;
14
+ options.output = value;
15
+ if (value === 'json') options.json = true;
16
+ continue;
17
+ }
18
+ if (token === '--url') {
19
+ options.url = requireValue(argv, i, token);
20
+ i += 1;
21
+ continue;
22
+ }
23
+ if (token === '--api-key') {
24
+ options.apiKey = requireValue(argv, i, token);
25
+ i += 1;
26
+ continue;
27
+ }
28
+ if (token === '--config') {
29
+ options.configPath = requireValue(argv, i, token);
30
+ i += 1;
31
+ continue;
32
+ }
33
+ if (token === '--non-interactive') {
34
+ options.nonInteractive = true;
35
+ continue;
36
+ }
37
+ rest.push(token);
38
+ }
39
+
40
+ return { options, argv: rest };
41
+ }
42
+
43
+ export function parseCallArguments(argv) {
44
+ const out = {};
45
+
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const token = argv[i];
48
+ if (token === '--args-json') {
49
+ const raw = requireValue(argv, i, token);
50
+ i += 1;
51
+ const parsed = parseJson(raw, '--args-json');
52
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
53
+ throw new Error('--args-json must be a JSON object');
54
+ }
55
+ Object.assign(out, parsed);
56
+ continue;
57
+ }
58
+ if (token === '--arg' || token === '--json-arg') {
59
+ const raw = requireValue(argv, i, token);
60
+ i += 1;
61
+ const [key, value] = splitKeyValue(raw, token);
62
+ out[key] = token === '--json-arg' ? parseJson(value, key) : value;
63
+ continue;
64
+ }
65
+ throw new Error(`Unknown call argument option: ${token}`);
66
+ }
67
+
68
+ return out;
69
+ }
70
+
71
+ export function optionValue(argv, name, fallback = undefined) {
72
+ const index = argv.indexOf(name);
73
+ if (index === -1) return fallback;
74
+ return requireValue(argv, index, name);
75
+ }
76
+
77
+ export function optionNumber(argv, name, fallback = undefined) {
78
+ const value = optionValue(argv, name);
79
+ if (value === undefined) return fallback;
80
+ const number = Number(value);
81
+ if (!Number.isFinite(number)) {
82
+ throw new Error(`${name} must be a number`);
83
+ }
84
+ return number;
85
+ }
86
+
87
+ export function optionFlag(argv, name) {
88
+ return argv.includes(name);
89
+ }
90
+
91
+ export function optionBoolean(argv, positive, negative, fallback = undefined) {
92
+ if (argv.includes(positive)) return true;
93
+ if (negative && argv.includes(negative)) return false;
94
+ return fallback;
95
+ }
96
+
97
+ export function optionList(argv, name) {
98
+ const values = [];
99
+ for (let i = 0; i < argv.length; i += 1) {
100
+ if (argv[i] === name) {
101
+ values.push(requireValue(argv, i, name));
102
+ i += 1;
103
+ }
104
+ }
105
+ return values;
106
+ }
107
+
108
+ export function compactObject(input) {
109
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => {
110
+ if (value === undefined || value === null) return false;
111
+ if (Array.isArray(value) && value.length === 0) return false;
112
+ if (value === '') return false;
113
+ return true;
114
+ }));
115
+ }
116
+
117
+ export function rejectUnknownOptions(argv, allowed) {
118
+ const allowedSet = new Set(allowed);
119
+ for (let i = 0; i < argv.length; i += 1) {
120
+ const token = argv[i];
121
+ if (!token.startsWith('--')) continue;
122
+ if (!allowedSet.has(token)) {
123
+ throw new Error(`Unknown option: ${token}`);
124
+ }
125
+ if (flagTakesValue(token)) i += 1;
126
+ }
127
+ }
128
+
129
+ function flagTakesValue(token) {
130
+ return !token.startsWith('--no-') && !['--sync', '--auto-translate', '--return-raw', '--cache'].includes(token);
131
+ }
132
+
133
+ function splitKeyValue(raw, label) {
134
+ const index = raw.indexOf('=');
135
+ if (index <= 0) {
136
+ throw new Error(`${label} expects key=value`);
137
+ }
138
+ return [raw.slice(0, index), raw.slice(index + 1)];
139
+ }
140
+
141
+ function parseJson(raw, label) {
142
+ try {
143
+ return JSON.parse(raw);
144
+ } catch (error) {
145
+ throw new Error(`Invalid JSON for ${label}: ${error.message}`);
146
+ }
147
+ }
148
+
149
+ function requireValue(argv, index, name) {
150
+ const value = argv[index + 1];
151
+ if (value === undefined || value.startsWith('--')) {
152
+ throw new Error(`${name} requires a value`);
153
+ }
154
+ return value;
155
+ }
156
+
package/src/cli.js ADDED
@@ -0,0 +1,355 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ compactObject,
6
+ optionBoolean,
7
+ optionFlag,
8
+ optionList,
9
+ optionNumber,
10
+ optionValue,
11
+ parseCallArguments,
12
+ parseGlobalOptions,
13
+ } from './args.js';
14
+ import { configPathFromEnv, deleteConfig, loadConfig, resolveAuth, saveConfig, statusPayload } from './config.js';
15
+ import { McpClient, normalizeMcpUrl, redactText } from './mcp-client.js';
16
+ import { DOUHOT_COMMANDS, TOOL_SPECS, getToolSpec } from './tools.js';
17
+
18
+ export async function main(rawArgv, io = defaultIo()) {
19
+ const { options, argv } = parseGlobalOptions(rawArgv);
20
+ const secrets = [options.apiKey, io.env.CASE_STORAGE_SERVER_API_KEY].filter(Boolean);
21
+ try {
22
+ const code = await dispatch(argv, options, io);
23
+ return code;
24
+ } catch (error) {
25
+ io.stderr.write(`${redactText(error.message || String(error), secrets)}\n`);
26
+ return 1;
27
+ }
28
+ }
29
+
30
+ async function dispatch(argv, options, io) {
31
+ const command = argv[0];
32
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
33
+ write(io, helpText());
34
+ return 0;
35
+ }
36
+ if (command === '--version' || command === 'version') {
37
+ write(io, 'jjs_cli 0.1.0\n');
38
+ return 0;
39
+ }
40
+ if (command === 'auth') return authCommand(argv.slice(1), options, io);
41
+ if (command === 'tools') return toolsCommand(argv.slice(1), options, io);
42
+ if (command === 'schema') return schemaCommand(argv.slice(1), options, io);
43
+ if (command === 'call') return callCommand(argv.slice(1), options, io);
44
+ if (command === 'user') return userCommand(argv.slice(1), options, io);
45
+ if (command === 'material') return materialCommand(argv.slice(1), options, io);
46
+ if (command === 'image') return imageCommand(argv.slice(1), options, io);
47
+ if (command === 'task') return taskCommand(argv.slice(1), options, io);
48
+ if (command === 'video') return videoCommand(argv.slice(1), options, io);
49
+ if (command === 'digital-human') return digitalHumanCommand(argv.slice(1), options, io);
50
+ if (command === 'voice') return voiceCommand(argv.slice(1), options, io);
51
+ if (command === 'douhot') return douhotCommand(argv.slice(1), options, io);
52
+ throw new Error(`Unknown command: ${command}\nRun: jjs_cli --help`);
53
+ }
54
+
55
+ async function authCommand(argv, options, io) {
56
+ const subcommand = argv[0] || 'status';
57
+ const configPath = configPathFromEnv(io.env, options);
58
+ const config = loadConfig(configPath);
59
+
60
+ if (subcommand === 'status') {
61
+ const payload = statusPayload({ auth: resolveAuth({ config, env: io.env, flags: options }), configPath });
62
+ return output(payload, options, io, humanAuthStatus(payload));
63
+ }
64
+
65
+ if (subcommand === 'login') {
66
+ const url = options.url || optionValue(argv, '--url') || config.mcp_url;
67
+ const apiKey = options.apiKey || optionValue(argv, '--api-key') || config.server_api_key;
68
+ if (!url) throw new Error('auth login requires --url <http(s)://host/mcp>');
69
+ if (!apiKey) throw new Error('auth login requires --api-key <SERVER_API_KEY>');
70
+ saveConfig(configPath, {
71
+ mcp_url: normalizeMcpUrl(url),
72
+ server_api_key: apiKey,
73
+ });
74
+ const payload = statusPayload({
75
+ auth: resolveAuth({ config: loadConfig(configPath), env: {}, flags: {} }),
76
+ configPath,
77
+ });
78
+ return output(payload, options, io, `Saved case_storage MCP credentials to ${configPath}\nAPI Key: ${payload.api_key}\n`);
79
+ }
80
+
81
+ if (subcommand === 'logout') {
82
+ deleteConfig(configPath);
83
+ return output({ ok: true, config_path: configPath }, options, io, `Removed ${configPath}\n`);
84
+ }
85
+
86
+ throw new Error(`Unknown auth command: ${subcommand}`);
87
+ }
88
+
89
+ async function toolsCommand(argv, options, io) {
90
+ if (optionFlag(argv, '--remote')) {
91
+ const result = await clientFromConfig(options, io).listTools();
92
+ return output(result, options, io, `${JSON.stringify(result, null, 2)}\n`);
93
+ }
94
+ const payload = { tools: TOOL_SPECS };
95
+ const human = TOOL_SPECS.map((tool) => `${tool.name.padEnd(38)} ${tool.description}`).join('\n');
96
+ return output(payload, options, io, `${human}\n`);
97
+ }
98
+
99
+ async function schemaCommand(argv, options, io) {
100
+ const toolName = argv[0];
101
+ if (!toolName) throw new Error('schema requires a tool name');
102
+ const spec = getToolSpec(toolName);
103
+ return output(spec, options, io, `${JSON.stringify(spec, null, 2)}\n`);
104
+ }
105
+
106
+ async function callCommand(argv, options, io) {
107
+ const toolName = argv[0];
108
+ if (!toolName) throw new Error('call requires a tool name');
109
+ getToolSpec(toolName);
110
+ const args = parseCallArguments(argv.slice(1));
111
+ return callAndOutput(toolName, args, options, io);
112
+ }
113
+
114
+ async function userCommand(argv, options, io) {
115
+ if (argv[0] !== 'info') throw new Error('Usage: jjs_cli user info');
116
+ return callAndOutput('get_user_info', {}, options, io);
117
+ }
118
+
119
+ async function materialCommand(argv, options, io) {
120
+ if (argv[0] !== 'upload') throw new Error('Usage: jjs_cli material upload --file <path>');
121
+ const file = optionValue(argv, '--file');
122
+ if (!file) throw new Error('material upload requires --file <path>');
123
+ const content = fs.readFileSync(file);
124
+ const args = compactObject({
125
+ file_name: optionValue(argv, '--file-name', path.basename(file)),
126
+ mime_type: optionValue(argv, '--mime-type', inferMimeType(file)),
127
+ content_base64: content.toString('base64'),
128
+ title: optionValue(argv, '--title'),
129
+ category: optionValue(argv, '--category'),
130
+ tags: optionValue(argv, '--tags'),
131
+ });
132
+ return callAndOutput('upload_material', args, options, io);
133
+ }
134
+
135
+ async function imageCommand(argv, options, io) {
136
+ if (argv[0] !== 'create') throw new Error('Usage: jjs_cli image create --prompt <text>');
137
+ const args = compactObject({
138
+ prompt: optionValue(argv, '--prompt'),
139
+ image_urls: optionList(argv, '--image-url'),
140
+ image_generation_type: optionValue(argv, '--type'),
141
+ model: optionValue(argv, '--model'),
142
+ aspect_ratio: optionValue(argv, '--aspect-ratio'),
143
+ image_size: optionValue(argv, '--image-size'),
144
+ title: optionValue(argv, '--title'),
145
+ task: optionValue(argv, '--task'),
146
+ is_sync: optionFlag(argv, '--sync') || undefined,
147
+ transparent_background: optionBoolean(argv, '--transparent-background', '--no-transparent-background'),
148
+ transparent_key_color: optionValue(argv, '--transparent-key-color'),
149
+ });
150
+ return callAndOutput('create_image', args, options, io);
151
+ }
152
+
153
+ async function taskCommand(argv, options, io) {
154
+ if (argv[0] !== 'get') throw new Error('Usage: jjs_cli task get --task-id <id> --task-type <image|video|digital_human>');
155
+ const args = compactObject({
156
+ task_id: optionValue(argv, '--task-id'),
157
+ task_type: optionValue(argv, '--task-type'),
158
+ });
159
+ return callAndOutput('get_task', args, options, io);
160
+ }
161
+
162
+ async function videoCommand(argv, options, io) {
163
+ const subcommand = argv[0];
164
+ const toolName = subcommand === 'seedance'
165
+ ? 'create_seedance_video'
166
+ : subcommand === 'happyhorse'
167
+ ? 'create_happyhorse_video'
168
+ : '';
169
+ if (!toolName) throw new Error('Usage: jjs_cli video <seedance|happyhorse> --prompt <text>');
170
+ const args = compactObject({
171
+ prompt: optionValue(argv, '--prompt'),
172
+ images: optionList(argv, '--image'),
173
+ videos: optionList(argv, '--video'),
174
+ audios: optionList(argv, '--audio'),
175
+ virtual_portrait_uri: optionValue(argv, '--virtual-portrait-uri'),
176
+ virtual_portrait_uris: optionList(argv, '--virtual-portrait-uri'),
177
+ aspect_ratio: optionValue(argv, '--aspect-ratio'),
178
+ quality: optionValue(argv, '--quality'),
179
+ orientation: optionValue(argv, '--orientation'),
180
+ duration: optionNumber(argv, '--duration'),
181
+ seed: optionValue(argv, '--seed'),
182
+ auto_translate: optionFlag(argv, '--auto-translate') || undefined,
183
+ title: optionValue(argv, '--title'),
184
+ });
185
+ return callAndOutput(toolName, args, options, io);
186
+ }
187
+
188
+ async function digitalHumanCommand(argv, options, io) {
189
+ if (argv[0] !== 'create') throw new Error('Usage: jjs_cli digital-human create --image-url <url> --audio-url <url>');
190
+ const args = compactObject({
191
+ image_url: optionValue(argv, '--image-url'),
192
+ audio_url: optionValue(argv, '--audio-url'),
193
+ resolution: optionValue(argv, '--resolution'),
194
+ start_time: optionValue(argv, '--start-time'),
195
+ end_time: optionValue(argv, '--end-time'),
196
+ prompt: optionValue(argv, '--prompt'),
197
+ title: optionValue(argv, '--title'),
198
+ });
199
+ return callAndOutput('create_digital_human', args, options, io);
200
+ }
201
+
202
+ async function voiceCommand(argv, options, io) {
203
+ const subcommand = argv[0];
204
+ if (subcommand === 'public') return callAndOutput('get_public_voice', {}, options, io);
205
+ if (subcommand === 'custom') return callAndOutput('get_custom_voice', {}, options, io);
206
+ if (subcommand === 'synthesize') {
207
+ const args = compactObject({
208
+ content: optionValue(argv, '--content'),
209
+ speaker_id: optionValue(argv, '--speaker-id'),
210
+ voice_id: optionValue(argv, '--voice-id'),
211
+ group_key: optionValue(argv, '--group-key'),
212
+ model: optionValue(argv, '--model'),
213
+ rate: optionNumber(argv, '--rate'),
214
+ volume: optionNumber(argv, '--volume'),
215
+ audio_format: optionValue(argv, '--audio-format'),
216
+ sample_rate: optionNumber(argv, '--sample-rate'),
217
+ });
218
+ return callAndOutput('create_audio_task', args, options, io);
219
+ }
220
+ if (subcommand === 'clone') {
221
+ const args = compactObject({
222
+ audio_url: optionValue(argv, '--audio-url'),
223
+ prefix: optionValue(argv, '--prefix'),
224
+ language_hints: optionList(argv, '--language-hint'),
225
+ });
226
+ return callAndOutput('clone_voice', args, options, io);
227
+ }
228
+ throw new Error('Usage: jjs_cli voice <public|custom|synthesize|clone>');
229
+ }
230
+
231
+ async function douhotCommand(argv, options, io) {
232
+ const subcommand = argv[0];
233
+ if (subcommand === 'call') return callCommand(argv.slice(1), options, io);
234
+ const toolName = DOUHOT_COMMANDS[subcommand];
235
+ if (!toolName) throw new Error(`Usage: jjs_cli douhot <${Object.keys(DOUHOT_COMMANDS).join('|')}>`);
236
+ if ((subcommand === 'hot-word' || subcommand === 'author') && !optionValue(argv, '--keyword')) {
237
+ throw new Error(`douhot ${subcommand} requires --keyword <text>`);
238
+ }
239
+ const args = compactObject({
240
+ keyword: optionValue(argv, '--keyword'),
241
+ page: optionNumber(argv, '--page'),
242
+ page_size: optionNumber(argv, '--page-size'),
243
+ type: optionValue(argv, '--type'),
244
+ sec_uid: optionValue(argv, '--sec-uid'),
245
+ min_cursor: optionValue(argv, '--min-cursor'),
246
+ max_cursor: optionValue(argv, '--max-cursor'),
247
+ item_id: optionValue(argv, '--item-id'),
248
+ option: optionNumber(argv, '--option'),
249
+ date_window: optionNumber(argv, '--date-window'),
250
+ return_raw: optionBoolean(argv, '--return-raw', '--no-return-raw'),
251
+ cache: optionBoolean(argv, '--cache', '--no-cache'),
252
+ cookie: optionValue(argv, '--cookie'),
253
+ });
254
+ return callAndOutput(toolName, args, options, io);
255
+ }
256
+
257
+ async function callAndOutput(toolName, args, options, io) {
258
+ const result = await clientFromConfig(options, io).callTool(toolName, args);
259
+ const code = result?.success === false ? 2 : 0;
260
+ output(result, options, io, `${JSON.stringify(result, null, 2)}\n`);
261
+ return code;
262
+ }
263
+
264
+ function clientFromConfig(options, io) {
265
+ const configPath = configPathFromEnv(io.env, options);
266
+ const config = loadConfig(configPath);
267
+ const auth = resolveAuth({ config, env: io.env, flags: options });
268
+ return new McpClient({ url: auth.url, apiKey: auth.apiKey });
269
+ }
270
+
271
+ function output(payload, options, io, human) {
272
+ if (options.json) {
273
+ write(io, `${JSON.stringify(payload)}\n`);
274
+ } else {
275
+ write(io, human);
276
+ }
277
+ return payload?.success === false ? 2 : 0;
278
+ }
279
+
280
+ function write(io, text) {
281
+ io.stdout.write(text);
282
+ }
283
+
284
+ function humanAuthStatus(payload) {
285
+ if (!payload.authenticated) {
286
+ return `Not authenticated\nConfig: ${payload.config_path}\nRun: jjs_cli auth login --url <MCP_URL> --api-key <SERVER_API_KEY>\n`;
287
+ }
288
+ return `Authenticated\nMCP URL: ${payload.mcp_url}\nAPI Key: ${payload.api_key}\nConfig: ${payload.config_path}\n`;
289
+ }
290
+
291
+ function inferMimeType(file) {
292
+ const ext = path.extname(file).toLowerCase();
293
+ if (ext === '.png') return 'image/png';
294
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
295
+ if (ext === '.webp') return 'image/webp';
296
+ throw new Error('Unable to infer MIME type. Pass --mime-type image/png|image/jpeg|image/webp');
297
+ }
298
+
299
+ function defaultIo() {
300
+ return {
301
+ stdout: process.stdout,
302
+ stderr: process.stderr,
303
+ env: process.env,
304
+ };
305
+ }
306
+
307
+ function helpText() {
308
+ return `jjs_cli - Node.js CLI for case_storage MCP tools
309
+
310
+ Install:
311
+ npm install -g jjs_cli
312
+
313
+ Commands after install:
314
+ jjs_cli
315
+ jjs
316
+
317
+ Repo-local install:
318
+ npm install -g ./cli/jjs_cli
319
+
320
+ Global options:
321
+ --url <url> MCP URL, for example http://127.0.0.1:8303/mcp
322
+ --api-key <key> Service API Key sent as X-Server-API-Key
323
+ --json Machine-readable output
324
+ --output json Bailian-style JSON output alias
325
+ --config <path> Override local config path
326
+
327
+ Auth:
328
+ jjs_cli auth login --url <url> --api-key <key>
329
+ jjs_cli auth status --json
330
+ jjs_cli auth logout
331
+
332
+ Introspection:
333
+ jjs_cli tools
334
+ jjs_cli schema <tool>
335
+ jjs_cli call <tool> --arg key=value --json-arg list='["a"]'
336
+
337
+ Friendly commands:
338
+ jjs_cli user info
339
+ jjs_cli material upload --file ref.png
340
+ jjs_cli image create --prompt "产品宣传图" --image-url https://...
341
+ jjs_cli task get --task-id <id> --task-type image
342
+ jjs_cli video seedance --prompt "..." --image https://...
343
+ jjs_cli video happyhorse --prompt "..." --image https://...
344
+ jjs_cli digital-human create --image-url https://... --audio-url https://...
345
+ jjs_cli voice public | custom
346
+ jjs_cli voice synthesize --content "你好" --speaker-id <id>
347
+ jjs_cli voice clone --audio-url https://...
348
+ jjs_cli douhot search --keyword "热点"
349
+
350
+ Environment:
351
+ CASE_STORAGE_MCP_URL
352
+ CASE_STORAGE_SERVER_API_KEY
353
+ CASE_STORAGE_CLI_CONFIG
354
+ `;
355
+ }
package/src/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const CONFIG_DIR_NAME = 'jjs_cli';
6
+
7
+ export function defaultConfigPath(runtime = {}) {
8
+ const platform = runtime.platform ?? process.platform;
9
+ const env = runtime.env ?? process.env;
10
+ const homedir = runtime.homedir ?? os.homedir;
11
+
12
+ if (platform === 'win32') {
13
+ const base = env.APPDATA || path.join(homedir(), 'AppData', 'Roaming');
14
+ return path.join(base, CONFIG_DIR_NAME, 'config.json');
15
+ }
16
+
17
+ const base = env.XDG_CONFIG_HOME || path.join(homedir(), '.config');
18
+ return path.join(base, CONFIG_DIR_NAME, 'config.json');
19
+ }
20
+
21
+ export function configPathFromEnv(env = process.env, flags = {}) {
22
+ return flags.configPath || env.CASE_STORAGE_CLI_CONFIG || defaultConfigPath({ env });
23
+ }
24
+
25
+ export function loadConfig(configPath) {
26
+ if (!fs.existsSync(configPath)) return {};
27
+ const raw = fs.readFileSync(configPath, 'utf8').trim();
28
+ if (!raw) return {};
29
+ return JSON.parse(raw);
30
+ }
31
+
32
+ export function saveConfig(configPath, config) {
33
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
34
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
35
+ try {
36
+ fs.chmodSync(configPath, 0o600);
37
+ } catch {
38
+ // Windows ACLs are handled by the user profile; chmod is best-effort.
39
+ }
40
+ }
41
+
42
+ export function deleteConfig(configPath) {
43
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
44
+ }
45
+
46
+ export function resolveAuth({ config = {}, env = process.env, flags = {} } = {}) {
47
+ const envUrl = env.CASE_STORAGE_MCP_URL || env.CASE_STORAGE_BASE_URL;
48
+ const envKey = env.CASE_STORAGE_SERVER_API_KEY;
49
+ const configUrl = config.mcp_url || config.url || config.base_url;
50
+ const configKey = config.server_api_key || config.api_key;
51
+
52
+ const url = flags.url || envUrl || configUrl || '';
53
+ const apiKey = flags.apiKey || envKey || configKey || '';
54
+ return {
55
+ url,
56
+ apiKey,
57
+ source: {
58
+ url: flags.url ? 'flag' : envUrl ? 'env' : configUrl ? 'config' : 'missing',
59
+ apiKey: flags.apiKey ? 'flag' : envKey ? 'env' : configKey ? 'config' : 'missing',
60
+ },
61
+ };
62
+ }
63
+
64
+ export function maskApiKey(apiKey) {
65
+ if (!apiKey) return null;
66
+ const text = String(apiKey);
67
+ if (text.length <= 8) return `${text.slice(0, 2)}***`;
68
+ if (text.startsWith('gva_sk_')) {
69
+ return `gva_***${text.slice(-4)}`;
70
+ }
71
+ return `${text.slice(0, 4)}***${text.slice(-4)}`;
72
+ }
73
+
74
+ export function statusPayload({ auth, configPath }) {
75
+ return {
76
+ authenticated: Boolean(auth.url && auth.apiKey),
77
+ mcp_url: auth.url || null,
78
+ api_key: maskApiKey(auth.apiKey),
79
+ source: auth.source,
80
+ config_path: configPath,
81
+ };
82
+ }
@@ -0,0 +1,141 @@
1
+ const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
2
+
3
+ export class McpClient {
4
+ constructor({ url, apiKey, fetchImpl = globalThis.fetch, protocolVersion = DEFAULT_PROTOCOL_VERSION } = {}) {
5
+ if (!url) throw new Error('MCP URL is required. Run: jjs_cli auth login --url <url> --api-key <key>');
6
+ if (!apiKey) throw new Error('Service API Key is required. Run: jjs_cli auth login --url <url> --api-key <key>');
7
+ if (typeof fetchImpl !== 'function') throw new Error('Node.js fetch is unavailable; Node >= 22.12.0 is required');
8
+ this.url = normalizeMcpUrl(url);
9
+ this.apiKey = apiKey;
10
+ this.fetchImpl = fetchImpl;
11
+ this.protocolVersion = protocolVersion;
12
+ this.sessionId = '';
13
+ this.nextId = 1;
14
+ this.initialized = false;
15
+ }
16
+
17
+ async initialize() {
18
+ if (this.initialized) return;
19
+ await this.request('initialize', {
20
+ protocolVersion: this.protocolVersion,
21
+ clientInfo: { name: 'jjs_cli', version: '0.1.0' },
22
+ capabilities: {},
23
+ }, { skipInitialize: true });
24
+ this.initialized = true;
25
+ }
26
+
27
+ async listTools() {
28
+ await this.initialize();
29
+ return this.request('tools/list', {});
30
+ }
31
+
32
+ async callTool(name, args = {}) {
33
+ await this.initialize();
34
+ const response = await this.request('tools/call', { name, arguments: args || {} });
35
+ return unwrapToolResult(response);
36
+ }
37
+
38
+ async request(method, params = {}, options = {}) {
39
+ const id = this.nextId;
40
+ this.nextId += 1;
41
+ const headers = {
42
+ Accept: 'application/json, text/event-stream',
43
+ 'Content-Type': 'application/json',
44
+ 'X-Server-API-Key': this.apiKey,
45
+ };
46
+ if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
47
+
48
+ const body = JSON.stringify({ jsonrpc: '2.0', id, method, params });
49
+ let response;
50
+ try {
51
+ response = await this.fetchImpl(this.url, { method: 'POST', headers, body });
52
+ } catch (error) {
53
+ throw new Error(redactText(`MCP request failed: ${error.message}`, [this.apiKey]));
54
+ }
55
+
56
+ const sessionId = response.headers?.get?.('mcp-session-id');
57
+ if (sessionId) this.sessionId = sessionId;
58
+
59
+ const text = await response.text();
60
+ if (!response.ok) {
61
+ throw new Error(redactText(`MCP HTTP ${response.status}: ${text}`, [this.apiKey]));
62
+ }
63
+
64
+ const payload = parseResponseBody(text);
65
+ if (payload.error) {
66
+ const message = payload.error.message || JSON.stringify(payload.error);
67
+ throw new Error(redactText(`MCP JSON-RPC error: ${message}`, [this.apiKey]));
68
+ }
69
+ if (!Object.prototype.hasOwnProperty.call(payload, 'result')) {
70
+ throw new Error('MCP response missing result');
71
+ }
72
+ return payload.result;
73
+ }
74
+ }
75
+
76
+ export function parseResponseBody(text) {
77
+ const raw = String(text || '').trim();
78
+ if (!raw) throw new Error('MCP response body is empty');
79
+ if (!looksLikeSse(raw)) return JSON.parse(raw);
80
+
81
+ const frames = [];
82
+ let current = [];
83
+ for (const line of raw.split(/\r?\n/)) {
84
+ if (line === '') {
85
+ if (current.length > 0) frames.push(current);
86
+ current = [];
87
+ continue;
88
+ }
89
+ current.push(line);
90
+ }
91
+ if (current.length > 0) frames.push(current);
92
+
93
+ let lastPayload = null;
94
+ for (const frame of frames) {
95
+ const dataLines = [];
96
+ for (const line of frame) {
97
+ if (line.startsWith(':')) continue;
98
+ if (!line.startsWith('data:')) continue;
99
+ dataLines.push(line.slice(5).trimStart());
100
+ }
101
+ if (dataLines.length === 0) continue;
102
+ const data = dataLines.join('\n').trim();
103
+ if (!data || data === '[DONE]') continue;
104
+ lastPayload = JSON.parse(data);
105
+ }
106
+ if (!lastPayload) throw new Error('MCP SSE response contained no JSON data');
107
+ return lastPayload;
108
+ }
109
+
110
+ export function redactText(text, secrets = []) {
111
+ let out = String(text ?? '');
112
+ for (const secret of secrets) {
113
+ if (secret) out = out.split(String(secret)).join('[REDACTED]');
114
+ }
115
+ out = out.replace(/gva_sk_[A-Za-z0-9_-]+/g, 'gva_sk_[REDACTED]');
116
+ out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED]');
117
+ return out;
118
+ }
119
+
120
+ export function normalizeMcpUrl(url) {
121
+ const trimmed = String(url || '').trim();
122
+ if (!trimmed) return '';
123
+ return trimmed.endsWith('/mcp') ? trimmed : `${trimmed.replace(/\/+$/, '')}/mcp`;
124
+ }
125
+
126
+ function looksLikeSse(text) {
127
+ return /^(:|event:|id:|data:)/m.test(text);
128
+ }
129
+
130
+ function unwrapToolResult(result) {
131
+ if (!result || typeof result !== 'object') return result;
132
+ if (!Array.isArray(result.content)) return result;
133
+
134
+ const textPart = result.content.find((item) => item?.type === 'text' && typeof item.text === 'string');
135
+ if (!textPart) return result;
136
+ try {
137
+ return JSON.parse(textPart.text);
138
+ } catch {
139
+ return { success: true, data: textPart.text, error: null };
140
+ }
141
+ }
package/src/tools.js ADDED
@@ -0,0 +1,172 @@
1
+ const serviceAuth = 'service-api-key';
2
+
3
+ export const TOOL_SPECS = [
4
+ tool('get_user_info', '获取当前 Service API Key 绑定用户的账户、会员和兼容信息。'),
5
+ tool('upload_material', '上传图片素材到 OSS 并注册到当前用户成品库。', [
6
+ arg('file_name', 'string', true),
7
+ arg('mime_type', 'string', true),
8
+ arg('content_base64', 'string', true),
9
+ arg('title'),
10
+ arg('category'),
11
+ arg('tags'),
12
+ ]),
13
+ tool('create_image', '创建图片生成或改图任务。', [
14
+ arg('prompt'),
15
+ arg('image_urls', 'string[]'),
16
+ arg('image_generation_type'),
17
+ arg('model'),
18
+ arg('aspect_ratio'),
19
+ arg('image_size'),
20
+ arg('title'),
21
+ arg('task'),
22
+ arg('is_sync', 'boolean'),
23
+ arg('providers', 'string[]'),
24
+ arg('transparent_background', 'boolean'),
25
+ arg('transparent_key_color'),
26
+ ]),
27
+ videoTool('create_seedance_video', '创建 Seedance 全能参考视频任务。'),
28
+ videoTool('create_happyhorse_video', '创建 HappyHorse 全能参考视频任务。'),
29
+ tool('create_digital_human', '使用固定模板 101 创建单图数字人视频任务。', [
30
+ arg('image_url', 'string', true),
31
+ arg('audio_url', 'string', true),
32
+ arg('resolution'),
33
+ arg('start_time'),
34
+ arg('end_time'),
35
+ arg('prompt'),
36
+ arg('title'),
37
+ ]),
38
+ tool('get_task', '统一查询图片、视频、数字人任务状态和结果 URL。', [
39
+ arg('task_id', 'string', true),
40
+ arg('task_type', 'string', true),
41
+ ]),
42
+ tool('get_public_voice', '查询可直接用于语音合成的公共音色列表。'),
43
+ tool('get_custom_voice', '查询当前用户已克隆或定制的音色列表。'),
44
+ tool('clone_voice', '根据公网音频 URL 克隆当前用户的定制音色。', [
45
+ arg('audio_url', 'string', true),
46
+ arg('prefix'),
47
+ arg('language_hints', 'string[]'),
48
+ ]),
49
+ tool('create_audio_task', '把文本合成为音频,返回 voice_url。', [
50
+ arg('content', 'string', true),
51
+ arg('speaker_id'),
52
+ arg('voice_id'),
53
+ arg('group_key'),
54
+ arg('model'),
55
+ arg('rate', 'number'),
56
+ arg('volume', 'number'),
57
+ arg('audio_format'),
58
+ arg('sample_rate', 'number'),
59
+ ]),
60
+ douhotSearchTool('douhot_search', '抖音热点宝搜索完整降级链路。'),
61
+ douhotSearchTool('douhot_search_video_billboard', '抖音热点宝视频榜搜索。'),
62
+ douhotSearchTool('douhot_search_hot_word', '抖音热点宝热词详情搜索。'),
63
+ douhotSearchTool('douhot_search_challenge_analysis', '抖音热点宝挑战分析搜索。'),
64
+ douhotSearchTool('douhot_search_hot_search', '抖音热点宝热搜榜搜索。'),
65
+ douhotSearchTool('douhot_search_author', '抖音热点宝作者搜索。'),
66
+ douhotCreatorTool('douhot_creator_info', '抖音热点宝创作者信息。', 'info'),
67
+ douhotCreatorTool('douhot_creator_contents', '抖音热点宝创作者作品列表。', 'contents'),
68
+ douhotCreatorTool('douhot_creator_fans_trend_index', '抖音热点宝创作者粉丝趋势概览。', 'info'),
69
+ douhotCreatorTool('douhot_creator_fans_trend_trends', '抖音热点宝创作者粉丝趋势明细。', 'trend'),
70
+ douhotVideoTool('douhot_video_detail', '抖音热点宝视频详情。', 'base'),
71
+ douhotVideoTool('douhot_video_comments', '抖音热点宝视频评论列表。', 'comments'),
72
+ douhotVideoTool('douhot_video_comment_word_cloud', '抖音热点宝视频评论词云。', 'base'),
73
+ douhotVideoTool('douhot_video_trends_index', '抖音热点宝视频趋势概览。', 'base'),
74
+ douhotVideoTool('douhot_video_trends', '抖音热点宝视频趋势明细。', 'trend'),
75
+ ];
76
+
77
+ export const friendlyCommandGroups = {
78
+ user: ['get_user_info'],
79
+ media: ['upload_material', 'create_image', 'create_seedance_video', 'create_happyhorse_video', 'create_digital_human'],
80
+ task: ['get_task'],
81
+ voice: ['get_public_voice', 'get_custom_voice', 'clone_voice', 'create_audio_task'],
82
+ douhot: TOOL_SPECS.filter((toolSpec) => toolSpec.name.startsWith('douhot_')).map((toolSpec) => toolSpec.name),
83
+ };
84
+
85
+ export const DOUHOT_COMMANDS = {
86
+ search: 'douhot_search',
87
+ 'video-billboard': 'douhot_search_video_billboard',
88
+ 'hot-word': 'douhot_search_hot_word',
89
+ 'challenge-analysis': 'douhot_search_challenge_analysis',
90
+ 'hot-search': 'douhot_search_hot_search',
91
+ author: 'douhot_search_author',
92
+ 'creator-info': 'douhot_creator_info',
93
+ 'creator-contents': 'douhot_creator_contents',
94
+ 'creator-fans-trend-index': 'douhot_creator_fans_trend_index',
95
+ 'creator-fans-trend-trends': 'douhot_creator_fans_trend_trends',
96
+ 'video-detail': 'douhot_video_detail',
97
+ 'video-comments': 'douhot_video_comments',
98
+ 'video-comment-word-cloud': 'douhot_video_comment_word_cloud',
99
+ 'video-trends-index': 'douhot_video_trends_index',
100
+ 'video-trends': 'douhot_video_trends',
101
+ };
102
+
103
+ export function getToolSpec(name) {
104
+ const spec = TOOL_SPECS.find((toolSpec) => toolSpec.name === name);
105
+ if (!spec) throw new Error(`Unknown MCP tool: ${name}`);
106
+ return spec;
107
+ }
108
+
109
+ function tool(name, description, args = []) {
110
+ return {
111
+ name,
112
+ description,
113
+ auth: serviceAuth,
114
+ arguments: args,
115
+ required: args.filter((item) => item.required).map((item) => item.name),
116
+ };
117
+ }
118
+
119
+ function arg(name, type = 'string', required = false) {
120
+ return { name, type, required };
121
+ }
122
+
123
+ function videoTool(name, description) {
124
+ return tool(name, description, [
125
+ arg('prompt', 'string', true),
126
+ arg('images', 'string[]'),
127
+ arg('videos', 'string[]'),
128
+ arg('audios', 'string[]'),
129
+ arg('virtual_portrait_uri'),
130
+ arg('virtual_portrait_uris', 'string[]'),
131
+ arg('aspect_ratio'),
132
+ arg('quality'),
133
+ arg('orientation'),
134
+ arg('duration', 'number'),
135
+ arg('seed'),
136
+ arg('auto_translate', 'boolean'),
137
+ arg('title'),
138
+ ]);
139
+ }
140
+
141
+ function douhotCommonArgs(args) {
142
+ return [
143
+ ...args,
144
+ arg('return_raw', 'boolean'),
145
+ arg('cache', 'boolean'),
146
+ arg('cookie'),
147
+ ];
148
+ }
149
+
150
+ function douhotSearchTool(name, description) {
151
+ return tool(name, description, douhotCommonArgs([
152
+ arg('keyword'),
153
+ arg('page', 'number'),
154
+ arg('page_size', 'number'),
155
+ arg('type'),
156
+ ]));
157
+ }
158
+
159
+ function douhotCreatorTool(name, description, kind) {
160
+ const args = [arg('sec_uid', 'string', true)];
161
+ if (kind === 'contents') args.push(arg('min_cursor'), arg('max_cursor'));
162
+ if (kind === 'trend') args.push(arg('option', 'number'), arg('date_window', 'number'));
163
+ return tool(name, description, douhotCommonArgs(args));
164
+ }
165
+
166
+ function douhotVideoTool(name, description, kind) {
167
+ const args = [arg('item_id', 'string', true)];
168
+ if (kind === 'comments') args.push(arg('page', 'number'), arg('page_size', 'number'), arg('keyword'));
169
+ if (kind === 'trend') args.push(arg('option', 'number'), arg('date_window', 'number'));
170
+ return tool(name, description, douhotCommonArgs(args));
171
+ }
172
+