@intentius/chant 0.0.16 → 0.0.22

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.
Files changed (61) hide show
  1. package/bin/chant +4 -1
  2. package/package.json +20 -1
  3. package/src/build.test.ts +4 -2
  4. package/src/build.ts +3 -0
  5. package/src/builder.test.ts +3 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
  7. package/src/cli/commands/build.ts +5 -12
  8. package/src/cli/commands/diff.test.ts +2 -1
  9. package/src/cli/commands/diff.ts +2 -1
  10. package/src/cli/commands/init-lexicon.test.ts +0 -9
  11. package/src/cli/commands/init-lexicon.ts +0 -94
  12. package/src/cli/commands/init.ts +2 -20
  13. package/src/cli/handlers/build.ts +3 -3
  14. package/src/cli/handlers/lint.ts +2 -2
  15. package/src/cli/handlers/spell.ts +396 -0
  16. package/src/cli/handlers/state.ts +230 -0
  17. package/src/cli/lsp/server.test.ts +4 -0
  18. package/src/cli/main.ts +37 -3
  19. package/src/cli/mcp/server.test.ts +13 -9
  20. package/src/cli/mcp/server.ts +220 -6
  21. package/src/cli/mcp/tools/build.ts +2 -1
  22. package/src/cli/plugins.ts +1 -1
  23. package/src/cli/reporters/stylish.test.ts +2 -2
  24. package/src/cli/reporters/stylish.ts +1 -1
  25. package/src/codegen/docs.ts +13 -2
  26. package/src/composite.test.ts +1 -1
  27. package/src/config.ts +4 -0
  28. package/src/declarable.test.ts +2 -1
  29. package/src/declarable.ts +1 -1
  30. package/src/discovery/graph.test.ts +40 -0
  31. package/src/discovery/import.test.ts +5 -5
  32. package/src/discovery/resolve.test.ts +20 -0
  33. package/src/discovery/resolve.ts +2 -2
  34. package/src/index.ts +2 -0
  35. package/src/lexicon.ts +24 -0
  36. package/src/lint/rule-options.test.ts +3 -3
  37. package/src/lint/rule-registry.test.ts +1 -1
  38. package/src/lint/rules/composite-scope.ts +1 -1
  39. package/src/serializer-walker.ts +2 -1
  40. package/src/spell/discovery.ts +183 -0
  41. package/src/spell/index.ts +3 -0
  42. package/src/spell/prompt.ts +133 -0
  43. package/src/spell/types.ts +89 -0
  44. package/src/state/digest.ts +88 -0
  45. package/src/state/git.ts +317 -0
  46. package/src/state/index.ts +4 -0
  47. package/src/state/snapshot.ts +179 -0
  48. package/src/state/types.ts +59 -0
  49. package/src/types.ts +2 -1
  50. package/src/utils.test.ts +16 -3
  51. package/src/utils.ts +31 -1
  52. package/src/validation.test.ts +11 -0
  53. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  54. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  55. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  56. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  57. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  58. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  59. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  60. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  61. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -10,6 +10,10 @@ function createMockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
10
10
  return {
11
11
  name: "mock",
12
12
  serializer: { name: "mock", serialize: () => "" } as unknown as Serializer,
13
+ generate: async () => {},
14
+ validate: async () => {},
15
+ coverage: async () => {},
16
+ package: async () => {},
13
17
  ...overrides,
14
18
  };
15
19
  }
