fix: Windows spawn, prefab path, preview API, asset deps, error messages; add execute-context resource, tool descriptions, scene/script/asset tools

Bug fixes (verified against Cocos Creator 3.8.8):
- diagnostics: shell:true for .cmd/.bat on Windows (T125/T126)
- prefabs: duplicatePrefab target resolves under assets/ (T420)
- cocos-project: add preview.open candidate for 3.8.8 (T444)
- assets-advanced: detect directory assets in inspectAssetDependencies (T110)
- scene: improve component-not-found errors with compilation hint (T429)

Documentation improvements:
- tool-registry: add enum and injected vars to execute_javascript, path format examples, asset ref limitation note
- resources: add cocos://mcp/execute-context resource with variables, patterns, pitfalls

New tools (core 37->38, full 101->110):
- scene-management: create_scene, query_scene_state (core), copy_paste_node, rename_node, reparent_node
- scripts: create_script with component/plain templates
- prefabs: create_prefab
- assets-advanced: batch_asset_ops, find_unused_assets
This commit is contained in:
mingyuansi
2026-06-30 21:53:06 +08:00
parent a9b539ca9c
commit b65a4f22c3
21 changed files with 5273 additions and 53 deletions
+182
View File
@@ -97,8 +97,12 @@ function collectUuidReferences(content) {
async function inspectAssetDependencies(projectPath, target, options = {}) {
const info = await queryAssetInfo(target);
const isDirectory = info && (info.isDirectory || info.type === 'folder' || (info.url && !path.extname(info.url)));
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
if (isDirectory) {
throw new Error(`Cannot inspect dependencies of a directory asset: ${target}. Please specify a file asset (e.g. a .prefab, .scene, or .mat file).`);
}
throw new Error(`Asset file was not found: ${target}`);
}
@@ -210,12 +214,190 @@ function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) {
return await validateAssetDependencies(projectPath, args);
},
},
{
name: 'batch_asset_ops',
profile: 'full',
description: '[core] Batch import or delete assets in the Cocos asset database.',
inputSchema: createSchema(
{
action: {
type: 'string',
enum: ['import', 'delete'],
description: 'Batch operation.',
},
sourceDirectory: {
type: 'string',
description: 'Local filesystem source path (import only).',
},
targetDirectory: {
type: 'string',
description: 'Asset-db target URL, e.g. db://assets/textures (import only).',
},
urls: {
type: 'array',
items: { type: 'string' },
description: 'Asset URLs to delete (delete only).',
},
},
['action']
),
handler: async (args) => {
const action = String(args.action || '').trim();
if (action === 'import') {
const sourceDirectory = String(args.sourceDirectory || '').trim();
const targetDirectory = String(args.targetDirectory || '').trim();
if (!sourceDirectory) {
throw new Error('sourceDirectory is required for action: import.');
}
if (!targetDirectory) {
throw new Error('targetDirectory is required for action: import.');
}
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
}
const result = await Editor.Message.request('asset-db', 'import-asset', sourceDirectory, targetDirectory);
return { action, sourceDirectory, targetDirectory, result };
}
if (action === 'delete') {
const urls = Array.isArray(args.urls) ? args.urls.filter(Boolean) : [];
if (urls.length === 0) {
throw new Error('urls is required for action: delete.');
}
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
}
const results = [];
for (const url of urls) {
try {
await Editor.Message.request('asset-db', 'delete-asset', url);
results.push({ url, deleted: true });
} catch (error) {
results.push({ url, deleted: false, error: error.message });
}
}
return { action, urls, results };
}
throw new Error(`Unknown action: ${action}. Must be one of: import, delete.`);
},
},
{
name: 'find_unused_assets',
profile: 'full',
description: '[core] Find assets not referenced by any scene, prefab, or animation in the project.',
inputSchema: createSchema(
{
directory: {
type: 'string',
description: 'Asset-db directory to scan. Default: db://assets.',
},
excludeDirectories: {
type: 'array',
items: { type: 'string' },
description: 'Directories to exclude from the reference scan.',
},
assetTypes: {
type: 'array',
items: { type: 'string' },
description: 'Filter by Cocos asset type, e.g. ["cc.Texture2D", "cc.AudioClip"].',
},
limit: { type: 'number', description: 'Max results. Default 200.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await findUnusedAssets(projectPath, args);
},
},
];
}
const SERIALIZED_EXTENSIONS = ['.scene', '.prefab', '.anim', '.mat'];
const SKIP_UNUSED_EXTENSIONS = ['.ts', '.scene'];
async function findUnusedAssets(projectPath, options = {}, listAssetsFn) {
const listAssetsRef = typeof listAssetsFn === 'function' ? listAssetsFn : listAssets;
const directory = options.directory || 'db://assets';
const excludeDirs = Array.isArray(options.excludeDirectories) ? options.excludeDirectories : [];
const assetTypes = Array.isArray(options.assetTypes) ? options.assetTypes : undefined;
const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 200;
const allAssets = await listAssetsRef({
pattern: directory + '/**',
ccType: assetTypes,
});
const referencedUuids = new Set();
const assetsDir = path.join(projectPath, 'assets');
function scanDir(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (_) {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'temp' || entry.name === 'library') {
continue;
}
const relativePath = path.relative(assetsDir, fullPath).replace(/\\/g, '/');
if (excludeDirs.some((excl) => relativePath.startsWith(String(excl)))) {
continue;
}
scanDir(fullPath);
} else {
const ext = path.extname(entry.name).toLowerCase();
if (!SERIALIZED_EXTENSIONS.includes(ext)) {
continue;
}
try {
const content = fs.readFileSync(fullPath, 'utf8');
const refs = collectUuidReferences(content);
for (const ref of refs) {
referencedUuids.add(ref.uuid);
}
} catch (_) {
// Skip unreadable files
}
}
}
}
scanDir(assetsDir);
const unused = allAssets.filter((asset) => {
if (!asset || !asset.uuid) {
return false;
}
if (asset.isDirectory) {
return false;
}
const url = asset.url || asset.source || '';
const ext = path.extname(url).toLowerCase();
if (SKIP_UNUSED_EXTENSIONS.includes(ext)) {
return false;
}
return !referencedUuids.has(asset.uuid);
});
return {
totalScanned: allAssets.length,
unusedCount: unused.length,
referencedCount: referencedUuids.size,
unused: unused.slice(0, limit),
};
}
module.exports = {
collectUuidReferences,
createAssetsAdvancedTools,
findUnusedAssets,
inspectAssetDependencies,
validateAssetDependencies,
};
+1
View File
@@ -174,6 +174,7 @@ function createCocosProjectTools({ createSchema }) {
[]
),
handler: async (args) => await tryEditorRequests([
{ channel: 'preview', method: 'open', args: [args || {}] },
{ channel: 'preview', method: 'start', args: [args || {}] },
{ channel: 'preview', method: 'open-preview', args: [args || {}] },
{ channel: 'builder', method: 'preview', args: [args || {}] },
+244
View File
@@ -0,0 +1,244 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { tryEditorRequests } = require('./cocos-project');
const { openAsset } = require('../assets');
const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'resources', 'template.scene');
function buildSceneContent(sceneName, templatePath) {
const tplPath = templatePath || TEMPLATE_PATH;
let content;
try {
content = fs.readFileSync(tplPath, 'utf8');
} catch (err) {
throw new Error(`Failed to read scene template at ${tplPath}: ${err.message}`);
}
const json = JSON.parse(content);
if (json[0] && json[0]._name !== undefined) {
json[0]._name = sceneName;
}
if (json[1] && json[1]._name !== undefined) {
json[1]._name = sceneName;
}
return JSON.stringify(json);
}
function createSceneManagementTools({ createSchema, sceneBridge }) {
return [
{
name: 'create_scene',
profile: 'full',
description: '[core] Create a new scene asset in the Cocos project and optionally open it.',
inputSchema: createSchema(
{
sceneName: { type: 'string', description: 'Name of the new scene (without extension).' },
savePath: { type: 'string', description: 'Asset-db URL, e.g. db://assets/scenes/NewScene.scene.' },
open: { type: 'boolean', description: 'Whether to open the scene after creation. Default true.' },
overwrite: { type: 'boolean', description: 'Overwrite if the asset already exists. Default false.' },
},
['sceneName', 'savePath']
),
handler: async (args) => {
const sceneName = String(args.sceneName || '').trim();
const savePath = String(args.savePath || '').trim();
if (!sceneName) {
throw new Error('sceneName is required.');
}
if (!savePath) {
throw new Error('savePath is required.');
}
if (!savePath.endsWith('.scene')) {
throw new Error('savePath must end with .scene');
}
const content = buildSceneContent(sceneName);
const createArgs = [savePath, content];
if (args.overwrite === true) {
createArgs.push({ overwrite: true });
}
const result = await tryEditorRequests([
{ channel: 'asset-db', method: 'create-asset', args: createArgs },
]);
const url = (result.result && (result.result.url || result.result.source)) || savePath;
const uuid = result.result && result.result.uuid;
if (args.open !== false) {
try {
await openAsset(url);
} catch (_) {
// Opening is best-effort; creation succeeded.
}
}
return {
created: true,
sceneName,
url,
uuid,
opened: args.open !== false,
};
},
},
{
name: 'query_scene_state',
profile: 'core',
description: '[specialist] Query scene state: dirty (unsaved changes), ready, or soft-reload.',
inputSchema: createSchema(
{
action: {
type: 'string',
enum: ['is_dirty', 'is_ready', 'soft_reload'],
description: 'State query or action.',
},
},
['action']
),
handler: async (args) => {
const action = String(args.action || '').trim();
const IPC_MAP = {
is_dirty: [
{ channel: 'scene', method: 'query-dirty' },
],
is_ready: [
{ channel: 'scene', method: 'query-is-ready' },
],
soft_reload: [
{ channel: 'scene', method: 'soft-reload' },
],
};
const candidates = IPC_MAP[action];
if (!candidates) {
throw new Error(`Unknown action: ${action}. Must be one of: is_dirty, is_ready, soft_reload.`);
}
const result = await tryEditorRequests(candidates);
return {
action,
result: result.result,
};
},
},
{
name: 'copy_paste_node',
profile: 'full',
description: '[core] Copy, cut, or paste scene nodes via the Cocos editor clipboard.',
inputSchema: createSchema(
{
action: {
type: 'string',
enum: ['copy', 'cut', 'paste', 'duplicate'],
description: 'Clipboard or duplication action.',
},
uuids: {
type: 'array',
items: { type: 'string' },
description: 'Node UUIDs to copy, cut, or duplicate.',
},
target: { type: 'string', description: 'Target parent node UUID for paste.' },
keepWorldTransform: {
type: 'boolean',
description: 'Keep world transform on paste. Default false.',
},
},
['action']
),
handler: async (args) => {
const action = String(args.action || '').trim();
if (action === 'copy' || action === 'cut') {
const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : [];
if (uuids.length === 0) {
throw new Error(`uuids is required for action: ${action}.`);
}
const method = action === 'copy' ? 'copy-node' : 'cut-node';
const result = await tryEditorRequests([
{ channel: 'scene', method, args: [uuids] },
]);
return { action, uuids, result: result.result };
}
if (action === 'duplicate') {
const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : [];
if (uuids.length === 0) {
throw new Error('uuids is required for action: duplicate.');
}
const result = await tryEditorRequests([
{ channel: 'scene', method: 'duplicate-node', args: [uuids] },
]);
return { action, uuids, result: result.result };
}
if (action === 'paste') {
const target = String(args.target || '').trim();
if (!target) {
throw new Error('target is required for action: paste.');
}
const keepWorldTransform = args.keepWorldTransform === true;
const pasteOpts = { target, keepWorldTransform };
const uuids = Array.isArray(args.uuids) ? args.uuids.filter(Boolean) : [];
if (uuids.length > 0) {
pasteOpts.uuids = uuids;
}
const result = await tryEditorRequests([
{
channel: 'scene',
method: 'paste-node',
args: [pasteOpts],
},
]);
return { action, target, keepWorldTransform, result: result.result };
}
throw new Error(`Unknown action: ${action}. Must be one of: copy, cut, paste, duplicate.`);
},
},
{
name: 'rename_node',
profile: 'full',
description: '[core] Rename a scene node.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path.' },
uuid: { type: 'string', description: 'Node UUID.' },
name: { type: 'string', description: 'Fallback exact node name.' },
newName: { type: 'string', description: 'New name for the node.' },
},
['newName']
),
handler: async (args) => sceneBridge.call('renameNode', args),
},
{
name: 'reparent_node',
profile: 'full',
description: '[core] Move a node to a new parent in the scene hierarchy.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Node hierarchy path to move.' },
uuid: { type: 'string', description: 'Node UUID to move.' },
name: { type: 'string', description: 'Fallback exact node name.' },
targetPath: { type: 'string', description: 'New parent node path.' },
targetUuid: { type: 'string', description: 'New parent node UUID.' },
targetName: { type: 'string', description: 'Fallback exact new parent name.' },
siblingIndex: {
type: 'number',
description: 'Optional insert position among siblings. Default: append.',
},
keepWorldTransform: {
type: 'boolean',
description: 'Keep world transform after reparenting. Default false.',
},
},
[]
),
handler: async (args) => sceneBridge.call('reparentNode', args),
},
];
}
module.exports = {
buildSceneContent,
createSceneManagementTools,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { resolveProjectPath } = require('../path-safety');
const { refreshAssets } = require('./files');
const PRIMITIVE_TYPES = new Set(['Number', 'String', 'Boolean']);
function generateComponentTemplate(className, properties = []) {
const ccTypes = new Set(['Component']);
const propLines = [];
for (const prop of properties) {
const propName = String(prop.name || 'property').trim();
const propType = String(prop.type || 'Number').trim();
const defaultValue = prop.default !== undefined ? prop.default : getDefaultForType(propType);
if (!PRIMITIVE_TYPES.has(propType)) {
ccTypes.add(propType);
}
if (PRIMITIVE_TYPES.has(propType)) {
propLines.push(` @property`);
propLines.push(` ${propName}: ${typeToTs(propType)} = ${defaultValue};`);
} else {
propLines.push(` @property(${propType})`);
propLines.push(` ${propName}: ${propType} | null = null;`);
}
}
const importTypes = Array.from(ccTypes).join(', ');
const propsBlock = propLines.length > 0
? propLines.join('\n')
: ' // Add properties here';
return `import { _decorator, ${importTypes} } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('${className}')
export class ${className} extends Component {
${propsBlock}
start() {
}
update(deltaTime: number) {
}
}
`;
}
function generatePlainTemplate(className) {
return `export class ${className} {
}
`;
}
function getDefaultForType(type) {
switch (type) {
case 'Number': return '0';
case 'String': return "''";
case 'Boolean': return 'false';
default: return 'null';
}
}
function typeToTs(type) {
switch (type) {
case 'Number': return 'number';
case 'String': return 'string';
case 'Boolean': return 'boolean';
default: return type;
}
}
function createScriptTools({ createSchema, getRuntimeContext }) {
return [
{
name: 'create_script',
profile: 'full',
description: '[core] Create a new TypeScript component script with a standard Cocos template.',
inputSchema: createSchema(
{
scriptName: { type: 'string', description: 'Class name in PascalCase, e.g. PlayerController.' },
savePath: { type: 'string', description: 'Asset-db URL or project path, e.g. db://assets/scripts/PlayerController.ts.' },
template: {
type: 'string',
enum: ['component', 'plain'],
description: 'Script template. Default: component.',
},
properties: {
type: 'array',
items: { type: 'object' },
description: 'Optional @property declarations. Each item: { name, type, default }. Type can be Number, String, Boolean, or a CC class name like Label, Sprite, Node.',
},
},
['scriptName', 'savePath']
),
handler: async (args) => {
const scriptName = String(args.scriptName || '').trim();
let savePath = String(args.savePath || '').trim();
if (!scriptName) {
throw new Error('scriptName is required.');
}
if (!savePath) {
throw new Error('savePath is required.');
}
if (!savePath.endsWith('.ts')) {
throw new Error('savePath must end with .ts');
}
if (savePath.startsWith('db://')) {
savePath = savePath.slice('db://'.length);
}
const templateType = args.template === 'plain' ? 'plain' : 'component';
const properties = Array.isArray(args.properties) ? args.properties : [];
const content = templateType === 'plain'
? generatePlainTemplate(scriptName)
: generateComponentTemplate(scriptName, properties);
const { projectPath } = getRuntimeContext();
const fullPath = resolveProjectPath(projectPath, savePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const refreshMessage = await refreshAssets(projectPath, fullPath);
return {
created: true,
scriptName,
savePath,
template: templateType,
className: scriptName,
contentLength: content.length,
refreshMessage,
};
},
},
];
}
module.exports = {
createScriptTools,
generateComponentTemplate,
generatePlainTemplate,
};