gdharness 0.11.0 → 0.12.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/README.md CHANGED
@@ -61,6 +61,29 @@ parts connect, and every tool, op and argument.
61
61
  [SECURITY.md](.github/SECURITY.md) is how to report something.
62
62
  [CONTRIBUTING.md](.github/CONTRIBUTING.md) is what a change has to clear.
63
63
 
64
+ ## Versions
65
+
66
+ 0.x, and the number is telling the truth: the tools, their arguments and the shape of their
67
+ answers are still moving, because the projects using this keep finding answers that were wrong or
68
+ missing. Pin an exact version and upgrade deliberately.
69
+
70
+ Built and measured against Godot 4.7.2 on Windows, Linux and macOS. The editor, engine, runtime
71
+ and integration suites run against a real engine on all three before anything is tagged. Older
72
+ 4.x is likely to work and is not tested.
73
+
74
+ What each kind of release means here:
75
+
76
+ - **Patch**: an answer that was wrong is now right. The output can change, because a wrong answer
77
+ corrected is a fix rather than a feature, and leaving one wrong to protect whoever parsed it is
78
+ how a tool stops being worth asking.
79
+ - **Minor**: a tool, an op, an argument or a field was added, or behaviour a caller can see
80
+ changed.
81
+ - **Major**: a tool, an op, an argument or a field was renamed or taken away. Nothing else earns
82
+ one.
83
+
84
+ 1.0 is not a date, it is a condition: the tool surface holding still for a fortnight, and the
85
+ issues arriving being things it cannot do yet rather than things it answers wrongly.
86
+
64
87
  ## Project
65
88
 
66
89
  Fork of [GoPeak](https://github.com/HaD0Yun/Doyunha-Gopeak) v2.3.9, September 2026, MIT: the
package/build/cli.js CHANGED
@@ -15725,8 +15725,14 @@ var init_tool_definitions = __esm(() => {
15725
15725
  type: "boolean",
15726
15726
  description: "Only entries printed since the previous editor_output. Default false."
15727
15727
  },
15728
- contains: { type: "string", description: "Only entries mentioning this text." },
15729
- limit: { type: "number", description: "The most entries to answer with, newest kept. Default 200." }
15728
+ contains: {
15729
+ type: "string",
15730
+ description: "Only entries mentioning this text, matched against everything the run has printed rather than against the entries this answer would otherwise carry: a line is found however much was printed after it."
15731
+ },
15732
+ limit: {
15733
+ type: "number",
15734
+ description: "The most entries to answer with, newest kept. Default 200, and omitted says how many matching entries that left out. A run long enough to pass it needs this raised, not just filtered: filtering narrows what counts as matching, and the newest of those is still all one answer carries."
15735
+ }
15730
15736
  },
15731
15737
  requires: []
15732
15738
  },
