@intentius/chant 0.1.6 → 0.1.8
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/package.json +1 -1
- package/src/cli/commands/build.test.ts +58 -5
- package/src/cli/commands/build.ts +24 -3
- package/src/cli/handlers/graph.test.ts +91 -0
- package/src/cli/handlers/graph.ts +23 -0
- package/src/cli/handlers/run-client.ts +134 -0
- package/src/cli/handlers/run-report.ts +160 -0
- package/src/cli/handlers/run.test.ts +448 -0
- package/src/cli/handlers/run.ts +453 -0
- package/src/cli/handlers/state.test.ts +409 -0
- package/src/cli/handlers/state.ts +232 -10
- package/src/cli/main.test.ts +65 -0
- package/src/cli/main.ts +32 -18
- package/src/cli/mcp/op-tools.ts +204 -0
- package/src/cli/mcp/resource-handlers.ts +69 -50
- package/src/cli/mcp/resources/context.ts +27 -0
- package/src/cli/mcp/server.test.ts +176 -3
- package/src/cli/mcp/server.ts +7 -3
- package/src/cli/mcp/state-tools.ts +0 -51
- package/src/cli/mcp/tools/search.ts +6 -1
- package/src/cli/registry.ts +3 -0
- package/src/composite.ts +10 -5
- package/src/index.ts +1 -2
- package/src/lexicon-plugin-helpers.ts +13 -5
- package/src/lexicon.ts +57 -1
- package/src/lint/config.test.ts +21 -0
- package/src/lint/config.ts +19 -3
- package/src/op/discover.test.ts +43 -0
- package/src/op/discover.ts +89 -0
- package/src/op/index.ts +3 -1
- package/src/op/types.ts +13 -6
- package/src/state/digest.test.ts +117 -0
- package/src/state/git.test.ts +191 -0
- package/src/state/git.ts +63 -11
- package/src/state/live-diff.test.ts +184 -0
- package/src/state/live-diff.ts +215 -0
- package/src/state/snapshot.test.ts +171 -0
- package/src/state/snapshot.ts +39 -19
- package/src/state/types.ts +4 -2
- package/src/cli/handlers/spell.ts +0 -396
- package/src/spell/discovery.ts +0 -183
- package/src/spell/index.ts +0 -3
- package/src/spell/prompt.ts +0 -133
- package/src/spell/types.ts +0 -89
|
@@ -195,6 +195,62 @@ describe("McpServer", () => {
|
|
|
195
195
|
expect(props.lexicon).toBeDefined();
|
|
196
196
|
expect(props.limit).toBeDefined();
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
describe("Op tools schema", () => {
|
|
200
|
+
async function getToolProps(name: string): Promise<Record<string, unknown>> {
|
|
201
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
202
|
+
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
203
|
+
const tool = result.tools.find((t) => t.name === name)!;
|
|
204
|
+
return tool.inputSchema.properties as Record<string, unknown>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test("op-list has profile property", async () => {
|
|
208
|
+
const props = await getToolProps("op-list");
|
|
209
|
+
expect(props.profile).toBeDefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("op-run has name (required) and profile", async () => {
|
|
213
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
214
|
+
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
215
|
+
const tool = result.tools.find((t) => t.name === "op-run")!;
|
|
216
|
+
const props = tool.inputSchema.properties as Record<string, unknown>;
|
|
217
|
+
expect(props.name).toBeDefined();
|
|
218
|
+
expect(props.profile).toBeDefined();
|
|
219
|
+
expect(tool.inputSchema.required).toContain("name");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("op-status has name (required) and profile", async () => {
|
|
223
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
224
|
+
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
225
|
+
const tool = result.tools.find((t) => t.name === "op-status")!;
|
|
226
|
+
const props = tool.inputSchema.properties as Record<string, unknown>;
|
|
227
|
+
expect(props.name).toBeDefined();
|
|
228
|
+
expect(props.profile).toBeDefined();
|
|
229
|
+
expect(tool.inputSchema.required).toContain("name");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("op-signal has name and signal (both required) and profile", async () => {
|
|
233
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
234
|
+
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
235
|
+
const tool = result.tools.find((t) => t.name === "op-signal")!;
|
|
236
|
+
const props = tool.inputSchema.properties as Record<string, unknown>;
|
|
237
|
+
expect(props.name).toBeDefined();
|
|
238
|
+
expect(props.signal).toBeDefined();
|
|
239
|
+
expect(props.profile).toBeDefined();
|
|
240
|
+
expect(tool.inputSchema.required).toContain("name");
|
|
241
|
+
expect(tool.inputSchema.required).toContain("signal");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("op-report has name (required) and profile", async () => {
|
|
245
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
246
|
+
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
247
|
+
const tool = result.tools.find((t) => t.name === "op-report")!;
|
|
248
|
+
const props = tool.inputSchema.properties as Record<string, unknown>;
|
|
249
|
+
expect(props.name).toBeDefined();
|
|
250
|
+
expect(props.profile).toBeDefined();
|
|
251
|
+
expect(tool.inputSchema.required).toContain("name");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
198
254
|
});
|
|
199
255
|
|
|
200
256
|
describe("tools/call", () => {
|
|
@@ -302,6 +358,74 @@ describe("McpServer", () => {
|
|
|
302
358
|
expect(parsed.total).toBe(0);
|
|
303
359
|
expect(parsed.results).toEqual([]);
|
|
304
360
|
});
|
|
361
|
+
|
|
362
|
+
describe("Op tool handlers", () => {
|
|
363
|
+
test("op-list returns list without throwing when Temporal unavailable", async () => {
|
|
364
|
+
const response = await server.handleRequest({
|
|
365
|
+
jsonrpc: "2.0",
|
|
366
|
+
id: 1,
|
|
367
|
+
method: "tools/call",
|
|
368
|
+
params: { name: "op-list", arguments: {} },
|
|
369
|
+
});
|
|
370
|
+
expect(response.error).toBeUndefined();
|
|
371
|
+
const result = response.result as { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
372
|
+
expect(result.content[0].type).toBe("text");
|
|
373
|
+
// May be empty list or error-degraded — but no thrown error
|
|
374
|
+
expect(result.isError).toBeUndefined();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("op-run returns isError when Temporal unavailable", async () => {
|
|
378
|
+
const response = await server.handleRequest({
|
|
379
|
+
jsonrpc: "2.0",
|
|
380
|
+
id: 1,
|
|
381
|
+
method: "tools/call",
|
|
382
|
+
params: { name: "op-run", arguments: { name: "nonexistent-op" } },
|
|
383
|
+
});
|
|
384
|
+
expect(response.error).toBeUndefined();
|
|
385
|
+
const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
|
|
386
|
+
// Either "not found" or Temporal error — either way should not be a protocol error
|
|
387
|
+
expect(result.content[0].text.length).toBeGreaterThan(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("op-status returns isError when Temporal unavailable", async () => {
|
|
391
|
+
const response = await server.handleRequest({
|
|
392
|
+
jsonrpc: "2.0",
|
|
393
|
+
id: 1,
|
|
394
|
+
method: "tools/call",
|
|
395
|
+
params: { name: "op-status", arguments: { name: "nonexistent-op" } },
|
|
396
|
+
});
|
|
397
|
+
expect(response.error).toBeUndefined();
|
|
398
|
+
const result = response.result as { content: Array<{ text: string }>; isError: boolean };
|
|
399
|
+
expect(result.isError).toBe(true);
|
|
400
|
+
expect(result.content[0].text).toContain("Error:");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("op-signal returns isError when Temporal unavailable", async () => {
|
|
404
|
+
const response = await server.handleRequest({
|
|
405
|
+
jsonrpc: "2.0",
|
|
406
|
+
id: 1,
|
|
407
|
+
method: "tools/call",
|
|
408
|
+
params: { name: "op-signal", arguments: { name: "nonexistent-op", signal: "gate" } },
|
|
409
|
+
});
|
|
410
|
+
expect(response.error).toBeUndefined();
|
|
411
|
+
const result = response.result as { content: Array<{ text: string }>; isError: boolean };
|
|
412
|
+
expect(result.isError).toBe(true);
|
|
413
|
+
expect(result.content[0].text).toContain("Error:");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("op-report returns content without throwing when op not found", async () => {
|
|
417
|
+
const response = await server.handleRequest({
|
|
418
|
+
jsonrpc: "2.0",
|
|
419
|
+
id: 1,
|
|
420
|
+
method: "tools/call",
|
|
421
|
+
params: { name: "op-report", arguments: { name: "nonexistent-op" } },
|
|
422
|
+
});
|
|
423
|
+
expect(response.error).toBeUndefined();
|
|
424
|
+
const result = response.result as { content: Array<{ text: string }>; isError?: boolean };
|
|
425
|
+
// Op not found → returns a "not found" message or Temporal error, not a protocol error
|
|
426
|
+
expect(result.content[0].text.length).toBeGreaterThan(0);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
305
429
|
});
|
|
306
430
|
|
|
307
431
|
// -----------------------------------------------------------------------
|
|
@@ -446,6 +570,9 @@ describe("McpServer", () => {
|
|
|
446
570
|
const uris = result.resources.map((r) => r.uri);
|
|
447
571
|
expect(uris).toContain("chant://context");
|
|
448
572
|
expect(uris).toContain("chant://examples/list");
|
|
573
|
+
expect(uris).toContain("chant://ops");
|
|
574
|
+
expect(uris).toContain("chant://ops/{name}/runs");
|
|
575
|
+
expect(uris).toContain("chant://ops/{name}/runs/latest");
|
|
449
576
|
|
|
450
577
|
// Each resource has required fields
|
|
451
578
|
for (const resource of result.resources) {
|
|
@@ -549,6 +676,48 @@ describe("McpServer", () => {
|
|
|
549
676
|
expect(response.error).toBeDefined();
|
|
550
677
|
expect(response.error?.message).toContain("Unknown resource");
|
|
551
678
|
});
|
|
679
|
+
|
|
680
|
+
describe("Op resources", () => {
|
|
681
|
+
test("chant://ops returns an array", async () => {
|
|
682
|
+
const response = await server.handleRequest({
|
|
683
|
+
jsonrpc: "2.0",
|
|
684
|
+
id: 1,
|
|
685
|
+
method: "resources/read",
|
|
686
|
+
params: { uri: "chant://ops" },
|
|
687
|
+
});
|
|
688
|
+
expect(response.error).toBeUndefined();
|
|
689
|
+
const result = response.result as { contents: Array<{ text: string; mimeType: string }> };
|
|
690
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
691
|
+
const ops = JSON.parse(result.contents[0].text);
|
|
692
|
+
expect(Array.isArray(ops)).toBe(true);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("chant://ops/{name}/runs degrades gracefully when Temporal unavailable", async () => {
|
|
696
|
+
const response = await server.handleRequest({
|
|
697
|
+
jsonrpc: "2.0",
|
|
698
|
+
id: 1,
|
|
699
|
+
method: "resources/read",
|
|
700
|
+
params: { uri: "chant://ops/nonexistent/runs" },
|
|
701
|
+
});
|
|
702
|
+
expect(response.error).toBeUndefined();
|
|
703
|
+
const result = response.result as { contents: Array<{ text: string }> };
|
|
704
|
+
const data = JSON.parse(result.contents[0].text);
|
|
705
|
+
expect(data.error).toBeDefined();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("chant://ops/{name}/runs/latest degrades gracefully when Temporal unavailable", async () => {
|
|
709
|
+
const response = await server.handleRequest({
|
|
710
|
+
jsonrpc: "2.0",
|
|
711
|
+
id: 1,
|
|
712
|
+
method: "resources/read",
|
|
713
|
+
params: { uri: "chant://ops/nonexistent/runs/latest" },
|
|
714
|
+
});
|
|
715
|
+
expect(response.error).toBeUndefined();
|
|
716
|
+
const result = response.result as { contents: Array<{ text: string }> };
|
|
717
|
+
const data = JSON.parse(result.contents[0].text);
|
|
718
|
+
expect(data.error).toBeDefined();
|
|
719
|
+
});
|
|
720
|
+
});
|
|
552
721
|
});
|
|
553
722
|
|
|
554
723
|
// -----------------------------------------------------------------------
|
|
@@ -962,8 +1131,12 @@ describe("McpServer", () => {
|
|
|
962
1131
|
|
|
963
1132
|
const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
964
1133
|
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
|
965
|
-
expect(tools).toHaveLength(
|
|
966
|
-
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
1134
|
+
expect(tools).toHaveLength(13);
|
|
1135
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
1136
|
+
"build", "explain", "import", "lint",
|
|
1137
|
+
"op-list", "op-report", "op-run", "op-signal", "op-status",
|
|
1138
|
+
"scaffold", "search", "state-diff", "state-snapshot",
|
|
1139
|
+
]);
|
|
967
1140
|
|
|
968
1141
|
const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
|
|
969
1142
|
const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
|
|
@@ -974,7 +1147,7 @@ describe("McpServer", () => {
|
|
|
974
1147
|
const s = new McpServer([]);
|
|
975
1148
|
const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
976
1149
|
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
|
977
|
-
expect(tools).toHaveLength(
|
|
1150
|
+
expect(tools).toHaveLength(13);
|
|
978
1151
|
});
|
|
979
1152
|
});
|
|
980
1153
|
});
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
|
|
|
8
8
|
import { searchTool, createSearchHandler } from "./tools/search";
|
|
9
9
|
import type { LexiconPlugin } from "../../lexicon";
|
|
10
10
|
import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
|
|
11
|
-
import { createSnapshotTool, createDiffTool
|
|
11
|
+
import { createSnapshotTool, createDiffTool } from "./state-tools";
|
|
12
|
+
import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
|
|
12
13
|
import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -35,8 +36,11 @@ export class McpServer {
|
|
|
35
36
|
const diff = createDiffTool(plugins ?? []);
|
|
36
37
|
this.registerTool(diff.definition, diff.handler);
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// Register Op tools
|
|
40
|
+
for (const factory of [createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool]) {
|
|
41
|
+
const t = factory();
|
|
42
|
+
this.registerTool(t.definition, t.handler);
|
|
43
|
+
}
|
|
40
44
|
|
|
41
45
|
// Register plugin contributions
|
|
42
46
|
if (plugins) {
|
|
@@ -6,8 +6,6 @@ import { build } from "../../build";
|
|
|
6
6
|
import { computeBuildDigest, diffDigests } from "../../state/digest";
|
|
7
7
|
import { takeSnapshot } from "../../state/snapshot";
|
|
8
8
|
import type { StateSnapshot } from "../../state/types";
|
|
9
|
-
import { discoverSpells } from "../../spell/discovery";
|
|
10
|
-
|
|
11
9
|
export interface ToolRegistration {
|
|
12
10
|
definition: ToolDefinition;
|
|
13
11
|
handler: ToolHandler;
|
|
@@ -87,52 +85,3 @@ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/**
|
|
91
|
-
* Create spell-done tool definition and handler
|
|
92
|
-
*/
|
|
93
|
-
export function createSpellDoneTool(): ToolRegistration {
|
|
94
|
-
return {
|
|
95
|
-
definition: {
|
|
96
|
-
name: "spell-done",
|
|
97
|
-
description: "Mark a spell task as done",
|
|
98
|
-
inputSchema: {
|
|
99
|
-
type: "object",
|
|
100
|
-
properties: {
|
|
101
|
-
name: { type: "string", description: "Spell name" },
|
|
102
|
-
taskNumber: { type: "number", description: "Task number (1-based)" },
|
|
103
|
-
},
|
|
104
|
-
required: ["name", "taskNumber"],
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
handler: async (params) => {
|
|
108
|
-
const { readFileSync, writeFileSync } = await import("node:fs");
|
|
109
|
-
const { spells } = await discoverSpells();
|
|
110
|
-
const name = params.name as string;
|
|
111
|
-
const taskNumber = params.taskNumber as number;
|
|
112
|
-
const spell = spells.get(name);
|
|
113
|
-
if (!spell) return `Spell "${name}" not found`;
|
|
114
|
-
if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
|
|
115
|
-
return `Invalid task number ${taskNumber}`;
|
|
116
|
-
}
|
|
117
|
-
const task = spell.definition.tasks[taskNumber - 1];
|
|
118
|
-
if (task.done) return `Task ${taskNumber} is already done`;
|
|
119
|
-
|
|
120
|
-
const content = readFileSync(spell.filePath, "utf-8");
|
|
121
|
-
let count = 0;
|
|
122
|
-
const rewritten = content.replace(
|
|
123
|
-
/task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
|
|
124
|
-
(match, desc, opts) => {
|
|
125
|
-
count++;
|
|
126
|
-
if (count !== taskNumber) return match;
|
|
127
|
-
if (opts && opts.includes("done:")) {
|
|
128
|
-
return match.replace(/done:\s*false/, "done: true");
|
|
129
|
-
}
|
|
130
|
-
return `task(${desc}, { done: true })`;
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
|
|
134
|
-
writeFileSync(spell.filePath, rewritten);
|
|
135
|
-
return `Task ${taskNumber} marked done: "${task.description}"`;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
@@ -52,7 +52,12 @@ export function createSearchHandler(
|
|
|
52
52
|
|
|
53
53
|
for (const plugin of candidates) {
|
|
54
54
|
const resources = plugin.mcpResources?.() ?? [];
|
|
55
|
-
|
|
55
|
+
// Catalog URIs were unscoped ("resource-catalog") before lexicon
|
|
56
|
+
// namespacing landed; new lexicons emit "<lexicon>:resource-catalog"
|
|
57
|
+
// to avoid cross-lexicon collision. Support both.
|
|
58
|
+
const catalog = resources.find(
|
|
59
|
+
(r) => r.uri === "resource-catalog" || r.uri.endsWith(":resource-catalog"),
|
|
60
|
+
);
|
|
56
61
|
if (!catalog) continue;
|
|
57
62
|
|
|
58
63
|
let entries: CatalogEntry[];
|
package/src/cli/registry.ts
CHANGED
package/src/composite.ts
CHANGED
|
@@ -95,11 +95,16 @@ export function Composite<P, M extends CompositeMembers = CompositeMembers>(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// Define `members` and `_definition` as non-enumerable so spreading a
|
|
99
|
+
// composite instance (`...someComposite`) only exposes the actual member
|
|
100
|
+
// resources, not the framework's bookkeeping properties. Without this, a
|
|
101
|
+
// parent composite that does `...childResult` ends up with a `members` key
|
|
102
|
+
// pointing at the child's CompositeMembers record — not a Declarable —
|
|
103
|
+
// which then trips the parent's own member validation.
|
|
104
|
+
const instance = {} as CompositeInstance<M>;
|
|
105
|
+
Object.defineProperty(instance, COMPOSITE_MARKER, { value: true, enumerable: false });
|
|
106
|
+
Object.defineProperty(instance, "members", { value: members, enumerable: false });
|
|
107
|
+
Object.defineProperty(instance, "_definition", { value: definition, enumerable: false });
|
|
103
108
|
|
|
104
109
|
return Object.assign(instance, members) as CompositeInstance<M> & M;
|
|
105
110
|
}) as CompositeDefinition<P, M>;
|
package/src/index.ts
CHANGED
|
@@ -60,8 +60,7 @@ export * from "./lsp/types";
|
|
|
60
60
|
export * from "./lsp/lexicon-providers";
|
|
61
61
|
export * from "./mcp/types";
|
|
62
62
|
export * from "./state/index";
|
|
63
|
-
export * from "./spell/index";
|
|
64
63
|
// Op builders — use explicit exports to avoid collision with the core `build` function
|
|
65
64
|
export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack,
|
|
66
65
|
gitlabPipeline, stateSnapshot, shell, teardown, OpResource } from "./op/index";
|
|
67
|
-
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep
|
|
66
|
+
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./op/index";
|
|
@@ -61,15 +61,18 @@ export function createSkillsLoader(
|
|
|
61
61
|
/**
|
|
62
62
|
* Create an MCP diff tool contribution for a lexicon.
|
|
63
63
|
*
|
|
64
|
-
* All lexicons (except Azure) expose an identical "diff" tool that
|
|
65
|
-
* current build output against previous output using the lexicon's
|
|
64
|
+
* All lexicons (except Azure) expose an identical-shape "diff" tool that
|
|
65
|
+
* compares current build output against previous output using the lexicon's
|
|
66
|
+
* serializer. The tool name is namespaced by `lexiconName` (e.g. `gcp:diff`)
|
|
67
|
+
* so multiple lexicons loaded together don't collide on the same tool key.
|
|
66
68
|
*/
|
|
67
69
|
export function createDiffTool(
|
|
68
70
|
serializer: Serializer,
|
|
69
71
|
description: string,
|
|
72
|
+
lexiconName: string,
|
|
70
73
|
): McpToolContribution {
|
|
71
74
|
return {
|
|
72
|
-
name:
|
|
75
|
+
name: `${lexiconName}:diff`,
|
|
73
76
|
description,
|
|
74
77
|
inputSchema: {
|
|
75
78
|
type: "object" as const,
|
|
@@ -96,21 +99,26 @@ export function createDiffTool(
|
|
|
96
99
|
/**
|
|
97
100
|
* Create an MCP resource that serves the lexicon's meta.json as a catalog.
|
|
98
101
|
*
|
|
99
|
-
* Most lexicons expose a "resource-catalog" resource with identical
|
|
102
|
+
* Most lexicons expose a "resource-catalog" resource with identical
|
|
103
|
+
* structure. The URI is namespaced by `lexiconName` (e.g.
|
|
104
|
+
* `gcp:resource-catalog`) so multiple lexicons loaded together don't
|
|
105
|
+
* collide on the same resource key.
|
|
100
106
|
*
|
|
101
107
|
* @param importMetaUrl — The plugin's import.meta.url (used to locate generated JSON)
|
|
102
108
|
* @param name — Display name (e.g. "AWS Resource Catalog")
|
|
103
109
|
* @param description — Resource description
|
|
104
110
|
* @param lexiconJsonFile — Filename of the generated lexicon JSON (e.g. "lexicon-aws.json")
|
|
111
|
+
* @param lexiconName — Short lexicon name (e.g. "aws", "gcp"). Becomes the URI prefix.
|
|
105
112
|
*/
|
|
106
113
|
export function createCatalogResource(
|
|
107
114
|
importMetaUrl: string,
|
|
108
115
|
name: string,
|
|
109
116
|
description: string,
|
|
110
117
|
lexiconJsonFile: string,
|
|
118
|
+
lexiconName: string,
|
|
111
119
|
): McpResourceContribution {
|
|
112
120
|
return {
|
|
113
|
-
uri:
|
|
121
|
+
uri: `${lexiconName}:resource-catalog`,
|
|
114
122
|
name,
|
|
115
123
|
description,
|
|
116
124
|
mimeType: "application/json",
|
package/src/lexicon.ts
CHANGED
|
@@ -195,12 +195,48 @@ export interface LexiconPlugin {
|
|
|
195
195
|
mcpResources?(): McpResourceContribution[];
|
|
196
196
|
|
|
197
197
|
// State
|
|
198
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Query deployed resources and return API metadata. Opt-in.
|
|
200
|
+
*
|
|
201
|
+
* Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
|
|
202
|
+
* AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
|
|
203
|
+
*
|
|
204
|
+
* `entities` carries the chant-side entity declarations for this lexicon,
|
|
205
|
+
* keyed by chant entity name (e.g. the export name from a `*.ts` file).
|
|
206
|
+
* Implementations that need to map cloud-side names back to chant entity
|
|
207
|
+
* names (e.g. Temporal — server-side namespace `prod` ↔ chant entity `ns`
|
|
208
|
+
* declared with `name: "prod"`) read this; implementations that already
|
|
209
|
+
* have name parity (e.g. AWS CloudFormation logical IDs == chant entity
|
|
210
|
+
* names) can ignore it.
|
|
211
|
+
*
|
|
212
|
+
* `entityNames` is preserved as a convenience for the simple case.
|
|
213
|
+
*/
|
|
199
214
|
describeResources?(options: {
|
|
200
215
|
environment: string;
|
|
201
216
|
buildOutput: string;
|
|
202
217
|
entityNames: string[];
|
|
218
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
203
219
|
}): Promise<Record<string, ResourceMetadata>>;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* List runtime artifacts in the given environment. Opt-in.
|
|
223
|
+
*
|
|
224
|
+
* Use this for lexicons whose chant entities describe *authoring*
|
|
225
|
+
* primitives rather than 1:1 cloud resources — e.g. Helm (charts vs
|
|
226
|
+
* releases), Docker (Compose vs running containers), Flyway (migration
|
|
227
|
+
* scripts vs applied migrations). The contract is context-keyed: given an
|
|
228
|
+
* environment, list all artifacts visible there. There is no `declared`
|
|
229
|
+
* comparison axis — `state diff --live` reports added/removed/changed
|
|
230
|
+
* between snapshots, not vs. declared.
|
|
231
|
+
*
|
|
232
|
+
* `entities` is passed for cases where the lexicon needs to know what
|
|
233
|
+
* was declared in order to enumerate (e.g. Flyway needs the declared
|
|
234
|
+
* `Flyway::Environment` entities to know which DBs to query).
|
|
235
|
+
*/
|
|
236
|
+
listArtifacts?(options: {
|
|
237
|
+
environment: string;
|
|
238
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
239
|
+
}): Promise<Record<string, ArtifactMetadata>>;
|
|
204
240
|
}
|
|
205
241
|
|
|
206
242
|
/**
|
|
@@ -219,6 +255,26 @@ export interface ResourceMetadata {
|
|
|
219
255
|
attributes?: Record<string, unknown>;
|
|
220
256
|
}
|
|
221
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Metadata about a runtime artifact, returned by listArtifacts. Same shape
|
|
260
|
+
* as ResourceMetadata; the conceptual distinction is whether the lexicon's
|
|
261
|
+
* chant entities have 1:1 runtime equivalents (resources) or whether the
|
|
262
|
+
* runtime artifacts are created by tooling outside chant's entity model
|
|
263
|
+
* (artifacts — e.g. Helm releases, Docker containers, Flyway migrations).
|
|
264
|
+
*/
|
|
265
|
+
export interface ArtifactMetadata {
|
|
266
|
+
/** Artifact type (e.g. Helm::Release, Docker::Container) */
|
|
267
|
+
type: string;
|
|
268
|
+
/** Server-side identifier */
|
|
269
|
+
physicalId?: string;
|
|
270
|
+
/** Provider-specific status string */
|
|
271
|
+
status: string;
|
|
272
|
+
/** ISO timestamp of last update */
|
|
273
|
+
lastUpdated?: string;
|
|
274
|
+
/** Provider-specific properties */
|
|
275
|
+
attributes?: Record<string, unknown>;
|
|
276
|
+
}
|
|
277
|
+
|
|
222
278
|
/**
|
|
223
279
|
* Type guard to check if a value is a LexiconPlugin.
|
|
224
280
|
* Checks for required lifecycle methods in addition to name/serializer.
|
package/src/lint/config.test.ts
CHANGED
|
@@ -641,4 +641,25 @@ describe("loadConfig", () => {
|
|
|
641
641
|
const config = loadConfig(subDir);
|
|
642
642
|
expect(config).toEqual(DEFAULT_CONFIG);
|
|
643
643
|
});
|
|
644
|
+
|
|
645
|
+
test("accepts ChantConfig-shape nested lint key in chant.config.json", () => {
|
|
646
|
+
const configPath = join(TEST_DIR, "chant.config.json");
|
|
647
|
+
writeFileSync(
|
|
648
|
+
configPath,
|
|
649
|
+
JSON.stringify({
|
|
650
|
+
lexicons: ["aws"],
|
|
651
|
+
lint: {
|
|
652
|
+
rules: {
|
|
653
|
+
"test-rule": "error",
|
|
654
|
+
"noisy-rule": "off",
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const config = loadConfig(TEST_DIR);
|
|
661
|
+
|
|
662
|
+
expect(config.rules?.["test-rule"]).toBe("error");
|
|
663
|
+
expect(config.rules?.["noisy-rule"]).toBe("off");
|
|
664
|
+
});
|
|
644
665
|
});
|
package/src/lint/config.ts
CHANGED
|
@@ -223,18 +223,34 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
|
|
|
223
223
|
throw new Error(`Failed to read config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
let
|
|
226
|
+
let raw: unknown;
|
|
227
227
|
try {
|
|
228
|
-
|
|
228
|
+
raw = JSON.parse(content);
|
|
229
229
|
} catch (err) {
|
|
230
230
|
throw new Error(`Failed to parse config file ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Validate config structure
|
|
234
|
-
if (typeof
|
|
234
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
235
235
|
throw new Error(`Invalid config file ${configPath}: must be an object`);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// Accept both shapes: lint fields at top level (legacy) OR nested under a
|
|
239
|
+
// "lint" key (matches chant.config.ts ChantConfig shape). When the JSON has
|
|
240
|
+
// both a top-level "rules"/"extends"/etc AND a "lint": {...}, prefer the
|
|
241
|
+
// nested one — that matches the explicit ChantConfig contract.
|
|
242
|
+
const rawObj = raw as Record<string, unknown>;
|
|
243
|
+
let config: LintConfig;
|
|
244
|
+
if (
|
|
245
|
+
rawObj.lint &&
|
|
246
|
+
typeof rawObj.lint === "object" &&
|
|
247
|
+
!Array.isArray(rawObj.lint)
|
|
248
|
+
) {
|
|
249
|
+
config = rawObj.lint as LintConfig;
|
|
250
|
+
} else {
|
|
251
|
+
config = rawObj as LintConfig;
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
// Validate with Zod schema
|
|
239
255
|
const parseResult = LintConfigSchema.safeParse(config);
|
|
240
256
|
if (!parseResult.success) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { discoverOps } from "./discover";
|
|
3
|
+
|
|
4
|
+
// Mock getRuntime to return git root pointing at the repo root
|
|
5
|
+
vi.mock("../runtime-adapter", () => ({
|
|
6
|
+
getRuntime: () => ({
|
|
7
|
+
spawn: async (cmd: string[]) => {
|
|
8
|
+
if (cmd[0] === "git" && cmd[1] === "rev-parse") {
|
|
9
|
+
// Return the actual repo root so the test can find the example op file
|
|
10
|
+
const { execFile } = await import("node:child_process");
|
|
11
|
+
const { promisify } = await import("node:util");
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
|
|
14
|
+
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
|
15
|
+
}
|
|
16
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe("discoverOps", () => {
|
|
22
|
+
test("discovers alb-deploy.op.ts from examples/", async () => {
|
|
23
|
+
const { ops, errors } = await discoverOps();
|
|
24
|
+
expect(errors).toHaveLength(0);
|
|
25
|
+
expect(ops.has("alb-deploy")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("discovered Op has correct config shape", async () => {
|
|
29
|
+
const { ops } = await discoverOps();
|
|
30
|
+
const op = ops.get("alb-deploy");
|
|
31
|
+
expect(op).toBeDefined();
|
|
32
|
+
expect(op!.config.name).toBe("alb-deploy");
|
|
33
|
+
expect(Array.isArray(op!.config.phases)).toBe(true);
|
|
34
|
+
expect(op!.config.phases.length).toBeGreaterThan(0);
|
|
35
|
+
expect(typeof op!.config.overview).toBe("string");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("filePath points to the .op.ts source file", async () => {
|
|
39
|
+
const { ops } = await discoverOps();
|
|
40
|
+
const op = ops.get("alb-deploy");
|
|
41
|
+
expect(op!.filePath).toMatch(/alb-deploy\.op\.ts$/);
|
|
42
|
+
});
|
|
43
|
+
});
|