diff --git a/cli/src/cli/cocos-path.js b/cli/src/cli/cocos-path.js new file mode 100644 index 0000000..803cc03 --- /dev/null +++ b/cli/src/cli/cocos-path.js @@ -0,0 +1,100 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function uniqExisting(paths) { + const out = []; + const seen = new Set(); + paths.forEach(function (p) { + if (!p || seen.has(p)) return; + seen.add(p); + try { + if (fs.existsSync(p)) out.push(p); + } catch (e) { /* ignore */ } + }); + return out; +} + +function envCandidates(env) { + return [ + env.COCOS_CREATOR, + env.COCOS_CREATOR_PATH, + env.COCOS_DASHBOARD_CREATOR, + ]; +} + +function buildCocosExecPathCandidates(version, platform, env, homeDir) { + env = env || process.env; + const versions = version ? [version] : []; + const candidates = envCandidates(env); + + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA || path.win32.join(homeDir, 'AppData', 'Local'); + const programFiles = env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const roots = [ + env.COCOS_EDITOR_ROOT, + path.win32.join(localAppData, 'CocosCreator'), + path.win32.join(localAppData, 'Programs', 'CocosCreator'), + path.win32.join(programFiles, 'CocosCreator'), + path.win32.join(programFilesX86, 'CocosCreator'), + 'C:\\ProgramData\\cocos\\editors\\Creator', + 'C:\\cocos\\editors\\Creator', + 'D:\\cocos\\editors\\Creator', + 'H:\\cocos\\editors\\Creator', + ]; + versions.forEach(function (v) { + roots.forEach(function (root) { + if (!root) return; + candidates.push(path.win32.join(root, v, 'CocosCreator.exe')); + }); + candidates.push(path.win32.join('C:\\', 'CocosCreator_' + v, 'CocosCreator.exe')); + candidates.push(path.win32.join('D:\\', 'CocosCreator_' + v, 'CocosCreator.exe')); + candidates.push(path.win32.join('H:\\', 'CocosCreator_' + v, 'CocosCreator.exe')); + }); + } else if (platform === 'darwin') { + versions.forEach(function (v) { + candidates.push('/Applications/Cocos/Creator/' + v + '/CocosCreator.app/Contents/MacOS/CocosCreator'); + candidates.push('/Applications/CocosCreator/Creator/' + v + '/CocosCreator.app/Contents/MacOS/CocosCreator'); + candidates.push('/Applications/CocosCreator_' + v + '.app/Contents/MacOS/CocosCreator'); + candidates.push(path.posix.join(homeDir, 'Applications', 'Cocos', 'Creator', v, 'CocosCreator.app', 'Contents', 'MacOS', 'CocosCreator')); + }); + } else { + versions.forEach(function (v) { + candidates.push('/opt/Cocos/Creator/' + v + '/CocosCreator'); + candidates.push('/opt/cocos/creator/' + v + '/CocosCreator'); + candidates.push(path.posix.join(homeDir, 'Cocos', 'Creator', v, 'CocosCreator')); + }); + } + + return candidates; +} + +function resolveCocosExec(opts) { + const a = opts || {}; + if (a.cocos) { + const explicit = path.resolve(a.cocos); + if (!fs.existsSync(explicit)) { + throw new Error('--cocos path does not exist: ' + explicit); + } + return explicit; + } + + const found = uniqExisting(buildCocosExecPathCandidates(a.version, process.platform, process.env, os.homedir())); + if (found.length === 1) return found[0]; + if (found.length > 1) { + throw new Error('multiple Cocos Creator executables found, pass --cocos explicitly: ' + found.join(', ')); + } + if (a.version) { + throw new Error('Cocos Creator ' + a.version + ' not found. Pass --cocos .'); + } + throw new Error('missing Cocos Creator executable. Pass --cocos or --version .'); +} + +module.exports = { + buildCocosExecPathCandidates, + resolveCocosExec, + uniqExisting, +}; diff --git a/cli/src/cli/help.js b/cli/src/cli/help.js index 18c6ce7..34816f3 100644 --- a/cli/src/cli/help.js +++ b/cli/src/cli/help.js @@ -15,6 +15,8 @@ Usage: cocos-mcp-cli diff # 字段级 diff cocos-mcp-cli create-prefab [--name X] [--width W] [--height H] [--add-spine ] cocos-mcp-cli extract-prefab --node [--name X] [--dry-run] + cocos-mcp-cli open --version 3.8.8 + cocos-mcp-cli open --project --cocos Commands: query 只读查询,输出 JSON diff --git a/cli/src/cli/main.js b/cli/src/cli/main.js index 0763236..7ff317b 100644 --- a/cli/src/cli/main.js +++ b/cli/src/cli/main.js @@ -21,6 +21,7 @@ const { cmdCompactPrefab } = require('./compact-cmd.js'); const { cmdEnsureMeta } = require('./ensure-meta-cmd.js'); const { cmdBuild } = require('./build-cmd.js'); const { cmdFixMeta } = require('./fix-meta-cmd.js'); +const { cmdOpen } = require('./open-cmd.js'); function die(msg) { process.stderr.write('Error: ' + msg + '\n'); @@ -56,6 +57,8 @@ function main(argv) { cmdEnsureMeta(rest); } else if (cmd === 'build') { cmdBuild(rest); + } else if (cmd === 'open') { + cmdOpen(rest); } else if (cmd === 'fix-meta') { cmdFixMeta(rest); } else { diff --git a/cli/src/cli/open-cmd.js b/cli/src/cli/open-cmd.js new file mode 100644 index 0000000..adb6869 --- /dev/null +++ b/cli/src/cli/open-cmd.js @@ -0,0 +1,89 @@ +'use strict'; + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { resolveCocosExec } = require('./cocos-path.js'); + +function die(msg) { + process.stderr.write('Error: ' + msg + '\n'); + process.exit(1); +} + +function parseArgs(rest) { + const a = { project: '', cocos: '', version: '', dryRun: false, wait: false, noLogin: true }; + for (let i = 0; i < rest.length; i++) { + const k = rest[i]; + if (k === '--project' || k === '-p') a.project = rest[++i]; + else if (k === '--cocos' || k === '-c') a.cocos = rest[++i]; + else if (k === '--version' || k === '-v') a.version = rest[++i]; + else if (k === '--dry-run') a.dryRun = true; + else if (k === '--wait') a.wait = true; + else if (k === '--no-login' || k === '--nologin') a.noLogin = true; + else if (k === '--with-login') a.noLogin = false; + else if (!a.project && k[0] !== '-') a.project = k; + else die('unknown argument "' + k + '". See cocos-mcp-cli open --help'); + } + return a; +} + +function buildOpenArgs(projectPath, opts) { + const args = ['--project', projectPath]; + if (!opts || opts.noLogin !== false) args.push('--nologin'); + return args; +} + +function cmdOpen(rest) { + if (rest[0] === '--help' || rest[0] === '-h') { + process.stdout.write( + 'cocos-mcp-cli open - open a Cocos Creator project\n\n' + + 'Usage:\n' + + ' cocos-mcp-cli open --version 3.8.8\n' + + ' cocos-mcp-cli open --project --cocos \n\n' + + 'Options:\n' + + ' --project, -p Cocos project root. Positional is also accepted.\n' + + ' --version, -v Resolve Cocos Creator by version from common install paths.\n' + + ' --cocos, -c CocosCreator executable path. Takes precedence over --version.\n' + + ' --no-login Add Cocos --nologin. Default.\n' + + ' --with-login Do not add --nologin.\n' + + ' --wait Wait for the Cocos process to exit instead of detaching.\n' + + ' --dry-run Print the command without launching.\n' + ); + return; + } + + const a = parseArgs(rest); + if (!a.project) die('missing project path: pass or --project '); + a.project = path.resolve(a.project); + if (!fs.existsSync(a.project)) die('project path does not exist: ' + a.project); + if (!fs.existsSync(path.join(a.project, 'assets'))) die('not a Cocos project root, missing assets/: ' + a.project); + + let cocos; + try { + cocos = resolveCocosExec(a); + } catch (e) { + die(e.message); + } + + const argv = buildOpenArgs(a.project, a); + if (a.dryRun) { + process.stdout.write('[dry-run] ' + cocos + ' ' + argv.map(function (x) { + return /\s/.test(x) ? '"' + x + '"' : x; + }).join(' ') + '\n'); + return; + } + + process.stdout.write('Opening Cocos project:\n ' + a.project + '\n'); + const child = spawn(cocos, argv, { + stdio: a.wait ? 'inherit' : 'ignore', + detached: !a.wait, + }); + child.on('error', function (e) { die('failed to start Cocos Creator: ' + e.message); }); + if (a.wait) { + child.on('exit', function (code) { process.exit(code == null ? 1 : code); }); + } else { + child.unref(); + } +} + +module.exports = { cmdOpen, parseArgs, buildOpenArgs }; diff --git a/cli/test/cli.test.js b/cli/test/cli.test.js index c5cc5f7..839e045 100644 --- a/cli/test/cli.test.js +++ b/cli/test/cli.test.js @@ -190,3 +190,27 @@ test('CLI set active 非法 value: 非零退出', () => { const result = run(['set', FIXTURE, 'touchArea', 'active', 'maybe']); assert.notEqual(result.status, 0); }); +test('CLI open --dry-run: accepts positional project and builds --project command', () => { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'cocos-open-project-')); + fs.mkdirSync(path.join(project, 'assets')); + + const result = run(['open', project, '--cocos', process.execPath, '--dry-run']); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.match(result.stdout, /\[dry-run\]/); + assert.match(result.stdout, /--project/); + assert.match(result.stdout, /--nologin/); + assert.match(result.stdout, new RegExp(project.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'))); + + fs.rmSync(project, { recursive: true, force: true }); +}); + +test('CLI open --with-login: dry-run omits --nologin', () => { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'cocos-open-project-')); + fs.mkdirSync(path.join(project, 'assets')); + + const result = run(['open', '--project', project, '--cocos', process.execPath, '--with-login', '--dry-run']); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + assert.doesNotMatch(result.stdout, /--nologin/); + + fs.rmSync(project, { recursive: true, force: true }); +}); diff --git a/router/bin.js b/router/bin.js index c849777..01c220e 100755 --- a/router/bin.js +++ b/router/bin.js @@ -121,6 +121,16 @@ async function probeEditor(info) { } } +async function probeEditorResources(info) { + try { + var listRes = await httpJsonRpc(info.url, { jsonrpc: '2.0', id: 3, method: 'resources/list' }); + if (listRes.error) throw new Error(listRes.error.message); + return listRes.result.resources || []; + } catch (e) { + return []; + } +} + /** 去掉 shortName 里的非法字符,MCP tool 名只允许 [a-zA-Z0-9_-] */ function sanitizeShortName(name) { return String(name || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); @@ -136,6 +146,7 @@ async function discover() { if (editors.has(key)) continue; // 已知,不重复 probe var tools = await probeEditor(info); if (tools == null) continue; + var resources = await probeEditorResources(info); editors.set(key, { baseShortName: sanitizeShortName(info.projectShortName), shortName: sanitizeShortName(info.projectShortName), // dedupeShortNames 会按冲突重设 @@ -143,6 +154,7 @@ async function discover() { pid: info.pid, url: info.url, tools: tools, + resources: resources, lastProbed: Date.now(), }); logErr('discovered editor', info.projectShortName, 'pid=' + info.pid, info.url, tools.length + ' tools'); @@ -210,6 +222,38 @@ function buildAggregatedToolList() { return out; } +function encodeRouterResourceUri(ed, uri) { + return 'cocos-router://' + ed.shortName + '/' + encodeURIComponent(uri); +} + +function decodeRouterResourceUri(uri) { + var m = String(uri || '').match(/^cocos-router:\/\/([^\/]+)\/(.+)$/); + if (!m) return null; + return { shortName: m[1], uri: decodeURIComponent(m[2]) }; +} + +function buildAggregatedResourceList() { + var out = []; + for (var ed of editors.values()) { + (ed.resources || []).forEach(function (r) { + out.push({ + uri: encodeRouterResourceUri(ed, r.uri), + name: '[' + ed.shortName + '] ' + (r.name || r.uri), + description: r.description || '', + mimeType: r.mimeType || 'text/plain', + }); + }); + } + return out; +} + +function findEditorByShortName(shortName) { + for (var ed of editors.values()) { + if (ed.shortName === shortName) return ed; + } + return null; +} + function findEditorByPrefixedTool(prefixedName) { for (var ed of editors.values()) { var pfx = ed.shortName + '__'; @@ -260,6 +304,7 @@ async function handleMessage(msg) { serverInfo: ROUTER_INFO, capabilities: { tools: { listChanged: true }, + resources: {}, logging: {}, }, }; @@ -276,6 +321,13 @@ async function handleMessage(msg) { await discover(); result = { tools: buildAggregatedToolList() }; break; + case 'resources/list': + await discover(); + result = { resources: buildAggregatedResourceList() }; + break; + case 'resources/read': + result = await handleResourceRead(params.uri); + break; case 'tools/call': result = await handleToolCall(params.name, params.arguments || {}); break; @@ -337,6 +389,31 @@ async function handleToolCall(name, args) { } // ── 周期性重扫 ── +async function handleResourceRead(uri) { + var decoded = decodeRouterResourceUri(uri); + if (!decoded) { + return { contents: [{ type: 'text', text: 'unknown router resource uri: ' + uri, mimeType: 'text/plain' }] }; + } + await discover(); + var ed = findEditorByShortName(decoded.shortName); + if (!ed) { + return { contents: [{ type: 'text', text: 'editor not found for resource: ' + decoded.shortName, mimeType: 'text/plain' }] }; + } + try { + var forward = await httpJsonRpc(ed.url, { + jsonrpc: '2.0', id: Date.now(), method: 'resources/read', + params: { uri: decoded.uri }, + }); + if (forward.error) { + return { contents: [{ type: 'text', text: 'editor error: ' + forward.error.message, mimeType: 'text/plain' }] }; + } + return forward.result; + } catch (e) { + editors.delete(ed.url); + return { contents: [{ type: 'text', text: 'forward failed: ' + e.message, mimeType: 'text/plain' }] }; + } +} + setInterval(function () { discover().catch(function () {}); }, DISCOVERY_INTERVAL_MS); // 启动首次发现 diff --git a/server/tools.js b/server/tools.js index b63b56e..afb0e75 100644 --- a/server/tools.js +++ b/server/tools.js @@ -116,10 +116,11 @@ function defineTools(ctx) { }, }, handler: async function (args) { - return await msg('asset-db', 'query-assets', { + var assets = await msg('asset-db', 'query-assets', { pattern: args.pattern, ccType: args.ccType, }); + return { assets: assets || [] }; }, }, {