gdharness 1.3.18 → 1.3.20
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/build/cli.js +69 -30
- package/build/godot/tests/listed.gd +54 -2
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -11803,8 +11803,42 @@ function dictionary(entries) {
|
|
|
11803
11803
|
|
|
11804
11804
|
// src/call-signal.ts
|
|
11805
11805
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11806
|
-
function
|
|
11807
|
-
|
|
11806
|
+
function stillGoingSaid(tool, op, elapsedMs) {
|
|
11807
|
+
const seconds = Math.floor(elapsedMs / 1000);
|
|
11808
|
+
const took = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
11809
|
+
return `${op === null || op === "" ? tool : `${tool} ${op}`} is still going; ${took} in.`;
|
|
11810
|
+
}
|
|
11811
|
+
async function withCallSignal(signal, answer, progress, still) {
|
|
11812
|
+
if (progress === undefined) {
|
|
11813
|
+
return signal === undefined ? answer() : current.run({ signal, progress: undefined }, answer);
|
|
11814
|
+
}
|
|
11815
|
+
const began = Date.now();
|
|
11816
|
+
const rising = risingOnly(progress);
|
|
11817
|
+
let going = still;
|
|
11818
|
+
let quiet;
|
|
11819
|
+
const rearm = () => {
|
|
11820
|
+
clearTimeout(quiet);
|
|
11821
|
+
if (going === undefined) {
|
|
11822
|
+
return;
|
|
11823
|
+
}
|
|
11824
|
+
const { everyMs, said } = going;
|
|
11825
|
+
quiet = setTimeout(() => {
|
|
11826
|
+
const elapsed = Date.now() - began;
|
|
11827
|
+
told(Math.floor(elapsed / 1000), said(elapsed));
|
|
11828
|
+
}, everyMs);
|
|
11829
|
+
quiet.unref();
|
|
11830
|
+
};
|
|
11831
|
+
const told = (number, message) => {
|
|
11832
|
+
rising(number, message);
|
|
11833
|
+
rearm();
|
|
11834
|
+
};
|
|
11835
|
+
rearm();
|
|
11836
|
+
try {
|
|
11837
|
+
return await current.run({ signal, progress: told }, answer);
|
|
11838
|
+
} finally {
|
|
11839
|
+
going = undefined;
|
|
11840
|
+
clearTimeout(quiet);
|
|
11841
|
+
}
|
|
11808
11842
|
}
|
|
11809
11843
|
function risingOnly(progress) {
|
|
11810
11844
|
let last = Number.NEGATIVE_INFINITY;
|
|
@@ -11819,7 +11853,7 @@ function callSignal() {
|
|
|
11819
11853
|
function callProgress() {
|
|
11820
11854
|
return current.getStore()?.progress;
|
|
11821
11855
|
}
|
|
11822
|
-
var current;
|
|
11856
|
+
var current, CALL_HEARTBEAT_MS = 20000;
|
|
11823
11857
|
var init_call_signal = __esm(() => {
|
|
11824
11858
|
current = new AsyncLocalStorage;
|
|
11825
11859
|
});
|
|
@@ -48344,14 +48378,14 @@ var init_suites_scene = __esm(() => {
|
|
|
48344
48378
|
});
|
|
48345
48379
|
|
|
48346
48380
|
// src/test-selection.ts
|
|
48347
|
-
import { existsSync as existsSync33
|
|
48381
|
+
import { existsSync as existsSync33 } from "node:fs";
|
|
48348
48382
|
import { basename as basename12, join as join42 } from "node:path";
|
|
48349
|
-
function casesIn(
|
|
48350
|
-
|
|
48351
|
-
return [...text.matchAll(/^func\s+(test_\w*)\s*\(/gm)].map((match) => match[1] ?? "");
|
|
48383
|
+
function casesIn(scripts, suite) {
|
|
48384
|
+
return scripts.suite(suite)?.cases ?? scripts.script(suite)?.funcs.filter((name) => name.startsWith("test_")) ?? [];
|
|
48352
48385
|
}
|
|
48353
48386
|
function scriptsWithoutCases(projectPath, asked) {
|
|
48354
|
-
|
|
48387
|
+
const scripts = new ProjectScripts(projectPath);
|
|
48388
|
+
return asked.filter((path) => path.endsWith(".gd") && existsSync33(join42(projectPath, path.slice("res://".length))) && casesIn(scripts, path).length === 0);
|
|
48355
48389
|
}
|
|
48356
48390
|
function suitesUnder(projectPath, asked) {
|
|
48357
48391
|
const scripts = projectFiles(projectPath, [".gd"]).map((path) => `res://${path}`);
|
|
@@ -48367,7 +48401,8 @@ function selectTests(projectPath, asked, given) {
|
|
|
48367
48401
|
if (entries.length === 0) {
|
|
48368
48402
|
return { ok: false, reason: "tests is an empty list: name a case, or leave tests out to run every one." };
|
|
48369
48403
|
}
|
|
48370
|
-
const
|
|
48404
|
+
const scripts = new ProjectScripts(projectPath);
|
|
48405
|
+
const declared = suitesUnder(projectPath, asked).flatMap((suite) => casesIn(scripts, suite).map((test) => ({ suite, test })));
|
|
48371
48406
|
const nameOf = (suite) => basename12(suite, ".gd");
|
|
48372
48407
|
const selected = [];
|
|
48373
48408
|
for (const [index, entry] of entries.entries()) {
|
|
@@ -48408,6 +48443,7 @@ function selectTests(projectPath, asked, given) {
|
|
|
48408
48443
|
}
|
|
48409
48444
|
var init_test_selection = __esm(() => {
|
|
48410
48445
|
init_project_scan();
|
|
48446
|
+
init_suite_plan();
|
|
48411
48447
|
});
|
|
48412
48448
|
|
|
48413
48449
|
// src/server.ts
|
|
@@ -48422,7 +48458,7 @@ import {
|
|
|
48422
48458
|
mkdirSync as mkdirSync23,
|
|
48423
48459
|
openSync as openSync6,
|
|
48424
48460
|
readdirSync as readdirSync19,
|
|
48425
|
-
readFileSync as
|
|
48461
|
+
readFileSync as readFileSync36,
|
|
48426
48462
|
readSync as readSync3,
|
|
48427
48463
|
realpathSync as realpathSync5,
|
|
48428
48464
|
rmdirSync as rmdirSync2,
|
|
@@ -48443,9 +48479,9 @@ function greetingWaitMs() {
|
|
|
48443
48479
|
return waitFromEnv("GDHARNESS_GREETING_WAIT_MS", 1e4);
|
|
48444
48480
|
}
|
|
48445
48481
|
function pictureToSend(pngPath, jpegPath) {
|
|
48446
|
-
const png =
|
|
48482
|
+
const png = readFileSync36(pngPath);
|
|
48447
48483
|
const beside = pngPath.replace(/\.png$/, ".jpg");
|
|
48448
|
-
const jpeg = jpegPath !== undefined && normalize2(jpegPath) === normalize2(beside) && existsSync34(beside) ?
|
|
48484
|
+
const jpeg = jpegPath !== undefined && normalize2(jpegPath) === normalize2(beside) && existsSync34(beside) ? readFileSync36(beside) : null;
|
|
48449
48485
|
const sent = jpeg !== null && jpeg.length < png.length ? jpeg : png;
|
|
48450
48486
|
return {
|
|
48451
48487
|
data: sent.toString("base64"),
|
|
@@ -48924,7 +48960,7 @@ function endedToStartThis(ended) {
|
|
|
48924
48960
|
return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
|
|
48925
48961
|
}
|
|
48926
48962
|
function lineNothingStopsOn(absolutePath, relativePath, line) {
|
|
48927
|
-
const text =
|
|
48963
|
+
const text = readFileSync36(absolutePath, "utf8");
|
|
48928
48964
|
const lines = text === "" ? 0 : text.split(`
|
|
48929
48965
|
`).length - (text.endsWith(`
|
|
48930
48966
|
`) ? 1 : 0);
|
|
@@ -49135,7 +49171,7 @@ function notRunNote(notRun, failFast) {
|
|
|
49135
49171
|
function presetsIn(projectPath) {
|
|
49136
49172
|
let text;
|
|
49137
49173
|
try {
|
|
49138
|
-
text =
|
|
49174
|
+
text = readFileSync36(join43(projectPath, "export_presets.cfg"), "utf8");
|
|
49139
49175
|
} catch {
|
|
49140
49176
|
return null;
|
|
49141
49177
|
}
|
|
@@ -49285,7 +49321,7 @@ function rewriteBusNamed(projectPath, from, to) {
|
|
|
49285
49321
|
const rewritten = [];
|
|
49286
49322
|
for (const file of projectFiles(projectPath, [".tscn", ".tres"])) {
|
|
49287
49323
|
const absolute = join43(projectPath, file);
|
|
49288
|
-
const lines =
|
|
49324
|
+
const lines = readFileSync36(absolute, "utf8").split(`
|
|
49289
49325
|
`);
|
|
49290
49326
|
const at = [];
|
|
49291
49327
|
const after = lines.map((line, index) => {
|
|
@@ -49304,7 +49340,7 @@ function rewriteBusNamed(projectPath, from, to) {
|
|
|
49304
49340
|
}
|
|
49305
49341
|
const namedInScripts = [];
|
|
49306
49342
|
for (const file of projectFiles(projectPath, [".gd"])) {
|
|
49307
|
-
|
|
49343
|
+
readFileSync36(join43(projectPath, file), "utf8").split(`
|
|
49308
49344
|
`).forEach((line, index) => {
|
|
49309
49345
|
if (line.includes(`"${from}"`)) {
|
|
49310
49346
|
namedInScripts.push({ file: `res://${file}`, line: index + 1, text: line.trim() });
|
|
@@ -49579,7 +49615,7 @@ function profileFrameFunctions(projectPath) {
|
|
|
49579
49615
|
let projectGodot = null;
|
|
49580
49616
|
if (projectPath !== null) {
|
|
49581
49617
|
try {
|
|
49582
|
-
projectGodot =
|
|
49618
|
+
projectGodot = readFileSync36(join43(projectPath, "project.godot"), "utf8");
|
|
49583
49619
|
} catch {}
|
|
49584
49620
|
}
|
|
49585
49621
|
return frameFunctionsFor(queuedMessagesOf(projectGodot));
|
|
@@ -49603,7 +49639,7 @@ function projectSourceOf(projectPath) {
|
|
|
49603
49639
|
let source = null;
|
|
49604
49640
|
if (inside.ok) {
|
|
49605
49641
|
try {
|
|
49606
|
-
source =
|
|
49642
|
+
source = readFileSync36(inside.absolutePath, "utf8");
|
|
49607
49643
|
} catch {}
|
|
49608
49644
|
}
|
|
49609
49645
|
read.set(script, source);
|
|
@@ -49762,7 +49798,7 @@ function foldedEntriesAnswer(log, floor = "warning") {
|
|
|
49762
49798
|
}
|
|
49763
49799
|
function gdUnitFailsOnScriptErrors(projectPath) {
|
|
49764
49800
|
try {
|
|
49765
|
-
return settingKeys(
|
|
49801
|
+
return settingKeys(readFileSync36(join43(projectPath, "project.godot"), "utf8")).get("gdunit4/report/godot/script_error") !== false;
|
|
49766
49802
|
} catch {
|
|
49767
49803
|
return true;
|
|
49768
49804
|
}
|
|
@@ -49890,11 +49926,11 @@ function isDirectory(path) {
|
|
|
49890
49926
|
}
|
|
49891
49927
|
}
|
|
49892
49928
|
function mainSceneOf(projectFile) {
|
|
49893
|
-
const scene = parseProjectGodot(
|
|
49929
|
+
const scene = parseProjectGodot(readFileSync36(projectFile, "utf8"))["application"]?.["run/main_scene"];
|
|
49894
49930
|
return typeof scene === "string" && scene !== "" ? scene : null;
|
|
49895
49931
|
}
|
|
49896
49932
|
function editorPlaysHeadless(projectFile) {
|
|
49897
|
-
const runArgs = parseProjectGodot(
|
|
49933
|
+
const runArgs = parseProjectGodot(readFileSync36(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
|
|
49898
49934
|
return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
|
|
49899
49935
|
}
|
|
49900
49936
|
function ownArguments(params) {
|
|
@@ -50347,7 +50383,10 @@ class GodotServer {
|
|
|
50347
50383
|
const call = this.callsTaken++;
|
|
50348
50384
|
this.inFlight.set(call, { tool: spec.name, op: checked.op ?? "", startedAt: Date.now() });
|
|
50349
50385
|
try {
|
|
50350
|
-
const answer = await withCallSignal(ctx.mcpReq.signal, () => this.answered(spec.name, checked.op ?? "", args), progress
|
|
50386
|
+
const answer = await withCallSignal(ctx.mcpReq.signal, () => this.answered(spec.name, checked.op ?? "", args), progress, {
|
|
50387
|
+
everyMs: CALL_HEARTBEAT_MS,
|
|
50388
|
+
said: (elapsedMs) => stillGoingSaid(spec.name, checked.op, elapsedMs)
|
|
50389
|
+
});
|
|
50351
50390
|
return this.withinTheClientsLimit(spec.name, this.withFeedbackNotice(this.withUpdateNotice(answer)));
|
|
50352
50391
|
} finally {
|
|
50353
50392
|
this.inFlight.delete(call);
|
|
@@ -51786,7 +51825,7 @@ class GodotServer {
|
|
|
51786
51825
|
if (!engine.ok) {
|
|
51787
51826
|
return engine.response;
|
|
51788
51827
|
}
|
|
51789
|
-
const application = parseProjectGodot(
|
|
51828
|
+
const application = parseProjectGodot(readFileSync36(project.value.file, "utf8"))["application"] ?? {};
|
|
51790
51829
|
const name = application["config/name"];
|
|
51791
51830
|
const mainScene = application["run/main_scene"];
|
|
51792
51831
|
const info = {
|
|
@@ -52306,13 +52345,13 @@ ${platform}`;
|
|
|
52306
52345
|
const reportsDir = join43(project.value.path, ".godot", ...ours.split("/"));
|
|
52307
52346
|
let report = null;
|
|
52308
52347
|
let reportProblem = null;
|
|
52309
|
-
const casePrinted = withNestedErrors(readRun(existsSync34(engineLog) ?
|
|
52348
|
+
const casePrinted = withNestedErrors(readRun(existsSync34(engineLog) ? readFileSync36(engineLog, "utf8") : ""), run.log.all);
|
|
52310
52349
|
const declaredErrors = readExpected(expectedFile);
|
|
52311
52350
|
try {
|
|
52312
52351
|
const written = existsSync34(reportsDir) && notCompiled === null ? readdirSync19(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
52313
52352
|
const newest = written.at(-1);
|
|
52314
52353
|
if (newest !== undefined) {
|
|
52315
|
-
report = parseJUnit(
|
|
52354
|
+
report = parseJUnit(readFileSync36(join43(reportsDir, newest, "results.xml"), "utf8"));
|
|
52316
52355
|
}
|
|
52317
52356
|
} catch (error) {
|
|
52318
52357
|
reportProblem = errorMessage(error);
|
|
@@ -52791,7 +52830,7 @@ ${error.text}`;
|
|
|
52791
52830
|
return null;
|
|
52792
52831
|
}
|
|
52793
52832
|
try {
|
|
52794
|
-
return
|
|
52833
|
+
return readFileSync36(contained.absolutePath, "utf8");
|
|
52795
52834
|
} catch {
|
|
52796
52835
|
return null;
|
|
52797
52836
|
}
|
|
@@ -52885,7 +52924,7 @@ ${error.text}`;
|
|
|
52885
52924
|
return null;
|
|
52886
52925
|
}
|
|
52887
52926
|
try {
|
|
52888
|
-
return
|
|
52927
|
+
return readFileSync36(contained.absolutePath, "utf8");
|
|
52889
52928
|
} catch {
|
|
52890
52929
|
return null;
|
|
52891
52930
|
}
|
|
@@ -53388,7 +53427,7 @@ ${error.text}`;
|
|
|
53388
53427
|
return null;
|
|
53389
53428
|
}
|
|
53390
53429
|
try {
|
|
53391
|
-
return settingKeys(
|
|
53430
|
+
return settingKeys(readFileSync36(join43(projectPath, "project.godot"), "utf8"));
|
|
53392
53431
|
} catch {
|
|
53393
53432
|
return null;
|
|
53394
53433
|
}
|
|
@@ -54168,7 +54207,7 @@ ${error.text}`;
|
|
|
54168
54207
|
return 0;
|
|
54169
54208
|
}
|
|
54170
54209
|
try {
|
|
54171
|
-
return reportAlreadyInTranscript(
|
|
54210
|
+
return reportAlreadyInTranscript(readFileSync36(game.transcript), readFileSync36(report));
|
|
54172
54211
|
} catch (error) {
|
|
54173
54212
|
this.logDebug(`Could not compare a picked-up run's report with its transcript: ${errorMessage(error)}`);
|
|
54174
54213
|
return 0;
|
|
@@ -56501,7 +56540,7 @@ ${error.text}`;
|
|
|
56501
56540
|
}
|
|
56502
56541
|
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56503
56542
|
const declared = new RegExp(`^\\s*(?:@\\w+(?:\\([^)]*\\))?\\s+)*class_name\\s+${escaped}\\b`, "m");
|
|
56504
|
-
if (declared.test(
|
|
56543
|
+
if (declared.test(readFileSync36(scriptPath, "utf8"))) {
|
|
56505
56544
|
return null;
|
|
56506
56545
|
}
|
|
56507
56546
|
const checked = await this.classesTheEditorCannotSee(args);
|
|
@@ -80,6 +80,11 @@ class GdhRunner:
|
|
|
80
80
|
var together: GDScript = null
|
|
81
81
|
## The cases dealt by their rows, by `res://suite.gd:test_case`.
|
|
82
82
|
var row_shares: Dictionary[String, GdhRowShare] = {}
|
|
83
|
+
## The cases to leave out, by `res://suite.gd:test_case` of the suite running them. gdUnit4's own
|
|
84
|
+
## list is matched against the script declaring a case, which for a case a suite inherits is its
|
|
85
|
+
## base, so an inherited case left out under its suite ran in every engine the suite was dealt to,
|
|
86
|
+
## and a case of `res://tests/a_test.gd` was also left out of `res://more/tests/a_test.gd`.
|
|
87
|
+
var left_out: Dictionary[String, bool] = {}
|
|
83
88
|
## The cases gdUnit4 said it finished, which [GdhSessionCheck] holds the session's lookups against.
|
|
84
89
|
var cases_ended: int = 0
|
|
85
90
|
## The marker of the shutdown walk's oldest end, made as the run starts.
|
|
@@ -106,10 +111,40 @@ class GdhRunner:
|
|
|
106
111
|
return options
|
|
107
112
|
return options_of(OS.get_cmdline_args(), own_path)
|
|
108
113
|
|
|
114
|
+
## gdUnit4's own discovery, with each case filed under the suite it was found in before it is
|
|
115
|
+
## asked whether to leave the case out. gdUnit4 files a case under the script declaring it, which
|
|
116
|
+
## for a case a suite inherits is its base: its reports added such a case to no suite, so
|
|
117
|
+
## project_test counted none of them, and a parameterised one ran as a suite of the base's own,
|
|
118
|
+
## outside the suite inheriting it and its hooks, and was judged a case that suite never ran.
|
|
119
|
+
func discover_tests() -> Array[GdUnitTestCase]:
|
|
120
|
+
var added: Signal = GdUnitSignals.instance().gdunit_test_discover_added
|
|
121
|
+
_test_cases = _runner_config.test_cases()
|
|
122
|
+
var scanner: GdUnitTestSuiteScanner = GdUnitTestSuiteScanner.new()
|
|
123
|
+
for path: String in _included_tests:
|
|
124
|
+
for script: Script in scanner.scan(path):
|
|
125
|
+
var suite: String = script.resource_path
|
|
126
|
+
var found: Callable = func(test: GdUnitTestCase) -> void:
|
|
127
|
+
filed_under(test, suite)
|
|
128
|
+
if not is_skipped(test):
|
|
129
|
+
_test_cases.append(test)
|
|
130
|
+
added.emit(test)
|
|
131
|
+
GdUnitTestDiscoverer.discover_tests(script, found)
|
|
132
|
+
return _test_cases
|
|
133
|
+
|
|
134
|
+
## [param test] as a case of the suite at [param suite], under which gdUnit4 groups, reports and
|
|
135
|
+
## leaves out its cases.
|
|
136
|
+
static func filed_under(test: GdUnitTestCase, suite: String) -> void:
|
|
137
|
+
if test.suite_resource_path == suite and test.source_file == suite:
|
|
138
|
+
return
|
|
139
|
+
test.suite_resource_path = suite
|
|
140
|
+
test.source_file = suite
|
|
141
|
+
test.suite_name = suite.get_file().get_basename()
|
|
142
|
+
test._build_fully_qualified_name(suite)
|
|
143
|
+
|
|
109
144
|
## gdUnit4 asks this of every case it finds, a parameterised case once a row, each row carrying
|
|
110
145
|
## its index.
|
|
111
146
|
func is_skipped(test: GdUnitTestCase) -> bool:
|
|
112
|
-
if super(test):
|
|
147
|
+
if left_out.has("%s:%s" % [test.suite_resource_path, test.test_name]) or super(test):
|
|
113
148
|
return true
|
|
114
149
|
var share: GdhRowShare = row_shares.get("%s:%s" % [test.suite_resource_path, test.test_name], null)
|
|
115
150
|
if share == null or not share.leaves_out(test.attribute_index):
|
|
@@ -348,7 +383,11 @@ static func started(given: PackedStringArray) -> GdhRunner:
|
|
|
348
383
|
if not expected_out.is_empty():
|
|
349
384
|
write_expected(expected_out, suites)
|
|
350
385
|
runner.add_test_suites(suites)
|
|
351
|
-
|
|
386
|
+
for entry: String in listed(asked, SKIPPING):
|
|
387
|
+
if is_one_case(entry):
|
|
388
|
+
runner.left_out[entry] = true
|
|
389
|
+
else:
|
|
390
|
+
runner.skip_test_suite(entry)
|
|
352
391
|
runner.row_shares = row_shares_of(listed(asked, ROWS))
|
|
353
392
|
# Added to the list itself rather than through `register`, which saves every hook it holds into
|
|
354
393
|
# `project.godot`.
|
|
@@ -426,6 +465,19 @@ static func listed(args: PackedStringArray, flag: String) -> PackedStringArray:
|
|
|
426
465
|
return named
|
|
427
466
|
|
|
428
467
|
|
|
468
|
+
## Whether [param entry] of a [constant SKIPPING] file names one case of one suite file, as
|
|
469
|
+
## `res://suite.gd:test_case`, rather than a suite or a case by gdUnit4's looser names, which go to
|
|
470
|
+
## gdUnit4 as they are.
|
|
471
|
+
static func is_one_case(entry: String) -> bool:
|
|
472
|
+
var at: int = entry.rfind(":")
|
|
473
|
+
return (
|
|
474
|
+
entry.begins_with("res://")
|
|
475
|
+
and at > "res://".length()
|
|
476
|
+
and entry.substr(0, at).ends_with(".gd")
|
|
477
|
+
and entry.substr(at + 1).is_valid_ascii_identifier()
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
429
481
|
## The row shares [param lines] of a [constant ROWS] file give, by case.
|
|
430
482
|
static func row_shares_of(lines: PackedStringArray) -> Dictionary[String, GdhRowShare]:
|
|
431
483
|
var shares: Dictionary[String, GdhRowShare] = {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.20",
|
|
4
4
|
"mcpName": "io.github.Aureliolo/gdharness",
|
|
5
5
|
"description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
|
|
6
6
|
"type": "module",
|