mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
Refine agent skill boundaries and secret handling
This commit is contained in:
@@ -1,20 +1,49 @@
|
||||
---
|
||||
name: moviepilot-cli
|
||||
version: 1
|
||||
description: Use this skill for any request involving movies, TV shows, or anime, including searching, downloads, subscriptions, library management. Also use this skill whenever the user explicitly mentions MoviePilot.
|
||||
version: 3
|
||||
description: >-
|
||||
Use this skill when the user asks to operate MoviePilot through the local
|
||||
`moviepilot tool` MCP CLI for normal product workflows: media search, torrent
|
||||
search, downloads, subscriptions, downloader tasks, library checks, sites,
|
||||
schedulers, workflows, and messages. Prefer dedicated skills for slash command
|
||||
dispatch, manual file organization or failed transfer retry, direct REST API
|
||||
calls, direct database SQL, browser operations, and restart/upgrade.
|
||||
---
|
||||
|
||||
# MoviePilot CLI
|
||||
|
||||
> All script paths are relative to this skill file.
|
||||
|
||||
Use `scripts/mp-cli.js` to interact with the MoviePilot backend.
|
||||
Use local `moviepilot tool ...` commands to interact with MoviePilot MCP tools.
|
||||
The command reads the local MoviePilot configuration; do not ask the user for
|
||||
`API_TOKEN`, database passwords, or a backend DSN during normal local use.
|
||||
|
||||
## Scope And Boundaries
|
||||
|
||||
This skill is for normal MoviePilot product operations exposed as MCP tools.
|
||||
Choose other skills first when they match more precisely:
|
||||
|
||||
| Request | Preferred skill |
|
||||
|---|---|
|
||||
| Slash commands or plugin/system command dispatch | `command-dispatch` |
|
||||
| Manual file organization | `organize-files` |
|
||||
| Retry failed transfer history records | `transfer-failed-retry` |
|
||||
| Direct REST endpoint not exposed by MCP tools | `moviepilot-api` |
|
||||
| Direct SQL query or database update | `database-operation` |
|
||||
| Restart, version check, or upgrade | `moviepilot-update` |
|
||||
| Browser-only state, site login pages, screenshots, cookies | `browser-use` |
|
||||
|
||||
Use `moviepilot-api` only after `moviepilot tool list` and
|
||||
`moviepilot tool show <command>` confirm that no MCP tool covers the required
|
||||
operation. Use `database-operation` only when the task explicitly requires SQL
|
||||
inspection or mutation, or when product tools/API cannot answer the data
|
||||
question.
|
||||
|
||||
## Discover Commands
|
||||
|
||||
List all available commands: `node scripts/mp-cli.js list`
|
||||
List all available commands: `moviepilot tool list`
|
||||
|
||||
Show parameters and usage for a specific command: `node scripts/mp-cli.js show <command>`
|
||||
Show parameters and usage for a specific command: `moviepilot tool show <command>`
|
||||
|
||||
Always run `show <command>` before calling a command — parameter names are not inferable, do not guess.
|
||||
|
||||
@@ -38,7 +67,7 @@ Always run `show <command>` before calling a command — parameter names are not
|
||||
#### 1. Search TMDB
|
||||
|
||||
Search for a movie or TV show by title:
|
||||
`node scripts/mp-cli.js search_media title="..." media_type="movie"`
|
||||
`moviepilot tool run search_media title="..." media_type="movie"`
|
||||
|
||||
If the user specifies a TV season, run Season Validation step first — the season number provided by the user may not match TMDB.
|
||||
|
||||
@@ -47,13 +76,13 @@ If the user specifies a TV season, run Season Validation step first — the seas
|
||||
Prefer `tmdb_id`; use `douban_id` only when `tmdb_id` is unavailable.
|
||||
|
||||
Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs:
|
||||
`node scripts/mp-cli.js query_sites`
|
||||
`moviepilot tool run query_sites`
|
||||
|
||||
Search torrents using default sites:
|
||||
`node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie"`
|
||||
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie"`
|
||||
|
||||
Search torrents using user-specified sites (pass site IDs from `query_sites`):
|
||||
`node scripts/mp-cli.js search_torrents tmdb_id=791373 media_type="movie" sites='1,3'`
|
||||
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie" sites='1,3'`
|
||||
|
||||
When `search_torrents` returns:
|
||||
1. **Stop** — do not call `get_search_results` yet.
|
||||
@@ -63,12 +92,12 @@ When `search_torrents` returns:
|
||||
|
||||
#### 3. Get filtered results (only after user has responded to filter_options)
|
||||
|
||||
Run `node scripts/mp-cli.js show get_search_results` to check available parameters. Filter logic: OR within a field, AND across fields.
|
||||
Run `moviepilot tool show get_search_results` to check available parameters. Filter logic: OR within a field, AND across fields.
|
||||
|
||||
Filter values must come from the `filter_options` returned by `search_torrents` — do not invent, translate, normalize, or use values from any other source. Note: `filter_options` keys are camelCase (e.g., `freeState`), but `get_search_results` params are snake_case (e.g., `free_state`).
|
||||
|
||||
Fetch results with selected filters:
|
||||
`node scripts/mp-cli.js get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
|
||||
If empty, tell the user which filter to relax and ask before retrying.
|
||||
|
||||
@@ -95,7 +124,7 @@ If the media already exists in the library or is already subscribed, **stop** an
|
||||
#### 6. Add download
|
||||
|
||||
Download one or more torrents (`torrent_url` comes from `get_search_results` output):
|
||||
`node scripts/mp-cli.js add_download_tasks torrent_url="abc1234:1,def5678:2"`
|
||||
`moviepilot tool run add_download_tasks torrent_url="abc1234:1,def5678:2"`
|
||||
|
||||
#### Error handling
|
||||
|
||||
@@ -113,59 +142,59 @@ Download one or more torrents (`torrent_url` comes from `get_search_results` out
|
||||
3. If the user specifies a TV season, run Season Validation step first.
|
||||
|
||||
Subscribe to a movie or TV show:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009`
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009`
|
||||
|
||||
Subscribe to a specific season:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4`
|
||||
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4`
|
||||
|
||||
Subscribe starting from a specific episode:
|
||||
`node scripts/mp-cli.js add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
|
||||
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
|
||||
|
||||
### Manage Downloads
|
||||
|
||||
List download tasks and get hash for further operations:
|
||||
`node scripts/mp-cli.js query_download_tasks status=downloading`
|
||||
`moviepilot tool run query_download_tasks status=downloading`
|
||||
|
||||
Use `status=completed` for tasks that are neither downloading nor paused in the downloader; use `status=all` to include every MoviePilot-tagged downloader task. Add `include_all_tags=true` when diagnosing tasks that do not have the MoviePilot built-in tag. Add `include_trackers=true` or query by `hash` when tracker URLs are needed.
|
||||
|
||||
Update a download task (supports start/stop, tags, speed limits, trackers, save path, category, ratio, and seeding time where the downloader supports them):
|
||||
`node scripts/mp-cli.js update_download_tasks hash=<hash> action=stop upload_limit=512 download_limit=2048`
|
||||
`moviepilot tool run update_download_tasks hash=<hash> action=stop upload_limit=512 download_limit=2048`
|
||||
|
||||
Add trackers to a download task:
|
||||
`node scripts/mp-cli.js update_download_tasks hash=<hash> trackers='https://tracker.example/announce,udp://tracker.example:80/announce'`
|
||||
`moviepilot tool run update_download_tasks hash=<hash> trackers='https://tracker.example/announce,udp://tracker.example:80/announce'`
|
||||
|
||||
Delete a download task (confirm with user first — irreversible):
|
||||
`node scripts/mp-cli.js delete_download_tasks hash=<hash>`
|
||||
`moviepilot tool run delete_download_tasks hash=<hash>`
|
||||
|
||||
Delete a download task and also remove its files (confirm with user first — irreversible):
|
||||
`node scripts/mp-cli.js delete_download_tasks hash=<hash> delete_files=true`
|
||||
`moviepilot tool run delete_download_tasks hash=<hash> delete_files=true`
|
||||
|
||||
### Manage Subscriptions
|
||||
|
||||
List active subscriptions:
|
||||
`node scripts/mp-cli.js query_subscribes status=R`
|
||||
`moviepilot tool run query_subscribes status=R`
|
||||
|
||||
Update subscription filters:
|
||||
`node scripts/mp-cli.js update_subscribe subscribe_id=123 resolution="1080p"`
|
||||
`moviepilot tool run update_subscribe subscribe_id=123 resolution="1080p"`
|
||||
|
||||
Only download full-season packs for a TV best-version subscription:
|
||||
`node scripts/mp-cli.js update_subscribe subscribe_id=123 best_version=1 best_version_full=1`
|
||||
`moviepilot tool run update_subscribe subscribe_id=123 best_version=1 best_version_full=1`
|
||||
|
||||
Trigger a search for missing episodes (confirm with user first):
|
||||
`node scripts/mp-cli.js search_subscribe subscribe_id=123`
|
||||
`moviepilot tool run search_subscribe subscribe_id=123`
|
||||
|
||||
Remove a subscription (confirm with user first):
|
||||
`node scripts/mp-cli.js delete_subscribe subscribe_id=123`
|
||||
`moviepilot tool run delete_subscribe subscribe_id=123`
|
||||
|
||||
### Check Library and Subscriptions
|
||||
|
||||
Run before any download or subscription to avoid duplicates.
|
||||
|
||||
Check if the media already exists in the library:
|
||||
`node scripts/mp-cli.js query_library_exists tmdb_id=123456 media_type="movie"`
|
||||
`moviepilot tool run query_library_exists tmdb_id=123456 media_type="movie"`
|
||||
|
||||
Check if the media is already subscribed:
|
||||
`node scripts/mp-cli.js query_subscribes tmdb_id=123456`
|
||||
`moviepilot tool run query_subscribes tmdb_id=123456`
|
||||
|
||||
### Season Validation
|
||||
|
||||
@@ -174,7 +203,7 @@ Mandatory when user specifies a season. Productions sometimes release a show in
|
||||
#### 1. Verify season exists
|
||||
|
||||
Fetch media detail to check available seasons:
|
||||
`node scripts/mp-cli.js query_media_detail tmdb_id=<id> media_type="tv"`
|
||||
`moviepilot tool run query_media_detail tmdb_id=<id> media_type="tv"`
|
||||
|
||||
Compare `season_info` with the user's requested season:
|
||||
1. If the season exists in `season_info` → use that season number directly and return to the calling workflow.
|
||||
@@ -183,11 +212,12 @@ Compare `season_info` with the user's requested season:
|
||||
#### 2. Identify the correct episode range
|
||||
|
||||
Fetch episode schedule for the latest season from `season_info`:
|
||||
`node scripts/mp-cli.js query_episode_schedule tmdb_id=<id> season=<latest_season_number>`
|
||||
`moviepilot tool run query_episode_schedule tmdb_id=<id> season=<latest_season_number>`
|
||||
|
||||
Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. Look for a gap in `air_date` between episodes — the gap indicates a part break, and the episodes after the gap are what the user likely refers to as the next "season". For example, if TMDB Season 1 has episodes 1–24 and there is a multi-month gap between episode 12 and 13, then episodes 13–24 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user.
|
||||
|
||||
## Error handling
|
||||
|
||||
Missing configuration: Ask the user for the backend host and API key. Once provided, save the config persistently — subsequent commands will use it automatically:
|
||||
`node scripts/mp-cli.js -h <HOST> -k <KEY>`
|
||||
Missing configuration or authentication failure: run `moviepilot doctor` to
|
||||
verify the local MoviePilot installation and settings. Do not ask the user to
|
||||
paste the API key into the prompt for local CLI usage.
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const SCRIPT_NAME = process.env.MP_SCRIPT_NAME || path.basename(process.argv[1] || 'mp-cli.js');
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.config', 'moviepilot_cli');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config');
|
||||
|
||||
let commandsJson = [];
|
||||
let commandsLoaded = false;
|
||||
|
||||
let optHost = '';
|
||||
let optKey = '';
|
||||
|
||||
const envHost = process.env.MP_HOST || '';
|
||||
const envKey = process.env.MP_API_KEY || '';
|
||||
|
||||
let mpHost = '';
|
||||
let mpApiKey = '';
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function spacePad(text = '', targetCol = 0) {
|
||||
const spaces = text.length < targetCol ? targetCol - text.length + 2 : 2;
|
||||
return ' '.repeat(spaces);
|
||||
}
|
||||
|
||||
function printBox(title, lines) {
|
||||
const rightPadding = 0;
|
||||
const contentWidth =
|
||||
lines.reduce((max, line) => Math.max(max, line.length), title.length) + rightPadding;
|
||||
const innerWidth = contentWidth + 2;
|
||||
const topLabel = `─ ${title}`;
|
||||
|
||||
console.error(`┌${topLabel}${'─'.repeat(Math.max(innerWidth - topLabel.length, 0))}┐`);
|
||||
for (const line of lines) {
|
||||
console.error(`│ ${line}${' '.repeat(contentWidth - line.length)} │`);
|
||||
}
|
||||
console.error(`└${'─'.repeat(innerWidth)}┘`);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
let cfgHost = '';
|
||||
let cfgKey = '';
|
||||
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
return { cfgHost, cfgKey };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (!line.trim() || /^\s*#/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = line.indexOf('=');
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, index).replace(/\s+/g, '');
|
||||
const value = line.slice(index + 1);
|
||||
|
||||
if (key === 'MP_HOST') {
|
||||
cfgHost = value;
|
||||
} else if (key === 'MP_API_KEY') {
|
||||
cfgKey = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { cfgHost, cfgKey };
|
||||
}
|
||||
|
||||
function saveConfig(host, key) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_FILE, `MP_HOST=${host}\nMP_API_KEY=${key}\n`, 'utf8');
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const { cfgHost: initialHost, cfgKey: initialKey } = readConfig();
|
||||
let cfgHost = initialHost;
|
||||
let cfgKey = initialKey;
|
||||
|
||||
if (optHost || optKey) {
|
||||
const nextHost = optHost || cfgHost;
|
||||
const nextKey = optKey || cfgKey;
|
||||
saveConfig(nextHost, nextKey);
|
||||
cfgHost = nextHost;
|
||||
cfgKey = nextKey;
|
||||
}
|
||||
|
||||
mpHost = optHost || mpHost || envHost || cfgHost;
|
||||
mpApiKey = optKey || mpApiKey || envKey || cfgKey;
|
||||
}
|
||||
|
||||
function normalizeType(schema = {}) {
|
||||
if (schema.type) {
|
||||
return schema.type;
|
||||
}
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const candidate = schema.anyOf.find((item) => item && item.type && item.type !== 'null');
|
||||
return candidate?.type || 'string';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function normalizeItemType(schema = {}) {
|
||||
const items = schema.items;
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
if (items.type) {
|
||||
return items.type;
|
||||
}
|
||||
if (Array.isArray(items.anyOf)) {
|
||||
const candidate = items.anyOf.find((item) => item && item.type && item.type !== 'null');
|
||||
return candidate?.type || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCommand(tool = {}) {
|
||||
const properties = tool?.inputSchema?.properties || {};
|
||||
const required = Array.isArray(tool?.inputSchema?.required) ? tool.inputSchema.required : [];
|
||||
const fields = Object.entries(properties)
|
||||
.filter(([fieldName]) => fieldName !== 'explanation')
|
||||
.map(([fieldName, schema]) => ({
|
||||
name: fieldName,
|
||||
type: normalizeType(schema),
|
||||
description: schema?.description || '',
|
||||
required: required.includes(fieldName),
|
||||
item_type: normalizeItemType(schema),
|
||||
}));
|
||||
|
||||
return {
|
||||
name: tool?.name,
|
||||
description: tool?.description || '',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
function request(method, targetUrl, headers = {}, body, timeout = 120000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(targetUrl);
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid URL: ${targetUrl}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const req = transport.request(
|
||||
{
|
||||
method,
|
||||
hostname: url.hostname,
|
||||
port: url.port || undefined,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode ? String(res.statusCode) : '',
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy(new Error(`Request timed out after ${timeout}ms`));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body !== undefined) {
|
||||
req.write(body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCommandsJson() {
|
||||
if (commandsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools`, {
|
||||
'X-API-KEY': mpApiKey,
|
||||
});
|
||||
|
||||
if (statusCode !== '200') {
|
||||
console.error(`Error: failed to load command definitions (HTTP ${statusCode || 'unknown'})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
} catch {
|
||||
fail('Error: backend returned invalid JSON for command definitions');
|
||||
}
|
||||
|
||||
commandsJson = Array.isArray(response)
|
||||
? response.map((tool) => normalizeCommand(tool))
|
||||
: [];
|
||||
|
||||
commandsLoaded = true;
|
||||
}
|
||||
|
||||
async function loadCommandJson(commandName) {
|
||||
const { statusCode, body } = await request('GET', `${mpHost}/api/v1/mcp/tools/${commandName}`, {
|
||||
'X-API-KEY': mpApiKey,
|
||||
});
|
||||
|
||||
if (statusCode === '404') {
|
||||
console.error(`Error: command '${commandName}' not found`);
|
||||
console.error(`Run 'node ${SCRIPT_NAME} list' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (statusCode !== '200') {
|
||||
console.error(`Error: failed to load command definition (HTTP ${statusCode || 'unknown'})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(body);
|
||||
} catch {
|
||||
fail(`Error: backend returned invalid JSON for command '${commandName}'`);
|
||||
}
|
||||
|
||||
return normalizeCommand(response);
|
||||
}
|
||||
|
||||
function ensureConfig() {
|
||||
loadConfig();
|
||||
let ok = true;
|
||||
|
||||
if (!mpHost) {
|
||||
console.error('Error: backend host is not configured.');
|
||||
console.error(' Use: -h HOST to set it');
|
||||
console.error(' Or set environment variable: MP_HOST=http://localhost:3001');
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!mpApiKey) {
|
||||
console.error('Error: API key is not configured.');
|
||||
console.error(' Use: -k KEY to set it');
|
||||
console.error(' Or set environment variable: MP_API_KEY=your_key');
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
process.stdout.write(`${value}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function formatUsageValue(field) {
|
||||
if (field?.type === 'array') {
|
||||
return "'<value1>,<value2>'";
|
||||
}
|
||||
return '<value>';
|
||||
}
|
||||
|
||||
async function cmdList() {
|
||||
await loadCommandsJson();
|
||||
const sortedCommands = [...commandsJson].sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const command of sortedCommands) {
|
||||
process.stdout.write(`${command.name}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdShow(commandName) {
|
||||
if (!commandName) {
|
||||
fail(`Usage: ${SCRIPT_NAME} show <command>`);
|
||||
}
|
||||
|
||||
const command = await loadCommandJson(commandName);
|
||||
|
||||
const commandLabel = 'Command:';
|
||||
const descriptionLabel = 'Description:';
|
||||
const paramsLabel = 'Parameters:';
|
||||
const usageLabel = 'Usage:';
|
||||
const detailLabelWidth = Math.max(
|
||||
commandLabel.length,
|
||||
descriptionLabel.length,
|
||||
paramsLabel.length,
|
||||
usageLabel.length
|
||||
);
|
||||
|
||||
process.stdout.write(`${commandLabel} ${command.name}\n`);
|
||||
process.stdout.write(`${descriptionLabel} ${command.description || '(none)'}\n\n`);
|
||||
|
||||
if (command.fields.length === 0) {
|
||||
process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`);
|
||||
} else {
|
||||
const fieldLines = command.fields.map((field) => [
|
||||
field.required ? `${field.name}*` : field.name,
|
||||
field.type,
|
||||
field.description,
|
||||
]);
|
||||
|
||||
const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);
|
||||
const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0);
|
||||
|
||||
process.stdout.write(`${paramsLabel}\n`);
|
||||
for (const [fieldName, fieldType, fieldDesc] of fieldLines) {
|
||||
process.stdout.write(
|
||||
` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldDesc}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const usageLine = `${command.name}`;
|
||||
const reqPart = command.fields
|
||||
.filter((field) => field.required)
|
||||
.map((field) => ` ${field.name}=${formatUsageValue(field)}`)
|
||||
.join('');
|
||||
const optPart = command.fields
|
||||
.filter((field) => !field.required)
|
||||
.map((field) => ` [${field.name}=${formatUsageValue(field)}]`)
|
||||
.join('');
|
||||
|
||||
process.stdout.write(`\n${usageLabel} ${usageLine}${reqPart}${optPart}\n`);
|
||||
}
|
||||
|
||||
function buildArguments(pairs) {
|
||||
const args = { explanation: 'CLI invocation' };
|
||||
|
||||
for (const kv of pairs) {
|
||||
if (!kv.includes('=')) {
|
||||
fail(`Error: argument must be in key=value format, got: '${kv}'`);
|
||||
}
|
||||
|
||||
const index = kv.indexOf('=');
|
||||
args[kv.slice(0, index)] = kv.slice(index + 1);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async function cmdRun(commandName, pairs) {
|
||||
if (!commandName) {
|
||||
fail(`Usage: ${SCRIPT_NAME} <command> [key=value ...]`);
|
||||
}
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
tool_name: commandName,
|
||||
arguments: buildArguments(pairs),
|
||||
});
|
||||
|
||||
const { statusCode, body } = await request(
|
||||
'POST',
|
||||
`${mpHost}/api/v1/mcp/tools/call`,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'X-API-KEY': mpApiKey,
|
||||
},
|
||||
requestBody
|
||||
);
|
||||
|
||||
if (statusCode && statusCode !== '200' && statusCode !== '201') {
|
||||
console.error(`Warning: HTTP status ${statusCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'error') && parsed.error) {
|
||||
printValue(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'result')) {
|
||||
if (typeof parsed.result === 'string') {
|
||||
try {
|
||||
printValue(JSON.parse(parsed.result));
|
||||
} catch {
|
||||
printValue(parsed.result);
|
||||
}
|
||||
} else {
|
||||
printValue(parsed.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
printValue(parsed);
|
||||
} catch {
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
const { cfgHost, cfgKey } = readConfig();
|
||||
let effectiveHost = mpHost || envHost || cfgHost;
|
||||
let effectiveKey = mpApiKey || envKey || cfgKey;
|
||||
|
||||
if (optHost) {
|
||||
effectiveHost = optHost;
|
||||
}
|
||||
if (optKey) {
|
||||
effectiveKey = optKey;
|
||||
}
|
||||
|
||||
if (!effectiveHost || !effectiveKey) {
|
||||
const warningLines = [];
|
||||
if (!effectiveHost) {
|
||||
const opt = '-h HOST';
|
||||
const desc = 'set backend host';
|
||||
warningLines.push(`${opt}${spacePad(opt)}${desc}`);
|
||||
}
|
||||
if (!effectiveKey) {
|
||||
const opt = '-k KEY';
|
||||
const desc = 'set API key';
|
||||
warningLines.push(`${opt}${spacePad(opt)}${desc}`);
|
||||
}
|
||||
printBox('Warning: not configured', warningLines);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
process.stdout.write(`Usage: ${SCRIPT_NAME} [-h HOST] [-k KEY] [COMMAND] [ARGS...]\n\n`);
|
||||
const optionWidth = Math.max('-h HOST'.length, '-k KEY'.length);
|
||||
process.stdout.write('Options:\n');
|
||||
process.stdout.write(` -h HOST${spacePad('-h HOST', optionWidth)}backend host\n`);
|
||||
process.stdout.write(` -k KEY${spacePad('-k KEY', optionWidth)}API key\n\n`);
|
||||
const commandWidth = Math.max(
|
||||
'(no command)'.length,
|
||||
'list'.length,
|
||||
'show <command>'.length,
|
||||
'<command> [k=v...]'.length
|
||||
);
|
||||
process.stdout.write('Commands:\n');
|
||||
process.stdout.write(
|
||||
` (no command)${spacePad('(no command)', commandWidth)}save config when -h and -k are provided\n`
|
||||
);
|
||||
process.stdout.write(` list${spacePad('list', commandWidth)}list all commands\n`);
|
||||
process.stdout.write(
|
||||
` show <command>${spacePad('show <command>', commandWidth)}show command details and usage example\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
` <command> [k=v...]${spacePad('<command> [k=v...]', commandWidth)}run a command\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = [];
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === '--help' || arg === '-?') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === '-h') {
|
||||
index += 1;
|
||||
optHost = argv[index] || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '-k') {
|
||||
index += 1;
|
||||
optKey = argv[index] || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--') {
|
||||
args.push(...argv.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
args.push(arg);
|
||||
}
|
||||
|
||||
if ((optHost && !optKey) || (!optHost && optKey)) {
|
||||
fail('Error: -h and -k must be provided together');
|
||||
}
|
||||
|
||||
const command = args[0] || '';
|
||||
|
||||
if (command === 'list') {
|
||||
ensureConfig();
|
||||
await cmdList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'show') {
|
||||
ensureConfig();
|
||||
await cmdShow(args[1] || '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
if (optHost || optKey) {
|
||||
loadConfig();
|
||||
process.stdout.write('Configuration saved.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureConfig();
|
||||
await cmdRun(command, args.slice(1));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(`Error: ${error.message}`);
|
||||
});
|
||||
Reference in New Issue
Block a user