@beremaran/godot-agent-loop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { copyFileSync, cpSync, existsSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
const DEFAULT_AUTOLOAD_NAME = 'McpInteractionServer';
|
|
4
|
+
const DESTINATION_SCRIPT_NAME = 'mcp_interaction_server.gd';
|
|
5
|
+
/** Domain scripts the server preloads from res://; a missing copy fails the autoload at parse time. */
|
|
6
|
+
const RUNTIME_DIR_NAME = 'mcp_runtime';
|
|
7
|
+
/**
|
|
8
|
+
* The engine merges override.cfg over project.godot at startup, so the autoload
|
|
9
|
+
* is declared in a file MCP creates and deletes and never in a file the user
|
|
10
|
+
* tracks. Proven against Godot 4.7 in the Phase 6a spike (see TODO.md).
|
|
11
|
+
*/
|
|
12
|
+
const OVERRIDE_FILE_NAME = 'override.cfg';
|
|
13
|
+
const BLOCK_BEGIN = '; godot-agent-loop: begin interaction server (generated; removed automatically)';
|
|
14
|
+
const BLOCK_END = '; godot-agent-loop: end interaction server';
|
|
15
|
+
const LEGACY_BLOCK_BEGIN = '; godot-mcp: begin interaction server (generated; removed automatically)';
|
|
16
|
+
const LEGACY_BLOCK_END = '; godot-mcp: end interaction server';
|
|
17
|
+
/** Owns the filesystem changes needed to expose the MCP interaction server to a project. */
|
|
18
|
+
export class InteractionServerInstaller {
|
|
19
|
+
options;
|
|
20
|
+
autoloadName;
|
|
21
|
+
logDebug;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.autoloadName = options.autoloadName ?? DEFAULT_AUTOLOAD_NAME;
|
|
25
|
+
this.logDebug = options.logDebug ?? (() => undefined);
|
|
26
|
+
}
|
|
27
|
+
/** Installs the server and returns whether MCP owns the resulting autoload entry. */
|
|
28
|
+
install(projectPath) {
|
|
29
|
+
// A crashed or SIGKILLed earlier server had no chance to clean up; start
|
|
30
|
+
// from a truthful state before deciding who owns the installation.
|
|
31
|
+
this.reapStaleInstallation(projectPath);
|
|
32
|
+
const projectFile = join(projectPath, 'project.godot');
|
|
33
|
+
const destinationScript = join(projectPath, DESTINATION_SCRIPT_NAME);
|
|
34
|
+
const existingContent = readFileSync(projectFile, 'utf8');
|
|
35
|
+
if (existingContent.includes(this.autoloadName)) {
|
|
36
|
+
if (!existsSync(destinationScript)) {
|
|
37
|
+
copyFileSync(this.options.sourceScriptPath, destinationScript);
|
|
38
|
+
this.logDebug(`Autoload present but script missing; restored ${destinationScript}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.logDebug('Interaction server autoload and script already present; leaving project untouched');
|
|
42
|
+
}
|
|
43
|
+
// The script is only useful with its domain scripts, so keep them in sync even
|
|
44
|
+
// when an existing installation is left in place.
|
|
45
|
+
this.syncRuntimeDir(projectPath);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
copyFileSync(this.options.sourceScriptPath, destinationScript);
|
|
49
|
+
this.syncRuntimeDir(projectPath);
|
|
50
|
+
this.logDebug(`Copied interaction server script to ${destinationScript}`);
|
|
51
|
+
this.appendOverrideBlock(projectPath);
|
|
52
|
+
this.logDebug(`Declared ${this.autoloadName} autoload in ${OVERRIDE_FILE_NAME}; project.godot is untouched`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/** Removes an MCP-owned installation. User-managed installations are left untouched. */
|
|
56
|
+
remove(projectPath, ownedByMcp) {
|
|
57
|
+
if (!ownedByMcp) {
|
|
58
|
+
this.logDebug('Interaction server was user-managed; skipping cleanup');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.stripOverrideBlock(projectPath)) {
|
|
62
|
+
this.logDebug(`Removed interaction server autoload from ${OVERRIDE_FILE_NAME}`);
|
|
63
|
+
}
|
|
64
|
+
this.removeInstalledArtifacts(projectPath);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Stateless cleanup of artifacts an earlier crashed or SIGKILLed server left
|
|
68
|
+
* behind. Ownership is re-derived from the artifacts themselves: a sentinel
|
|
69
|
+
* block in override.cfg is proof MCP wrote the installation, and a server
|
|
70
|
+
* script byte-identical to the shipped source covers a crash that happened
|
|
71
|
+
* before the override block was written. A user-managed installation — the
|
|
72
|
+
* autoload declared in project.godot — never loses its scripts.
|
|
73
|
+
*
|
|
74
|
+
* Two live MCP servers pointed at the same project cannot be distinguished
|
|
75
|
+
* from a crash by looking at the filesystem; concurrent same-project servers
|
|
76
|
+
* are unsupported (they would collide on the runtime port anyway).
|
|
77
|
+
*
|
|
78
|
+
* Returns the artifacts that were removed.
|
|
79
|
+
*/
|
|
80
|
+
reapStaleInstallation(projectPath) {
|
|
81
|
+
const reaped = [];
|
|
82
|
+
const projectFile = join(projectPath, 'project.godot');
|
|
83
|
+
const userManaged = existsSync(projectFile) && readFileSync(projectFile, 'utf8').includes(this.autoloadName);
|
|
84
|
+
const hadOverrideBlock = this.stripOverrideBlock(projectPath);
|
|
85
|
+
if (hadOverrideBlock)
|
|
86
|
+
reaped.push(join(projectPath, OVERRIDE_FILE_NAME));
|
|
87
|
+
if (!userManaged) {
|
|
88
|
+
const destinationScript = join(projectPath, DESTINATION_SCRIPT_NAME);
|
|
89
|
+
const scriptIsOurs = existsSync(destinationScript)
|
|
90
|
+
&& (hadOverrideBlock || this.fileMatchesShippedSource(destinationScript));
|
|
91
|
+
if (scriptIsOurs) {
|
|
92
|
+
reaped.push(...this.removeInstalledArtifacts(projectPath));
|
|
93
|
+
}
|
|
94
|
+
else if (!existsSync(destinationScript) && this.runtimeDirLooksOwned(projectPath)) {
|
|
95
|
+
// A crash between deleting the script and deleting the domain scripts
|
|
96
|
+
// leaves an orphan mcp_runtime directory that is provably ours.
|
|
97
|
+
rmSync(join(projectPath, RUNTIME_DIR_NAME), { recursive: true, force: true });
|
|
98
|
+
reaped.push(join(projectPath, RUNTIME_DIR_NAME));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (reaped.length > 0) {
|
|
102
|
+
this.logDebug(`Reaped stale interaction server artifacts: ${reaped.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
return reaped;
|
|
105
|
+
}
|
|
106
|
+
/** Deletes the script, its .uid sidecar, and the domain scripts; returns what existed. */
|
|
107
|
+
removeInstalledArtifacts(projectPath) {
|
|
108
|
+
const removed = [];
|
|
109
|
+
const destinationScript = join(projectPath, DESTINATION_SCRIPT_NAME);
|
|
110
|
+
if (existsSync(destinationScript)) {
|
|
111
|
+
unlinkSync(destinationScript);
|
|
112
|
+
removed.push(destinationScript);
|
|
113
|
+
this.logDebug('Deleted interaction server script from project');
|
|
114
|
+
}
|
|
115
|
+
const uidFile = `${destinationScript}.uid`;
|
|
116
|
+
if (existsSync(uidFile)) {
|
|
117
|
+
unlinkSync(uidFile);
|
|
118
|
+
removed.push(uidFile);
|
|
119
|
+
this.logDebug('Deleted interaction server .uid file');
|
|
120
|
+
}
|
|
121
|
+
const runtimeDir = join(projectPath, RUNTIME_DIR_NAME);
|
|
122
|
+
if (existsSync(runtimeDir)) {
|
|
123
|
+
// Removes the domain scripts and any .uid files Godot generated beside them.
|
|
124
|
+
rmSync(runtimeDir, { recursive: true, force: true });
|
|
125
|
+
removed.push(runtimeDir);
|
|
126
|
+
this.logDebug('Deleted interaction server domain scripts from project');
|
|
127
|
+
}
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
130
|
+
/** Appends the sentinel-delimited autoload block, preserving any user-owned override.cfg content. */
|
|
131
|
+
appendOverrideBlock(projectPath) {
|
|
132
|
+
const overrideFile = join(projectPath, OVERRIDE_FILE_NAME);
|
|
133
|
+
const block = [
|
|
134
|
+
BLOCK_BEGIN,
|
|
135
|
+
'[autoload]',
|
|
136
|
+
`${this.autoloadName}="*res://${DESTINATION_SCRIPT_NAME}"`,
|
|
137
|
+
BLOCK_END,
|
|
138
|
+
'',
|
|
139
|
+
].join('\n');
|
|
140
|
+
if (!existsSync(overrideFile)) {
|
|
141
|
+
writeFileSync(overrideFile, block, 'utf8');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const current = readFileSync(overrideFile, 'utf8');
|
|
145
|
+
// If the user file lacks a trailing newline one is added and survives
|
|
146
|
+
// removal; a benign normalization, and the only byte the strip cannot
|
|
147
|
+
// attribute. MCP-created files round-trip byte-identically.
|
|
148
|
+
writeFileSync(overrideFile, current.endsWith('\n') || current === '' ? current + block : `${current}\n${block}`, 'utf8');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Removes the sentinel block from override.cfg. Deletes the file when
|
|
152
|
+
* nothing else is in it, so a project that had no override.cfg gets none
|
|
153
|
+
* back. Returns whether a block was found.
|
|
154
|
+
*/
|
|
155
|
+
stripOverrideBlock(projectPath) {
|
|
156
|
+
const overrideFile = join(projectPath, OVERRIDE_FILE_NAME);
|
|
157
|
+
if (!existsSync(overrideFile))
|
|
158
|
+
return false;
|
|
159
|
+
const content = readFileSync(overrideFile, 'utf8');
|
|
160
|
+
const currentBegin = content.indexOf(BLOCK_BEGIN);
|
|
161
|
+
const legacyBegin = content.indexOf(LEGACY_BLOCK_BEGIN);
|
|
162
|
+
const begin = currentBegin >= 0 ? currentBegin : legacyBegin;
|
|
163
|
+
if (begin < 0)
|
|
164
|
+
return false;
|
|
165
|
+
const endToken = currentBegin >= 0 ? BLOCK_END : LEGACY_BLOCK_END;
|
|
166
|
+
const endMarker = content.indexOf(endToken, begin);
|
|
167
|
+
const end = endMarker < 0 ? content.length : Math.min(endMarker + endToken.length + 1, content.length);
|
|
168
|
+
const stripped = content.slice(0, begin) + content.slice(end);
|
|
169
|
+
if (stripped.trim() === '') {
|
|
170
|
+
unlinkSync(overrideFile);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
writeFileSync(overrideFile, stripped, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
/** Whether the installed server script is byte-identical to the shipped source. */
|
|
178
|
+
fileMatchesShippedSource(installedPath) {
|
|
179
|
+
try {
|
|
180
|
+
return readFileSync(installedPath).equals(readFileSync(this.options.sourceScriptPath));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether every file in the project's mcp_runtime directory is either a
|
|
188
|
+
* Godot-generated .uid sidecar or byte-identical to the shipped domain
|
|
189
|
+
* script of the same name. Anything else means the directory is not ours.
|
|
190
|
+
*/
|
|
191
|
+
runtimeDirLooksOwned(projectPath) {
|
|
192
|
+
const installedDir = join(projectPath, RUNTIME_DIR_NAME);
|
|
193
|
+
if (!existsSync(installedDir))
|
|
194
|
+
return false;
|
|
195
|
+
const sourceDir = join(dirname(this.options.sourceScriptPath), RUNTIME_DIR_NAME);
|
|
196
|
+
const entries = readdirSync(installedDir, { recursive: true, encoding: 'utf8' });
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (entry.endsWith('.uid'))
|
|
199
|
+
continue;
|
|
200
|
+
const installed = join(installedDir, entry);
|
|
201
|
+
const shipped = join(sourceDir, entry);
|
|
202
|
+
try {
|
|
203
|
+
if (readFileSync(installed).equals(readFileSync(shipped)))
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Directories throw EISDIR on both sides; a file missing from the
|
|
208
|
+
// shipped tree throws ENOENT and means the directory is not ours.
|
|
209
|
+
if (isDirectory(installed) && isDirectory(shipped))
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
/** Copies the domain scripts the installed server preloads, overwriting stale copies. */
|
|
217
|
+
syncRuntimeDir(projectPath) {
|
|
218
|
+
const source = join(dirname(this.options.sourceScriptPath), RUNTIME_DIR_NAME);
|
|
219
|
+
if (!existsSync(source)) {
|
|
220
|
+
this.logDebug(`No ${RUNTIME_DIR_NAME} directory beside the source script; nothing to sync`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
cpSync(source, join(projectPath, RUNTIME_DIR_NAME), { recursive: true });
|
|
224
|
+
this.logDebug(`Synced ${RUNTIME_DIR_NAME} domain scripts into ${projectPath}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function isDirectory(path) {
|
|
228
|
+
try {
|
|
229
|
+
return readdirSync(path) !== undefined;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { applyEdits, modify, parse } from 'jsonc-parser';
|
|
7
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const pluginRoot = join(packageRoot, 'agent-plugin');
|
|
9
|
+
function loadAdapter() {
|
|
10
|
+
return JSON.parse(readFileSync(join(pluginRoot, 'adapter-manifest.json'), 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
function stable(value) {
|
|
13
|
+
return JSON.stringify(value);
|
|
14
|
+
}
|
|
15
|
+
function directoryHash(path) {
|
|
16
|
+
const hash = createHash('sha256');
|
|
17
|
+
const visit = (dir) => {
|
|
18
|
+
for (const name of readdirSync(dir).sort()) {
|
|
19
|
+
const fullPath = join(dir, name);
|
|
20
|
+
const info = statSync(fullPath);
|
|
21
|
+
if (info.isDirectory())
|
|
22
|
+
visit(fullPath);
|
|
23
|
+
else {
|
|
24
|
+
hash.update(relative(path, fullPath));
|
|
25
|
+
hash.update(readFileSync(fullPath));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(path);
|
|
30
|
+
return hash.digest('hex');
|
|
31
|
+
}
|
|
32
|
+
function readOwnership(path) {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return undefined;
|
|
35
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
function parseConfig(source, path) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
const value = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
40
|
+
if (errors.length > 0 || !value || Array.isArray(value)) {
|
|
41
|
+
throw new Error(`Cannot safely update ${path}: invalid JSON/JSONC configuration`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function configSource(path) {
|
|
46
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
|
|
47
|
+
}
|
|
48
|
+
function editConfig(source, name, value) {
|
|
49
|
+
const edits = modify(source, ['mcp', name], value, {
|
|
50
|
+
formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' },
|
|
51
|
+
});
|
|
52
|
+
return applyEdits(source, edits);
|
|
53
|
+
}
|
|
54
|
+
function defaultProjectConfig(cwd) {
|
|
55
|
+
const jsonc = join(cwd, 'opencode.jsonc');
|
|
56
|
+
const json = join(cwd, 'opencode.json');
|
|
57
|
+
return existsSync(jsonc) ? jsonc : json;
|
|
58
|
+
}
|
|
59
|
+
export function setupOpenCode(options = {}) {
|
|
60
|
+
const adapter = loadAdapter();
|
|
61
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
62
|
+
const home = resolve(options.home ?? homedir());
|
|
63
|
+
const scope = options.scope ?? 'project';
|
|
64
|
+
const configPath = resolve(options.configPath ?? (scope === 'user' ? join(home, '.config', 'opencode', 'opencode.json') : defaultProjectConfig(cwd)));
|
|
65
|
+
const agentsRoot = scope === 'user' ? join(home, '.agents') : join(cwd, '.agents');
|
|
66
|
+
const skillsRoot = join(agentsRoot, 'skills');
|
|
67
|
+
const ownershipPath = join(agentsRoot, '.godot-agent-loop-setup.json');
|
|
68
|
+
const ownership = readOwnership(ownershipPath);
|
|
69
|
+
const expectedEntry = {
|
|
70
|
+
type: 'local',
|
|
71
|
+
command: adapter.mcp.command,
|
|
72
|
+
enabled: true,
|
|
73
|
+
environment: adapter.mcp.environment,
|
|
74
|
+
};
|
|
75
|
+
const source = configSource(configPath);
|
|
76
|
+
const config = parseConfig(source, configPath);
|
|
77
|
+
const currentEntry = config.mcp?.[adapter.name];
|
|
78
|
+
const changes = [];
|
|
79
|
+
const warnings = [];
|
|
80
|
+
const skillOperations = [];
|
|
81
|
+
let nextConfig = source;
|
|
82
|
+
if (options.uninstall) {
|
|
83
|
+
const ownedEntry = ownership?.configEntry ?? expectedEntry;
|
|
84
|
+
if (currentEntry !== undefined && stable(currentEntry) === stable(ownedEntry)) {
|
|
85
|
+
nextConfig = editConfig(source, adapter.name, undefined);
|
|
86
|
+
changes.push(`remove MCP entry ${adapter.name} from ${configPath}`);
|
|
87
|
+
}
|
|
88
|
+
else if (currentEntry !== undefined) {
|
|
89
|
+
warnings.push(`preserve modified MCP entry ${adapter.name} in ${configPath}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (currentEntry === undefined) {
|
|
93
|
+
nextConfig = editConfig(source, adapter.name, expectedEntry);
|
|
94
|
+
changes.push(`add MCP entry ${adapter.name} to ${configPath}`);
|
|
95
|
+
}
|
|
96
|
+
else if (stable(currentEntry) !== stable(expectedEntry)) {
|
|
97
|
+
const previouslyOwned = ownership && stable(currentEntry) === stable(ownership.configEntry);
|
|
98
|
+
if (!previouslyOwned)
|
|
99
|
+
throw new Error(`Refusing to overwrite existing OpenCode MCP entry: ${adapter.name}`);
|
|
100
|
+
nextConfig = editConfig(source, adapter.name, expectedEntry);
|
|
101
|
+
changes.push(`update MCP entry ${adapter.name} in ${configPath}`);
|
|
102
|
+
}
|
|
103
|
+
const nextSkills = {};
|
|
104
|
+
for (const declared of adapter.skills) {
|
|
105
|
+
const sourceSkill = join(pluginRoot, 'skills', declared.name);
|
|
106
|
+
const targetSkill = join(skillsRoot, declared.name);
|
|
107
|
+
const sourceHash = directoryHash(sourceSkill);
|
|
108
|
+
nextSkills[declared.name] = sourceHash;
|
|
109
|
+
if (options.uninstall) {
|
|
110
|
+
if (!existsSync(targetSkill))
|
|
111
|
+
continue;
|
|
112
|
+
const installedHash = directoryHash(targetSkill);
|
|
113
|
+
if (ownership?.skills[declared.name] === installedHash) {
|
|
114
|
+
changes.push(`remove owned skill ${targetSkill}`);
|
|
115
|
+
skillOperations.push({ action: 'remove', target: targetSkill });
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
warnings.push(`preserve modified or unowned skill ${targetSkill}`);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync(targetSkill)) {
|
|
123
|
+
changes.push(`install skill ${targetSkill}`);
|
|
124
|
+
skillOperations.push({ action: 'install', source: sourceSkill, target: targetSkill });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const installedHash = directoryHash(targetSkill);
|
|
128
|
+
if (installedHash === sourceHash)
|
|
129
|
+
continue;
|
|
130
|
+
if (ownership?.skills[declared.name] !== installedHash) {
|
|
131
|
+
throw new Error(`Refusing to overwrite modified or unowned skill: ${targetSkill}`);
|
|
132
|
+
}
|
|
133
|
+
changes.push(`update owned skill ${targetSkill}`);
|
|
134
|
+
skillOperations.push({ action: 'update', source: sourceSkill, target: targetSkill });
|
|
135
|
+
}
|
|
136
|
+
if (options.write) {
|
|
137
|
+
for (const operation of skillOperations) {
|
|
138
|
+
if (operation.action === 'remove' || operation.action === 'update') {
|
|
139
|
+
rmSync(operation.target, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
if (operation.source) {
|
|
142
|
+
mkdirSync(dirname(operation.target), { recursive: true });
|
|
143
|
+
cpSync(operation.source, operation.target, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (nextConfig !== source) {
|
|
147
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
148
|
+
writeFileSync(configPath, nextConfig);
|
|
149
|
+
}
|
|
150
|
+
if (options.uninstall) {
|
|
151
|
+
if (existsSync(ownershipPath))
|
|
152
|
+
rmSync(ownershipPath);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
mkdirSync(dirname(ownershipPath), { recursive: true });
|
|
156
|
+
const record = {
|
|
157
|
+
version: 1,
|
|
158
|
+
configPath,
|
|
159
|
+
configEntry: expectedEntry,
|
|
160
|
+
skills: nextSkills,
|
|
161
|
+
};
|
|
162
|
+
writeFileSync(ownershipPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
action: options.uninstall ? 'uninstall' : 'install',
|
|
167
|
+
applied: options.write === true,
|
|
168
|
+
configPath,
|
|
169
|
+
skillsRoot,
|
|
170
|
+
changes,
|
|
171
|
+
warnings,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export async function runOpenCodeSetup(args) {
|
|
175
|
+
const value = (flag) => {
|
|
176
|
+
const index = args.indexOf(flag);
|
|
177
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
178
|
+
};
|
|
179
|
+
const scope = value('--scope');
|
|
180
|
+
if (scope !== undefined && scope !== 'project' && scope !== 'user') {
|
|
181
|
+
throw new Error('--scope must be project or user');
|
|
182
|
+
}
|
|
183
|
+
const result = setupOpenCode({
|
|
184
|
+
scope,
|
|
185
|
+
configPath: value('--config'),
|
|
186
|
+
write: args.includes('--write') || args.includes('--yes'),
|
|
187
|
+
uninstall: args.includes('uninstall') || args.includes('--uninstall'),
|
|
188
|
+
});
|
|
189
|
+
const mode = result.applied ? 'Applied' : 'Preview';
|
|
190
|
+
console.log(`${mode}: OpenCode ${result.action}`);
|
|
191
|
+
console.log(`Config: ${result.configPath}`);
|
|
192
|
+
console.log(`Skills: ${result.skillsRoot}`);
|
|
193
|
+
for (const change of result.changes)
|
|
194
|
+
console.log(` - ${change}`);
|
|
195
|
+
for (const warning of result.warnings)
|
|
196
|
+
console.log(` ! ${warning}`);
|
|
197
|
+
if (!result.applied)
|
|
198
|
+
console.log('Run again with --write to apply this preview.');
|
|
199
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { basename, dirname, join, relative } from 'path';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { collectGdPaths, errorMessage, parseGodotScriptDiagnostics, } from './utils.js';
|
|
7
|
+
import { GODOT_COMMAND_OPTIONS, GODOT_VERSION_OPTIONS } from './godot-subprocess.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
/**
|
|
10
|
+
* Filesystem and executable-backed support for project-oriented tools.
|
|
11
|
+
*
|
|
12
|
+
* Keeping this work outside GodotServer lets the server focus on lifecycle
|
|
13
|
+
* orchestration while project handlers share a single, testable service.
|
|
14
|
+
*/
|
|
15
|
+
export class ProjectSupport {
|
|
16
|
+
context;
|
|
17
|
+
validateScriptPath;
|
|
18
|
+
constructor(context, validateScriptPath = join(dirname(fileURLToPath(import.meta.url)), 'scripts', 'validate_script.gd')) {
|
|
19
|
+
this.context = context;
|
|
20
|
+
this.validateScriptPath = validateScriptPath;
|
|
21
|
+
}
|
|
22
|
+
findGodotProjects(directory, recursive) {
|
|
23
|
+
const projects = [];
|
|
24
|
+
try {
|
|
25
|
+
const projectFile = join(directory, 'project.godot');
|
|
26
|
+
if (existsSync(projectFile)) {
|
|
27
|
+
projects.push({
|
|
28
|
+
path: directory,
|
|
29
|
+
name: basename(directory),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory() || (recursive && entry.name.startsWith('.')))
|
|
35
|
+
continue;
|
|
36
|
+
const subdirectory = join(directory, entry.name);
|
|
37
|
+
if (existsSync(join(subdirectory, 'project.godot'))) {
|
|
38
|
+
projects.push({ path: subdirectory, name: entry.name });
|
|
39
|
+
}
|
|
40
|
+
else if (recursive) {
|
|
41
|
+
projects.push(...this.findGodotProjects(subdirectory, true));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
this.context.logDebug(`Error searching directory ${directory}: ${error}`);
|
|
47
|
+
}
|
|
48
|
+
return projects;
|
|
49
|
+
}
|
|
50
|
+
async getProjectStructureAsync(projectPath) {
|
|
51
|
+
const structure = {
|
|
52
|
+
scenes: 0,
|
|
53
|
+
scripts: 0,
|
|
54
|
+
assets: 0,
|
|
55
|
+
other: 0,
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const scanDirectory = (currentPath) => {
|
|
59
|
+
const entries = readdirSync(currentPath, { withFileTypes: true });
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.name.startsWith('.'))
|
|
62
|
+
continue;
|
|
63
|
+
const entryPath = join(currentPath, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
scanDirectory(entryPath);
|
|
66
|
+
}
|
|
67
|
+
else if (entry.isFile()) {
|
|
68
|
+
const extension = entry.name.split('.').pop()?.toLowerCase();
|
|
69
|
+
if (extension === 'tscn') {
|
|
70
|
+
structure.scenes++;
|
|
71
|
+
}
|
|
72
|
+
else if (extension === 'gd' || extension === 'gdscript' || extension === 'cs') {
|
|
73
|
+
structure.scripts++;
|
|
74
|
+
}
|
|
75
|
+
else if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'ttf', 'wav', 'mp3', 'ogg'].includes(extension || '')) {
|
|
76
|
+
structure.assets++;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
structure.other++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
scanDirectory(projectPath);
|
|
85
|
+
return structure;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
this.context.logDebug(`Error getting project structure asynchronously: ${error}`);
|
|
89
|
+
return {
|
|
90
|
+
error: 'Failed to get project structure',
|
|
91
|
+
scenes: 0,
|
|
92
|
+
scripts: 0,
|
|
93
|
+
assets: 0,
|
|
94
|
+
other: 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
isDotnetProject(projectPath) {
|
|
99
|
+
try {
|
|
100
|
+
return readdirSync(projectPath).some(entry => entry.toLowerCase().endsWith('.csproj'));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async detectGodotNetSdkVersion() {
|
|
107
|
+
try {
|
|
108
|
+
const godotPath = await this.requireGodotPath();
|
|
109
|
+
if (!godotPath)
|
|
110
|
+
return null;
|
|
111
|
+
const { stdout } = await execFileAsync(godotPath, ['--version'], GODOT_VERSION_OPTIONS);
|
|
112
|
+
const match = /^(\d+)\.(\d+)(?:\.\d+)?\.stable\b/.exec(stdout.trim());
|
|
113
|
+
return match ? `${match[1]}.${match[2]}.0` : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
keyNameToScancode(key) {
|
|
120
|
+
const map = {
|
|
121
|
+
'A': 65, 'B': 66, 'C': 67, 'D': 68, 'E': 69, 'F': 70, 'G': 71, 'H': 72,
|
|
122
|
+
'I': 73, 'J': 74, 'K': 75, 'L': 76, 'M': 77, 'N': 78, 'O': 79, 'P': 80,
|
|
123
|
+
'Q': 81, 'R': 82, 'S': 83, 'T': 84, 'U': 85, 'V': 86, 'W': 87, 'X': 88,
|
|
124
|
+
'Y': 89, 'Z': 90, 'SPACE': 32, 'ENTER': 16777221, 'ESCAPE': 16777217,
|
|
125
|
+
'TAB': 16777218, 'BACKSPACE': 16777220, 'UP': 16777232, 'DOWN': 16777234,
|
|
126
|
+
'LEFT': 16777231, 'RIGHT': 16777233, 'SHIFT': 16777237, 'CTRL': 16777238,
|
|
127
|
+
'ALT': 16777240, 'F1': 16777244, 'F2': 16777245, 'F3': 16777246,
|
|
128
|
+
'F4': 16777247, 'F5': 16777248, 'F6': 16777249, 'F7': 16777250,
|
|
129
|
+
'F8': 16777251, 'F9': 16777252, 'F10': 16777253, 'F11': 16777254,
|
|
130
|
+
'F12': 16777255,
|
|
131
|
+
};
|
|
132
|
+
const upper = key.toUpperCase();
|
|
133
|
+
return map[upper] || (key.length === 1 ? key.charCodeAt(0) : 0);
|
|
134
|
+
}
|
|
135
|
+
async runGdScriptCheck(projectPath, scriptFull) {
|
|
136
|
+
const godotPath = await this.requireGodotPath();
|
|
137
|
+
if (!godotPath) {
|
|
138
|
+
return { completed: false, errors: [], error: 'Could not find a valid Godot executable path' };
|
|
139
|
+
}
|
|
140
|
+
let output;
|
|
141
|
+
let failed = false;
|
|
142
|
+
const scriptResourcePath = `res://${relative(projectPath, scriptFull).replace(/\\/g, '/')}`;
|
|
143
|
+
try {
|
|
144
|
+
// `--check-only --script <target>` compiles too early for project autoload
|
|
145
|
+
// globals to be registered. The SceneTree validator loads the target from
|
|
146
|
+
// `_initialize()`, after autoload bootstrap, while CACHE_MODE_IGNORE keeps
|
|
147
|
+
// every check a fresh parse of the current file contents.
|
|
148
|
+
const { stdout, stderr } = await execFileAsync(godotPath, ['--headless', '--path', projectPath, '--script', this.validateScriptPath, scriptResourcePath], GODOT_COMMAND_OPTIONS);
|
|
149
|
+
output = `${stdout ?? ''}${stderr ?? ''}`;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
failed = true;
|
|
153
|
+
const execError = error;
|
|
154
|
+
output = `${execError.stdout ?? ''}${execError.stderr ?? ''}`;
|
|
155
|
+
const aborted = execError.killed === true || execError.signal != null ||
|
|
156
|
+
execError.code === 'ETIMEDOUT' || execError.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
|
|
157
|
+
if (aborted)
|
|
158
|
+
return { completed: false, errors: [], error: 'Godot timed out or produced too much output' };
|
|
159
|
+
if (!output)
|
|
160
|
+
return { completed: false, errors: [], error: errorMessage(error) };
|
|
161
|
+
}
|
|
162
|
+
const errors = parseGodotScriptDiagnostics(output);
|
|
163
|
+
if (errors.length === 0 && failed) {
|
|
164
|
+
const tail = output.trim().split(/\r?\n/).slice(-6).join(' ');
|
|
165
|
+
return { completed: false, errors: [], error: `Godot exited with an error: ${tail}` };
|
|
166
|
+
}
|
|
167
|
+
return { completed: true, errors };
|
|
168
|
+
}
|
|
169
|
+
async listChangedGdFiles(projectPath) {
|
|
170
|
+
const git = (gitArgs) => execFileAsync('git', ['-c', 'core.quotepath=false', '-C', projectPath, ...gitArgs], { timeout: 15000, maxBuffer: 16 * 1024 * 1024 });
|
|
171
|
+
try {
|
|
172
|
+
await git(['rev-parse', '--is-inside-work-tree']);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return { error: 'Not a git repository (or git is unavailable). Use scope: "all" or pass scriptPaths.' };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const outputs = await Promise.all([
|
|
179
|
+
git(['diff', '--name-only', '--relative']),
|
|
180
|
+
git(['diff', '--name-only', '--relative', '--cached']),
|
|
181
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
182
|
+
]);
|
|
183
|
+
return { files: collectGdPaths(outputs.map(output => output.stdout ?? '')) };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return { error: `Failed to list changed files: ${errorMessage(error)}` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
listAllGdFiles(projectPath) {
|
|
190
|
+
const results = [];
|
|
191
|
+
const skipDirectories = new Set(['.godot', '.git', 'node_modules', '.import']);
|
|
192
|
+
const walk = (directory) => {
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
if (skipDirectories.has(entry.name) || entry.name.startsWith('.'))
|
|
203
|
+
continue;
|
|
204
|
+
walk(join(directory, entry.name));
|
|
205
|
+
}
|
|
206
|
+
else if (entry.isFile() && /\.gd$/i.test(entry.name)) {
|
|
207
|
+
results.push(relative(projectPath, join(directory, entry.name)).replace(/\\/g, '/'));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
walk(projectPath);
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
async requireGodotPath() {
|
|
215
|
+
if (!this.context.getGodotPath())
|
|
216
|
+
await this.context.detectGodotPath();
|
|
217
|
+
return this.context.getGodotPath();
|
|
218
|
+
}
|
|
219
|
+
}
|