@@ -401,11 +405,11 @@ describe("McpServer", () => {
401
405
  test("passes template name to initTemplates", async () => {
402
406
  const plugin = createMockPlugin({
403
407
  name: "test-lex",
404
- initTemplates: (template?: string) => {
405
- if (template === "special") {
406
- return { src: { "special.ts": "export const special = {};" } };
407
- }
408
- return { src: { "default.ts": "export const def = {};" } };
408
+ initTemplates: (template?: string | undefined) => {
409
+ const src: Record<string, string> = template === "special"
410
+ ? { "special.ts": "export const special = {};" }
411
+ : { "default.ts": "export const def = {};" };
412
+ return { src };
409
413
  },
410
414
  });
411
415
 
@@ -958,19 +962,19 @@ describe("McpServer", () => {
958
962
 
959
963
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
960
964
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
961
- expect(tools).toHaveLength(6);
962
- expect(tools.map((t) => t.name).sort()).toEqual(["build", "explain", "import", "lint", "scaffold", "search"]);
965
+ expect(tools).toHaveLength(9);
966
+ expect(tools.map((t) => t.name).sort()).toEqual(["build", "explain", "import", "lint", "scaffold", "search", "spell-done", "state-diff", "state-snapshot"]);
963
967
 
964
968
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
965
969
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
966
- expect(resources).toHaveLength(2);
970
+ expect(resources).toHaveLength(7);
967
971
  });
968
972
 
969
973
  test("server with empty plugins array works", async () => {
970
974
  const s = new McpServer([]);
971
975
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
972
976
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
973
- expect(tools).toHaveLength(6);
977
+ expect(tools).toHaveLength(9);
974
978
  });
975
979
  });
976
980
  });
@@ -9,6 +9,14 @@ import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import { getContext } from "./resources/context";
10
10
  import type { LexiconPlugin } from "../../lexicon";
11
11
  import type { McpToolContribution, McpResourceContribution } from "../../mcp/types";
12
+ import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
13
+ import { build } from "../../build";
14
+ import { computeBuildDigest, diffDigests } from "../../state/digest";
15
+ import { takeSnapshot } from "../../state/snapshot";
16
+ import type { StateSnapshot } from "../../state/types";
17
+ import { discoverSpells } from "../../spell/discovery";
18
+ import { generatePrompt } from "../../spell/prompt";
19
+ import { getRuntime } from "../../runtime-adapter";
12
20
 
13
21
  /**
14
22
  * MCP message types
@@ -31,12 +39,6 @@ interface McpResponse {
31
39
  };
32
40
  }
33
41
 
34
- interface McpNotification {
35
- jsonrpc: "2.0";
36
- method: string;
37
- params?: Record<string, unknown>;
38
- }
39
-
40
42
  /**
41
43
  * Tool definition for MCP
42
44
  */
@@ -77,6 +79,117 @@ export class McpServer {
77
79
  this.registerTool(scaffoldTool, createScaffoldHandler(plugins ?? []));
78
80
  this.registerTool(searchTool, createSearchHandler(plugins ?? []));
79
81
 
82
+ // Register state tools
83
+ this.registerTool(
84
+ {
85
+ name: "state-snapshot",
86
+ description: "Capture deployed state for an environment",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ environment: { type: "string", description: "Target environment" },
91
+ lexicon: { type: "string", description: "Optional — snapshot all lexicons if omitted" },
92
+ },
93
+ required: ["environment"],
94
+ },
95
+ },
96
+ async (params) => {
97
+ const env = params.environment as string;
98
+ const lexiconFilter = params.lexicon as string | undefined;
99
+ const targetPlugins = lexiconFilter
100
+ ? (plugins ?? []).filter((p) => p.name === lexiconFilter)
101
+ : (plugins ?? []);
102
+ const pluginsWithDescribe = targetPlugins.filter((p) => p.describeResources);
103
+ if (pluginsWithDescribe.length === 0) return "No plugins implement describeResources";
104
+ const serializers = (plugins ?? []).map((p) => p.serializer);
105
+ const buildResult = await build(resolve("."), serializers);
106
+ if (buildResult.errors.length > 0) return "Build failed";
107
+ const result = await takeSnapshot(env, pluginsWithDescribe, buildResult);
108
+ return { snapshots: result.snapshots.length, warnings: result.warnings, errors: result.errors };
109
+ },
110
+ );
111
+
112
+ this.registerTool(
113
+ {
114
+ name: "state-diff",
115
+ description: "Compare current build declarations against last snapshot's digest",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ environment: { type: "string", description: "Target environment" },
120
+ lexicon: { type: "string", description: "Optional — diff all lexicons if omitted" },
121
+ },
122
+ required: ["environment"],
123
+ },
124
+ },
125
+ async (params) => {
126
+ const env = params.environment as string;
127
+ const lexiconFilter = params.lexicon as string | undefined;
128
+ const serializers = (plugins ?? []).map((p) => p.serializer);
129
+ const buildResult = await build(resolve("."), serializers);
130
+ if (buildResult.errors.length > 0) return "Build failed";
131
+ const currentDigest = computeBuildDigest(buildResult);
132
+ const lexicons = lexiconFilter ? [lexiconFilter] : buildResult.manifest.lexicons;
133
+ const results: Record<string, unknown> = {};
134
+ for (const lex of lexicons) {
135
+ const content = await readSnapshot(env, lex);
136
+ let previousDigest = undefined;
137
+ if (content) {
138
+ const snapshot: StateSnapshot = JSON.parse(content);
139
+ previousDigest = snapshot.digest;
140
+ }
141
+ results[lex] = diffDigests(currentDigest, previousDigest);
142
+ }
143
+ return results;
144
+ },
145
+ );
146
+
147
+ // Register spell tools
148
+ this.registerTool(
149
+ {
150
+ name: "spell-done",
151
+ description: "Mark a spell task as done",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ name: { type: "string", description: "Spell name" },
156
+ taskNumber: { type: "number", description: "Task number (1-based)" },
157
+ },
158
+ required: ["name", "taskNumber"],
159
+ },
160
+ },
161
+ async (params) => {
162
+ const { readFileSync, writeFileSync } = await import("node:fs");
163
+ const { spells } = await discoverSpells();
164
+ const name = params.name as string;
165
+ const taskNumber = params.taskNumber as number;
166
+ const spell = spells.get(name);
167
+ if (!spell) return `Spell "${name}" not found`;
168
+ if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
169
+ return `Invalid task number ${taskNumber}`;
170
+ }
171
+ const task = spell.definition.tasks[taskNumber - 1];
172
+ if (task.done) return `Task ${taskNumber} is already done`;
173
+
174
+ const content = readFileSync(spell.filePath, "utf-8");
175
+ let count = 0;
176
+ const rewritten = content.replace(
177
+ /task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
178
+ (match, desc, opts) => {
179
+ count++;
180
+ if (count !== taskNumber) return match;
181
+ if (opts && opts.includes("done:")) {
182
+ return match.replace(/done:\s*false/, "done: true");
183
+ }
184
+ return `task(${desc}, { done: true })`;
185
+ },
186
+ );
187
+ if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
188
+ writeFileSync(spell.filePath, rewritten);
189
+ return `Task ${taskNumber} marked done: "${task.description}"`;
190
+ },
191
+ );
192
+
80
193
  // Register plugin contributions
