@intentius/chant 0.72.3 → 0.72.5
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/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/declarable.d.ts +46 -1
- package/dist/declarable.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -3
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/main.ts +5 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -0
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +6 -0
- package/src/declarable-host-marker.test.ts +88 -0
- package/src/declarable.ts +72 -7
- package/src/discovery/sandbox/driver.ts +25 -0
- package/src/discovery/sandbox/fork-diagnostic.test.ts +125 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/fold/subset.ts +15 -3
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- package/src/op/activities/converge.ts +31 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from "./server";
|
|
6
|
+
import type { ToolDefinition, ResourceDefinition } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The MCP docs say what this server offers; this test says they are right.
|
|
10
|
+
*
|
|
11
|
+
* `docs/src/content/docs/cli/mcp.mdx` is a hand-written list of tools,
|
|
12
|
+
* resources, methods and protocol revisions, and the server is the thing that
|
|
13
|
+
* actually registers them. Nothing connected the two, so the page fell two
|
|
14
|
+
* revisions behind twice over (#2385): it advertised six tools while the
|
|
15
|
+
* constructor registered thirteen, left `lifecycle-snapshot` and
|
|
16
|
+
* `lifecycle-diff` undocumented anywhere, omitted `server/discover` (#1194)
|
|
17
|
+
* and `chant://knowledge` (#1867), and claimed protocol version 2024-11-05
|
|
18
|
+
* long after #1194 made 2026-07-28 the preferred one.
|
|
19
|
+
*
|
|
20
|
+
* Each claim here is read back against the running server rather than against
|
|
21
|
+
* source text: the tool and resource lists come from a real `McpServer`
|
|
22
|
+
* answering `tools/list` and `resources/list`, which is what a client sees.
|
|
23
|
+
* The dispatch methods are the exception and are read out of `server.ts` by
|
|
24
|
+
* regex, because a switch statement has no listing API; that regex fails
|
|
25
|
+
* loudly if the switch is ever restructured.
|
|
26
|
+
*
|
|
27
|
+
* Same shape as `scripts/fold-depth-bound-claims.test.ts` (#2367) and
|
|
28
|
+
* `scripts/lexicon-count-claims.test.ts` (#2316): a doc claim pinned to
|
|
29
|
+
* ground truth, with a message that says what to edit.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
|
|
33
|
+
const MCP_DOC = join("docs", "src", "content", "docs", "cli", "mcp.mdx");
|
|
34
|
+
const GUIDE_DOC = join("docs", "src", "content", "docs", "guide", "agent-integration.mdx");
|
|
35
|
+
const SERVER_SRC = join("packages", "core", "src", "cli", "mcp", "server.ts");
|
|
36
|
+
|
|
37
|
+
const mcpDoc = readFileSync(join(repoRoot, MCP_DOC), "utf8");
|
|
38
|
+
const guideDoc = readFileSync(join(repoRoot, GUIDE_DOC), "utf8");
|
|
39
|
+
const serverSrc = readFileSync(join(repoRoot, SERVER_SRC), "utf8");
|
|
40
|
+
|
|
41
|
+
/** What a client is told when it asks, built from a server with no plugins loaded. */
|
|
42
|
+
async function servedListing(method: "tools/list" | "resources/list"): Promise<string[]> {
|
|
43
|
+
const response = await new McpServer().handleRequest({ jsonrpc: "2.0", id: 1, method });
|
|
44
|
+
const result = response.result as { tools?: ToolDefinition[]; resources?: ResourceDefinition[] };
|
|
45
|
+
const names = result.tools?.map((t) => t.name) ?? result.resources?.map((r) => r.uri);
|
|
46
|
+
if (!names || names.length === 0) {
|
|
47
|
+
throw new Error(`mcp docs parity: ${method} returned nothing to compare the docs against`);
|
|
48
|
+
}
|
|
49
|
+
return names;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The lines of one `##`/`###` section, up to the next heading at any level. */
|
|
53
|
+
function section(doc: string, heading: string, label: string): string {
|
|
54
|
+
const start = doc.indexOf(heading);
|
|
55
|
+
if (start < 0) throw new Error(`mcp docs parity: no "${heading}" heading in ${label} — was it renamed? Update this test to match.`);
|
|
56
|
+
const rest = doc.slice(start + heading.length);
|
|
57
|
+
const end = rest.search(/\n#{1,4} /);
|
|
58
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every `` | `thing` | `` first column of a markdown table in `text`. */
|
|
62
|
+
function firstColumnCells(text: string): string[] {
|
|
63
|
+
return [...text.matchAll(/^\| `([^`]+)` \|/gm)].map((m) => m[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sorted(values: readonly string[]): string[] {
|
|
67
|
+
return [...values].sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("the MCP docs describe the server that ships (#2385)", () => {
|
|
71
|
+
test("cli/mcp.mdx documents exactly the tools the server registers", async () => {
|
|
72
|
+
const registered = await servedListing("tools/list");
|
|
73
|
+
// Every core tool gets its own `### \`name\`` heading. The `#### ` heading
|
|
74
|
+
// under op-approve and the un-backticked "### Plugin Tools" are not tools.
|
|
75
|
+
const documented = [...mcpDoc.matchAll(/^### `([^`]+)`$/gm)].map((m) => m[1]);
|
|
76
|
+
expect(documented.length, `${MCP_DOC}: found no "### \`tool-name\`" headings — was the Tools section reformatted?`).toBeGreaterThan(0);
|
|
77
|
+
expect(
|
|
78
|
+
sorted(documented),
|
|
79
|
+
`${MCP_DOC} and the McpServer constructor disagree about the core tools. ` +
|
|
80
|
+
`Registered: ${sorted(registered).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
|
|
81
|
+
`Give every registered tool a "### \`name\`" section on that page, and delete the sections for tools that no longer exist.`,
|
|
82
|
+
).toEqual(sorted(registered));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("guide/agent-integration.mdx lists exactly the tools the server registers", async () => {
|
|
86
|
+
const registered = await servedListing("tools/list");
|
|
87
|
+
const listed = firstColumnCells(section(guideDoc, "### Available Tools", GUIDE_DOC));
|
|
88
|
+
expect(
|
|
89
|
+
sorted(listed),
|
|
90
|
+
`${GUIDE_DOC}'s "Available Tools" table and the McpServer constructor disagree. ` +
|
|
91
|
+
`Registered: ${sorted(registered).join(", ")}. Listed: ${sorted(listed).join(", ")}. ` +
|
|
92
|
+
`Add or remove rows so the table matches; the parameter detail lives on the cli/mcp reference page.`,
|
|
93
|
+
).toEqual(sorted(registered));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("cli/mcp.mdx documents exactly the resources the server serves", async () => {
|
|
97
|
+
const served = await servedListing("resources/list");
|
|
98
|
+
// Rows of the Resources table: `| `chant://...` | `mime` | description |`.
|
|
99
|
+
const documented = [...mcpDoc.matchAll(/^\| `(chant:\/\/[^`]+)` \| `[^`]+` \|/gm)].map((m) => m[1]);
|
|
100
|
+
expect(
|
|
101
|
+
sorted(documented),
|
|
102
|
+
`${MCP_DOC}'s Resources table and the server's resources/list disagree. ` +
|
|
103
|
+
`Served: ${sorted(served).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
|
|
104
|
+
`A URI the server reads but never lists (chant://examples/{name}) belongs in prose under the table, not in it.`,
|
|
105
|
+
).toEqual(sorted(served));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("cli/mcp.mdx states the protocol revisions the server negotiates, preferred first", () => {
|
|
109
|
+
const documented = [...mcpDoc.matchAll(/^\| `(\d{4}-\d{2}-\d{2})` \|/gm)].map((m) => m[1]);
|
|
110
|
+
expect(
|
|
111
|
+
documented,
|
|
112
|
+
`${MCP_DOC} claims protocol revisions ${documented.join(", ") || "(none found)"}, but server.ts ` +
|
|
113
|
+
`negotiates ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}. The first row is the preferred revision, ` +
|
|
114
|
+
`the one a client that names no version gets back.`,
|
|
115
|
+
).toEqual([...SUPPORTED_PROTOCOL_VERSIONS]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("cli/mcp.mdx lists exactly the JSON-RPC methods dispatch answers", () => {
|
|
119
|
+
const dispatched = [...serverSrc.matchAll(/^ case "([^"]+)":$/gm)].map((m) => m[1]);
|
|
120
|
+
if (dispatched.length === 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`mcp docs parity: no \`case "method":\` labels found in ${SERVER_SRC} — the dispatch switch was ` +
|
|
123
|
+
`restructured, so this test can no longer read the method list out of it. Update the test to match.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const documented = firstColumnCells(section(mcpDoc, "### Supported Methods", MCP_DOC));
|
|
127
|
+
expect(
|
|
128
|
+
sorted(documented),
|
|
129
|
+
`${MCP_DOC}'s "Supported Methods" table and the dispatch switch in ${SERVER_SRC} disagree. ` +
|
|
130
|
+
`Dispatched: ${sorted(dispatched).join(", ")}. Documented: ${sorted(documented).join(", ")}.`,
|
|
131
|
+
).toEqual(sorted(dispatched));
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -17,8 +17,12 @@ import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
|
|
|
17
17
|
* Protocol versions this server understands, newest first. `initialize` and
|
|
18
18
|
* `server/discover` both negotiate against this list rather than assuming
|
|
19
19
|
* the client's revision (#1194).
|
|
20
|
+
*
|
|
21
|
+
* Exported because `docs-parity.test.ts` reads it: `cli/mcp.mdx` states these
|
|
22
|
+
* revisions in prose, and stating them twice is how the page came to claim
|
|
23
|
+
* 2024-11-05 for two releases after this list moved past it (#2385).
|
|
20
24
|
*/
|
|
21
|
-
const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
|
|
25
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
|
|
22
26
|
const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
6
|
+
import { initCommand } from "./commands/init";
|
|
7
|
+
import { doctorCommand } from "./commands/doctor";
|
|
8
|
+
import { updateCommand } from "./commands/update";
|
|
9
|
+
import { parseArgs, commandRegistry } from "./main";
|
|
10
|
+
import { MCP_CONFIG_FILENAME, MCP_SETUP_COMMAND, mcpConfigPath } from "./mcp-config";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* chant #2383. `chant init` used to write `mcp.json` into the user's home
|
|
14
|
+
* directory while `chant doctor` looked for `<project>/.mcp.json` and told
|
|
15
|
+
* you to run `chant agent setup`, a command the registry never had. Three
|
|
16
|
+
* sites, three answers, and a project chant had just scaffolded failed
|
|
17
|
+
* chant's own doctor with an unrunnable fix.
|
|
18
|
+
*
|
|
19
|
+
* These tests are written against the agreement rather than against today's
|
|
20
|
+
* string: the round trip below fails if init and doctor ever pick different
|
|
21
|
+
* paths again, and the registry check fails if the remediation ever names a
|
|
22
|
+
* command that is not registered. Neither can be satisfied by editing one
|
|
23
|
+
* site in isolation.
|
|
24
|
+
*/
|
|
25
|
+
describe("MCP config: one location, three agreeing sites (#2383)", () => {
|
|
26
|
+
test("init then doctor round-trips: mcp-config passes with no manual step", async () => {
|
|
27
|
+
await withTestDir(async (testDir) => {
|
|
28
|
+
const result = await initCommand({
|
|
29
|
+
path: testDir,
|
|
30
|
+
lexicon: "aws",
|
|
31
|
+
skipInstall: true,
|
|
32
|
+
});
|
|
33
|
+
expect(result.success).toBe(true);
|
|
34
|
+
|
|
35
|
+
const report = await doctorCommand(testDir);
|
|
36
|
+
const check = report.checks.find((c) => c.name === "mcp-config");
|
|
37
|
+
expect(check).toBeDefined();
|
|
38
|
+
expect(check!.status).toBe("pass");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("init writes the MCP config inside the project it was pointed at", async () => {
|
|
43
|
+
await withTestDir(async (testDir) => {
|
|
44
|
+
const result = await initCommand({
|
|
45
|
+
path: testDir,
|
|
46
|
+
lexicon: "aws",
|
|
47
|
+
skipInstall: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(existsSync(mcpConfigPath(testDir))).toBe(true);
|
|
51
|
+
expect(result.createdFiles).toContain(MCP_CONFIG_FILENAME);
|
|
52
|
+
|
|
53
|
+
const config = JSON.parse(readFileSync(mcpConfigPath(testDir), "utf-8"));
|
|
54
|
+
expect(config.mcpServers.chant.args).toEqual(["chant", "serve", "mcp"]);
|
|
55
|
+
|
|
56
|
+
// Every path init reports as created is relative to the project. A
|
|
57
|
+
// `~/` or absolute entry means init reached outside the directory it
|
|
58
|
+
// was handed, which is the half of #2383 worth not regressing.
|
|
59
|
+
for (const file of result.createdFiles) {
|
|
60
|
+
expect(file.startsWith("~"), `${file} escapes the project`).toBe(false);
|
|
61
|
+
expect(file.startsWith("/"), `${file} escapes the project`).toBe(false);
|
|
62
|
+
expect(file.includes(homedir()), `${file} escapes the project`).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("--skip-mcp parses and suppresses the write", async () => {
|
|
68
|
+
// The flag was documented and typed long before anything parsed it, so
|
|
69
|
+
// assert the parse and the effect together — either one alone passed
|
|
70
|
+
// while `chant init --skip-mcp` still wrote the file.
|
|
71
|
+
expect(parseArgs(["init", ".", "--lexicon", "aws", "--skip-mcp"]).skipMcp).toBe(true);
|
|
72
|
+
expect(parseArgs(["init", ".", "--lexicon", "aws"]).skipMcp).toBeUndefined();
|
|
73
|
+
|
|
74
|
+
await withTestDir(async (testDir) => {
|
|
75
|
+
const result = await initCommand({
|
|
76
|
+
path: testDir,
|
|
77
|
+
lexicon: "aws",
|
|
78
|
+
skipMcp: true,
|
|
79
|
+
skipInstall: true,
|
|
80
|
+
});
|
|
81
|
+
expect(existsSync(mcpConfigPath(testDir))).toBe(false);
|
|
82
|
+
expect(result.createdFiles).not.toContain(MCP_CONFIG_FILENAME);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the doctor's remediation names a registered command", async () => {
|
|
87
|
+
// Reconstruct the warning the doctor actually emits, then check that the
|
|
88
|
+
// command inside it exists. `chant agent setup` satisfied neither.
|
|
89
|
+
await withTestDir(async (testDir) => {
|
|
90
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
91
|
+
const report = await doctorCommand(testDir);
|
|
92
|
+
const check = report.checks.find((c) => c.name === "mcp-config");
|
|
93
|
+
expect(check!.status).toBe("warn");
|
|
94
|
+
expect(check!.message).toContain(MCP_SETUP_COMMAND);
|
|
95
|
+
|
|
96
|
+
const named = check!.message!.match(/run ([a-z][a-z -]*)/)?.[1].trim();
|
|
97
|
+
expect(named).toBeDefined();
|
|
98
|
+
expect(named!.startsWith("chant ")).toBe(true);
|
|
99
|
+
const registered = commandRegistry.map((c) => c.name);
|
|
100
|
+
expect(registered).toContain(named!.slice("chant ".length));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the remediation command restores a deleted config", async () => {
|
|
105
|
+
await withTestDir(async (testDir) => {
|
|
106
|
+
// A project that has everything except the MCP config: exactly the
|
|
107
|
+
// state the doctor warns about. `chant init` refuses this directory
|
|
108
|
+
// without --force, so the remediation has to be something else.
|
|
109
|
+
mkdirSync(join(testDir, "src"), { recursive: true });
|
|
110
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
111
|
+
writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
|
|
112
|
+
|
|
113
|
+
expect(MCP_SETUP_COMMAND).toBe("chant update");
|
|
114
|
+
const result = await updateCommand({ path: testDir });
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
|
|
117
|
+
const report = await doctorCommand(testDir);
|
|
118
|
+
expect(report.checks.find((c) => c.name === "mcp-config")!.status).toBe("pass");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("update leaves an existing config alone", async () => {
|
|
123
|
+
await withTestDir(async (testDir) => {
|
|
124
|
+
writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
125
|
+
writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
|
|
126
|
+
const hand = JSON.stringify({ mcpServers: { chant: { command: "custom" } } }, null, 2);
|
|
127
|
+
writeFileSync(mcpConfigPath(testDir), hand);
|
|
128
|
+
|
|
129
|
+
await updateCommand({ path: testDir });
|
|
130
|
+
|
|
131
|
+
expect(readFileSync(mcpConfigPath(testDir), "utf-8")).toBe(hand);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("every chant command a doctor check tells you to run is registered", async () => {
|
|
136
|
+
// Broader than the mcp-config check that prompted #2383: whatever the
|
|
137
|
+
// doctor prints as `run chant <something>`, the registry has to have it.
|
|
138
|
+
// `chant agent setup` shipped as advice for a command that never existed;
|
|
139
|
+
// this fails the moment any check does that again.
|
|
140
|
+
const registered = commandRegistry.map((c) => c.name);
|
|
141
|
+
expect(registered).not.toContain("agent");
|
|
142
|
+
expect(registered).not.toContain("agent setup");
|
|
143
|
+
|
|
144
|
+
const messages: string[] = [];
|
|
145
|
+
await withTestDir(async (empty) => {
|
|
146
|
+
messages.push(...(await doctorCommand(empty)).checks.flatMap((c) => c.message ?? []));
|
|
147
|
+
});
|
|
148
|
+
await withTestDir(async (partial) => {
|
|
149
|
+
writeFileSync(join(partial, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
|
|
150
|
+
writeFileSync(join(partial, "package.json"), JSON.stringify({ name: "p" }));
|
|
151
|
+
mkdirSync(join(partial, "src"), { recursive: true });
|
|
152
|
+
messages.push(...(await doctorCommand(partial)).checks.flatMap((c) => c.message ?? []));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const named = messages.flatMap((m) => [...m.matchAll(/run `?(chant [a-z][a-z0-9 -]*)/g)].map((x) => x[1]));
|
|
156
|
+
expect(named.length).toBeGreaterThan(0);
|
|
157
|
+
for (const advice of named) {
|
|
158
|
+
// Drop flags and the trailing prose the regex may have swept up, then
|
|
159
|
+
// keep the longest registered name that prefixes what was advised.
|
|
160
|
+
const words = advice.slice("chant ".length).split(/\s+/).filter((w) => w && !w.startsWith("-"));
|
|
161
|
+
const match = registered.find((name) => {
|
|
162
|
+
const parts = name.split(" ");
|
|
163
|
+
return parts.every((p, i) => words[i] === p);
|
|
164
|
+
});
|
|
165
|
+
expect(match, `doctor advises "${advice}", which no registered command matches`).toBeDefined();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place chant decides where a project's MCP server registration
|
|
6
|
+
* lives, what goes in it, and which command writes it (chant #2383).
|
|
7
|
+
*
|
|
8
|
+
* Before this module the three sites disagreed: `chant init` wrote
|
|
9
|
+
* `mcp.json` into the user's home directory (picking a harness directory
|
|
10
|
+
* that happened to exist), `chant doctor` looked for `<project>/.mcp.json`,
|
|
11
|
+
* and the doctor's remediation named `chant agent setup`, which was never
|
|
12
|
+
* registered in `./main.ts`. A project chant had just scaffolded therefore
|
|
13
|
+
* failed chant's own doctor, and the fix the warning named could not be run.
|
|
14
|
+
*
|
|
15
|
+
* Project scope wins. The file versions with the project it describes, it
|
|
16
|
+
* keeps `chant init <dir>` from writing outside `<dir>`, and it is already
|
|
17
|
+
* what `../agents/discover.ts` treats as the project-scope location when
|
|
18
|
+
* chant audits an agent installation it did not create. Every consumer must
|
|
19
|
+
* go through the constants here rather than rebuilding the path, so the
|
|
20
|
+
* three sites cannot drift apart again without the shared test in
|
|
21
|
+
* ./mcp-config.test.ts noticing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Filename of the project-scoped MCP server registration. */
|
|
25
|
+
export const MCP_CONFIG_FILENAME = ".mcp.json";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The command a user runs to (re)write a missing {@link MCP_CONFIG_FILENAME}
|
|
29
|
+
* in an existing project. `chant init` also writes it, but init refuses a
|
|
30
|
+
* non-empty directory without `--force`, so the remediation an already
|
|
31
|
+
* scaffolded project needs is `chant update` — which is also what the
|
|
32
|
+
* doctor's neighbouring skills check tells you to run for the same reason.
|
|
33
|
+
* Kept as a constant so ./mcp-config.test.ts can assert it is a command
|
|
34
|
+
* `commandRegistry` actually registers.
|
|
35
|
+
*/
|
|
36
|
+
export const MCP_SETUP_COMMAND = "chant update";
|
|
37
|
+
|
|
38
|
+
/** Absolute (or caller-relative) path to a project's MCP config. */
|
|
39
|
+
export function mcpConfigPath(projectDir: string): string {
|
|
40
|
+
return join(projectDir, MCP_CONFIG_FILENAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect whether a project uses bun or npm, from its lock file. Lives here
|
|
45
|
+
* because the package manager is the only variable in the generated config.
|
|
46
|
+
*/
|
|
47
|
+
export function detectPackageManager(dir?: string): "bun" | "npm" {
|
|
48
|
+
if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun";
|
|
49
|
+
return "npm";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The MCP registration chant writes: one stdio server named `chant`. */
|
|
53
|
+
export function generateMcpConfig(pm: "bun" | "npm"): string {
|
|
54
|
+
const config = {
|
|
55
|
+
mcpServers: {
|
|
56
|
+
chant: {
|
|
57
|
+
command: pm === "bun" ? "bunx" : "npx",
|
|
58
|
+
args: ["chant", "serve", "mcp"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return JSON.stringify(config, null, 2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write `<projectDir>/.mcp.json` unless one is already there. Returns the
|
|
68
|
+
* relative path when a file was created, `undefined` when an existing config
|
|
69
|
+
* was left alone — callers report that difference to the user.
|
|
70
|
+
*/
|
|
71
|
+
export function writeProjectMcpConfig(projectDir: string): string | undefined {
|
|
72
|
+
const path = mcpConfigPath(projectDir);
|
|
73
|
+
if (existsSync(path)) return undefined;
|
|
74
|
+
writeFileSync(path, generateMcpConfig(detectPackageManager(projectDir)));
|
|
75
|
+
return MCP_CONFIG_FILENAME;
|
|
76
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -101,6 +101,12 @@ export interface ParsedArgs {
|
|
|
101
101
|
reportFile?: string;
|
|
102
102
|
/** `chant init --skill <name>` filter (added in #95 commit) */
|
|
103
103
|
skill?: string;
|
|
104
|
+
/**
|
|
105
|
+
* `chant init --skip-mcp` (#2383) — scaffold without writing the project's
|
|
106
|
+
* `.mcp.json`. The option existed on `InitOptions` and was documented long
|
|
107
|
+
* before anything parsed it, so there was no way to decline the write.
|
|
108
|
+
*/
|
|
109
|
+
skipMcp?: boolean;
|
|
104
110
|
/** `chant import --type <ResourceType>` selector */
|
|
105
111
|
selectType?: string;
|
|
106
112
|
/** `chant import --name <name>` selector */
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { isDeclarable, DECLARABLE_MARKER } from "./declarable";
|
|
3
|
+
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
4
|
+
import { STACK_OUTPUT_MARKER } from "./stack-output";
|
|
5
|
+
import { COMPOSITE_MARKER } from "./composite";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* chant#2444 — `isDeclarable` accepts an entity a HOST built.
|
|
9
|
+
*
|
|
10
|
+
* `F-Host-Interface` item 1 says an entity carries a non-enumerable declarable
|
|
11
|
+
* marker, and a host supplies its own. Testing identity against chant's symbol
|
|
12
|
+
* alone refused those for not being chant's rather than for being malformed —
|
|
13
|
+
* which is what chant#2442 hit inside composite member validation, and what
|
|
14
|
+
* would otherwise keep biting in the entity tallies and the revival
|
|
15
|
+
* pass-through, where it fails silently rather than loudly.
|
|
16
|
+
*
|
|
17
|
+
* The interesting half of this test file is the second describe block. The
|
|
18
|
+
* reference implementation reads the same rule as "any own symbol or
|
|
19
|
+
* non-enumerable own property", which is right for a domain with one marker and
|
|
20
|
+
* wrong for chant, which has seven. Those cases pin the narrowing.
|
|
21
|
+
*/
|
|
22
|
+
function marked(symbol: symbol, value: unknown = true): object {
|
|
23
|
+
const o = {};
|
|
24
|
+
Object.defineProperty(o, symbol, { value, enumerable: false });
|
|
25
|
+
return o;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("isDeclarable accepts a host's marker (chant#2444)", () => {
|
|
29
|
+
test("chant's own marker, which is every entity a chant build makes", () => {
|
|
30
|
+
expect(isDeclarable(marked(DECLARABLE_MARKER))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the specification host's marker", () => {
|
|
34
|
+
// The exact symbol `@tsad/shapes` uses. This is the case chant#2442 hit.
|
|
35
|
+
expect(isDeclarable(marked(Symbol.for("tsad.conformance.declarable")))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("any implementation's, as long as the marker says declarable", () => {
|
|
39
|
+
expect(isDeclarable(marked(Symbol.for("some.other.tool.declarable")))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a plain object is not one", () => {
|
|
43
|
+
expect(isDeclarable({})).toBe(false);
|
|
44
|
+
expect(isDeclarable({ entityType: "Bucket", props: {} })).toBe(false);
|
|
45
|
+
expect(isDeclarable(null)).toBe(false);
|
|
46
|
+
expect(isDeclarable("Bucket")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("the narrowing, and why the reference's reading is unsafe here", () => {
|
|
51
|
+
test("chant's OTHER markers are not declarable markers", () => {
|
|
52
|
+
// The measurement that decided the implementation. Under "any own symbol",
|
|
53
|
+
// every one of these classifies as an entity and enters the tallies — a
|
|
54
|
+
// worse bug than the one chant#2444 fixes.
|
|
55
|
+
expect(isDeclarable(marked(INTRINSIC_MARKER))).toBe(false);
|
|
56
|
+
expect(isDeclarable(marked(STACK_OUTPUT_MARKER))).toBe(false);
|
|
57
|
+
expect(isDeclarable(marked(COMPOSITE_MARKER))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a non-enumerable own NAME is not a marker either", () => {
|
|
61
|
+
// A real chant entity's non-enumerable own names are `lexicon`,
|
|
62
|
+
// `entityType`, `kind`, `props`, `attributes` and `Ref`. Reading "any
|
|
63
|
+
// non-enumerable own property" as the marker is close to "any chant object".
|
|
64
|
+
const o = {};
|
|
65
|
+
Object.defineProperty(o, "props", { value: {}, enumerable: false });
|
|
66
|
+
Object.defineProperty(o, "entityType", { value: "Bucket", enumerable: false });
|
|
67
|
+
expect(isDeclarable(o)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an ENUMERABLE declarable symbol is refused", () => {
|
|
71
|
+
// F-Host-Interface item 1 says non-enumerable, and it matters: an
|
|
72
|
+
// enumerable marker travels through a spread and would reach an artifact.
|
|
73
|
+
const o = {};
|
|
74
|
+
Object.defineProperty(o, Symbol.for("x.declarable"), { value: true, enumerable: true });
|
|
75
|
+
expect(isDeclarable(o)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a declarable symbol whose value is not true is refused", () => {
|
|
79
|
+
expect(isDeclarable(marked(Symbol.for("x.declarable"), false))).toBe(false);
|
|
80
|
+
expect(isDeclarable(marked(Symbol.for("x.declarable"), "yes"))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a symbol that merely contains the word is refused", () => {
|
|
84
|
+
// Suffix, not substring: `declarable.thing` is not a declarable marker.
|
|
85
|
+
expect(isDeclarable(marked(Symbol.for("x.declarable.thing")))).toBe(false);
|
|
86
|
+
expect(isDeclarable(marked(Symbol.for("notdeclarable")))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/declarable.ts
CHANGED
|
@@ -48,15 +48,80 @@ export interface CoreOutput extends Declarable {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
51
|
+
* The suffix a declarable marker's symbol description ends with.
|
|
52
|
+
*
|
|
53
|
+
* chant#2444 — what makes a marker mean "entity" rather than "some other kind
|
|
54
|
+
* of chant value". `Symbol.for("chant.declarable")` has it; so does the
|
|
55
|
+
* specification host's `Symbol.for("tsad.conformance.declarable")`.
|
|
56
|
+
*/
|
|
57
|
+
const DECLARABLE_MARKER_SUFFIX = ".declarable";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Type guard to check if a value is a Declarable.
|
|
61
|
+
*
|
|
62
|
+
* chant#2444 — accepts an entity a HOST built, not only one chant built.
|
|
63
|
+
*
|
|
64
|
+
* `F-Host-Interface` item 1 says an entity carries a non-enumerable declarable
|
|
65
|
+
* marker, and a host supplies its own: the specification's conformance host
|
|
66
|
+
* marks with `Symbol.for("tsad.conformance.declarable")`. Testing identity
|
|
67
|
+
* against chant's symbol alone refused those for not being chant's rather than
|
|
68
|
+
* for being malformed, which is what chant#2442 hit inside composite member
|
|
69
|
+
* validation and what would otherwise keep biting in the entity tallies and the
|
|
70
|
+
* revival pass-through.
|
|
71
|
+
*
|
|
72
|
+
* ## Why NOT the reference implementation's reading
|
|
73
|
+
*
|
|
74
|
+
* The reference reads L6.1 as "any own symbol or non-enumerable own property",
|
|
75
|
+
* which is right in its domain and **unsafe in chant's**. Two measurements say
|
|
76
|
+
* so, and both were taken rather than assumed:
|
|
77
|
+
*
|
|
78
|
+
* - A real chant entity's non-enumerable own NAMES are `lexicon`,
|
|
79
|
+
* `entityType`, `kind`, `props`, `attributes`, `Ref`. "Any non-enumerable
|
|
80
|
+
* own property" is therefore close to "any chant object at all".
|
|
81
|
+
* - chant has SEVEN marker symbols, not one — `chant.intrinsic`,
|
|
82
|
+
* `chant.composite`, `chant.stackOutput`, `chant.lexiconOutput`,
|
|
83
|
+
* `chant.effect-receipt`, `chant.secret-declaration`, `chant.childProject`
|
|
84
|
+
* — each a non-enumerable own symbol set to `true`. Under "any own symbol"
|
|
85
|
+
* an Intrinsic and a StackOutput both classify as Declarable, which is a
|
|
86
|
+
* worse bug than the one being fixed: they would enter the entity tallies.
|
|
87
|
+
*
|
|
88
|
+
* The reference can read any symbol because its domain has one. chant
|
|
89
|
+
* distinguishes seven kinds BY symbol identity, so it needs the marker's name.
|
|
90
|
+
* Hence the suffix: the marker must say `declarable`, whoever owns it.
|
|
91
|
+
*
|
|
92
|
+
* ## What the suffix cannot do
|
|
93
|
+
*
|
|
94
|
+
* It is a convention, not a registry. A host that marks with, say,
|
|
95
|
+
* `Symbol.for("acme.entity")` carries a perfectly good non-enumerable marker
|
|
96
|
+
* and is refused here, because nothing in this function knows that package is
|
|
97
|
+
* one the caller named. The context that would settle it —
|
|
98
|
+
* `FoldProjectOptions.lexiconPackages`, chant#2438 — lives in the fold path and
|
|
99
|
+
* does not reach a type guard called from a dozen places.
|
|
100
|
+
*
|
|
101
|
+
* That is a deliberate trade, not an oversight: a convention that refuses an
|
|
102
|
+
* unknown-but-valid marker costs a host one symbol name, while a rule loose
|
|
103
|
+
* enough to accept any marker miscounts chant's own seven kinds on every build.
|
|
104
|
+
* If a real host ever needs the other side of it, the fix is to thread the
|
|
105
|
+
* named packages through rather than to widen the test.
|
|
52
106
|
*/
|
|
53
107
|
export function isDeclarable(value: unknown): value is Declarable {
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
108
|
+
if (typeof value !== "object" || value === null) return false;
|
|
109
|
+
// chant's own, which is every entity a chant build makes.
|
|
110
|
+
if ((value as Record<symbol, unknown>)[DECLARABLE_MARKER] === true) return true;
|
|
111
|
+
return carriesForeignDeclarableMarker(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A declarable marker some other implementation owns — see {@link isDeclarable}. */
|
|
115
|
+
function carriesForeignDeclarableMarker(value: object): boolean {
|
|
116
|
+
for (const marker of Object.getOwnPropertySymbols(value)) {
|
|
117
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, marker);
|
|
118
|
+
// Non-enumerable and `true`, per F-Host-Interface item 1. An enumerable
|
|
119
|
+
// symbol would travel through a spread and reach an artifact, which a
|
|
120
|
+
// marker must not.
|
|
121
|
+
if (descriptor === undefined || descriptor.enumerable || descriptor.value !== true) continue;
|
|
122
|
+
if (marker.description?.endsWith(DECLARABLE_MARKER_SUFFIX) === true) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
60
125
|
}
|
|
61
126
|
|
|
62
127
|
/**
|
|
@@ -85,6 +85,7 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
85
85
|
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
86
86
|
`import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
|
|
87
87
|
`import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
|
|
88
|
+
`import { writeSync } from "node:fs";`,
|
|
88
89
|
``,
|
|
89
90
|
`const BUILD_ROOT = ${lit(buildRoot)};`,
|
|
90
91
|
``,
|
|
@@ -95,7 +96,29 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
95
96
|
// them exactly. Bound before any project import below.
|
|
96
97
|
`setBuildParams(${lit({ ...currentBuildParams })});`,
|
|
97
98
|
``,
|
|
99
|
+
// chant#2461 — which file was being imported when the loop drained.
|
|
100
|
+
//
|
|
101
|
+
// A project file whose module scope awaits something that never settles
|
|
102
|
+
// makes its `await import(...)` never complete. Nothing keeps the loop
|
|
103
|
+
// alive, so Node exits 0 having sent nothing, and `main().catch` never
|
|
104
|
+
// fires because an unsettled promise is not a rejection. The parent then
|
|
105
|
+
// knows only that the child vanished.
|
|
106
|
+
//
|
|
107
|
+
// The child DOES know, right up to the moment it exits. `process.on("exit")`
|
|
108
|
+
// runs synchronously, so it cannot `process.send` — that is asynchronous and
|
|
109
|
+
// the channel will never be serviced — but a synchronous `writeSync` to fd 2
|
|
110
|
+
// lands, and the parent already captures and forwards stderr.
|
|
111
|
+
`let importing;`,
|
|
112
|
+
`let reported = false;`,
|
|
113
|
+
`process.on("exit", () => {`,
|
|
114
|
+
` if (reported || importing === undefined) return;`,
|
|
115
|
+
` writeSync(2, "chant: this file's module scope never finished evaluating, so the build could not " +`,
|
|
116
|
+
` "report anything: " + importing + "\\n A top-level \`await\` that never settles does this. " +`,
|
|
117
|
+
` "Nothing is thrown, so nothing else can name it (chant#2461).\\n");`,
|
|
118
|
+
`});`,
|
|
119
|
+
``,
|
|
98
120
|
`function send(payload) {`,
|
|
121
|
+
` reported = true;`,
|
|
99
122
|
` if (typeof process.send === "function") process.send(payload);`,
|
|
100
123
|
` else console.log(JSON.stringify(payload));`,
|
|
101
124
|
`}`,
|
|
@@ -115,12 +138,14 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
115
138
|
|
|
116
139
|
for (const file of files) {
|
|
117
140
|
lines.push(
|
|
141
|
+
` importing = ${lit(file)};`,
|
|
118
142
|
` try {`,
|
|
119
143
|
` const mod = await import(${lit(file)});`,
|
|
120
144
|
` modules.push({ file: ${lit(file)}, exports: mod });`,
|
|
121
145
|
` } catch (err) {`,
|
|
122
146
|
` errors.push(classifyChildError(${lit(file)}, err).toJSON());`,
|
|
123
147
|
` }`,
|
|
148
|
+
` importing = undefined;`,
|
|
124
149
|
);
|
|
125
150
|
}
|
|
126
151
|
|