fix(setup): keep Hermes config numbers as written (#2368)
Setup and uninstall re-printed every number in an existing Hermes config.yaml from a JavaScript number. Hermes reads the file as YAML 1.1 with exact integers, so a Discord channel ID lost its low digits, an octal mode such as 0600 became 600, and 0.0000001 became 1e-7, which YAML 1.1 does not define as a float. Two IDs that round to the same double were also rejected as duplicate keys, so setup could not register at all. Hermes YAML parsing and editing move to HermesConfigurationYaml. A parsed number is written with its source spelling while that spelling still reads as the node's value, the rule yaml's own boolean tag follows, and two keys are duplicates only when both their value and their spelling match. Parsed values stay JavaScript numbers.
This commit is contained in:
parent
36132d3e17
commit
d3a55720fc
|
|
@ -1,9 +1,5 @@
|
|||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import {
|
||||
parse as parseYaml,
|
||||
parseDocument as parseYamlDocument,
|
||||
stringify as stringifyYaml,
|
||||
} from "yaml";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
|
|
@ -12,6 +8,10 @@ import {
|
|||
editDshDocument,
|
||||
dshClientRegistrationEntry,
|
||||
} from "./DshCordisPatch.js";
|
||||
import {
|
||||
editHermesConfigurationYaml,
|
||||
parseHermesConfigurationYaml,
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
import {
|
||||
applyEdits,
|
||||
findNodeAtLocation,
|
||||
|
|
@ -118,7 +118,10 @@ const parseDocument = (
|
|||
if (isEmptyClientConfigurationText(text)) return {};
|
||||
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
|
||||
if (format === "hermes")
|
||||
return requireObject(parseYaml(text), "The configuration root");
|
||||
return requireObject(
|
||||
parseHermesConfigurationYaml(text),
|
||||
"The configuration root",
|
||||
);
|
||||
// ZCode loads nonempty native files with strict JSON.parse.
|
||||
if (format === "zcode")
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
|
|
@ -1419,12 +1422,11 @@ const serializeHermesConfiguration = (
|
|||
typeof servers === "object" && servers !== null
|
||||
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
|
||||
: undefined;
|
||||
const yamlDocument = parseYamlDocument(originalText);
|
||||
if (entry === undefined)
|
||||
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
|
||||
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return yamlDocument.toString({ lineWidth: 0 });
|
||||
return editHermesConfigurationYaml(
|
||||
originalText,
|
||||
[serversKey, PRODUCT_IDENTITY.mcpServerKey],
|
||||
entry,
|
||||
);
|
||||
};
|
||||
|
||||
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
editHermesConfigurationYaml,
|
||||
parseHermesConfigurationYaml,
|
||||
} from "./HermesConfigurationYaml.js";
|
||||
|
||||
describe("Hermes configuration YAML", () => {
|
||||
it("writes an edited number from its new value", () => {
|
||||
expect(
|
||||
editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 384),
|
||||
).toBe("mode: 384\nmask: 0x1F\n");
|
||||
});
|
||||
|
||||
it("accepts keys Hermes reads as distinct and rejects a repeated key", () => {
|
||||
expect(() =>
|
||||
parseHermesConfigurationYaml("0600: octal\n600: decimal\n"),
|
||||
).not.toThrow();
|
||||
expect(() => parseHermesConfigurationYaml('a: 1\n"a": 2\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
isScalar,
|
||||
parse,
|
||||
parseDocument,
|
||||
type ParsedNode,
|
||||
type ScalarTag,
|
||||
type Tags,
|
||||
} from "yaml";
|
||||
|
||||
const NUMBER_TAGS = new Set([
|
||||
"tag:yaml.org,2002:int",
|
||||
"tag:yaml.org,2002:float",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Write a parsed number with its source spelling while that spelling still
|
||||
* reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact
|
||||
* integers, so re-printing a JavaScript number changes what it reads: a
|
||||
* Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and
|
||||
* `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float.
|
||||
*/
|
||||
const keepNumberSource = (tag: Tags[number]): Tags[number] => {
|
||||
if (typeof tag === "string" || tag.collection !== undefined) return tag;
|
||||
const { stringify } = tag;
|
||||
if (!NUMBER_TAGS.has(tag.tag) || stringify === undefined) return tag;
|
||||
return {
|
||||
...tag,
|
||||
stringify: (item, context, ...rest) => {
|
||||
const { source } = item;
|
||||
if (source === undefined) return stringify(item, context, ...rest);
|
||||
const reader = context.doc.schema.tags.find(
|
||||
(candidate): candidate is ScalarTag =>
|
||||
candidate.default === true && candidate.test?.test(source) === true,
|
||||
);
|
||||
const read = reader?.resolve(
|
||||
source,
|
||||
() => undefined,
|
||||
context.doc.options,
|
||||
);
|
||||
return Object.is(isScalar(read) ? read.value : read, item.value)
|
||||
? source
|
||||
: stringify(item, context, ...rest);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const HERMES_YAML_OPTIONS = {
|
||||
customTags: (tags: Tags) => tags.map(keepNumberSource),
|
||||
// Hermes keeps integers exact, so IDs that round to one double, or `0600`
|
||||
// and `600`, are distinct keys.
|
||||
uniqueKeys: (a: ParsedNode, b: ParsedNode) =>
|
||||
a === b ||
|
||||
(isScalar(a) &&
|
||||
isScalar(b) &&
|
||||
a.value === b.value &&
|
||||
a.source === b.source),
|
||||
};
|
||||
|
||||
/** Parse a Hermes `config.yaml`. */
|
||||
export const parseHermesConfigurationYaml = (text: string): unknown =>
|
||||
parse(text, HERMES_YAML_OPTIONS);
|
||||
|
||||
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
|
||||
export const editHermesConfigurationYaml = (
|
||||
text: string,
|
||||
path: readonly string[],
|
||||
value: unknown,
|
||||
): string => {
|
||||
const document = parseDocument(text, HERMES_YAML_OPTIONS);
|
||||
if (value === undefined) document.deleteIn(path);
|
||||
else document.setIn(path, value);
|
||||
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
||||
return document.toString({ lineWidth: 0 });
|
||||
};
|
||||
|
|
@ -237,6 +237,38 @@ mcp_servers:
|
|||
expect(await readFile(client.configPath, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("keeps unrelated numbers as written, including Discord IDs", async () => {
|
||||
const { home, client } = await hermesClient();
|
||||
// Hermes reads these exactly: two IDs one apart, an octal mode, a float.
|
||||
const original = `discord:
|
||||
free_response_channels:
|
||||
- 1187654321098765432
|
||||
channel_prompts:
|
||||
1187654321098765433: Build questions.
|
||||
1187654321098765434: Release questions.
|
||||
mode: 0600
|
||||
min_score: 0.0000001
|
||||
mcp_servers:
|
||||
other:
|
||||
command: other-server
|
||||
`;
|
||||
await writeFile(client.configPath, original);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const text = await readFile(client.configPath, "utf8");
|
||||
expect(text.startsWith(original)).toBe(true);
|
||||
expect(parseYaml(text, { intAsBigInt: true })).toMatchObject({
|
||||
mcp_servers: {
|
||||
rea: { command: command[0], args: command.slice(1), enabled: true },
|
||||
},
|
||||
});
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
expect(await readFile(client.configPath, "utf8")).toBe(original);
|
||||
});
|
||||
|
||||
it("creates config.yaml when Hermes has no configuration yet", async () => {
|
||||
const { client } = await hermesClient();
|
||||
expect(
|
||||
|
|
|
|||
Loading…
Reference in New Issue