81
194
  if (plugins) {
82
195
  for (const plugin of plugins) {
@@ -268,6 +381,36 @@ export class McpServer {
268
381
  description: "List of available chant examples",
269
382
  mimeType: "application/json",
270
383
  },
384
+ {
385
+ uri: "chant://spells",
386
+ name: "Spells",
387
+ description: "List all spells with status, tasks, and lexicon",
388
+ mimeType: "application/json",
389
+ },
390
+ {
391
+ uri: "chant://spell/{name}",
392
+ name: "Spell details",
393
+ description: "Show spell definition and status",
394
+ mimeType: "application/json",
395
+ },
396
+ {
397
+ uri: "chant://spell/{name}/prompt",
398
+ name: "Spell bootstrap prompt",
399
+ description: "Bootstrap prompt for agent consumption",
400
+ mimeType: "text/markdown",
401
+ },
402
+ {
403
+ uri: "chant://state/{environment}",
404
+ name: "State (all lexicons)",
405
+ description: "All lexicon snapshots for an environment",
406
+ mimeType: "application/json",
407
+ },
408
+ {
409
+ uri: "chant://state/{environment}/{lexicon}",
410
+ name: "State (single lexicon)",
411
+ description: "Single lexicon snapshot for an environment",
412
+ mimeType: "application/json",
413
+ },
271
414
  ];
272
415
 
273
416
  // Merge plugin resources
@@ -322,6 +465,77 @@ export class McpServer {
322
465
  };
323
466
  }
324
467
 