@@ -15793,7 +15799,7 @@ var init_tool_definitions = __esm(() => {
15793
15799
  property: {
15794
15800
  type: "string",
15795
15801
  ops: ["property", "find"],
15796
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
15802
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step that is not there is named, and says what was there instead.'
15797
15803
  },
15798
15804
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15799
15805
  includeProperties: {
@@ -15867,7 +15873,7 @@ var init_tool_definitions = __esm(() => {
15867
15873
  property: {
15868
15874
  type: "string",
15869
15875
  ops: ["set"],
15870
- description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15876
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15871
15877
  },
15872
15878
  value: {
15873
15879
  ops: ["set"],
@@ -15877,7 +15883,7 @@ var init_tool_definitions = __esm(() => {
15877
15883
  method: {
15878
15884
  type: "string",
15879
15885
  ops: ["call"],
15880
- description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
15886
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, and a list or a map is stepped into by index or key, "_game:run:roster:0:retire".'
15881
15887
  },
15882
15888
  args: {
15883
15889
  type: "array",
@@ -36097,6 +36103,9 @@ function runsDirectory() {
36097
36103
  function recordPath() {
36098
36104
  return join14(runsDirectory(), "run.json");
36099
36105
  }
36106
+ function recordPaths() {
36107
+ return runtimeDirectories().map((directory) => join14(directory, "runs", "run.json"));
36108
+ }
36100
36109
  function openTranscript(startedAt) {
36101
36110
  mkdirSync6(runsDirectory(), { recursive: true });
36102
36111
  const path = join14(runsDirectory(), `run-${startedAt}.log`);
@@ -36107,9 +36116,18 @@ function writeRunRecord(record) {
36107
36116
  writeFileSync8(recordPath(), JSON.stringify(record, null, 2), "utf8");
36108
36117
  }
36109
36118
  function readRunRecord() {
36119
+ for (const path of recordPaths()) {
36120
+ const record = recordAt(path);
36121
+ if (record !== null) {
36122
+ return record;
36123
+ }
36124
+ }
36125
+ return null;
36126
+ }
36127
+ function recordAt(path) {
36110
36128
  let parsed;
36111
36129
  try {
36112
- parsed = JSON.parse(readFileSync12(recordPath(), "utf8"));
36130
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
36113
36131
  } catch {
36114
36132
  return null;
36115
36133
  }
@@ -36132,10 +36150,16 @@ function readRunRecord() {
36132
36150
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
36133
36151
  };
36134
36152
  }
36135
- function clearRunRecord() {
36136
- try {
36137
- rmSync7(recordPath());
36138
- } catch {}
36153
+ function clearRunRecord(ours = () => true) {
36154
+ for (const path of recordPaths()) {
36155
+ const record = recordAt(path);
36156
+ if (record === null || !ours(record)) {
36157
+ continue;
36158
+ }
36159
+ try {
36160
+ rmSync7(path);
36161
+ } catch {}
36162
+ }
36139
36163
  }
36140
36164
  function ageMs(path, now) {
36141
36165
  try {
@@ -37764,7 +37788,7 @@ class GodotServer {
37764
37788
  async endActiveGame() {
37765
37789
  const running = this.activeProcess;
37766
37790
  this.activeProcess = null;
37767
- clearRunRecord();
37791
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
37768
37792
  if (!running) {
37769
37793
  return;
37770
37794
  }
@@ -172,12 +172,12 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
172
172
  if reached.has("message"):
173
173
  entry["has_property"] = false
174
174
  return entry
175
- var holder: Object = reached["holder"]
175
+ var holder: Variant = reached["holder"]
176
176
  var named: String = reached["name"]
177
- var has: bool = _has_property(holder, named)
177
+ var has: bool = _can_read(holder, named)
178
178
  entry["has_property"] = has
179
179
  if has:
180
- entry["value"] = _values.serialize(holder.get(named))
180
+ entry["value"] = _values.serialize(_read(holder, named))
181
181
  return entry
182
182
 
183
183
 
@@ -500,18 +500,18 @@ func get_property(params: Dictionary) -> Dictionary:
500
500
  if reached.has("message"):
501
501
  return reached
502
502
 
503
- var holder: Object = reached["holder"]
503
+ var holder: Variant = reached["holder"]
504
504
  var named: String = reached["name"]
505
- # Asked of the property list rather than read and compared to null, because a property the
506
- # holder does not have and a property that is null both read as null.
507
- if not _has_property(holder, named):
508
- return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
505
+ # Asked of the holder rather than read and compared to null, because a property the holder does
506
+ # not have and a property that is null both read as null.
507
+ if not _can_read(holder, named):
508
+ return {"type": "error", "message": _nothing_there(holder, named, str(reached["called"]))}
509
509
 
510
510
  return {
511
511
  "type": "property",
512
512
  "path": node_path,
513
513
  "property": property,
514
- "value": _values.serialize(holder.get(named)),
514
+ "value": _values.serialize(_read(holder, named)),
515
515
  }
516
516
 
517
517
 
@@ -532,21 +532,115 @@ func get_property(params: Dictionary) -> Dictionary:
532
532
  ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
533
533
  func _reached(node: Node, node_path: String, reaching: String) -> Dictionary:
534
534
  var parts: PackedStringArray = reaching.split(":")
535
- var holder: Object = node
535
+ var holder: Variant = node
536
536
  var called: String = node_path
537
537
  for step: int in parts.size() - 1:
538
538
  var named: String = parts[step]
539
- if not _has_property(holder, named):
540
- return {"type": "error", "message": "%s has no property %s" % [called, named]}
541
- var next: Variant = holder.get(named)
539
+ if not _can_read(holder, named):
540
+ return {"type": "error", "message": _nothing_there(holder, named, called)}
541
+ holder = _read(holder, named)
542
542
  called = "%s:%s" % [called, named]
543
- if not next is Object:
543
+ if not _can_hold(holder):
544
544
  var wanted: String = parts[step + 1]
545
545
  return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
546
- holder = next
547
546
  return {"holder": holder, "name": parts[parts.size() - 1], "called": called}
548
547
 
549
548
 
549
+ ## Whether a step can be taken into [param value] at all: an object, a list or a map can be walked
550
+ ## into, and a number or a string is where a path ends whether the caller meant it to or not.
551
+ static func _can_hold(value: Variant) -> bool:
552
+ return value is Object or value is Array or value is Dictionary
553
+
554
+
555
+ ## Whether [param named] is something [param holder] has.
556
+ ##
557
+ ## Three kinds of holder, because the state a game keeps is not all properties of objects: a roster
558
+ ## is a list and an in-tray is a map, and a path that could not step into either stopped at the
559
+ ## first one. An index reads the way a property does, and a negative one counts from the end the
560
+ ## way GDScript's own does, so reading the last of something does not mean asking how many first.
561
+ static func _can_read(holder: Variant, named: String) -> bool:
562
+ if holder is Array:
563
+ var items: Array = holder
564
+ return _index_in(named, items.size()) != -1
565
+ if holder is Dictionary:
566
+ var map: Dictionary = holder
567
+ return map.has(named) or map.has(StringName(named))
568
+ if holder is Object:
569
+ return _has_property(holder, named)
570
+ return false
571
+
572
+
573
+ ## Put [param value] where [param named] points. Only ever called once [method _can_read] agrees,
574
+ ## and lists and maps are reference types, so writing into one reaches the game's own copy.
575
+ static func _write(holder: Variant, named: String, value: Variant) -> void:
576
+ if holder is Array:
577
+ var items: Array = holder
578
+ items[_index_in(named, items.size())] = value
579
+ return
580
+ if holder is Dictionary:
581
+ var map: Dictionary = holder
582
+ # Written back under the key it was found under, since a map keyed by StringName and one
583
+ # keyed by String both read the same way and a write to the wrong one adds a second entry.
584
+ if map.has(named):
585
+ map[named] = value
586
+ else:
587
+ map[StringName(named)] = value
588
+ return
589
+ var object: Object = holder
590
+ object.set(named, value)
591
+
592
+
593
+ ## What [param holder] holds under [param named]. Only ever called once [method _can_read] agrees.
594
+ static func _read(holder: Variant, named: String) -> Variant:
595
+ if holder is Array:
596
+ var items: Array = holder
597
+ return items[_index_in(named, items.size())]
598
+ if holder is Dictionary:
599
+ var map: Dictionary = holder
600
+ if map.has(named):
601
+ return map[named]
602
+ return map[StringName(named)]
603
+ var object: Object = holder
604
+ return object.get(named)
605
+
606
+
607
+ ## Where [param named] lands in a list of [param size], or -1 for a step that is not in it.
608
+ ##
609
+ ## -1 for "nowhere" is safe because every answer this gives is an index into a list that long, so a
610
+ ## real one is never negative by the time it is returned.
611
+ static func _index_in(named: String, size: int) -> int:
612
+ if not named.is_valid_int():
613
+ return -1
614
+ var index: int = int(named)
615
+ if index < 0:
616
+ index += size
617
+ return index if index >= 0 and index < size else -1
618
+
619
+
620
+ ## Why a step could not be taken, said in the terms of what was actually there.
621
+ ##
622
+ ## "has no property 0" about a list is a true sentence that sends somebody looking for a property,
623
+ ## so a list says how long it is and a map says what it is keyed by. The step is named rather than
624
+ ## the whole path, because the step is the typo.
625
+ static func _nothing_there(holder: Variant, named: String, called: String) -> String:
626
+ if holder is Array:
627
+ var items: Array = holder
628
+ return "%s is a list of %d, so there is no %s in it" % [called, items.size(), named]
629
+ if holder is Dictionary:
630
+ var map: Dictionary = holder
631
+ var keys: Array = map.keys()
632
+ # Eight of them, because a map keyed by something unexpected is told by the first few and a
633
+ # map of two hundred would bury the sentence saying which key was missing.
634
+ var some: PackedStringArray = []
635
+ for key: Variant in keys.slice(0, 8):
636
+ some.append(str(key))
637
+ var rest: String = ""
638
+ if keys.size() > 8:
639
+ rest = " and %d more" % [keys.size() - 8]
640
+ return "%s has no key %s; it is keyed by %s%s" % [called, named, ", ".join(some), rest]
641
+ return "%s has no property %s" % [called, named]
642
+
643
+
550
644
  ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
551
645
  ## property read back as null answers it wrongly.
552
646
  static func _has_property(holder: Object, named: String) -> bool:
@@ -576,17 +670,19 @@ func set_property(params: Dictionary) -> Dictionary:
576
670
  if reached.has("message"):
577
671
  return reached
578
672
 
579
- var holder: Object = reached["holder"]
673
+ var holder: Variant = reached["holder"]
580
674
  var named: String = reached["name"]
581
- var old_value: Variant = holder.get(named)
582
- holder.set(named, _values.fitted(value, typeof(old_value)))
675
+ if not _can_read(holder, named):
676
+ return {"type": "error", "message": _nothing_there(holder, named, str(reached["called"]))}
677
+ var old_value: Variant = _read(holder, named)
678
+ _write(holder, named, _values.fitted(value, typeof(old_value)))
583
679
 
584
680
  return {
585
681
  "type": "property_set",
586
682
  "path": node_path,
587
683
  "property": property,
588
684
  "old_value": _values.serialize(old_value),
589
- "new_value": _values.serialize(holder.get(named))
685
+ "new_value": _values.serialize(_read(holder, named))
590
686
  }
591
687
 
592
688
 
@@ -610,6 +706,19 @@ func call_method(params: Dictionary) -> Dictionary:
610
706
  if reached.has("message"):
611
707
  return reached
612
708
 
709
+ # A list or a map can be stepped through on the way, and is not a thing with methods when the
710
+ # path stops on one: said in those terms rather than as "has no method", which reads as a
711
+ # misspelling of a method that was never going to be there.
712
+ if not reached["holder"] is Object:
713
+ var sort: String = "a list" if reached["holder"] is Array else "a map"
714
+ return {
715
+ "type": "error",
716
+ "message":
717
+ (
718
+ "%s is %s, which has no methods: name the element to call it on, as in %s:0:%s"
719
+ % [reached["called"], sort, reached["called"], reached["name"]]
720
+ )
721
+ }
613
722
  var holder: Object = reached["holder"]
614
723
  var named: String = reached["name"]
615
724
  if not holder.has_method(named):
@@ -136,7 +136,36 @@ func _serialize_object(value: Object) -> Dictionary:
136
136
  var node: Node = value
137
137
  if node.is_inside_tree():
138
138
  return {"_type": "Node", "class": node.get_class(), "path": str(node.get_path())}
139
- return {"_type": "Object", "class": value.get_class()}
139
+ return _describe_object(value)
140
+
141
+
142
+ ## A plain object, said in enough detail to tell it from the one beside it.
143
+ ##
144
+ ## `{"class": "RefCounted"}` was all of it, so a roster of twelve people came back as the word
145
+ ## RefCounted twelve times: correct, useless, and indistinguishable from a list of twelve of
146
+ ## anything else. The state worth looking at in a game is lists of these, so each one carries what
147
+ ## it is (the `class_name` its script declares, or the script's path when it has no name), what it
148
+ ## says about itself when the game has given it a `_to_string`, and an id that is at least
149
+ ## different per object. None of it is invented: every field is left out when there is nothing to
150
+ ## put in it, because a key holding "" is a worse answer than a key that is not there.
151
+ func _describe_object(value: Object) -> Dictionary:
152
+ var described: Dictionary = {"_type": "Object", "class": value.get_class()}
153
+ # Asked as a Variant and narrowed, because get_script answers one and an object without a
154
+ # script answers null: casting that to Script is the unsafe cast the engine refuses to compile.
155
+ var attached: Variant = value.get_script()
156
+ if attached is Script:
157
+ var script: Script = attached
158
+ var declared: String = String(script.get_global_name())
159
+ if not declared.is_empty():
160
+ described["script_class"] = declared
161
+ elif not script.resource_path.is_empty():
162
+ described["script"] = script.resource_path
163
+ # Only when the game wrote one. The default is "<RefCounted#31098236>", which is the id again
164
+ # in a costume and reads as if the object had been asked and had nothing to say.
165
+ if value.has_method("_to_string"):
166
+ described["says"] = value.to_string()
167
+ described["id"] = value.get_instance_id()
168
+ return described
140
169
 
141
170
 
142
171
  ## Rebuilds a Godot value from the shape serialize gave it.
package/build/index.js CHANGED
@@ -27798,6 +27798,9 @@ function runsDirectory() {
27798
27798
  function recordPath() {
27799
27799
  return join8(runsDirectory(), "run.json");
27800
27800
  }
27801
+ function recordPaths() {
27802
+ return runtimeDirectories().map((directory) => join8(directory, "runs", "run.json"));
27803
+ }
27801
27804
  function openTranscript(startedAt) {
27802
27805
  mkdirSync3(runsDirectory(), { recursive: true });
27803
27806
  const path = join8(runsDirectory(), `run-${startedAt}.log`);
@@ -27808,9 +27811,18 @@ function writeRunRecord(record) {
27808
27811
  writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
27809
27812
  }
27810
27813
  function readRunRecord() {
27814
+ for (const path of recordPaths()) {
27815
+ const record = recordAt(path);
27816
+ if (record !== null) {
27817
+ return record;
27818
+ }
27819
+ }
27820
+ return null;
27821
+ }
27822
+ function recordAt(path) {
27811
27823
  let parsed;
27812
27824
  try {
27813
- parsed = JSON.parse(readFileSync8(recordPath(), "utf8"));
27825
+ parsed = JSON.parse(readFileSync8(path, "utf8"));
27814
27826
  } catch {
27815
27827
  return null;
27816
27828
  }
@@ -27833,10 +27845,16 @@ function readRunRecord() {
27833
27845
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
27834
27846
  };
27835
27847
  }
27836
- function clearRunRecord() {
27837
- try {
27838
- rmSync3(recordPath());
27839
- } catch {}
27848
+ function clearRunRecord(ours = () => true) {
27849
+ for (const path of recordPaths()) {
27850
+ const record = recordAt(path);
27851
+ if (record === null || !ours(record)) {
27852
+ continue;
27853
+ }
27854
+ try {
27855
+ rmSync3(path);
27856
+ } catch {}
27857
+ }
27840
27858
  }
27841
27859
  function ageMs(path, now) {
27842
27860
  try {
@@ -28511,8 +28529,14 @@ var TOOL_SPECS = [
28511
28529
  type: "boolean",
28512
28530
  description: "Only entries printed since the previous editor_output. Default false."
28513
28531
  },
28514
- contains: { type: "string", description: "Only entries mentioning this text." },
28515
- limit: { type: "number", description: "The most entries to answer with, newest kept. Default 200." }
28532
+ contains: {
28533
+ type: "string",
28534
+ description: "Only entries mentioning this text, matched against everything the run has printed rather than against the entries this answer would otherwise carry: a line is found however much was printed after it."
28535
+ },
28536
+ limit: {
28537
+ type: "number",
28538
+ description: "The most entries to answer with, newest kept. Default 200, and omitted says how many matching entries that left out. A run long enough to pass it needs this raised, not just filtered: filtering narrows what counts as matching, and the newest of those is still all one answer carries."
28539
+ }
28516
28540
  },
28517
28541
  requires: []
28518
28542
  },
@@ -28579,7 +28603,7 @@ var TOOL_SPECS = [
28579
28603
  property: {
28580
28604
  type: "string",
28581
28605
  ops: ["property", "find"],
28582
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
28606
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A negative number counts from the end. A step that is not there is named, and says what was there instead.'
28583
28607
  },
28584
28608
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28585
28609
  includeProperties: {
@@ -28653,7 +28677,7 @@ var TOOL_SPECS = [
28653
28677
  property: {
28654
28678
  type: "string",
28655
28679
  ops: ["set"],
28656
- description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28680
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end. The answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28657
28681
  },
28658
28682
  value: {
28659
28683
  ops: ["set"],
@@ -28663,7 +28687,7 @@ var TOOL_SPECS = [
28663
28687
  method: {
28664
28688
  type: "string",
28665
28689
  ops: ["call"],
28666
- description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
28690
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, and a list or a map is stepped into by index or key, "_game:run:roster:0:retire".'
28667
28691
  },
28668
28692
  args: {
28669
28693
  type: "array",
@@ -30546,7 +30570,7 @@ class GodotServer {
30546
30570
  async endActiveGame() {
30547
30571
  const running = this.activeProcess;
30548
30572
  this.activeProcess = null;
30549
- clearRunRecord();
30573
+ clearRunRecord((record) => this.couldBeOurs(record.projectPath));
30550
30574
  if (!running) {
30551
30575
  return;
30552
30576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",