mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-19 19:46:55 +08:00
feat(mcp): add torrent filter workflow and moviepilot cli skill
This commit is contained in:
@@ -26,6 +26,11 @@
|
|||||||
|
|
||||||
官方Wiki:https://wiki.movie-pilot.org
|
官方Wiki:https://wiki.movie-pilot.org
|
||||||
|
|
||||||
|
### 为 AI Agent 添加 Skills
|
||||||
|
```shell
|
||||||
|
npx skills add https://github.com/jxxghp/MoviePilot
|
||||||
|
```
|
||||||
|
|
||||||
## 参与开发
|
## 参与开发
|
||||||
|
|
||||||
API文档:https://api.movie-pilot.org
|
API文档:https://api.movie-pilot.org
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
|
|||||||
from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool
|
from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool
|
||||||
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
|
||||||
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
|
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
|
||||||
|
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
|
||||||
from app.agent.tools.impl.search_web import SearchWebTool
|
from app.agent.tools.impl.search_web import SearchWebTool
|
||||||
from app.agent.tools.impl.send_message import SendMessageTool
|
from app.agent.tools.impl.send_message import SendMessageTool
|
||||||
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
|
||||||
@@ -70,6 +71,7 @@ class MoviePilotToolFactory:
|
|||||||
UpdateSubscribeTool,
|
UpdateSubscribeTool,
|
||||||
SearchSubscribeTool,
|
SearchSubscribeTool,
|
||||||
SearchTorrentsTool,
|
SearchTorrentsTool,
|
||||||
|
GetSearchResultsTool,
|
||||||
SearchWebTool,
|
SearchWebTool,
|
||||||
AddDownloadTool,
|
AddDownloadTool,
|
||||||
QuerySubscribesTool,
|
QuerySubscribesTool,
|
||||||
|
|||||||
176
app/agent/tools/impl/_torrent_search_utils.py
Normal file
176
app/agent/tools/impl/_torrent_search_utils.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""种子搜索工具辅助函数"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.core.context import Context
|
||||||
|
from app.utils.crypto import HashUtils
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
SEARCH_RESULT_CACHE_FILE = "__search_result__"
|
||||||
|
TORRENT_RESULT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def build_torrent_ref(context: Optional[Context]) -> str:
|
||||||
|
"""生成用于下载校验的短引用"""
|
||||||
|
if not context or not context.torrent_info:
|
||||||
|
return ""
|
||||||
|
return HashUtils.sha1(context.torrent_info.enclosure or "")[:7]
|
||||||
|
|
||||||
|
|
||||||
|
def sort_season_options(options: List[str]) -> List[str]:
|
||||||
|
"""按前端逻辑排序季集选项"""
|
||||||
|
if len(options) <= 1:
|
||||||
|
return options
|
||||||
|
|
||||||
|
parsed_options = []
|
||||||
|
for index, option in enumerate(options):
|
||||||
|
match = re.match(r"^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$", option or "")
|
||||||
|
if not match:
|
||||||
|
parsed_options.append({
|
||||||
|
"original": option,
|
||||||
|
"season_num": 0,
|
||||||
|
"episode_num": 0,
|
||||||
|
"max_episode_num": 0,
|
||||||
|
"is_whole_season": False,
|
||||||
|
"index": index,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
episode_num = int(match.group(3)) if match.group(3) else 0
|
||||||
|
max_episode_num = int(match.group(4)) if match.group(4) else episode_num
|
||||||
|
parsed_options.append({
|
||||||
|
"original": option,
|
||||||
|
"season_num": int(match.group(1)),
|
||||||
|
"episode_num": episode_num,
|
||||||
|
"max_episode_num": max_episode_num,
|
||||||
|
"is_whole_season": not match.group(3),
|
||||||
|
"index": index,
|
||||||
|
})
|
||||||
|
|
||||||
|
whole_seasons = [item for item in parsed_options if item["is_whole_season"]]
|
||||||
|
episodes = [item for item in parsed_options if not item["is_whole_season"]]
|
||||||
|
|
||||||
|
whole_seasons.sort(key=lambda item: (-item["season_num"], item["index"]))
|
||||||
|
episodes.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
-item["season_num"],
|
||||||
|
-(item["max_episode_num"] or item["episode_num"]),
|
||||||
|
-item["episode_num"],
|
||||||
|
item["index"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [item["original"] for item in whole_seasons + episodes]
|
||||||
|
|
||||||
|
|
||||||
|
def append_option(options: List[str], value: Optional[str]) -> None:
|
||||||
|
"""按前端逻辑收集去重后的筛选项"""
|
||||||
|
if value and value not in options:
|
||||||
|
options.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_filter_options(items: List[Context]) -> dict:
|
||||||
|
"""从搜索结果中构建筛选项汇总"""
|
||||||
|
filter_options = {
|
||||||
|
"site": [],
|
||||||
|
"season": [],
|
||||||
|
"freeState": [],
|
||||||
|
"edition": [],
|
||||||
|
"resolution": [],
|
||||||
|
"videoCode": [],
|
||||||
|
"releaseGroup": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
torrent_info = item.torrent_info
|
||||||
|
meta_info = item.meta_info
|
||||||
|
append_option(filter_options["site"], getattr(torrent_info, "site_name", None))
|
||||||
|
append_option(filter_options["season"], getattr(meta_info, "season_episode", None))
|
||||||
|
append_option(filter_options["freeState"], getattr(torrent_info, "volume_factor", None))
|
||||||
|
append_option(filter_options["edition"], getattr(meta_info, "edition", None))
|
||||||
|
append_option(filter_options["resolution"], getattr(meta_info, "resource_pix", None))
|
||||||
|
append_option(filter_options["videoCode"], getattr(meta_info, "video_encode", None))
|
||||||
|
append_option(filter_options["releaseGroup"], getattr(meta_info, "resource_team", None))
|
||||||
|
|
||||||
|
filter_options["season"] = sort_season_options(filter_options["season"])
|
||||||
|
return filter_options
|
||||||
|
|
||||||
|
|
||||||
|
def match_filter(filter_values: Optional[List[str]], value: Optional[str]) -> bool:
|
||||||
|
"""匹配前端同款多选筛选规则"""
|
||||||
|
return not filter_values or bool(value and value in filter_values)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_contexts(items: List[Context],
|
||||||
|
site: Optional[List[str]] = None,
|
||||||
|
season: Optional[List[str]] = None,
|
||||||
|
free_state: Optional[List[str]] = None,
|
||||||
|
video_code: Optional[List[str]] = None,
|
||||||
|
edition: Optional[List[str]] = None,
|
||||||
|
resolution: Optional[List[str]] = None,
|
||||||
|
release_group: Optional[List[str]] = None) -> List[Context]:
|
||||||
|
"""按前端同款维度筛选结果"""
|
||||||
|
filtered_items = []
|
||||||
|
for item in items:
|
||||||
|
torrent_info = item.torrent_info
|
||||||
|
meta_info = item.meta_info
|
||||||
|
if (
|
||||||
|
match_filter(site, getattr(torrent_info, "site_name", None))
|
||||||
|
and match_filter(free_state, getattr(torrent_info, "volume_factor", None))
|
||||||
|
and match_filter(season, getattr(meta_info, "season_episode", None))
|
||||||
|
and match_filter(release_group, getattr(meta_info, "resource_team", None))
|
||||||
|
and match_filter(video_code, getattr(meta_info, "video_encode", None))
|
||||||
|
and match_filter(resolution, getattr(meta_info, "resource_pix", None))
|
||||||
|
and match_filter(edition, getattr(meta_info, "edition", None))
|
||||||
|
):
|
||||||
|
filtered_items.append(item)
|
||||||
|
return filtered_items
|
||||||
|
|
||||||
|
|
||||||
|
def simplify_search_result(context: Context, index: int) -> dict:
|
||||||
|
"""精简单条搜索结果"""
|
||||||
|
simplified = {}
|
||||||
|
torrent_info = context.torrent_info
|
||||||
|
meta_info = context.meta_info
|
||||||
|
media_info = context.media_info
|
||||||
|
|
||||||
|
if torrent_info:
|
||||||
|
simplified["torrent_info"] = {
|
||||||
|
"title": torrent_info.title,
|
||||||
|
"size": StringUtils.format_size(torrent_info.size),
|
||||||
|
"seeders": torrent_info.seeders,
|
||||||
|
"peers": torrent_info.peers,
|
||||||
|
"site_name": torrent_info.site_name,
|
||||||
|
"torrent_url": f"{build_torrent_ref(context)}:{index}",
|
||||||
|
"page_url": torrent_info.page_url,
|
||||||
|
"volume_factor": torrent_info.volume_factor,
|
||||||
|
"freedate_diff": torrent_info.freedate_diff,
|
||||||
|
"pubdate": torrent_info.pubdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if media_info:
|
||||||
|
simplified["media_info"] = {
|
||||||
|
"title": media_info.title,
|
||||||
|
"en_title": media_info.en_title,
|
||||||
|
"year": media_info.year,
|
||||||
|
"type": media_info.type.value if media_info.type else None,
|
||||||
|
"season": media_info.season,
|
||||||
|
"tmdb_id": media_info.tmdb_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta_info:
|
||||||
|
simplified["meta_info"] = {
|
||||||
|
"name": meta_info.name,
|
||||||
|
"cn_name": meta_info.cn_name,
|
||||||
|
"en_name": meta_info.en_name,
|
||||||
|
"year": meta_info.year,
|
||||||
|
"type": meta_info.type.value if meta_info.type else None,
|
||||||
|
"begin_season": meta_info.begin_season,
|
||||||
|
"season_episode": meta_info.season_episode,
|
||||||
|
"resource_team": meta_info.resource_team,
|
||||||
|
"video_encode": meta_info.video_encode,
|
||||||
|
"edition": meta_info.edition,
|
||||||
|
"resource_pix": meta_info.resource_pix,
|
||||||
|
}
|
||||||
|
|
||||||
|
return simplified
|
||||||
@@ -1,25 +1,33 @@
|
|||||||
"""添加下载工具"""
|
"""添加下载工具"""
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool, ToolChain
|
from app.agent.tools.base import MoviePilotTool, ToolChain
|
||||||
|
from app.chain.search import SearchChain
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import TorrentInfo
|
from app.schemas import TorrentInfo
|
||||||
|
from app.utils.crypto import HashUtils
|
||||||
|
|
||||||
|
|
||||||
class AddDownloadInput(BaseModel):
|
class AddDownloadInput(BaseModel):
|
||||||
"""添加下载工具的输入参数模型"""
|
"""添加下载工具的输入参数模型"""
|
||||||
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
site_name: str = Field(..., description="Name of the torrent site/source (e.g., 'The Pirate Bay')")
|
site_name: Optional[str] = Field(None, description="Name of the torrent site/source (e.g., 'The Pirate Bay')")
|
||||||
torrent_title: str = Field(...,
|
torrent_title: Optional[str] = Field(
|
||||||
description="The display name/title of the torrent (e.g., 'The.Matrix.1999.1080p.BluRay.x264')")
|
None,
|
||||||
torrent_url: str = Field(..., description="Direct URL to the torrent file (.torrent) or magnet link")
|
description="The display name/title of the torrent (e.g., 'The.Matrix.1999.1080p.BluRay.x264')"
|
||||||
|
)
|
||||||
|
torrent_url: str = Field(
|
||||||
|
...,
|
||||||
|
description="Torrent download link, magnet URI, or search result reference returned by search_torrents"
|
||||||
|
)
|
||||||
torrent_description: Optional[str] = Field(None,
|
torrent_description: Optional[str] = Field(None,
|
||||||
description="Brief description of the torrent content (optional)")
|
description="Brief description of the torrent content (optional)")
|
||||||
downloader: Optional[str] = Field(None,
|
downloader: Optional[str] = Field(None,
|
||||||
@@ -38,10 +46,11 @@ class AddDownloadTool(MoviePilotTool):
|
|||||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
"""根据下载参数生成友好的提示消息"""
|
"""根据下载参数生成友好的提示消息"""
|
||||||
torrent_title = kwargs.get("torrent_title", "")
|
torrent_title = kwargs.get("torrent_title", "")
|
||||||
|
torrent_url = kwargs.get("torrent_url")
|
||||||
site_name = kwargs.get("site_name", "")
|
site_name = kwargs.get("site_name", "")
|
||||||
downloader = kwargs.get("downloader")
|
downloader = kwargs.get("downloader")
|
||||||
|
|
||||||
message = f"正在添加下载任务: {torrent_title}"
|
message = f"正在添加下载任务: {torrent_title or f'资源 {torrent_url}'}"
|
||||||
if site_name:
|
if site_name:
|
||||||
message += f" (来源: {site_name})"
|
message += f" (来源: {site_name})"
|
||||||
if downloader:
|
if downloader:
|
||||||
@@ -49,14 +58,71 @@ class AddDownloadTool(MoviePilotTool):
|
|||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
async def run(self, site_name: str, torrent_title: str, torrent_url: str, torrent_description: Optional[str] = None,
|
@staticmethod
|
||||||
|
def _build_torrent_ref(context: Context) -> str:
|
||||||
|
"""生成用于校验缓存项的短引用"""
|
||||||
|
if not context or not context.torrent_info:
|
||||||
|
return ""
|
||||||
|
return HashUtils.sha1(context.torrent_info.enclosure or "")[:7]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_torrent_ref(torrent_ref: Optional[str]) -> bool:
|
||||||
|
"""判断是否为内部搜索结果引用"""
|
||||||
|
if not torrent_ref:
|
||||||
|
return False
|
||||||
|
return bool(re.fullmatch(r"[0-9a-f]{7}:\d+", str(torrent_ref).strip()))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_cached_context(cls, torrent_ref: str) -> Optional[Context]:
|
||||||
|
"""从最近一次搜索缓存中解析种子上下文,仅支持 hash:id 格式"""
|
||||||
|
ref = str(torrent_ref).strip()
|
||||||
|
if ":" not in ref:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ref_hash, ref_index = ref.split(":", 1)
|
||||||
|
index = int(ref_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if index < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
results = SearchChain().last_search_results() or []
|
||||||
|
if index > len(results):
|
||||||
|
return None
|
||||||
|
context = results[index - 1]
|
||||||
|
if not ref_hash or cls._build_torrent_ref(context) != ref_hash:
|
||||||
|
return None
|
||||||
|
return context
|
||||||
|
|
||||||
|
async def run(self, site_name: Optional[str] = None, torrent_title: Optional[str] = None,
|
||||||
|
torrent_url: Optional[str] = None,
|
||||||
|
torrent_description: Optional[str] = None,
|
||||||
downloader: Optional[str] = None, save_path: Optional[str] = None,
|
downloader: Optional[str] = None, save_path: Optional[str] = None,
|
||||||
labels: Optional[str] = None, **kwargs) -> str:
|
labels: Optional[str] = None, **kwargs) -> str:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"执行工具: {self.name}, 参数: site_name={site_name}, torrent_title={torrent_title}, torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}")
|
f"执行工具: {self.name}, 参数: site_name={site_name}, torrent_title={torrent_title}, torrent_url={torrent_url}, downloader={downloader}, save_path={save_path}, labels={labels}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not torrent_title or not torrent_url:
|
cached_context = None
|
||||||
|
if torrent_url:
|
||||||
|
is_torrent_ref = self._is_torrent_ref(torrent_url)
|
||||||
|
cached_context = self._resolve_cached_context(torrent_url)
|
||||||
|
if is_torrent_ref and (not cached_context or not cached_context.torrent_info):
|
||||||
|
return "错误:torrent_url 无效,请重新使用 search_torrents 搜索"
|
||||||
|
if not cached_context or not cached_context.torrent_info:
|
||||||
|
cached_context = None
|
||||||
|
|
||||||
|
if cached_context and cached_context.torrent_info:
|
||||||
|
cached_torrent = cached_context.torrent_info
|
||||||
|
site_name = site_name or cached_torrent.site_name
|
||||||
|
torrent_title = torrent_title or cached_torrent.title
|
||||||
|
torrent_url = cached_torrent.enclosure
|
||||||
|
torrent_description = torrent_description or cached_torrent.description
|
||||||
|
|
||||||
|
if not torrent_title:
|
||||||
|
return "错误:必须提供种子标题和下载链接"
|
||||||
|
if not torrent_url:
|
||||||
return "错误:必须提供种子标题和下载链接"
|
return "错误:必须提供种子标题和下载链接"
|
||||||
|
|
||||||
# 使用DownloadChain添加下载
|
# 使用DownloadChain添加下载
|
||||||
@@ -82,7 +148,9 @@ class AddDownloadTool(MoviePilotTool):
|
|||||||
site_downloader=siteinfo.downloader
|
site_downloader=siteinfo.downloader
|
||||||
)
|
)
|
||||||
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
|
meta_info = MetaInfo(title=torrent_title, subtitle=torrent_description)
|
||||||
media_info = await ToolChain().async_recognize_media(meta=meta_info)
|
media_info = cached_context.media_info if cached_context and cached_context.media_info else None
|
||||||
|
if not media_info:
|
||||||
|
media_info = await ToolChain().async_recognize_media(meta=meta_info)
|
||||||
if not media_info:
|
if not media_info:
|
||||||
return "错误:无法识别媒体信息,无法添加下载任务"
|
return "错误:无法识别媒体信息,无法添加下载任务"
|
||||||
context = Context(
|
context = Context(
|
||||||
|
|||||||
81
app/agent/tools/impl/get_search_results.py
Normal file
81
app/agent/tools/impl/get_search_results.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""获取搜索结果工具"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agent.tools.base import MoviePilotTool
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.log import logger
|
||||||
|
from ._torrent_search_utils import (
|
||||||
|
TORRENT_RESULT_LIMIT,
|
||||||
|
filter_contexts,
|
||||||
|
simplify_search_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetSearchResultsInput(BaseModel):
|
||||||
|
"""获取搜索结果工具的输入参数模型"""
|
||||||
|
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
|
||||||
|
site: Optional[List[str]] = Field(None, description="Filter by site name, supports multiple values")
|
||||||
|
season: Optional[List[str]] = Field(None, description="Filter by season/episode label, supports multiple values")
|
||||||
|
free_state: Optional[List[str]] = Field(None, description="Filter by promotion state, supports multiple values")
|
||||||
|
video_code: Optional[List[str]] = Field(None, description="Filter by video codec, supports multiple values")
|
||||||
|
edition: Optional[List[str]] = Field(None, description="Filter by edition/quality, supports multiple values")
|
||||||
|
resolution: Optional[List[str]] = Field(None, description="Filter by resolution, supports multiple values")
|
||||||
|
release_group: Optional[List[str]] = Field(None, description="Filter by release group, supports multiple values")
|
||||||
|
|
||||||
|
class GetSearchResultsTool(MoviePilotTool):
|
||||||
|
name: str = "get_search_results"
|
||||||
|
description: str = "Get torrent search results from the most recent search_torrents call, with optional frontend-style filters such as site, season, promotion state, codec, quality, resolution, and release group. Returns at most the first 50 matching results."
|
||||||
|
args_schema: Type[BaseModel] = GetSearchResultsInput
|
||||||
|
|
||||||
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
|
return "正在获取搜索结果"
|
||||||
|
|
||||||
|
async def run(self, site: Optional[List[str]] = None, season: Optional[List[str]] = None,
|
||||||
|
free_state: Optional[List[str]] = None, video_code: Optional[List[str]] = None,
|
||||||
|
edition: Optional[List[str]] = None, resolution: Optional[List[str]] = None,
|
||||||
|
release_group: Optional[List[str]] = None, **kwargs) -> str:
|
||||||
|
logger.info(
|
||||||
|
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = await SearchChain().async_last_search_results() or []
|
||||||
|
if not items:
|
||||||
|
return "没有可用的搜索结果,请先使用 search_torrents 搜索"
|
||||||
|
|
||||||
|
filtered_items = filter_contexts(
|
||||||
|
items=items,
|
||||||
|
site=site,
|
||||||
|
season=season,
|
||||||
|
free_state=free_state,
|
||||||
|
video_code=video_code,
|
||||||
|
edition=edition,
|
||||||
|
resolution=resolution,
|
||||||
|
release_group=release_group,
|
||||||
|
)
|
||||||
|
if not filtered_items:
|
||||||
|
return "没有符合筛选条件的搜索结果,请调整筛选条件"
|
||||||
|
|
||||||
|
total_count = len(filtered_items)
|
||||||
|
filtered_ids = {id(item) for item in filtered_items}
|
||||||
|
matched_indices = [index for index, item in enumerate(items, start=1) if id(item) in filtered_ids]
|
||||||
|
limited_items = filtered_items[:TORRENT_RESULT_LIMIT]
|
||||||
|
limited_indices = matched_indices[:TORRENT_RESULT_LIMIT]
|
||||||
|
results = [
|
||||||
|
simplify_search_result(item, index)
|
||||||
|
for item, index in zip(limited_items, limited_indices)
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"total_count": total_count,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
if total_count > TORRENT_RESULT_LIMIT:
|
||||||
|
payload["message"] = f"搜索结果共找到 {total_count} 条,仅显示前 {TORRENT_RESULT_LIMIT} 条结果。"
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
error_message = f"获取搜索结果失败: {str(e)}"
|
||||||
|
logger.error(f"获取搜索结果失败: {e}", exc_info=True)
|
||||||
|
return error_message
|
||||||
@@ -4,13 +4,16 @@ import json
|
|||||||
import re
|
import re
|
||||||
from typing import List, Optional, Type
|
from typing import List, Optional, Type
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.string import StringUtils
|
from ._torrent_search_utils import (
|
||||||
|
SEARCH_RESULT_CACHE_FILE,
|
||||||
|
build_filter_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchTorrentsInput(BaseModel):
|
class SearchTorrentsInput(BaseModel):
|
||||||
@@ -28,32 +31,9 @@ class SearchTorrentsInput(BaseModel):
|
|||||||
filter_pattern: Optional[str] = Field(None,
|
filter_pattern: Optional[str] = Field(None,
|
||||||
description="Regular expression pattern to filter torrent titles by resolution, quality, or other keywords (e.g., '4K|2160p|UHD' for 4K content, '1080p|BluRay' for 1080p BluRay)")
|
description="Regular expression pattern to filter torrent titles by resolution, quality, or other keywords (e.g., '4K|2160p|UHD' for 4K content, '1080p|BluRay' for 1080p BluRay)")
|
||||||
|
|
||||||
@field_validator("sites", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_sites(cls, value):
|
|
||||||
"""兼容字符串格式的站点列表(如 "[28]"、"28,30")"""
|
|
||||||
if value is None:
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = json.loads(value)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
return parsed
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "," in value:
|
|
||||||
return [v.strip() for v in value.split(",") if v.strip()]
|
|
||||||
if value.isdigit():
|
|
||||||
return [value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class SearchTorrentsTool(MoviePilotTool):
|
class SearchTorrentsTool(MoviePilotTool):
|
||||||
name: str = "search_torrents"
|
name: str = "search_torrents"
|
||||||
description: str = "Search for torrent files across configured indexer sites based on media information. Returns available torrent downloads with details like file size, quality, and download links."
|
description: str = "Search for torrent files across configured indexer sites based on media information. Returns available frontend-style filter options for the most recent search and caches the underlying results for get_search_results."
|
||||||
args_schema: Type[BaseModel] = SearchTorrentsInput
|
args_schema: Type[BaseModel] = SearchTorrentsInput
|
||||||
|
|
||||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||||
@@ -99,8 +79,8 @@ class SearchTorrentsTool(MoviePilotTool):
|
|||||||
# torrent 是 Context 对象,需要通过 meta_info 和 media_info 访问属性
|
# torrent 是 Context 对象,需要通过 meta_info 和 media_info 访问属性
|
||||||
if year and torrent.meta_info and torrent.meta_info.year != year:
|
if year and torrent.meta_info and torrent.meta_info.year != year:
|
||||||
continue
|
continue
|
||||||
if media_type and torrent.media_info:
|
if media_type and torrent.meta_info and torrent.meta_info.type:
|
||||||
if torrent.media_info.type != MediaType(media_type):
|
if torrent.meta_info.type != MediaType(media_type):
|
||||||
continue
|
continue
|
||||||
if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season:
|
if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season:
|
||||||
continue
|
continue
|
||||||
@@ -111,51 +91,12 @@ class SearchTorrentsTool(MoviePilotTool):
|
|||||||
filtered_torrents.append(torrent)
|
filtered_torrents.append(torrent)
|
||||||
|
|
||||||
if filtered_torrents:
|
if filtered_torrents:
|
||||||
# 限制最多50条结果
|
await search_chain.async_save_cache(filtered_torrents, SEARCH_RESULT_CACHE_FILE)
|
||||||
total_count = len(filtered_torrents)
|
result_json = json.dumps({
|
||||||
limited_torrents = filtered_torrents[:50]
|
"total_count": len(filtered_torrents),
|
||||||
# 精简字段,只保留关键信息
|
"message": "搜索完成。请使用 get_search_results 工具获取搜索结果。",
|
||||||
simplified_torrents = []
|
"filter_options": build_filter_options(filtered_torrents),
|
||||||
for t in limited_torrents:
|
}, ensure_ascii=False, indent=2)
|
||||||
simplified = {}
|
|
||||||
# 精简 torrent_info
|
|
||||||
if t.torrent_info:
|
|
||||||
simplified["torrent_info"] = {
|
|
||||||
"title": t.torrent_info.title,
|
|
||||||
"size": StringUtils.format_size(t.torrent_info.size),
|
|
||||||
"seeders": t.torrent_info.seeders,
|
|
||||||
"peers": t.torrent_info.peers,
|
|
||||||
"site_name": t.torrent_info.site_name,
|
|
||||||
"enclosure": t.torrent_info.enclosure,
|
|
||||||
"page_url": t.torrent_info.page_url,
|
|
||||||
"volume_factor": t.torrent_info.volume_factor,
|
|
||||||
"pubdate": t.torrent_info.pubdate
|
|
||||||
}
|
|
||||||
# 精简 media_info
|
|
||||||
if t.media_info:
|
|
||||||
simplified["media_info"] = {
|
|
||||||
"title": t.media_info.title,
|
|
||||||
"en_title": t.media_info.en_title,
|
|
||||||
"year": t.media_info.year,
|
|
||||||
"type": t.media_info.type.value if t.media_info.type else None,
|
|
||||||
"season": t.media_info.season,
|
|
||||||
"tmdb_id": t.media_info.tmdb_id
|
|
||||||
}
|
|
||||||
# 精简 meta_info
|
|
||||||
if t.meta_info:
|
|
||||||
simplified["meta_info"] = {
|
|
||||||
"name": t.meta_info.name,
|
|
||||||
"cn_name": t.meta_info.cn_name,
|
|
||||||
"en_name": t.meta_info.en_name,
|
|
||||||
"year": t.meta_info.year,
|
|
||||||
"type": t.meta_info.type.value if t.meta_info.type else None,
|
|
||||||
"begin_season": t.meta_info.begin_season
|
|
||||||
}
|
|
||||||
simplified_torrents.append(simplified)
|
|
||||||
result_json = json.dumps(simplified_torrents, ensure_ascii=False, indent=2)
|
|
||||||
# 如果结果被裁剪,添加提示信息
|
|
||||||
if total_count > 50:
|
|
||||||
return f"注意:搜索结果共找到 {total_count} 条,为节省上下文空间,仅显示前 50 条结果。\n\n{result_json}"
|
|
||||||
return result_json
|
return result_json
|
||||||
else:
|
else:
|
||||||
return f"未找到相关种子资源: {title}"
|
return f"未找到相关种子资源: {title}"
|
||||||
|
|||||||
@@ -99,6 +99,56 @@ class MoviePilotToolsManager:
|
|||||||
return tool
|
return tool
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
解析字段schema,兼容 Optional[T] 生成的 anyOf 结构
|
||||||
|
"""
|
||||||
|
if field_info.get("type"):
|
||||||
|
return field_info
|
||||||
|
|
||||||
|
any_of = field_info.get("anyOf")
|
||||||
|
if not any_of:
|
||||||
|
return field_info
|
||||||
|
|
||||||
|
for type_option in any_of:
|
||||||
|
if type_option.get("type") and type_option["type"] != "null":
|
||||||
|
merged = dict(type_option)
|
||||||
|
if "description" not in merged and field_info.get("description"):
|
||||||
|
merged["description"] = field_info["description"]
|
||||||
|
if "default" not in merged and "default" in field_info:
|
||||||
|
merged["default"] = field_info["default"]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
return field_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_scalar_value(field_type: Optional[str], value: Any, key: str) -> Any:
|
||||||
|
"""
|
||||||
|
根据字段类型规范化单个值
|
||||||
|
"""
|
||||||
|
if field_type == "integer" and isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"无法将参数 {key}='{value}' 转换为整数,保持原值")
|
||||||
|
return None
|
||||||
|
if field_type == "number" and isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,保持原值")
|
||||||
|
return None
|
||||||
|
if field_type == "boolean":
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in ("true", "1", "yes", "on")
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return True
|
||||||
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -132,40 +182,11 @@ class MoviePilotToolsManager:
|
|||||||
normalized[key] = value
|
normalized[key] = value
|
||||||
continue
|
continue
|
||||||
|
|
||||||
field_info = properties[key]
|
field_info = MoviePilotToolsManager._resolve_field_schema(properties[key])
|
||||||
field_type = field_info.get("type")
|
field_type = field_info.get("type")
|
||||||
|
|
||||||
# 处理 anyOf 类型(例如 Optional[int] 会生成 anyOf)
|
|
||||||
any_of = field_info.get("anyOf")
|
|
||||||
if any_of and not field_type:
|
|
||||||
# 从 anyOf 中提取实际类型
|
|
||||||
for type_option in any_of:
|
|
||||||
if "type" in type_option and type_option["type"] != "null":
|
|
||||||
field_type = type_option["type"]
|
|
||||||
break
|
|
||||||
|
|
||||||
# 根据类型进行转换
|
# 根据类型进行转换
|
||||||
if field_type == "integer" and isinstance(value, str):
|
normalized[key] = MoviePilotToolsManager._normalize_scalar_value(field_type, value, key)
|
||||||
try:
|
|
||||||
normalized[key] = int(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.warning(f"无法将参数 {key}='{value}' 转换为整数,保持原值")
|
|
||||||
normalized[key] = None
|
|
||||||
elif field_type == "number" and isinstance(value, str):
|
|
||||||
try:
|
|
||||||
normalized[key] = float(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,保持原值")
|
|
||||||
normalized[key] = None
|
|
||||||
elif field_type == "boolean":
|
|
||||||
if isinstance(value, str):
|
|
||||||
normalized[key] = value.lower() in ("true", "1", "yes", "on")
|
|
||||||
elif isinstance(value, (int, float)):
|
|
||||||
normalized[key] = value != 0
|
|
||||||
else:
|
|
||||||
normalized[key] = True
|
|
||||||
else:
|
|
||||||
normalized[key] = value
|
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@@ -235,14 +256,15 @@ class MoviePilotToolsManager:
|
|||||||
|
|
||||||
if "properties" in schema:
|
if "properties" in schema:
|
||||||
for field_name, field_info in schema["properties"].items():
|
for field_name, field_info in schema["properties"].items():
|
||||||
|
resolved_field_info = MoviePilotToolsManager._resolve_field_schema(field_info)
|
||||||
# 转换字段类型
|
# 转换字段类型
|
||||||
field_type = field_info.get("type", "string")
|
field_type = resolved_field_info.get("type", "string")
|
||||||
field_description = field_info.get("description", "")
|
field_description = resolved_field_info.get("description", "")
|
||||||
|
|
||||||
# 处理可选字段
|
# 处理可选字段
|
||||||
if field_name not in schema.get("required", []):
|
if field_name not in schema.get("required", []):
|
||||||
# 可选字段
|
# 可选字段
|
||||||
default_value = field_info.get("default")
|
default_value = resolved_field_info.get("default")
|
||||||
properties[field_name] = {
|
properties[field_name] = {
|
||||||
"type": field_type,
|
"type": field_type,
|
||||||
"description": field_description
|
"description": field_description
|
||||||
@@ -257,12 +279,12 @@ class MoviePilotToolsManager:
|
|||||||
required.append(field_name)
|
required.append(field_name)
|
||||||
|
|
||||||
# 处理枚举类型
|
# 处理枚举类型
|
||||||
if "enum" in field_info:
|
if "enum" in resolved_field_info:
|
||||||
properties[field_name]["enum"] = field_info["enum"]
|
properties[field_name]["enum"] = resolved_field_info["enum"]
|
||||||
|
|
||||||
# 处理数组类型
|
# 处理数组类型
|
||||||
if field_type == "array" and "items" in field_info:
|
if field_type == "array" and "items" in resolved_field_info:
|
||||||
properties[field_name]["items"] = field_info["items"]
|
properties[field_name]["items"] = resolved_field_info["items"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ router = APIRouter()
|
|||||||
# MCP 协议版本
|
# MCP 协议版本
|
||||||
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
|
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
|
||||||
MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本
|
MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本
|
||||||
|
MCP_HIDDEN_TOOLS = {"execute_command", "search_web"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_exposed_tools():
|
||||||
|
"""
|
||||||
|
获取 MCP 可见工具列表
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
tool for tool in moviepilot_tool_manager.list_tools()
|
||||||
|
if tool.name not in MCP_HIDDEN_TOOLS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:
|
def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:
|
||||||
@@ -174,7 +185,7 @@ async def handle_tools_list() -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
处理工具列表请求
|
处理工具列表请求
|
||||||
"""
|
"""
|
||||||
tools = moviepilot_tool_manager.list_tools()
|
tools = list_exposed_tools()
|
||||||
|
|
||||||
# 转换为 MCP 工具格式
|
# 转换为 MCP 工具格式
|
||||||
mcp_tools = []
|
mcp_tools = []
|
||||||
@@ -202,6 +213,9 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
raise ValueError("Missing tool name")
|
raise ValueError("Missing tool name")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if tool_name in MCP_HIDDEN_TOOLS:
|
||||||
|
raise ValueError(f"工具 '{tool_name}' 未找到")
|
||||||
|
|
||||||
result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments)
|
result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -248,7 +262,7 @@ async def list_tools(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 获取所有工具定义
|
# 获取所有工具定义
|
||||||
tools = moviepilot_tool_manager.list_tools()
|
tools = list_exposed_tools()
|
||||||
|
|
||||||
# 转换为字典格式
|
# 转换为字典格式
|
||||||
tools_list = []
|
tools_list = []
|
||||||
@@ -278,7 +292,9 @@ async def call_tool(
|
|||||||
工具执行结果
|
工具执行结果
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 调用工具
|
if request.tool_name in MCP_HIDDEN_TOOLS:
|
||||||
|
raise ValueError(f"工具 '{request.tool_name}' 未找到")
|
||||||
|
|
||||||
result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)
|
result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)
|
||||||
|
|
||||||
return schemas.ToolCallResponse(
|
return schemas.ToolCallResponse(
|
||||||
@@ -306,7 +322,7 @@ async def get_tool_info(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 获取所有工具
|
# 获取所有工具
|
||||||
tools = moviepilot_tool_manager.list_tools()
|
tools = list_exposed_tools()
|
||||||
|
|
||||||
# 查找指定工具
|
# 查找指定工具
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
@@ -338,7 +354,7 @@ async def get_tool_schema(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 获取所有工具
|
# 获取所有工具
|
||||||
tools = moviepilot_tool_manager.list_tools()
|
tools = list_exposed_tools()
|
||||||
|
|
||||||
# 查找指定工具
|
# 查找指定工具
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
|
|||||||
@@ -109,6 +109,19 @@ class HashUtils:
|
|||||||
data = data.encode(encoding)
|
data = data.encode(encoding)
|
||||||
return hashlib.md5(data).hexdigest()
|
return hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sha1(data: Union[str, bytes], encoding: str = "utf-8") -> str:
|
||||||
|
"""
|
||||||
|
生成数据的SHA-1哈希值,并以字符串形式返回
|
||||||
|
|
||||||
|
:param data: 输入的数据,类型为字符串或字节
|
||||||
|
:param encoding: 字符串编码类型,默认使用UTF-8
|
||||||
|
:return: 生成的SHA-1哈希字符串
|
||||||
|
"""
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.encode(encoding)
|
||||||
|
return hashlib.sha1(data).hexdigest()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes:
|
def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes:
|
||||||
"""
|
"""
|
||||||
|
|||||||
79
skills/moviepilot-cli/SKILL.md
Normal file
79
skills/moviepilot-cli/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: moviepilot-cli
|
||||||
|
description: Use this skill when the user wants to manage a home media ecosystem via MoviePilot. Covers searching movies/TV shows/anime, managing subscriptions, controlling downloads (torrent search, quality filtering), monitoring download progress, and organizing media libraries. Trigger when user mentions movie/show titles, asks about subscriptions, downloads, library organization, or references MoviePilot directly.
|
||||||
|
---
|
||||||
|
|
||||||
|
# MoviePilot Media Management Skill
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This skill interacts with the MoviePilot backend via the Node.js command-line script `scripts/mp-cli.js`. It supports four core capabilities: media search and recognition, subscription management, download control, and media library organization.
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: mp-cli.js [-h HOST] [-k KEY] [COMMAND] [ARGS...]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h HOST backend host
|
||||||
|
-k KEY API key
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
(no command) save config when -h and -k are provided
|
||||||
|
list list all commands
|
||||||
|
show <command> show command details and usage example
|
||||||
|
<command> [k=v...] run a command
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discovering Available Tools
|
||||||
|
|
||||||
|
Before performing any task, use these two commands to understand what the current environment supports.
|
||||||
|
|
||||||
|
**List all available commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/mp-cli.js list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inspect a command's parameters:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/mp-cli.js show <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
`show` displays a command's name, its parameters, and a usage example. For each parameter, it shows the name, type, required/optional status, and description. **Always run `show` before calling any command** — never guess parameter names or formats.
|
||||||
|
|
||||||
|
## Standard Workflow
|
||||||
|
|
||||||
|
Follow this sequence for any media task:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. list → confirm which commands are available
|
||||||
|
2. show <command> → confirm parameter format before calling
|
||||||
|
3. Search / recognize → resolve exact metadata (TMDB ID, season, episode)
|
||||||
|
4. Check library / subs → avoid duplicate downloads or subscriptions
|
||||||
|
5. Execute action → downloads require explicit user confirmation first
|
||||||
|
6. Confirm final state → report the outcome to the user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Calling Strategy
|
||||||
|
|
||||||
|
**Fallback search**: If a media search returns no results, try in order: fuzzy recognition → web search → ask the user for more information.
|
||||||
|
|
||||||
|
**Disambiguation**: If search results are ambiguous, call the detail-query command to obtain precise metadata before proceeding.
|
||||||
|
|
||||||
|
## Download Safety Rules
|
||||||
|
|
||||||
|
Before executing any download command, you **must**:
|
||||||
|
|
||||||
|
1. Search for and retrieve a list of available torrent resources.
|
||||||
|
2. Present torrent details to the user (size, seeders, quality, release group).
|
||||||
|
3. **Wait for explicit user confirmation** before initiating the download.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Resolution |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| No search results | Try fuzzy recognition → web search → ask the user |
|
||||||
|
| Download failure | Check downloader status; advise user to verify disk space |
|
||||||
|
| Missing configuration | Prompt user to run `node scripts/mp-cli.js -h <HOST> -k <KEY>` to configure |
|
||||||
593
skills/moviepilot-cli/scripts/mp-cli.js
Executable file
593
skills/moviepilot-cli/scripts/mp-cli.js
Executable file
@@ -0,0 +1,593 @@
|
|||||||
|
#!/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 request(method, targetUrl, headers = {}, body) {
|
||||||
|
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.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) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
commandsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdList() {
|
||||||
|
await loadCommandsJson();
|
||||||
|
for (const command of commandsJson) {
|
||||||
|
process.stdout.write(`- ${command.name}${spacePad(command.name)}${command.description}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdShow(commandName) {
|
||||||
|
await loadCommandsJson();
|
||||||
|
|
||||||
|
if (!commandName) {
|
||||||
|
fail(`Usage: ${SCRIPT_NAME} show <command>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = commandsJson.find((item) => item.name === commandName);
|
||||||
|
if (!command) {
|
||||||
|
console.error(`Error: command '${commandName}' not found`);
|
||||||
|
console.error(`Run '${SCRIPT_NAME} list' to see available commands`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandLabel = 'Command:';
|
||||||
|
const paramsLabel = 'Parameters:';
|
||||||
|
const usageLabel = 'Usage example:';
|
||||||
|
const detailLabelWidth = Math.max(commandLabel.length, paramsLabel.length, usageLabel.length);
|
||||||
|
|
||||||
|
process.stdout.write(`${commandLabel} ${command.name}\n\n`);
|
||||||
|
|
||||||
|
if (command.fields.length === 0) {
|
||||||
|
process.stdout.write(`${paramsLabel}${spacePad(paramsLabel, detailLabelWidth)}(none)\n`);
|
||||||
|
} else {
|
||||||
|
const fieldLines = command.fields.map((field) => [
|
||||||
|
field.name,
|
||||||
|
field.type,
|
||||||
|
field.required ? '[required]' : '[optional]',
|
||||||
|
field.description,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nameWidth = Math.max(...fieldLines.map(([name]) => name.length), 0);
|
||||||
|
const typeWidth = Math.max(...fieldLines.map(([, type]) => type.length), 0);
|
||||||
|
const reqWidth = Math.max(...fieldLines.map(([, , required]) => required.length), 0);
|
||||||
|
|
||||||
|
process.stdout.write(`${paramsLabel}\n`);
|
||||||
|
for (const [fieldName, fieldType, fieldRequired, fieldDesc] of fieldLines) {
|
||||||
|
process.stdout.write(
|
||||||
|
` ${fieldName}${spacePad(fieldName, nameWidth)}${fieldType}${spacePad(fieldType, typeWidth)}${fieldRequired}${spacePad(fieldRequired, reqWidth)}${fieldDesc}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageLine = `${SCRIPT_NAME} ${command.name}`;
|
||||||
|
const reqPart = command.fields.filter((field) => field.required).map((field) => ` ${field.name}=<value>`).join('');
|
||||||
|
const optPart = command.fields
|
||||||
|
.filter((field) => !field.required)
|
||||||
|
.map((field) => ` [${field.name}=<value>]`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`\n${usageLabel}${spacePad(usageLabel, detailLabelWidth)}${usageLine}${reqPart}${optPart}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value) {
|
||||||
|
return value === 'true' || value === '1' || value === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value, key) {
|
||||||
|
if (value === '') {
|
||||||
|
fail(`Error: invalid numeric value for '${key}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Number(value);
|
||||||
|
if (Number.isNaN(result)) {
|
||||||
|
fail(`Error: invalid numeric value for '${key}': '${value}'`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalarValue(value, key, type = 'string') {
|
||||||
|
if (type === 'integer' || type === 'number') {
|
||||||
|
return parseNumber(value, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return parseBoolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArrayValue(value, key, itemType = 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
fail(`Error: invalid array value for '${key}': '${value}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
fail(`Error: invalid array value for '${key}': '${value}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return parseScalarValue(item.trim(), key, itemType);
|
||||||
|
}
|
||||||
|
if (itemType === 'integer' || itemType === 'number') {
|
||||||
|
if (typeof item !== 'number') {
|
||||||
|
fail(`Error: invalid numeric value for '${key}': '${item}'`);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (itemType === 'boolean') {
|
||||||
|
if (typeof item !== 'boolean') {
|
||||||
|
fail(`Error: invalid boolean value for '${key}': '${item}'`);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => parseScalarValue(item, key, itemType));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArguments(command, 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('=');
|
||||||
|
const key = kv.slice(0, index);
|
||||||
|
const value = kv.slice(index + 1);
|
||||||
|
const field = command.fields.find((item) => item.name === key);
|
||||||
|
const fieldType = field?.type || 'string';
|
||||||
|
const itemType = field?.item_type || 'string';
|
||||||
|
|
||||||
|
if (fieldType === 'array') {
|
||||||
|
args[key] = parseArrayValue(value, key, itemType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
args[key] = parseScalarValue(value, key, fieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRun(commandName, pairs) {
|
||||||
|
await loadCommandsJson();
|
||||||
|
|
||||||
|
if (!commandName) {
|
||||||
|
fail(`Usage: ${SCRIPT_NAME} <command> [key=value ...]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = commandsJson.find((item) => item.name === commandName);
|
||||||
|
if (!command) {
|
||||||
|
console.error(`Error: command '${commandName}' not found`);
|
||||||
|
console.error(`Run '${SCRIPT_NAME} list' to see available commands`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = JSON.stringify({
|
||||||
|
tool_name: commandName,
|
||||||
|
arguments: buildArguments(command, 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, '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