468
+ // Spell resources
469
+ if (uri === "chant://spells") {
470
+ const { spells } = await discoverSpells();
471
+ const list = Array.from(spells.entries()).map(([name, s]) => ({
472
+ name,
473
+ status: s.status,
474
+ tasks: `${s.definition.tasks.filter((t) => t.done).length}/${s.definition.tasks.length}`,
475
+ lexicon: s.definition.lexicon ?? null,
476
+ overview: s.definition.overview,
477
+ }));
478
+ return {
479
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(list, null, 2) }],
480
+ };
481
+ }
482
+
483
+ if (uri.startsWith("chant://spell/") && uri.endsWith("/prompt")) {
484
+ const name = uri.replace("chant://spell/", "").replace("/prompt", "");
485
+ const { spells } = await discoverSpells();
486
+ const spell = spells.get(name);
487
+ if (!spell) throw new Error(`Spell "${name}" not found`);
488
+ const rt = getRuntime();
489
+ const gitRootResult = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
490
+ const gitRoot = gitRootResult.stdout.trim();
491
+ const prompt = await generatePrompt(spell.definition, { gitRoot });
492
+ return {
493
+ contents: [{ uri, mimeType: "text/markdown", text: prompt }],
494
+ };
495
+ }
496
+
497
+ if (uri.startsWith("chant://spell/")) {
498
+ const name = uri.replace("chant://spell/", "");
499
+ const { spells } = await discoverSpells();
500
+ const spell = spells.get(name);
501
+ if (!spell) throw new Error(`Spell "${name}" not found`);
502
+ return {
503
+ contents: [{
504
+ uri,
505
+ mimeType: "application/json",
506
+ text: JSON.stringify({
507
+ ...spell.definition,
508
+ status: spell.status,
509
+ filePath: spell.filePath,
510
+ }, null, 2),
511
+ }],
512
+ };
513
+ }
514
+
515
+ // State resources: chant://state/{environment} and chant://state/{environment}/{lexicon}
516
+ if (uri.startsWith("chant://state/")) {
517
+ const parts = uri.replace("chant://state/", "").split("/");
518
+ const environment = parts[0];
519
+ const lexicon = parts[1];
520
+
521
+ if (lexicon) {
522
+ const content = await readSnapshot(environment, lexicon);
523
+ if (!content) throw new Error(`No snapshot found for ${environment}/${lexicon}`);
524
+ return {
525
+ contents: [{ uri, mimeType: "application/json", text: content }],
526
+ };
527
+ } else {
528
+ const snapshots = await readEnvironmentSnapshots(environment);
529
+ const result: Record<string, unknown> = {};
530
+ for (const [lex, content] of snapshots) {
531
+ result[lex] = JSON.parse(content);
532
+ }
533
+ return {
534
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
535
+ };
536
+ }
537
+ }
538
+
325
539
  if (uri.startsWith("chant://examples/")) {
326
540
  // Look up example in plugin resources
327
541
  const name = uri.replace("chant://examples/", "");
@@ -58,7 +58,8 @@ export async function handleBuild(params: Record<string, unknown>): Promise<unkn
58
58
  // Combine all lexicon outputs
59
59
  const combined: Record<string, unknown> = {};
60
60
  for (const [lexiconName, lexiconOutput] of result.outputs) {
61
- combined[lexiconName] = JSON.parse(lexiconOutput);
61
+ const raw = lexiconOutput;
62
+ combined[lexiconName] = JSON.parse(typeof raw === "string" ? raw : raw.primary);
62
63
  }
63
64
 
64
65
  let output = JSON.stringify(combined, null, 2);
@@ -33,7 +33,7 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
33
33
  validate: notSupported("validate"),
34
34
  coverage: notSupported("coverage"),
35
35
  package: notSupported("package"),
36
- rollback: notSupported("rollback"),
36
+
37
37
  };
38
38
  }
39
39
 
@@ -17,9 +17,9 @@ describe("formatStylish", () => {
17
17
  }
18
18
  });
19
19
 
