545 lines
21 KiB
GDScript
545 lines
21 KiB
GDScript
@tool
|
|
class_name McpYamlStrategy
|
|
extends RefCounted
|
|
|
|
## Minimal YAML upsert for Hermes Agent MCP config.
|
|
##
|
|
## Hermes reads MCP servers from ~/.hermes/config.yaml under the
|
|
## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred:
|
|
## just `url` (plus optional `headers`), no `type` field. We only parse the
|
|
## `mcp_servers` block and re-emit it; other top-level keys in the user's
|
|
## config.yaml are preserved verbatim by round-tripping the raw lines around
|
|
## that block. No general YAML parser — Godot has none in stdlib, and Hermes
|
|
## only needs this one shape. See issue #640.
|
|
|
|
const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml
|
|
|
|
|
|
static func configure(
|
|
client: McpClient,
|
|
server_name: String,
|
|
server_url: String,
|
|
launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
var resolution := client.resolved_config_path_details()
|
|
var path := str(resolution.get("path", ""))
|
|
var path_error := str(resolution.get("error", ""))
|
|
if not path_error.is_empty():
|
|
return {"status": "error", "message": path_error}
|
|
if path.is_empty():
|
|
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
|
## Fail closed before touching the file — same contract as JSON/TOML.
|
|
var launch_error := command_launch_error(client, launch)
|
|
if not launch_error.is_empty():
|
|
return {"status": "error", "message": launch_error}
|
|
|
|
var seed_path := str(resolution.get("seed_path", ""))
|
|
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
|
|
var read := _read(read_path)
|
|
if not read["ok"]:
|
|
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
|
|
|
|
var text: String = read["data"]
|
|
var block := _extract_block(text)
|
|
var entries: Dictionary = block["entries"]
|
|
|
|
# Preserve existing entry's user-mutable keys; force the transport keys.
|
|
var existing: Dictionary = entries.get(server_name, {})
|
|
var new_entry := build_entry(client, server_url, existing, launch)
|
|
entries[server_name] = new_entry
|
|
|
|
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
|
|
if not McpAtomicWrite.write(path, out):
|
|
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
|
|
|
|
|
|
static func check_status(
|
|
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
) -> McpClient.Status:
|
|
return check_status_details(client, server_name, server_url, launch)["status"]
|
|
|
|
|
|
## Same contract as the JSON/TOML strategies (#711): {status, error_msg}.
|
|
## An existing-but-unreadable config is ERROR with the diagnostic — not
|
|
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
|
|
## editor can't read" instead of offering a Configure that would fail.
|
|
static func check_status_details(
|
|
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
|
|
) -> Dictionary:
|
|
var resolution := client.resolved_config_path_details()
|
|
var path := str(resolution.get("path", ""))
|
|
var path_error := str(resolution.get("error", ""))
|
|
if not path_error.is_empty():
|
|
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
|
|
if path.is_empty() or not FileAccess.file_exists(path):
|
|
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
var read := _read(path)
|
|
if not read["ok"]:
|
|
return {
|
|
"status": McpClient.Status.ERROR,
|
|
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
|
|
}
|
|
var block := _extract_block(String(read["data"]))
|
|
var entries: Dictionary = block["entries"]
|
|
if not entries.has(server_name):
|
|
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
var entry: Variant = entries[server_name]
|
|
if not (entry is Dictionary):
|
|
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
|
## An entry exists but no verified launcher does — mirror JSON/TOML: this
|
|
## is an environment ERROR, not entry drift.
|
|
var launch_error := command_launch_error(client, launch)
|
|
if not launch_error.is_empty():
|
|
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
|
|
if verify_entry(client, entry, server_url, launch):
|
|
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
|
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
|
|
|
|
|
|
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
|
var resolution := client.resolved_config_path_details()
|
|
var path := str(resolution.get("path", ""))
|
|
var path_error := str(resolution.get("error", ""))
|
|
if not path_error.is_empty():
|
|
return {"status": "error", "message": path_error}
|
|
if path.is_empty() or not FileAccess.file_exists(path):
|
|
return {"status": "ok", "message": "Not configured"}
|
|
var read := _read(path)
|
|
if not read["ok"]:
|
|
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
|
|
var text: String = read["data"]
|
|
var block := _extract_block(text)
|
|
var entries: Dictionary = block["entries"]
|
|
if not entries.has(server_name):
|
|
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
entries.erase(server_name)
|
|
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
|
|
if not McpAtomicWrite.write(path, out):
|
|
return {"status": "error", "message": "Cannot write to %s" % path}
|
|
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
|
|
|
|
|
|
## Build the entry dict written under mcp_servers[server_name].
|
|
##
|
|
## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred
|
|
## — { url: <url> } plus whatever user-mutable keys (headers, enabled, tools,
|
|
## ...) the existing entry carries. Stdio-bridge keys are the one exception
|
|
## (see _STDIO_BRIDGE_KEYS). No `type` field.
|
|
##
|
|
## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys,
|
|
## transport inferred the same way — which is exactly why the descriptor's
|
|
## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with
|
|
## both a url and a command picks the wrong transport. User keys survive.
|
|
static func build_entry(
|
|
client: McpClient,
|
|
server_url: String,
|
|
existing: Variant = null,
|
|
launch: Dictionary = {},
|
|
) -> Dictionary:
|
|
if client.command_shape == McpClient.CommandShape.FLAT:
|
|
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
|
|
command_entry["command"] = str(launch.get("command", ""))
|
|
command_entry["args"] = _array_copy(launch.get("args", []))
|
|
if not client.command_transport_key.is_empty():
|
|
command_entry[client.command_transport_key] = client.command_transport_value
|
|
for key in client.command_initial_fields:
|
|
if not command_entry.has(key):
|
|
command_entry[key] = client.command_initial_fields[key]
|
|
for key in client.command_legacy_keys:
|
|
command_entry.erase(String(key))
|
|
return command_entry
|
|
if client.command_shape != McpClient.CommandShape.NONE:
|
|
## Every production caller checks command_launch_error first. Keep this
|
|
## builder defensive too so a future unsupported shape cannot silently
|
|
## degrade into a flat YAML entry.
|
|
return {}
|
|
var entry: Dictionary = {}
|
|
if existing is Dictionary:
|
|
## User-mutable keys (headers, enabled, tools, ...) survive a
|
|
## reconfigure — the same preservation contract the JSON strategy's
|
|
## entry_initial_fields split implements. Only the stdio-bridge keys
|
|
## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed.
|
|
entry = (existing as Dictionary).duplicate(true)
|
|
for stale_key in _STDIO_BRIDGE_KEYS:
|
|
entry.erase(stale_key)
|
|
entry[client.entry_url_field] = server_url
|
|
return entry
|
|
|
|
|
|
## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry.
|
|
## These must NOT survive a URL reconfigure: a Hermes entry with both a url
|
|
## and a command picks the wrong transport.
|
|
const _STDIO_BRIDGE_KEYS := ["command", "args", "env"]
|
|
|
|
|
|
## Empty string when this client's launch requirements are satisfied.
|
|
## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only.
|
|
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
|
|
if client.command_shape == McpClient.CommandShape.NONE:
|
|
return ""
|
|
if client.command_shape != McpClient.CommandShape.FLAT:
|
|
return "%s uses a command shape not supported by YAML yet" % client.display_name
|
|
if not bool(launch.get("ok", false)):
|
|
return str(launch.get("error", "No compatible attach launcher was found."))
|
|
return ""
|
|
|
|
|
|
## Verify a stored entry matches.
|
|
##
|
|
## URL mode: Hermes entries have no transport type pin, so verification is:
|
|
## url matches. Extra keys (headers, enabled, tools) are user-mutable and
|
|
## intentionally NOT checked (mirrors json entry_initial_fields).
|
|
##
|
|
## Command mode: every launch-affecting value must match exactly and legacy
|
|
## URL-transport keys must be gone — their presence is migration drift.
|
|
static func verify_entry(
|
|
client: McpClient,
|
|
entry: Dictionary,
|
|
server_url: String,
|
|
launch: Dictionary = {},
|
|
) -> bool:
|
|
if client.command_shape != McpClient.CommandShape.NONE:
|
|
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
|
|
return false
|
|
for key in client.command_legacy_keys:
|
|
if entry.has(String(key)):
|
|
return false
|
|
if entry.get("command") != launch.get("command"):
|
|
return false
|
|
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
|
|
return false
|
|
if not client.command_transport_key.is_empty():
|
|
if entry.get(client.command_transport_key, null) != client.command_transport_value:
|
|
return false
|
|
return true
|
|
return entry.get(client.entry_url_field, "") == server_url
|
|
|
|
|
|
static func _array_copy(value: Variant) -> Array:
|
|
if value is Array:
|
|
return (value as Array).duplicate(true)
|
|
if value is PackedStringArray:
|
|
return McpClient._array_from_packed(value)
|
|
return []
|
|
|
|
|
|
static func _arrays_equal(left: Variant, right: Variant) -> bool:
|
|
if not (left is Array or left is PackedStringArray):
|
|
return false
|
|
if not (right is Array or right is PackedStringArray):
|
|
return false
|
|
var left_array := _array_copy(left)
|
|
var right_array := _array_copy(right)
|
|
if left_array.size() != right_array.size():
|
|
return false
|
|
for i in range(left_array.size()):
|
|
if left_array[i] != right_array[i]:
|
|
return false
|
|
return true
|
|
|
|
|
|
# --- YAML block handling (scoped to mcp_servers) -------------------------
|
|
|
|
## Parse the file into three regions:
|
|
## prefix_lines — everything before `mcp_servers:` (may be empty)
|
|
## entries — the map of server_name -> {url, ...} under mcp_servers
|
|
## suffix_lines — everything after the mcp_servers block (may be empty)
|
|
## This lets us rewrite only the mcp_servers block and keep the rest of the
|
|
## user's config.yaml byte-for-byte intact.
|
|
static func _extract_block(text: String) -> Dictionary:
|
|
## allow_empty must stay true: dropping empty splits would silently strip
|
|
## the user's blank lines from the preserved prefix/suffix regions on
|
|
## every rewrite. The parse loops below already skip blank lines.
|
|
var lines := text.split("\n")
|
|
var prefix: PackedStringArray = []
|
|
var entries: Dictionary = {}
|
|
var suffix: PackedStringArray = []
|
|
var header_idx := -1
|
|
for i in range(lines.size()):
|
|
if lines[i].strip_edges().begins_with("mcp_servers:"):
|
|
header_idx = i
|
|
break
|
|
if header_idx < 0:
|
|
# No mcp_servers yet — whole file is prefix; block will be appended.
|
|
prefix = lines.duplicate()
|
|
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1}
|
|
|
|
for i in range(0, header_idx):
|
|
prefix.append(lines[i])
|
|
|
|
# Determine the indent of the first entry so we can tell sibling
|
|
# entries (same indent) apart from nested keys (deeper indent) and
|
|
# parent-level keys (less indent). All server entries under
|
|
# `mcp_servers:` share one indent level; breaking on 0-indent alone
|
|
# mis-nests 2-space-indented siblings under the first entry.
|
|
var entry_indent := -1
|
|
var probe := header_idx + 1
|
|
while probe < lines.size() and _is_blank_or_comment(lines[probe]):
|
|
probe += 1
|
|
if probe < lines.size():
|
|
entry_indent = _indent_of(lines[probe])
|
|
|
|
# Empty block guard: the first nonblank line after the header must sit
|
|
# DEEPER than the header itself to be an entry. At or above the header's
|
|
# indent it is a sibling/parent key — parsing it as an entry would
|
|
# swallow the user's next top-level key and re-emit it nested under
|
|
# mcp_servers, corrupting the file.
|
|
if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]):
|
|
for j in range(header_idx + 1, lines.size()):
|
|
suffix.append(lines[j])
|
|
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
|
|
|
|
var i := header_idx + 1
|
|
while i < lines.size():
|
|
var raw := lines[i]
|
|
## Comment-only lines inside the block are skipped like blanks —
|
|
## treating one as an entry header would re-emit it as a bogus
|
|
## `# comment:` server on rewrite. (Comments INSIDE the rewritten
|
|
## block are consequently dropped; comments outside the block live
|
|
## in prefix/suffix and survive verbatim.)
|
|
if _is_blank_or_comment(raw):
|
|
i += 1
|
|
continue
|
|
# Stop at any line indented less than a sibling entry (parent key
|
|
# or a new top-level section), or at the header's own level.
|
|
if _indent_of(raw) < entry_indent:
|
|
break
|
|
var entry := _parse_entry(raw, lines, i, entry_indent)
|
|
if not entry["name"].is_empty():
|
|
entries[entry["name"]] = entry["data"]
|
|
i = entry["next_idx"]
|
|
|
|
for j in range(i, lines.size()):
|
|
suffix.append(lines[j])
|
|
|
|
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
|
|
|
|
|
|
## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper-
|
|
## indented sublines (url, headers, etc.) and returns the next sibling index.
|
|
static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
|
|
var name := raw.strip_edges().trim_suffix(":").strip_edges()
|
|
var data: Dictionary = {}
|
|
var i := start + 1
|
|
while i < lines.size():
|
|
var l := lines[i]
|
|
## Comments inside an entry (e.g. ` # auth for CI`) would parse
|
|
## as a `# auth for CI` key — skip them like blanks.
|
|
if _is_blank_or_comment(l):
|
|
i += 1
|
|
continue
|
|
# A line at or above the entry's indent is a sibling/parent key.
|
|
if _indent_of(l) <= entry_indent:
|
|
break
|
|
var stripped := l.strip_edges()
|
|
var colon := stripped.find(":")
|
|
if colon < 0:
|
|
i += 1
|
|
continue
|
|
var key := stripped.substr(0, colon).strip_edges()
|
|
var val := stripped.substr(colon + 1).strip_edges()
|
|
if val.is_empty():
|
|
# Nested block (e.g. headers:). Parse as raw sub-dict lines for
|
|
# preservation; we don't introspect deeper than url at the top.
|
|
var sub := _parse_subblock(lines, i + 1, entry_indent)
|
|
data[key] = sub["value"]
|
|
i = sub["next_idx"]
|
|
else:
|
|
data[key] = _coerce_scalar(val)
|
|
i += 1
|
|
return {"name": name, "data": data, "next_idx": i}
|
|
|
|
|
|
## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of
|
|
## scalar key/values. Deeper nesting is flattened into scalar strings — fine
|
|
## for Hermes' known shape (headers are flat key: value).
|
|
static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
|
|
var sub: Dictionary = {}
|
|
var i := start
|
|
while i < lines.size():
|
|
var l := lines[i]
|
|
if _is_blank_or_comment(l):
|
|
i += 1
|
|
continue
|
|
# A line at or above the parent entry's indent ends the nested block.
|
|
if _indent_of(l) <= entry_indent:
|
|
break
|
|
var stripped := l.strip_edges()
|
|
var colon := stripped.find(":")
|
|
if colon < 0:
|
|
i += 1
|
|
continue
|
|
var key := stripped.substr(0, colon).strip_edges()
|
|
var val := stripped.substr(colon + 1).strip_edges()
|
|
if val.is_empty():
|
|
i += 1
|
|
continue
|
|
sub[key] = _coerce_scalar(val)
|
|
i += 1
|
|
return {"value": sub, "next_idx": i}
|
|
|
|
|
|
## Reassemble the full file text from prefix + a freshly built mcp_servers
|
|
## block + suffix. If the block didn't exist before, it is appended.
|
|
static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String:
|
|
var out: PackedStringArray = []
|
|
for l in prefix:
|
|
out.append(l)
|
|
# Trim trailing blank lines from prefix so we don't stack double blanks.
|
|
while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty():
|
|
out.remove_at(out.size() - 1)
|
|
|
|
if not _text.contains("mcp_servers:"):
|
|
# File existed but had no mcp_servers block — append it.
|
|
if out.size() > 0:
|
|
out.append("")
|
|
out.append("mcp_servers:")
|
|
for name in entries:
|
|
out.append_array(_emit_entry(name, entries[name]))
|
|
else:
|
|
out.append("mcp_servers:")
|
|
for name in entries:
|
|
out.append_array(_emit_entry(name, entries[name]))
|
|
|
|
# Suffix: keep as-is.
|
|
for l in suffix:
|
|
out.append(l)
|
|
return "\n".join(out)
|
|
|
|
|
|
## Public rendering seam for the dock's manual-instruction text, so the
|
|
## pasted YAML matches what Configure would write byte-for-byte.
|
|
static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray:
|
|
return _emit_entry(name, data)
|
|
|
|
|
|
## Emit one ` name:` entry with its scalar keys (top level only; headers
|
|
## sub-dict is re-emitted as nested scalars; arrays — the command entry's
|
|
## `args` — are emitted in flow style on one line).
|
|
static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray:
|
|
var lines: PackedStringArray = []
|
|
lines.append(INDENT + "%s:" % name)
|
|
for key in data:
|
|
var val = data[key]
|
|
if val is Dictionary:
|
|
lines.append(INDENT + INDENT + "%s:" % key)
|
|
for sk in val:
|
|
lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, _emit_scalar(val[sk])])
|
|
elif val is Array or val is PackedStringArray:
|
|
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_flow_array(_array_copy(val))])
|
|
else:
|
|
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_scalar(val)])
|
|
return lines
|
|
|
|
|
|
## Flow-style sequence with every item double-quoted. JSON string quoting is
|
|
## valid YAML double-quote style (shared escape set), so the same encoding
|
|
## both writes the file and — via JSON.parse_string in `_coerce_scalar` —
|
|
## reads it back for verification.
|
|
static func _emit_flow_array(values: Array) -> String:
|
|
var parts: Array[String] = []
|
|
for v in values:
|
|
parts.append(JSON.stringify(str(v)))
|
|
return "[%s]" % ", ".join(parts)
|
|
|
|
|
|
static func _emit_scalar(v: Variant) -> String:
|
|
match typeof(v):
|
|
TYPE_BOOL:
|
|
return "true" if bool(v) else "false"
|
|
TYPE_INT:
|
|
return str(int(v))
|
|
TYPE_FLOAT:
|
|
return str(float(v))
|
|
_:
|
|
return _emit_string_scalar(str(v))
|
|
|
|
|
|
## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
|
|
## indicators, or leading indicator characters — a Windows launcher path with
|
|
## spaces would silently corrupt the entry. Quote exactly when needed so
|
|
## existing plain values (urls, bools-as-strings) keep their current
|
|
## byte-shape on rewrite.
|
|
static func _emit_string_scalar(s: String) -> String:
|
|
if s.is_empty():
|
|
return "\"\""
|
|
var needs_quote := s.begins_with(" ") or s.ends_with(" ")
|
|
if not needs_quote:
|
|
for needle in [": ", " #", "\"", "'", "\n", "\t", "{", "}", "[", "]", ","]:
|
|
if s.contains(needle):
|
|
needs_quote = true
|
|
break
|
|
if not needs_quote:
|
|
for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
|
|
if s.begins_with(prefix):
|
|
needs_quote = true
|
|
break
|
|
return JSON.stringify(s) if needs_quote else s
|
|
|
|
|
|
## Blank and comment-only lines carry no structure — every scan loop skips
|
|
## them the same way so a `# comment` can never be mistaken for an entry
|
|
## header or a key/value line.
|
|
static func _is_blank_or_comment(line: String) -> bool:
|
|
var stripped := line.strip_edges()
|
|
return stripped.is_empty() or stripped.begins_with("#")
|
|
|
|
|
|
## Returns the leading-whitespace indent width of a line (spaces + tabs
|
|
## counted as 1 each). Used to distinguish sibling entries (same indent)
|
|
## from nested keys (deeper indent) and parent-level keys (less indent).
|
|
static func _indent_of(line: String) -> int:
|
|
var n := 0
|
|
while n < line.length() and (line[n] == " " or line[n] == "\t"):
|
|
n += 1
|
|
return n
|
|
|
|
|
|
## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
|
|
## bare true/false/numbers are typed; double-quoted flow sequences (the
|
|
## command entry's `args`) parse back into an Array. Good enough for Hermes'
|
|
## url/headers/command/args. A hand-edited args in block style or with
|
|
## unquoted items doesn't JSON-parse — it stays a raw string, compares
|
|
## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
|
|
## normalizes back to the flow form.
|
|
static func _coerce_scalar(s: String) -> Variant:
|
|
var t := s.strip_edges()
|
|
if t.begins_with("[") and t.ends_with("]"):
|
|
var parsed_array: Variant = JSON.parse_string(t)
|
|
if parsed_array is Array:
|
|
return parsed_array
|
|
return t
|
|
if t.begins_with("\"") and t.ends_with("\""):
|
|
var parsed_string: Variant = JSON.parse_string(t)
|
|
if parsed_string is String:
|
|
return parsed_string
|
|
return t.substr(1, t.length() - 2)
|
|
if t.begins_with("'") and t.ends_with("'"):
|
|
return t.substr(1, t.length() - 2)
|
|
if t == "true":
|
|
return true
|
|
if t == "false":
|
|
return false
|
|
if t.is_valid_int():
|
|
return t.to_int()
|
|
if t.is_valid_float():
|
|
return t.to_float()
|
|
return t
|
|
|
|
|
|
## Returns {"ok": true, "data": String} when the file is absent or readable,
|
|
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
|
|
## back to an empty string on the error path — doing so blows away the user's
|
|
## other config.yaml entries on the next write.
|
|
static func _read(path: String) -> Dictionary:
|
|
if not FileAccess.file_exists(path):
|
|
return {"ok": true, "data": ""}
|
|
var f := FileAccess.open(path, FileAccess.READ)
|
|
if f == null:
|
|
var err := FileAccess.get_open_error()
|
|
return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
|
|
var t := f.get_as_text()
|
|
f.close()
|
|
return {"ok": true, "data": t}
|