Files
Brachistochrone/addons/godot_ai/clients/_dsh_strategy.gd
T
scorpion21211-cmd e24bbbda5d Initial commit
2026-10-03 23:29:41 -04:00

760 lines
30 KiB
GDScript

@tool
class_name McpDshStrategy
extends RefCounted
## Configure / status / remove for DeepSeek Harness (dsh) MCP servers.
##
## DeepSeek Harness has no `mcp` CLI verb (verified against dsh
## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers
## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch
## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies
## over EVERY profile's own patch layer (the web GUI included), so one entry
## covers every profile the user boots.
##
## The patch file is a top-level YAML array of loader patch entries
## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert
## row only OVERRIDES an existing bundle row (a new id is skipped with a
## warning), so a brand-new server must be added with an `insert` row:
##
## - insert:
## - id: mcp-godot-ai
## name: '@deepseek-ai/dsh-mcp-client'
## config:
## serverName: godot-ai
## transport: stdio
## command: "..."
## args: [...]
##
## Both shapes verified live against the dsh loader
## (`dsh --profile web --dump-config` composes the inserted entry; a plain
## row for a new id warns "patch: entry not found" and is skipped). We write
## one dedicated insert row per server, preserve every other row byte-for-byte
## (including other users' insert rows, overrides, comments, and `!!js`
## expressions), and status-verify the entry we wrote — the same file-read
## drift contract the JSON/TOML/YAML strategies use.
##
## The entry's `config` nests the launch under `serverName`/`transport`/
## `command`/`args`; `serverName` is the model-facing tool namespace
## (`mcp__godot-ai__<tool>`), so it carries the server name like the
## `server_key_path` map key does for the other strategies.
## Loader entry id prefix inside the patch list. The full id is
## `<prefix><server_name>` — "mcp-godot-ai" for the godot-ai server.
const ENTRY_ID_PREFIX := "mcp-"
## The loader plugin that bridges MCP servers into the harness. Must be
## installed in the profile's node_modules (dsh ships it as a dependency of
## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and
## cannot start a plain scalar.
const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client"
## Indent of a nested entry header under `- insert:` (children step 2 spaces
## deeper), matching the shipped profile patch files.
const ENTRY_INDENT := 4
static func entry_id(server_name: String) -> String:
return ENTRY_ID_PREFIX + server_name
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/YAML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var 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." % [path, read["error"]]}
## Fail closed on content that is not a loader patch list: dsh's patch
## parser rejects a non-array file at boot, so appending our row to a
## top-level mapping would hand the user a broken file (#867 review).
## `[]` is a valid empty sequence meaning "no rows"; the literal must not
## survive an append (block rows after `[]` are malformed YAML), so only
## that line is dropped — comments around it belong to the user and stay.
var text := String(read["data"])
text = _strip_empty_flow_sequence(text)
if not _is_patch_sequence(text):
return {
"status": "error",
"message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path,
}
var existing_config: Variant = null
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
var plain := _find_entry_block(lines, entry_id(server_name), false)
if not plain.is_empty():
## A plain `- id: mcp-godot-ai` row is inert for a new id — the
## loader skips it with a warning and never registers the server.
## Replacing the whole row with the insert form migrates it.
found = plain
if not found.is_empty():
## Pass the parsed config through so user-mutable fields (env,
## toolCallTimeoutMs, reconnect) survive a reconfigure.
existing_config = _extract_config(lines, found)
var new_entry := build_entry(client, server_name, server_url, existing_config, launch)
## Nested replacement must keep the sequence's existing header indent —
## items of one block sequence share indentation, so a hand-written
## 2-space file re-emitted at 4 spaces would no longer parse (#867
## review). Fresh rows and plain-row migrations keep the 4-space style.
var base_indent := ENTRY_INDENT
if not found.is_empty():
var header_indent := int(found.get("indent", 0))
if header_indent > 0:
base_indent = header_indent
var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent)
var out := ""
if found.is_empty():
out = _append_row(lines, rendered)
else:
out = _replace_lines(lines, found, rendered)
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 other 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 text := String(read["data"])
if not _is_patch_sequence(text):
## A top-level mapping/scalar is not a dsh patch list: nothing in it
## can be "configured", and a nested `- id:` inside a mapping must
## not read as ours (#867 review).
return {
"status": McpClient.Status.ERROR,
"error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path,
}
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
## A plain row for our id is inert (the loader skips unknown ids), so
## the server is NOT registered no matter what the file says. It only
## becomes registered once Configure rewrites it as an insert row.
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var config := _extract_config(lines, found)
if config.is_empty():
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
## An entry exists but no verified launcher does — mirror the other
## strategies: 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, config, server_name, 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"])
if not _is_patch_sequence(text):
## Same fail-closed contract as configure (#867 review): never modify
## a file that dsh cannot load as a patch list.
return {
"status": "error",
"message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path,
}
var lines := _split_lines(text)
var found := _find_entry_block(lines, entry_id(server_name), true)
if found.is_empty():
found = _find_entry_block(lines, entry_id(server_name), false)
if found.is_empty():
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
var out := _remove_lines(lines, found)
if _is_blank_or_comment_only(out):
## dsh's patch parser requires a top-level YAML array: an empty or
## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on
## a non-array file), while an absent file means "no layer". After our
## row is gone, an all-blank file must therefore be deleted, not
## written back.
if FileAccess.file_exists(path):
var remove_err := DirAccess.remove_absolute(path)
if remove_err != OK:
return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
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 `config` dict written inside the loader entry. Command mode
## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests
## `serverName`/`transport`/`url`. Existing user state (env, timeouts,
## reconnect) survives a reconfigure via the deep copy.
static func build_entry(
client: McpClient,
server_name: String,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry["serverName"] = server_name
if client.command_shape == McpClient.CommandShape.FLAT:
entry["command"] = str(launch.get("command", ""))
entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not entry.has(key):
entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
entry.erase(String(key))
return entry
if client.command_shape == McpClient.CommandShape.NONE:
## Single URL-mode source of truth (#867 review): build_entry's URL
## branch delegates to build_url_entry so the two can never drift.
return build_url_entry(client, server_name, server_url, existing)
return {}
## URL-mode sibling for the manual-instruction fallback text and the
## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts
## `transport: streamable-http` with a `url`, so the entry keeps the same
## `serverName` namespace with an HTTP transport instead of the stdio attach
## bridge. Existing user state survives via the deep copy.
static func build_url_entry(
client: McpClient, server_name: String, server_url: String, existing: Variant = null
) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry["serverName"] = server_name
entry["transport"] = "streamable-http"
if not client.entry_url_field.is_empty():
entry[client.entry_url_field] = server_url
return entry
## Verify a stored `config` dict matches the current launch. Command mode
## checks every launch-affecting value exactly and requires legacy URL keys
## gone; serverName must match the pinned namespace. URL mode checks the url
## and the streamable-http pin.
static func verify_entry(
client: McpClient,
config: Dictionary,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> bool:
if config.get("serverName") != server_name:
return false
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 config.has(String(key)):
return false
if config.get("command") != launch.get("command"):
return false
if not _arrays_equal(config.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if config.get(client.command_transport_key, null) != client.command_transport_value:
return false
return true
if config.get(client.entry_url_field, "") != server_url:
return false
return config.get("transport", "") == "streamable-http"
## Empty string when this client's launch requirements are satisfied.
## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect
## supports FLAT (and the URL fallback) 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 DeepSeek Harness config yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
# --- Rendering --------------------------------------------------------------
## Render a full `- insert:` row containing one nested loader entry. Public
## seam for the dock's manual-instruction text so the pasted YAML matches
## what Configure would write byte-for-byte.
static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = ["- insert:"]
lines.append_array(render_nested_entry(entry_id_value, config))
return lines
## Render one nested loader entry (the `- id:` block inside an `insert`
## list). `base_indent` defaults to the 4-space style used by the shipped
## patch files; a nested replacement reuses the indent the existing entry was
## found at so the block sequence keeps a single indentation (#867 review).
static func render_nested_entry(
entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT
) -> PackedStringArray:
var lines: PackedStringArray = []
lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value])
lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME])
lines.append("%sconfig:" % _indent_str(base_indent + 2))
for key in config:
lines.append_array(_emit_field(String(key), config[key], base_indent + 4))
return lines
## Emit one `key: value` line (or a nested block for Dictionary values) at
## the given indent. Arrays render in flow style; scalars through the shared
## YAML emitter so quoting rules stay identical across the two YAML dialects.
static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray:
var lines: PackedStringArray = []
var prefix := _indent_str(indent)
if value is Dictionary:
lines.append("%s%s:" % [prefix, key])
for sub_key in value:
lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2))
elif value is Array or value is PackedStringArray:
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))])
else:
lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)])
return lines
# --- Line-level parsing (no YAML parser in Godot) ---------------------------
## Split file text into lines, dropping only a single trailing newline so the
## remainder round-trips byte-for-byte. `text` is never split on the empty
## string, so an empty file yields an empty array.
static func _split_lines(text: String) -> PackedStringArray:
if text.is_empty():
return PackedStringArray()
var lines := text.split("\n")
if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty():
lines.remove_at(lines.size() - 1)
return lines
## Locate the block owned by our loader entry:
## require_nested=true — the `- id: <entry_id>` line inside an `insert`
## list (indent > 0), plus its indented children.
## require_nested=false — a plain top-level `- id: <entry_id>` row (indent
## 0), plus its indented children.
## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {}
## when absent. The header indent is detected dynamically so a hand-written
## file using non-2-space indentation is still found; children are lines
## deeper than the header. `end` stops right AFTER the last content line:
## trailing blank and comment lines between our entry and the next sibling
## belong to the file, not to our block, so `_replace_lines` / `_remove_lines`
## (which splice [start, end)) never delete them (#867 review).
static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary:
for i in range(lines.size()):
var indent := _indent_of(lines[i])
if require_nested and indent <= 0:
continue
if not require_nested and indent != 0:
continue
var parsed := _parse_entry_header(lines[i])
if parsed.is_empty() or parsed.get("id") != entry_id_value:
continue
## A nested match must actually live inside an `- insert:` row: a
## `- id:` list under some other top-level row (an override row's own
## sequence, say) is not a loader entry and is not ours to touch.
if require_nested and not _under_insert_row(lines, i):
continue
var j := i + 1
var last_content := i
while j < lines.size():
var l := lines[j]
if _is_blank_or_comment(l):
j += 1
continue
if _indent_of(l) <= indent:
break
last_content = j
j += 1
return {"start": i, "end": last_content + 1, "indent": indent}
return {}
## True when the nearest preceding 0-indent content line above `index` is an
## `- insert:` row header. Blank and comment lines are skipped; hitting the
## top of the file without a header means the nested line is orphaned and
## cannot be a loader entry.
static func _under_insert_row(lines: PackedStringArray, index: int) -> bool:
for i in range(index - 1, -1, -1):
if _is_blank_or_comment(lines[i]):
continue
if _indent_of(lines[i]) == 0:
return lines[i].strip_edges().begins_with("- insert:")
return false
## Parse a `- id: <value>` header line into {"id": String}. Returns {} for
## any other line. The value may be plain or single/double-quoted.
static func _parse_entry_header(line: String) -> Dictionary:
var stripped := line.strip_edges()
if not stripped.begins_with("- "):
return {}
var body := stripped.substr(2).strip_edges()
if not body.begins_with("id:"):
return {}
var value := body.substr(3).strip_edges()
if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2:
var parsed: Variant = JSON.parse_string(value)
if parsed is String:
value = parsed
elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2:
value = value.substr(1, value.length() - 2)
return {"id": value}
## Extract the `config:` sub-dict from a found entry block. Fields may nest
## (env, reconnect); deeper values are parsed with the same scalar coercion
## the YAML strategy uses. Returns {} when the block has no config block.
static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
if start < 0:
return {}
var config_indent := -1
var config_start := -1
for i in range(start, end):
var stripped := lines[i].strip_edges()
if _is_blank_or_comment(lines[i]):
continue
if stripped == "config:":
config_indent = _indent_of(lines[i])
config_start = i
break
if config_start < 0:
return {}
return _parse_block_lines(lines, config_start + 1, end, config_indent)
## Parse sibling `key: value` lines at a fixed minimum indent into a
## Dictionary. Nested blocks (a key with no inline value) recurse and their
## child lines are consumed by the recursion — the parent index advances past
## them so they are never re-parsed as top-level config fields (#867 review).
## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars
## strip via the YAML strategy's shared `coerce_scalar`.
static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary:
var out: Dictionary = {}
var i := start
while i < end:
var l := lines[i]
if _is_blank_or_comment(l):
i += 1
continue
var indent := _indent_of(l)
if indent <= min_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 value := stripped.substr(colon + 1).strip_edges()
if value.is_empty():
## Nested block: find where its children end (a line at or above
## the block key's indent), parse only that span, and jump past it.
var child_end := i + 1
while child_end < end:
if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent:
break
child_end += 1
out[key] = _parse_block_lines(lines, i + 1, child_end, indent)
i = child_end
else:
## Shared with the YAML strategy so the two dialects can never
## drift on quoting/typing rules (#867 review).
out[key] = McpYamlStrategy.coerce_scalar(value)
i += 1
return out
# --- Rewrites ---------------------------------------------------------------
## Append a new `- insert:` row at the end of the file. A blank separator
## line keeps rows visually distinct; the file always ends with a newline.
static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String:
var parts: Array[String] = []
for l in lines:
parts.append(l)
## Collapse trailing blank lines so the appended row is not orphaned
## below stray empties; one separator blank keeps rows readable.
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
parts.remove_at(parts.size() - 1)
if parts.size() > 0:
parts.append("")
parts.append("- insert:")
for l in nested_lines:
parts.append(l)
return _join_lines(parts)
## Replace the lines owned by our entry. When the found block is a plain
## top-level row (indent 0 header), the whole row — including its children —
## is replaced by the insert form. When it is a nested block inside an
## insert row, only that block's lines are replaced, leaving sibling entries
## and comments in the same row byte-for-byte intact.
static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
var parts: Array[String] = []
for i in range(lines.size()):
if i == start:
## A nested block keeps the surrounding `- insert:` row, so only
## the nested entry lines (indent > 0) are spliced in.
if _indent_of(lines[start]) > 0:
for l in nested_lines:
parts.append(l)
else:
parts.append("- insert:")
for l in nested_lines:
parts.append(l)
if i >= start and i < end:
continue
parts.append(lines[i])
return _join_lines(parts)
## Remove our entry. A nested block is spliced out; if its enclosing insert
## row is left without any nested entries (only the bare `- insert:` header,
## blanks, and comments), the row header goes too so the file stays a valid
## patch list. A plain row for our id is removed wholesale.
static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String:
var start := int(block.get("start", -1))
var end := int(block.get("end", -1))
var nested := _indent_of(lines[start]) > 0
var parts: Array[String] = []
if nested:
## Find the enclosing `- insert:` row header: the nearest preceding
## 0-indent `- ` line.
var row_start := -1
for i in range(start - 1, -1, -1):
if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "):
row_start = i
break
## Row end: first 0-indent non-blank/comment line after the block.
var row_end := lines.size()
for i in range(end, lines.size()):
if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]):
row_end = i
break
var has_other_entries := false
for i in range(row_start + 1, row_end):
if i >= start and i < end:
continue
if _is_blank_or_comment(lines[i]):
continue
if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "):
has_other_entries = true
break
if has_other_entries or row_start < 0:
## Only our block goes; sibling entries and any trailing
## comments in the same row survive.
for i in range(lines.size()):
if i >= start and i < end:
continue
parts.append(lines[i])
else:
## The row held only our entry: drop the `- insert:` header and
## our block, but keep trailing blank/comment lines — a comment
## the user wrote above the next row must survive (#867 review).
for i in range(lines.size()):
if i >= row_start and i < end:
continue
parts.append(lines[i])
parts = _trim_trailing_blanks(parts)
else:
## Plain top-level row for our id: drop the whole block.
for i in range(lines.size()):
if i >= start and i < end:
continue
parts.append(lines[i])
parts = _trim_trailing_blanks(parts)
return _join_lines(parts)
## Remove trailing blank lines left behind by a removal.
static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]:
while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
parts.remove_at(parts.size() - 1)
return parts
## True when the text holds no patch rows at all — blank lines and comments
## only. Such a file cannot be written back (see `remove`), because dsh's
## patch parser rejects a non-array file at boot.
static func _is_blank_or_comment_only(text: String) -> bool:
for line in text.split("\n"):
var stripped := line.strip_edges()
if not stripped.is_empty() and not stripped.begins_with("#"):
return false
return true
## When the document's only content line is the empty flow sequence `[]`,
## return the text with that one line removed — blanks and comments stay,
## because they belong to the user. Any other document comes back unchanged;
## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse.
static func _strip_empty_flow_sequence(text: String) -> String:
var lines := _split_lines(text)
var bracket_line := -1
for i in range(lines.size()):
if _is_blank_or_comment(lines[i]):
continue
if bracket_line < 0 and lines[i].strip_edges() == "[]":
bracket_line = i
continue
return text
if bracket_line < 0:
return text
var parts: Array[String] = []
for i in range(lines.size()):
if i != bracket_line:
parts.append(lines[i])
if parts.is_empty():
return ""
return _join_lines(parts)
## True when the text is a valid loader patch list (or empty/comment-only /
## the empty flow sequence `[]`, which all mean "no layer"). Anything else —
## a top-level mapping or scalar — would make dsh fail boot, so Configure
## refuses to write into it instead of producing a broken file (#867 review).
## Only 0-indent content lines define the top-level structure: they must all
## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is
## valid only as the COMPLETE document — appending rows after the empty flow
## sequence would produce malformed YAML, so a mixed `[]` + rows file fails
## the check too.
static func _is_patch_sequence(text: String) -> bool:
if text.strip_edges().is_empty():
return true
var saw_brackets := false
var content_count := 0
for line in text.split("\n"):
var stripped := line.strip_edges()
if stripped.is_empty() or stripped.begins_with("#"):
continue
content_count += 1
if stripped == "[]":
saw_brackets = true
continue
if _indent_of(line) == 0 and not stripped.begins_with("- "):
return false
if saw_brackets and content_count > 1:
return false
return true
static func _join_lines(parts: Array) -> String:
if parts.is_empty():
return ""
var lines: Array[String] = []
for p in parts:
lines.append(String(p))
return "\n".join(lines) + "\n"
# --- Small helpers ----------------------------------------------------------
static func _indent_str(n: int) -> String:
var out := ""
for i in range(n):
out += " "
return out
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
static func _is_blank_or_comment(line: String) -> bool:
var stripped := line.strip_edges()
return stripped.is_empty() or stripped.begins_with("#")
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
## 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 patch rows 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}