20
- test("returns empty string for no diagnostics", () => {
20
+ test("returns summary line for no diagnostics", () => {
21
21
  const result = formatStylish([]);
22
- expect(result).toBe("");
22
+ expect(result).toBe("\u2713 No problems found");
23
23
  });
24
24
 
25
25
  test("formats single diagnostic", () => {
@@ -36,7 +36,7 @@ function color(text: string, colorCode: string): string {
36
36
  */
37
37
  export function formatStylish(diagnostics: LintDiagnostic[]): string {
38
38
  if (diagnostics.length === 0) {
39
- return "";
39
+ return formatSummary(0, 0);
40
40
  }
41
41
 
42
42
  // Group by file
@@ -46,6 +46,8 @@ export interface DocsConfig {
46
46
  basePath?: string;
47
47
  /** Root directory for resolving {{file:...}} markers in extra page content */
48
48
  examplesDir?: string;
49
+ /** Extra sidebar entries appended after extraPages (supports Starlight groups) */
50
+ sidebarExtra?: Array<Record<string, unknown>>;
49
51
  }
50
52
 
51
53
  export interface DocsResult {
@@ -285,8 +287,12 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
285
287
  const outDir = config.outDir;
286
288
  const contentDir = join(outDir, "src", "content", "docs");
287
289
 
288
- // Clear stale content and Astro caches so changes are picked up on next build
289
- rmSync(contentDir, { recursive: true, force: true });
290
+ // Clear stale generated content and Astro caches so changes are picked up on next build.
291
+ // Only remove files that will be regenerated — preserve hand-written pages.
292
+ for (const filename of result.pages.keys()) {
293
+ const filePath = join(contentDir, filename);
294
+ rmSync(filePath, { force: true });
295
+ }
290
296
  rmSync(join(outDir, ".astro"), { recursive: true, force: true });
291
297
  rmSync(join(outDir, "node_modules", ".astro"), { recursive: true, force: true });
292
298
 
@@ -411,6 +417,11 @@ function buildSidebar(
411
417
  items.push({ label: "Serialization", slug: "serialization" });
412
418
  }
413
419
 
420
+ // Append raw sidebar entries (supports groups and nested items)
421
+ if (config.sidebarExtra) {
422
+ items.push(...config.sidebarExtra);
423
+ }
424
+
414
425
  return items;
415
426
  }
416
427
 
@@ -116,7 +116,7 @@ describe("Composite", () => {
116
116
  });
117
117
 
118
118
  const instance = MyComp({});
119
- const roleProps = instance.members.role.props as Record<string, unknown>;
119
+ const roleProps = (instance.members.role as MockResource).props;
120
120
  expect(roleProps.bucketArn).toBeInstanceOf(AttrRef);
121
121
  // The AttrRef's parent should be the bucket instance
122
122
  expect((roleProps.bucketArn as AttrRef).attribute).toBe("Arn");
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ import type { LintConfig } from "./lint/config";
9
9
  export const ChantConfigSchema = z.object({
10
10
  runtime: z.enum(["bun", "node"]).optional(),
11
11
  lexicons: z.array(z.string().min(1)).optional(),
12
+ environments: z.array(z.string().min(1)).optional(),
12
13
  lint: z.record(z.string(), z.unknown()).optional(),
13
14
  }).passthrough();
14
15
 
@@ -24,6 +25,9 @@ export interface ChantConfig {
24
25
  /** Lexicon package names to load (e.g. ["aws"]) */
25
26
  lexicons?: string[];
26
27
 
28
+ /** Environment names (e.g. ["staging", "prod"]) */
29
+ environments?: string[];
30
+
27
31
  /** Lint configuration (rules, extends, overrides, plugins) */
28
32
  lint?: LintConfig;
29
33
  }
@@ -12,7 +12,8 @@ describe("DECLARABLE_MARKER", () => {
12
12
  });
13
13
 
14
14
  test("uses Symbol.for for global registry", () => {
15
- expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable"));
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ expect(DECLARABLE_MARKER).toBe(Symbol.for("chant.declarable") as any);
16
17
  });
17
18
  });
18
19
 
package/src/declarable.ts CHANGED
@@ -9,7 +9,7 @@ export const DECLARABLE_MARKER = Symbol.for("chant.declarable");
9
9
  export interface Declarable {
10
10
  readonly lexicon: string;
11
11
  readonly entityType: string;
12
- readonly kind?: "resource" | "property";
12
+ readonly kind?: "resource" | "property" | "output";
13
13
  readonly [DECLARABLE_MARKER]: true;
14
14
  }
15
15