From 935b943b6a48df187747e33376f52a4bf269226b Mon Sep 17 00:00:00 2001 From: Mango Date: Tue, 13 Oct 2026 01:21:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AI=20Agent=20=E7=9A=84=20M?= =?UTF-8?q?CP=20=E5=B7=A5=E5=85=B7=EF=BC=8C=E5=8F=AF=E5=A2=9E=E5=88=A0?= =?UTF-8?q?=E6=94=B9=E6=9F=A5=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 uTools 官方的 utools.registerTool 暴露 9 个 qc_* 工具,工具声明写在 plugin.json 的 tools 字段里,外部 Agent 连 uTools 的本地 MCP 服务即可调用。 数据层自包含在 plugin/lib 下(命令 CRUD 不依赖界面是否渲染),权限按 读取/写入/运行 三档控制,默认读开写开、运行关,开关放在 AI 配置界面底部。 顺带把打包后去掉 development 入口的构建钩子带过来,uTools 开发者工具加载 dist 目录需要它,否则会去连 quasar dev 的 8080 端口导致白屏。 --- README.md | 15 ++ package.json | 2 +- plugin/lib/commandSchema.js | 103 ++++++++++ plugin/lib/commandStore.js | 335 +++++++++++++++++++++++++++++++++ plugin/lib/quickcommandMcp.js | 147 +++++++++++++++ plugin/plugin.json | 119 +++++++++++- plugin/preload.js | 2 + quasar.config.js | 11 ++ src/App.vue | 9 + src/components/ai/AIConfig.vue | 29 ++- test/commandStore.test.js | 106 +++++++++++ test/mcp-smoke.mjs | 153 +++++++++++++++ 12 files changed, 1028 insertions(+), 3 deletions(-) create mode 100644 plugin/lib/commandSchema.js create mode 100644 plugin/lib/commandStore.js create mode 100644 plugin/lib/quickcommandMcp.js create mode 100644 test/commandStore.test.js create mode 100644 test/mcp-smoke.mjs diff --git a/README.md b/README.md index 50b7e27..abf183a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ quasar build - 快速收藏文件、网址、插件别名,通过面板视图,实现类似软件启动器、网页搜藏夹、插件面板等功能 - 定时运行命令 - 提供后台服务,将插件内部和外部环境打通 +- 把命令的增删改查作为 MCP 工具开放给 AI Agent(需要 uTools 支持本地 MCP 服务) # 三、配置参数 @@ -88,6 +89,20 @@ quasar build - 发送到系统通知 - 在终端中显示 +## 「 AI Agent 」 + +把快捷命令的增删改查开放给外部 AI Agent(Claude Code、Codex、Cursor 等),让它帮你批量写命令、改命令。 + +使用步骤: + +1. 在 uTools 的 `设置 → MCP 服务` 里开启本地 MCP 服务,并用它的一键写入功能把 uTools 加进 AI Agent 的 MCP 配置 +2. 打开任意命令的编辑器,点右侧 AI 助手里的齿轮图标,在 `API配置` 弹窗底部按需打开 AI Agent 的 `读取` / `写入` / `运行` 开关 +3. 让 Agent 调用 `qc_get_schema` 了解命令格式后再动手 + +提供的工具:`qc_list_commands`、`qc_get_command`、`qc_list_tags`、`qc_get_schema`、`qc_create_command`、`qc_update_command`、`qc_delete_command`、`qc_set_enabled`、`qc_run_command`。 + +插件自带的默认命令(code 以 `default_` 开头)不能被读写。 + # 四、可视化编排功能概览 ## 音频操作 diff --git a/package.json b/package.json index 8aa174a..07e1595 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "lint": "eslint --ext .js,.vue ./", "format": "prettier --write \"**/*.{js,vue,css,html,md,json}\" --ignore-path .gitignore", - "test": "echo \"No test specified\" && exit 0" + "test": "node test/commandStore.test.js" }, "dependencies": { "@quasar/extras": "^1.14.0", diff --git a/plugin/lib/commandSchema.js b/plugin/lib/commandSchema.js new file mode 100644 index 0000000..99e7253 --- /dev/null +++ b/plugin/lib/commandSchema.js @@ -0,0 +1,103 @@ +/** + * 快捷命令的数据结构说明,qc_get_schema 直接返回这段文本给 AI Agent + */ +module.exports = `# 快捷命令数据结构 + +一条命令就是一个 JSON 对象,字段如下: + +{ + "program": "quickcommand", + "cmd": "脚本内容", + "features": { + "code": "key_1a2b3c4d", + "explain": "命令名称(uTools 搜索框里显示的名字,不能为空)", + "cmds": ["触发关键词", { "type": "regex", "label": "名称", "match": "/^https?:\\\\/\\\\//i" }], + "platform": ["win32", "darwin", "linux"], + "mainPush": false, + "icon": "" + }, + "output": "text", + "tags": ["日常"] +} + +## program(执行方式) + +- quickcommand:JavaScript,跑在插件沙箱里,可以用 quickcommand.* / utools.* / fs / require 等,输出的对象里带 code 字段时会把 code 当结果输出 +- quickcomposer:可视化编排,内容在 flows 数组里(见下文) +- html:HTML 页面 +- shell / applescript / cmd / powershell / python / javascript / ruby / php / c / csharp / lua / perl:对应语言的脚本文件 +- custom:自定义解释器,额外需要 customOptions: { bin, argv, ext } + +除了 quickcommand 和 quickcomposer,其它 program 都要求 cmd 是脚本正文,额外可选: +- scptarg:命令行参数 +- charset: { scriptCode: "gbk", outputCode: "gbk" }(Windows 中文环境常见) + +## features + +- code:命令的唯一标识,由插件生成。创建时不用填(填了也会重新生成),更新时不要改这个字段。 +- explain:必填,同时会作为匹配规则的 label +- cmds:必填数组,每个元素是「触发方式」: + - 字符串:关键词,如 "翻译" + - { type: "regex", match: "/^\\\\d+$/i" }:正则匹配主输入框或选中的文本 + - { type: "over" }:匹配任意文本(未被其它插件占用时) + - { type: "files", fileType: "file"|"directory", extensions: ["png","jpg"], match: "/\\\\.png$/i" } + - { type: "img" }:匹配剪贴板图片 + - { type: "window", match: { app: ["chrome.exe"], title: "/github/", class: ["..." ] } }:匹配当前活动窗口 + - 多种类型混用时会自动变成 "professional" +- platform:命令生效的系统,默认三个平台都生效 +- mainPush:为 true 时命令出现在 uTools 主输入框的推送列表里(输出类型会被限制) +- icon:data URL 或相对路径,**建议留空**(超过 32KB 会报错),用户可以在界面里自己设置 + +## output(输出方式) + +- text(默认):在 uTools 窗口显示 +- html:按 HTML 渲染 +- nothing:忽略输出且不关闭窗口 +- ignore:忽略输出并隐藏窗口 +- terminal:在终端显示 +- clip:复制到剪贴板 +- send:发送到当前活动窗口 +- notice:发送系统通知 + +## tags + +字符串数组,如 ["日常", "图片"],留空则归入「未分类」。 + +## flows(quickcomposer 专用) + +可视化编排的内容,结构复杂且随版本变化,**不要凭空生成**。要创建编排命令,请让用户在插件界面里拖拽生成,再用 qc_get_command 读取、用 qc_update_command 做小范围调整。 + +## 示例一:JavaScript 脚本命令 + +{ + "program": "quickcommand", + "cmd": "quickcommand.showInputBox(['要加密的内容']).then(([text]) => {\\n console.log(quickcommand.md5(text))\\n})", + "features": { + "explain": "计算 MD5", + "cmds": ["计算md5"] + }, + "output": "text", + "tags": ["日常"] +} + +## 示例二:Shell 命令 + +{ + "program": "shell", + "cmd": "ls -la ~/Downloads | head -20", + "features": { + "explain": "列出下载目录", + "cmds": [{ "type": "over", "label": "列出下载目录" }] + }, + "output": "text", + "tags": [] +} + +## 返回值约定 + +所有工具都返回对象:成功是 { ok: true, ... },失败是 { ok: false, error: "原因" },写操作的参数不合法时额外返回 errors: [{ path: "features.cmds[0].type", got: "regexp", allow: ["key","regex","over","img","files","window","professional"] }],照着 errors 改参数重试即可。 + +## 内置命令 + +插件自带一批 code 以 default_ 开头的默认命令,它们不落库、不参与 Qc 的读写,qc_list_commands 不会列出它们,也不能修改或删除。用户想调整内置命令时,用 qc_create_command 新建一条自己的即可。 +`; diff --git a/plugin/lib/commandStore.js b/plugin/lib/commandStore.js new file mode 100644 index 0000000..dce9cd4 --- /dev/null +++ b/plugin/lib/commandStore.js @@ -0,0 +1,335 @@ +/** + * 快捷命令的读写与校验,供 MCP 工具使用(preload 环境,CommonJS) + * + * 逻辑对应 src/js/commandManager.js,那边是 webpack 模块,preload require 不到, + * 只能把必需片段复制过来。改动其中一边时记得同步另一边。 + */ +const utoolsFull = window.utools; // App.vue 初始化后 window.utools 会被替换成阉割版,必须在 preload 加载时捕获 + +const PROGRAMS = [ + "quickcommand", + "quickcomposer", + "html", + "shell", + "applescript", + "cmd", + "powershell", + "python", + "javascript", + "ruby", + "php", + "c", + "csharp", + "lua", + "perl", + "custom", +]; +const OUTPUTS = [ + "ignore", + "nothing", + "text", + "html", + "terminal", + "clip", + "send", + "notice", +]; +const PLATFORMS = ["win32", "darwin", "linux"]; +const CMD_TYPES = [ + "key", + "regex", + "over", + "img", + "files", + "window", + "professional", +]; +const ICON_MAX_LENGTH = 32 * 1024; + +const getUniqueId = (options = {}) => { + const { short = false } = options; + const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + return short ? uuid.substring(0, 8) : uuid; +}; + +const isDefaultCommand = (code) => String(code).slice(0, 8) === "default_"; + +const getCmdType = (cmds) => { + const firstCmdType = cmds[0].type || "key"; + if (!cmds.find((x) => typeof x !== "string")) return "key"; + if (!cmds.find((x) => x.type !== firstCmdType)) return firstCmdType; + return "professional"; +}; + +const getFeatureCode = (cmds) => { + return `${getCmdType(cmds)}_${getUniqueId({ short: true })}`; +}; + +const dbGet = (id) => { + const db = utoolsFull.db.get(id); + return db ? db.data : null; +}; + +const dbPut = (value, id) => { + const db = utoolsFull.db.get(id); + return db + ? utoolsFull.db.put({ _id: id, data: value, _rev: db._rev }) + : utoolsFull.db.put({ _id: id, data: value }); +}; + +const dbRemove = (id) => utoolsFull.db.remove(id); + +const getActivatedCodes = () => utoolsFull.getFeatures().map((x) => x.code); + +// 是否为不需要显示输入框的输出类型 +const outPluginOutputs = ["ignore", "terminal", "clip", "send", "notice"]; + +const validateCmdRule = (rule, index, errors) => { + const path = `features.cmds[${index}]`; + if (typeof rule === "string") { + if (!rule.trim()) + errors.push({ path, error: "关键词不能为空字符串" }); + return; + } + if (!rule || typeof rule !== "object") { + errors.push({ path, got: rule, error: "只能是字符串(关键词)或匹配规则对象" }); + return; + } + if (!CMD_TYPES.includes(rule.type)) { + errors.push({ path: `${path}.type`, got: rule.type, allow: CMD_TYPES }); + return; + } + if (rule.type === "regex" && typeof rule.match !== "string") { + errors.push({ path: `${path}.match`, error: "正则匹配规则需要 match 字段,形如 \"/^https?:\\\\/\\\\//i\"" }); + } + if (rule.type === "files" && rule.fileType && !["file", "directory"].includes(rule.fileType)) { + errors.push({ path: `${path}.fileType`, got: rule.fileType, allow: ["file", "directory"] }); + } + if (rule.type === "window" && (!rule.match || !Array.isArray(rule.match.app))) { + errors.push({ path: `${path}.match.app`, error: "窗口匹配规则需要 match.app 数组,形如 [\"chrome.exe\"]" }); + } +}; + +const validateCommand = (command) => { + const errors = []; + if (!command || typeof command !== "object" || Array.isArray(command)) { + return [{ path: "", error: "命令必须是一个对象" }]; + } + if (!PROGRAMS.includes(command.program)) { + errors.push({ path: "program", got: command.program, allow: PROGRAMS }); + } + const features = command.features; + if (!features || typeof features !== "object") { + errors.push({ path: "features", error: "缺少 features 对象" }); + return errors; + } + if (typeof features.explain !== "string" || !features.explain.trim()) { + errors.push({ path: "features.explain", error: "名称不能为空,它是命令在 uTools 搜索框里显示的名字" }); + } + if (!Array.isArray(features.cmds) || !features.cmds.length) { + errors.push({ path: "features.cmds", error: "至少需要一个触发方式(关键词字符串或匹配规则对象)" }); + } else { + features.cmds.forEach((rule, i) => validateCmdRule(rule, i, errors)); + } + if (features.platform !== undefined) { + if ( + !Array.isArray(features.platform) || + !features.platform.length || + features.platform.some((p) => !PLATFORMS.includes(p)) + ) { + errors.push({ path: "features.platform", got: features.platform, allow: PLATFORMS }); + } + } + if (features.mainPush !== undefined && typeof features.mainPush !== "boolean") { + errors.push({ path: "features.mainPush", got: features.mainPush, error: "需要布尔值" }); + } + if (typeof features.icon === "string" && features.icon.length > ICON_MAX_LENGTH) { + errors.push({ + path: "features.icon", + error: `图标过大(${features.icon.length} 字符,上限 ${ICON_MAX_LENGTH}),请留空让用户自己在界面里设置`, + }); + } + if (command.output !== undefined && !OUTPUTS.includes(command.output)) { + errors.push({ path: "output", got: command.output, allow: OUTPUTS }); + } + if (command.tags !== undefined && !Array.isArray(command.tags)) { + errors.push({ path: "tags", got: command.tags, error: "需要字符串数组,如 [\"日常\"]" }); + } + if (command.program === "quickcomposer") { + if (command.flows !== undefined && !Array.isArray(command.flows)) { + errors.push({ path: "flows", got: command.flows, error: "需要数组" }); + } + } else if (typeof command.cmd !== "string") { + errors.push({ path: "cmd", got: command.cmd, error: "脚本内容必须是字符串" }); + } + return errors; +}; + +// 补齐派生字段,等价于 commandManager 里的 getValidCommand +const normalizeCommand = (command) => { + const normalized = JSON.parse(JSON.stringify(command)); + const { cmds, explain } = normalized.features; + normalized.features.cmds = cmds.map((cmd) => + typeof cmd === "string" ? cmd : { ...cmd, label: explain } + ); + normalized.features.mainHide = outPluginOutputs.includes(normalized.output); + normalized.features.code = + normalized.features.code || getFeatureCode(normalized.features.cmds); + if (!Array.isArray(normalized.tags)) normalized.tags = []; + if (normalized.output === undefined) normalized.output = "text"; + return normalized; +}; + +const getDbCommands = () => { + return utoolsFull.db + .allDocs("qc_") + .map((x) => x.data) + // 打包进插件里的默认命令不落库,这条只是兜底 + .filter((x) => x && x.features && !String(x.features.code).includes("default_")); +}; + +const summarize = (command, activatedCodes) => ({ + code: command.features.code, + explain: command.features.explain, + cmds: command.features.cmds, + program: command.program, + output: command.output, + tags: command.tags || [], + enabled: activatedCodes.includes(command.features.code), +}); + +const list = (params = {}) => { + const activatedCodes = getActivatedCodes(); + let commands = getDbCommands(); + if (params.tag) commands = commands.filter((x) => (x.tags || []).includes(params.tag)); + if (params.program) commands = commands.filter((x) => x.program === params.program); + if (typeof params.enabled === "boolean") { + commands = commands.filter((x) => activatedCodes.includes(x.features.code) === params.enabled); + } + if (params.keyword) { + const kw = String(params.keyword).toLowerCase(); + commands = commands.filter((x) => { + const haystack = [ + x.features.code, + x.features.explain, + JSON.stringify(x.features.cmds), + (x.tags || []).join(" "), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(kw); + }); + } + return { + total: commands.length, + commands: commands.map((x) => summarize(x, activatedCodes)), + }; +}; + +const get = (code) => { + if (!code) throw new Error("缺少 code 参数"); + if (isDefaultCommand(code)) { + throw new Error( + `${code} 是插件内置的默认命令,不落库也没有对应的数据,MCP 无法读取。内置命令一律只读,需要类似功能请用 qc_create_command 新建一条。` + ); + } + const command = dbGet("qc_" + code); + if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`); + return command; +}; + +const create = (command) => { + const errors = validateCommand(command); + if (errors.length) return { errors }; + const normalized = normalizeCommand(command); + if (isDefaultCommand(normalized.features.code)) { + normalized.features.code = getFeatureCode(normalized.features.cmds); + } + return { command: persist(normalized) }; +}; + +const update = (code, command) => { + if (!code) throw new Error("缺少 code 参数"); + if (isDefaultCommand(code)) { + throw new Error(`${code} 是插件内置的默认命令,不能修改。要改请用 qc_create_command 新建一条自己的命令。`); + } + const current = dbGet("qc_" + code); + if (!current) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`); + const errors = validateCommand(command); + if (errors.length) return { errors }; + const normalized = normalizeCommand({ ...command, features: { ...command.features, code } }); + return { command: persist(normalized) }; +}; + +const remove = (code) => { + if (!code) throw new Error("缺少 code 参数"); + if (isDefaultCommand(code)) { + throw new Error(`${code} 是插件内置的默认命令,不能删除。`); + } + const command = dbGet("qc_" + code); + if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`); + dbRemove("qc_" + code); + utoolsFull.removeFeature(code); + // 历史记录一并清掉,等价于界面上的删除 + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith("editor_history_" + code)) localStorage.removeItem(key); + } + return command; +}; + +const setEnabled = (codes, enabled) => { + const changed = []; + codes.forEach((code) => { + if (isDefaultCommand(code)) return; + const command = dbGet("qc_" + code); + if (!command) return; + utoolsFull.removeFeature(code); + if (enabled) utoolsFull.setFeature(window.lodashM.cloneDeep(command.features)); + changed.push({ code, enabled }); + }); + return changed; +}; + +const listTags = () => { + const counts = {}; + getDbCommands().forEach((x) => { + const tags = x.tags && x.tags.length ? x.tags : ["未分类"]; + tags.forEach((t) => (counts[t] = (counts[t] || 0) + 1)); + }); + return Object.keys(counts).map((tag) => ({ tag, count: counts[tag] })); +}; + +// 落库 + 注册 feature,等价于界面保存 +const persist = (command) => { + const code = command.features.code; + dbPut(command, "qc_" + code); + utoolsFull.removeFeature(code); + utoolsFull.setFeature(window.lodashM.cloneDeep(command.features)); + notifyUI(); + return command; +}; + +// 通知页面刷新列表,页面没打开时没人监听,忽略即可 +const notifyUI = () => { + try { + window.dispatchEvent(new CustomEvent("qc:mcp-changed")); + } catch (e) { + console.log("notifyUI failed", e); + } +}; + +module.exports = { + list, + get, + create, + update, + remove, + setEnabled, + listTags, + notifyUI, +}; diff --git a/plugin/lib/quickcommandMcp.js b/plugin/lib/quickcommandMcp.js new file mode 100644 index 0000000..2746748 --- /dev/null +++ b/plugin/lib/quickcommandMcp.js @@ -0,0 +1,147 @@ +/** + * 把快捷命令的增删改查注册成 uTools 的 MCP 工具,供外部 AI Agent 调用 + * + * 工具声明在 plugin/plugin.json 的 tools 字段里,这里只负责运行时注册 handler。 + * 三档权限(读取/写入/运行)在 AI 配置界面里开关,存在本机 profile 的 mcpPermissions 字段。 + */ +const store = require("./commandStore.js"); +const schema = require("./commandSchema.js"); + +const utoolsFull = window.utools; // App.vue 初始化后 window.utools 会被替换成阉割版,必须在 preload 加载时捕获 + +const PROFILE_KEY = () => `cfg_${utoolsFull.getNativeId()}_profile`; +const DEFAULT_PERMISSIONS = { read: true, write: true, run: false }; +const GROUP_LABELS = { read: "读取", write: "写入", run: "运行" }; + +const TOOL_GROUPS = { + read: ["qc_list_commands", "qc_get_command", "qc_list_tags", "qc_get_schema"], + write: [ + "qc_create_command", + "qc_update_command", + "qc_delete_command", + "qc_set_enabled", + ], + run: ["qc_run_command"], +}; + +const getPermissions = () => { + let saved = {}; + try { + const profile = utoolsFull.db.get(PROFILE_KEY()); + saved = (profile && profile.data && profile.data.mcpPermissions) || {}; + } catch (e) { + console.log("读取 MCP 权限配置失败", e); + } + return { ...DEFAULT_PERMISSIONS, ...saved }; +}; + +const validationFailed = (errors) => ({ + ok: false, + error: "参数校验没通过,按 errors 里的提示修正后重试", + errors, +}); + +const HANDLERS = { + qc_list_commands: (params) => ({ ok: true, ...store.list(params) }), + qc_get_command: (params) => ({ + ok: true, + command: store.get(params.code), + }), + qc_list_tags: () => ({ ok: true, tags: store.listTags() }), + qc_get_schema: () => ({ ok: true, schema }), + qc_create_command: (params) => { + const result = store.create(params.command); + if (result.errors) return validationFailed(result.errors); + return { + ok: true, + code: result.command.features.code, + command: result.command, + }; + }, + qc_update_command: (params) => { + const result = store.update(params.code, params.command); + if (result.errors) return validationFailed(result.errors); + return { ok: true, command: result.command }; + }, + qc_delete_command: (params) => ({ + ok: true, + command: store.remove(params.code), + }), + qc_set_enabled: (params) => { + if (!Array.isArray(params.codes) || !params.codes.length) { + return validationFailed([ + { path: "codes", got: params.codes, error: "需要非空的 code 数组" }, + ]); + } + const changed = store.setEnabled(params.codes, params.enabled === true); + if (!changed.length) { + return { + ok: false, + error: "没有匹配到可操作的命令(内置命令不能启用/禁用,也可以先用 qc_list_commands 确认 code)", + }; + } + store.notifyUI(); + return { ok: true, changed }; + }, + qc_run_command: async (params) => { + // 先确认命令真的存在,避免把空对象丢给界面侧的运行器 + store.get(params.code); + // 运行要靠界面侧的命令运行器(需要窗口、输出面板配合),插件页面没加载时用不了 + if (!window.qcBridge || typeof window.qcBridge.runCommand !== "function") { + return { + ok: false, + error: + "运行命令需要 quickcommand 插件界面处于加载状态。请在 uTools 里打开一次「快捷命令」后重试,或改用界面上的运行按钮。", + }; + } + const output = await window.qcBridge.runCommand( + params.code, + params.input, + params.timeout || 10000 + ); + return { ok: true, code: params.code, output }; + }, +}; + +const groupOf = (name) => + Object.keys(TOOL_GROUPS).find((group) => TOOL_GROUPS[group].includes(name)); + +// handler 里再查一次权限:uTools 的 tools/list 只认 plugin.json 里的声明, +// 反注册拦不住调用,只会让 Agent 收到「未注册」这种没法行动的错,所以这里给出明确原因 +const withPermissionCheck = (name) => { + const group = groupOf(name); + return async (params, ctx) => { + if (!getPermissions()[group]) { + return { + ok: false, + error: `用户在 quickcommand 的 AI 配置里关闭了 AI Agent 的「${GROUP_LABELS[group]}」权限,打开后重试`, + }; + } + try { + return await HANDLERS[name](params || {}, ctx); + } catch (e) { + console.log("MCP 工具执行失败:" + name, e); + return { ok: false, error: String((e && e.message) || e) }; + } + }; +}; + +const supported = () => typeof utoolsFull.registerTool === "function"; + +const applyPermissions = () => { + if (!supported()) { + console.log("当前 uTools 版本不支持 registerTool,跳过 MCP 工具注册"); + return getPermissions(); + } + Object.keys(TOOL_GROUPS).forEach((group) => { + TOOL_GROUPS[group].forEach((name) => { + utoolsFull.registerTool(name, withPermissionCheck(name)); + }); + }); + return getPermissions(); +}; + +// 权限在每次调用时从 profile 里读,所以不需要再暴露重注册的接口 +applyPermissions(); + +module.exports = { applyPermissions, getPermissions, TOOL_GROUPS }; diff --git a/plugin/plugin.json b/plugin/plugin.json index 55c7c38..fca120e 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -70,5 +70,122 @@ ], "icon": "features/server.png" } - ] + ], + "tools": { + "qc_list_commands": { + "description": "列出用户自己创建的快捷命令(只返回摘要,不含脚本正文)。可用 tag、keyword、program、enabled 过滤。插件内置的 default_ 命令不会列出,也不能通过 MCP 读写。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "tag": { "type": "string", "description": "只看某个标签下的命令" }, + "keyword": { "type": "string", "description": "按名称、关键词、匹配规则、标签模糊搜索" }, + "program": { + "type": "string", + "enum": ["quickcommand", "quickcomposer", "html", "shell", "applescript", "cmd", "powershell", "python", "javascript", "ruby", "php", "c", "csharp", "lua", "perl", "custom"], + "description": "只看某种执行方式的命令" + }, + "enabled": { "type": "boolean", "description": "只看已启用(true)或已禁用(false)的命令" } + } + } + }, + "qc_get_command": { + "description": "按 code 取一条快捷命令的完整 JSON(含脚本正文、匹配规则、输出方式)。修改前先读一次,改完用 qc_update_command 整体写回。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { "type": "string", "description": "命令的唯一标识,由 qc_list_commands 返回" } + }, + "required": ["code"] + } + }, + "qc_list_tags": { + "description": "列出所有标签以及每个标签下的命令数量,用于把新命令归类和按标签筛选。", + "inputSchema": { "type": "object", "additionalProperties": false } + }, + "qc_get_schema": { + "description": "返回快捷命令的数据结构说明、各种 program 和匹配类型的含义、输出方式枚举,以及可直接照抄的完整示例。第一次创建或修改命令之前先调用它。", + "inputSchema": { "type": "object", "additionalProperties": false } + }, + "qc_create_command": { + "description": "新建一条快捷命令。传入命令对象(字段说明见 qc_get_schema),服务端会补齐 code、匹配规则的 label 等派生字段,并注册到 uTools 让它可以被关键词唤起。返回新建命令的完整内容和 code。参数不合法时返回 errors 数组,按提示改完重试。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "object", + "description": "完整命令对象,至少包含 program、features.explain、features.cmds,其余字段参考 qc_get_schema", + "properties": { + "program": { "type": "string", "description": "执行方式,如 quickcommand、shell、python" }, + "features": { + "type": "object", + "properties": { + "explain": { "type": "string", "description": "命令名称,不能为空" }, + "cmds": { "type": "array", "description": "触发方式数组:关键词字符串,或 {type, match} 形式的匹配规则" }, + "icon": { "type": "string", "description": "建议留空,用户可以在界面里自己设置图标" }, + "platform": { "type": "array", "items": { "type": "string" }, "description": "生效平台,默认三个平台都生效" } + }, + "required": ["explain", "cmds"] + }, + "cmd": { "type": "string", "description": "脚本正文,program 为 quickcommand 等脚本类型时必填" }, + "output": { "type": "string", "enum": ["ignore", "nothing", "text", "html", "terminal", "clip", "send", "notice"], "description": "输出方式,默认 text" }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "标签,留空归入未分类" } + }, + "required": ["program", "features"] + } + }, + "required": ["command"] + } + }, + "qc_update_command": { + "description": "整体替换一条命令的内容,按 code 定位,code 本身不能改。参数必须是一条完整命令:先用 qc_get_command 读出来,改完再整条写回,只传要改的字段会被判为参数不合法。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { "type": "string", "description": "要修改的命令的 code" }, + "command": { "type": "object", "description": "修改后的完整命令对象,字段说明见 qc_get_schema" } + }, + "required": ["code", "command"] + } + }, + "qc_delete_command": { + "description": "删除一条命令,返回被删除的命令内容以便确认删对了。插件内置的 default_ 命令不能删除。删除不可撤销,删之前先 qc_get_command 确认一下。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { "type": "string", "description": "要删除的命令的 code" } + }, + "required": ["code"] + } + }, + "qc_set_enabled": { + "description": "启用或禁用一条或多条命令,等价于插件界面命令卡片上的开关。禁用的命令不会出现在 uTools 的搜索结果里,但数据仍然保留。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "codes": { "type": "array", "items": { "type": "string" }, "description": "要操作的命令 code 列表" }, + "enabled": { "type": "boolean", "description": "true 启用,false 禁用" } + }, + "required": ["codes", "enabled"] + } + }, + "qc_run_command": { + "description": "运行一条命令并返回它的输出,等价于点界面上命令卡片的运行按钮。需要 uTools 保持运行,插件进程被回收时会返回提示。只返回第一次输出的内容,输出方式为剪贴板/通知/终端的命令不会回传结果。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { "type": "string", "description": "要运行的命令的 code" }, + "input": { "type": "string", "description": "替换脚本里 {{input}} 占位符的输入内容,可选" }, + "timeout": { "type": "number", "description": "等待输出的毫秒数,默认 10000" } + }, + "required": ["code"] + } + } + } } diff --git a/plugin/preload.js b/plugin/preload.js index d99ca42..14f83f9 100644 --- a/plugin/preload.js +++ b/plugin/preload.js @@ -45,6 +45,8 @@ window.convertFilePathToUtoolsPayload = convertFilePathToUtoolsPayload; window.getuToolsLite = require("./lib/utoolsLite"); window.quickcommand = require("./lib/quickcommand"); window.quickcomposer = require("./lib/quickcomposer"); +// 注册 AI Agent 可调用的 MCP 工具(声明在 plugin.json 的 tools 字段里) +require("./lib/quickcommandMcp"); window.showUb = require("./lib/showDocs"); window.getQuickcommandTempFile = require("./lib/getQuickcommandFile").getQuickcommandTempFile; diff --git a/quasar.config.js b/quasar.config.js index 5385d16..45fffeb 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -55,6 +55,17 @@ module.exports = configure(function (ctx) { build: { vueRouterMode: "hash", // available values: 'hash', 'history' + // 产物里的 development 入口指向 quasar dev 的 8080,uTools 开发者工具加载该目录时会优先连它, + // 没启动 dev server 就是白屏,所以打包完直接去掉 + afterBuild: async ({ quasarConf }) => { + const fs = require("fs"); + const pluginJson = path.join(quasarConf.build.distDir, "plugin.json"); + if (!fs.existsSync(pluginJson)) return; + const pluginConfig = JSON.parse(fs.readFileSync(pluginJson, "utf8")); + delete pluginConfig.development; + fs.writeFileSync(pluginJson, JSON.stringify(pluginConfig, null, 2)); + }, + // transpile: false, // publicPath: '/', diff --git a/src/App.vue b/src/App.vue index 6359b9e..b7fb0f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -38,6 +38,15 @@ export default defineComponent({ }; }, created: function () { + // 给 preload 里的 MCP 工具用的桥接(运行命令需要界面侧的命令运行器) + window.qcBridge = { + runCommand: (code, input, timeout) => + this.runCommand(code, input, timeout), + }; + // Agent 改了命令后刷新列表 + window.addEventListener("qc:mcp-changed", () => { + this.commandManager.getAllQuickCommands(); + }); this.init(); }, methods: { diff --git a/src/components/ai/AIConfig.vue b/src/components/ai/AIConfig.vue index a0203d0..5705e2a 100644 --- a/src/components/ai/AIConfig.vue +++ b/src/components/ai/AIConfig.vue @@ -161,7 +161,26 @@ -
+ +
+
+ + AI Agent(MCP) + + 允许 Agent 查询命令列表和内容 + + + 允许 Agent 新建、修改、删除、启用/禁用命令 + + + 允许 Agent 直接运行命令,建议只在需要时开 + +
+
+ 开启后,外部 AI Agent 可以通过 uTools 的本地 MCP 服务增删改查快捷命令(需先在 uTools 设置 → MCP 服务里开启本地服务,并把地址写进 Agent 的配置) +
+
+
diff --git a/test/commandStore.test.js b/test/commandStore.test.js new file mode 100644 index 0000000..51c5444 --- /dev/null +++ b/test/commandStore.test.js @@ -0,0 +1,106 @@ +/** + * commandStore 的自检脚本(不依赖 uTools,用假 db 跑一遍增删改查) + * 运行:node test/commandStore.test.js + */ +const assert = require("assert"); + +const db = new Map(); +const features = new Map(); + +global.window = { + lodashM: { cloneDeep: (x) => JSON.parse(JSON.stringify(x)) }, + dispatchEvent: () => true, + utools: { + db: { + get: (id) => (db.has(id) ? { _id: id, _rev: "1-x", data: db.get(id) } : undefined), + put: (doc) => (db.set(doc._id, doc.data), { ok: true }), + remove: (id) => (db.delete(id), { ok: true }), + allDocs: (prefix) => + [...db.entries()] + .filter(([id]) => id.startsWith(prefix)) + .map(([id, data]) => ({ _id: id, data })), + }, + getFeatures: () => [...features.values()], + setFeature: (feature) => (features.set(feature.code, feature), true), + removeFeature: (code) => features.delete(code), + }, +}; +global.localStorage = { length: 0, key: () => null, removeItem: () => {} }; + +const store = require("../plugin/lib/commandStore.js"); + +const draft = { + program: "quickcommand", + cmd: "console.log(1)", + features: { explain: "测试命令", cmds: ["测试命令"] }, + output: "text", + tags: ["测试"], +}; + +// 建 +const created = store.create(draft); +assert.deepStrictEqual(created.errors, undefined); +const command = created.command; +const code = command.features.code; +assert.match(code, /^key_[0-9a-f]{8}$/); +assert.strictEqual(command.features.cmds[0], "测试命令"); +assert.strictEqual(command.features.mainHide, false); +assert.ok(db.has("qc_" + code)); +assert.ok(features.has(code)); + +// 参数不合法时不落库 +const before = db.size; +assert.ok(store.create({ ...draft, program: "python3" }).errors.length); +assert.ok(store.create({ ...draft, features: { explain: "", cmds: [] } }).errors.length); +assert.strictEqual(db.size, before); + +// 查 +assert.strictEqual(store.list({}).total, 1); +assert.strictEqual(store.list({ tag: "测试" }).total, 1); +assert.strictEqual(store.list({ tag: "不存在" }).total, 0); +assert.strictEqual(store.list({ keyword: "测试" }).total, 1); +assert.strictEqual(store.list({ enabled: true }).total, 1); +assert.strictEqual(store.list({ enabled: false }).total, 0); +assert.deepStrictEqual(store.listTags(), [{ tag: "测试", count: 1 }]); +assert.strictEqual(store.get(code).cmd, "console.log(1)"); + +// 改 +const updated = store.update(code, { + ...command, + cmd: "console.log(2)", + features: { ...command.features, explain: "改名了" }, +}); +assert.strictEqual(updated.command.features.code, code); +assert.strictEqual(updated.command.features.explain, "改名了"); +assert.strictEqual(db.get("qc_" + code).cmd, "console.log(2)"); +// 字符串形式的触发方式是关键词,不派生 label;对象形式的匹配规则 label 跟随 explain +assert.strictEqual(db.get("qc_" + code).features.cmds[0], "测试命令"); +const withRule = store.create({ + ...draft, + features: { explain: "正则命令", cmds: [{ type: "regex", match: "/^\\d+$/i" }] }, +}).command; +assert.strictEqual(withRule.features.cmds[0].label, "正则命令"); +assert.ok(store.update(code, { ...command, tags: "不是数组" }).errors.length); +assert.ok(store.update(code, { ...command, features: { ...command.features, icon: "x".repeat(40000) } }).errors.length); + +// 启用/禁用 +assert.deepStrictEqual(store.setEnabled([code], false), [{ code, enabled: false }]); +assert.ok(!features.has(code)); +assert.strictEqual(store.list({ enabled: false }).total, 1); +store.setEnabled([code], true); +assert.ok(features.has(code)); + +// 内置命令一律只读 +assert.throws(() => store.get("default_textReplacer"), /内置/); +assert.throws(() => store.remove("default_textReplacer"), /内置/); +assert.throws(() => store.update("default_textReplacer", draft), /内置/); +assert.notDeepStrictEqual(store.create({ ...draft, features: { ...draft.features, code: "default_x" } }).command.features.code, "default_x"); + +// 删 +const removed = store.remove(code); +assert.strictEqual(removed.features.code, code); +assert.ok(!db.has("qc_" + code)); +assert.ok(!features.has(code)); +assert.throws(() => store.get(code), /没有找到/); + +console.log("commandStore 自检通过"); diff --git a/test/mcp-smoke.mjs b/test/mcp-smoke.mjs new file mode 100644 index 0000000..1e2d57b --- /dev/null +++ b/test/mcp-smoke.mjs @@ -0,0 +1,153 @@ +/** + * uTools MCP 服务的端到端自检:打一遍 initialize / tools/list / 增删改查 + * + * 用法:node test/mcp-smoke.mjs http://127.0.0.1:3501/mcp + * 端口和 key 在 uTools 设置 → MCP 服务 里。 + */ +const [url, key] = process.argv.slice(2); +if (!url || !key) { + console.error("用法:node test/mcp-smoke.mjs "); + process.exit(1); +} + +let sessionId = null; +let nextId = 1; +let allTools = []; + +const call = async (method, params) => { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "x-mcp-key": key, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }), + }); + const newSession = res.headers.get("mcp-session-id"); + if (newSession) sessionId = newSession; + const text = await res.text(); + if (!res.ok) throw new Error(`${method} HTTP ${res.status}: ${text.slice(0, 200)}`); + const payload = text + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join(""); + const parsed = JSON.parse(payload || text); + if (parsed.error) throw new Error(`${method} 返回错误:${JSON.stringify(parsed.error)}`); + return parsed.result; +}; + +const callTool = async (bareName, args = {}) => { + // uTools 会给工具名加上插件前缀,tools/call 必须用完整名字 + const tool = allTools.find((t) => t.name === bareName || t.name.endsWith("_" + bareName)); + const result = await call("tools/call", { name: tool ? tool.name : bareName, arguments: args }); + const text = (result.content || []).map((c) => c.text || "").join(""); + try { + return JSON.parse(text); + } catch (e) { + return { raw: text, isError: result.isError }; + } +}; + +const check = (label, condition, detail) => { + console.log(`${condition ? "✅" : "❌"} ${label}${detail ? " — " + detail : ""}`); + if (!condition) process.exitCode = 1; +}; + +await call("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "quickcommand-smoke", version: "1" }, +}); +await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "x-mcp-key": key, + "mcp-session-id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), +}); +console.log("✅ initialize"); + +const { tools } = await call("tools/list"); +allTools = tools; +const ours = tools.map((t) => t.name).filter((n) => n.includes("qc_")); +check("tools/list 里有 quickcommand 的工具", ours.length > 0, ours.join(", ") || "一个都没有"); +if (!ours.length) { + console.log( + "提示:确认 uTools 的 MCP 设置里没有禁用 quickcommand,且插件已重新加载过 plugin.json" + ); + process.exit(1); +} + +const schema = await callTool("qc_get_schema"); +check("qc_get_schema", schema.ok === true && schema.schema.length > 500); + +const before = await callTool("qc_list_commands"); +check("qc_list_commands", before.ok === true, `当前 ${before.total} 条用户命令`); + +const created = await callTool("qc_create_command", { + command: { + program: "quickcommand", + cmd: "console.log('hello from mcp')", + features: { explain: "MCP 自检命令", cmds: ["mcp自检"] }, + output: "text", + tags: ["自检"], + }, +}); +check("qc_create_command", created.ok === true && /^key_/.test(created.code || ""), created.code); +if (!created.ok) { + console.log(JSON.stringify(created, null, 2)); + process.exit(1); +} +const code = created.code; + +// 故意写错,确认返回可读的校验错误 +const bad = await callTool("qc_create_command", { + command: { program: "python3", features: { explain: "", cmds: [{ type: "regexp" }] } }, +}); +check("参数不合法时返回 errors", bad.ok === false && Array.isArray(bad.errors), `${(bad.errors || []).length} 条`); + +const one = await callTool("qc_get_command", { code }); +check("qc_get_command", one.ok === true && one.command.cmd.includes("hello from mcp")); + +const listed = await callTool("qc_list_commands", { keyword: "自检" }); +check("qc_list_commands 能按关键词搜到", (listed.commands || []).some((c) => c.code === code)); + +const updated = await callTool("qc_update_command", { + code, + command: { ...one.command, cmd: "console.log('updated')" }, +}); +check("qc_update_command", updated.ok === true && updated.command.cmd.includes("updated")); + +const disabled = await callTool("qc_set_enabled", { codes: [code], enabled: false }); +const offList = await callTool("qc_list_commands", { enabled: false }); +check( + "qc_set_enabled 禁用后列表可见", + disabled.ok === true && offList.commands.some((c) => c.code === code) +); +await callTool("qc_set_enabled", { codes: [code], enabled: true }); + +const tags = await callTool("qc_list_tags"); +check("qc_list_tags", tags.ok === true && tags.tags.some((t) => t.tag === "自检")); + +const run = await callTool("qc_run_command", { code }); +// 运行档默认关:工具会被反注册,uTools 仍会列出它但调用报「未注册」 +const runBlocked = run.ok !== true; +check( + "qc_run_command 的运行权限按开关生效", + runBlocked ? /未注册|权限/.test(run.raw || run.error || "") : run.ok === true, + runBlocked ? "运行档关闭(默认),工具未注册" : JSON.stringify(run.output) +); + +const removed = await callTool("qc_delete_command", { code }); +check("qc_delete_command", removed.ok === true && removed.command.features.code === code); + +const builtin = await callTool("qc_delete_command", { code: "default_textReplacer" }); +check("内置命令拒写", builtin.ok === false, builtin.error); + +console.log(process.exitCode ? "有失败项" : "全部通过");