@intentius/chant 0.0.18 → 0.0.24

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 (87) 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/templates/codegen.ts +188 -0
  11. package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
  12. package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
  13. package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
  14. package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
  15. package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
  16. package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
  17. package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
  18. package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
  19. package/src/cli/commands/init-lexicon.test.ts +0 -9
  20. package/src/cli/commands/init-lexicon.ts +12 -868
  21. package/src/cli/commands/init.ts +2 -20
  22. package/src/cli/conflict-check.test.ts +43 -0
  23. package/src/cli/handlers/build.ts +3 -3
  24. package/src/cli/handlers/lint.ts +2 -2
  25. package/src/cli/handlers/spell.ts +396 -0
  26. package/src/cli/handlers/state.ts +230 -0
  27. package/src/cli/lsp/server.test.ts +4 -0
  28. package/src/cli/main.ts +37 -3
  29. package/src/cli/mcp/resource-handlers.ts +227 -0
  30. package/src/cli/mcp/server.test.ts +13 -9
  31. package/src/cli/mcp/server.ts +24 -199
  32. package/src/cli/mcp/state-tools.ts +138 -0
  33. package/src/cli/mcp/tools/build.ts +2 -1
  34. package/src/cli/mcp/types.ts +45 -0
  35. package/src/cli/plugins.ts +1 -1
  36. package/src/cli/reporters/stylish.test.ts +2 -2
  37. package/src/cli/reporters/stylish.ts +1 -1
  38. package/src/codegen/docs-file-markers.ts +69 -0
  39. package/src/codegen/docs-rule-scanning.ts +159 -0
  40. package/src/codegen/docs-sections.ts +159 -0
  41. package/src/codegen/docs-sidebar.ts +56 -0
  42. package/src/codegen/docs-types.ts +79 -0
  43. package/src/codegen/docs.ts +9 -495
  44. package/src/composite.test.ts +76 -1
  45. package/src/composite.ts +37 -0
  46. package/src/config.ts +4 -0
  47. package/src/declarable.test.ts +2 -1
  48. package/src/declarable.ts +1 -1
  49. package/src/discovery/collect.test.ts +34 -0
  50. package/src/discovery/collect.ts +12 -0
  51. package/src/discovery/graph.test.ts +40 -0
  52. package/src/discovery/import.test.ts +5 -5
  53. package/src/discovery/resolve.test.ts +20 -0
  54. package/src/discovery/resolve.ts +2 -2
  55. package/src/index.ts +2 -0
  56. package/src/lexicon-plugin-helpers.ts +130 -0
  57. package/src/lexicon.ts +24 -0
  58. package/src/lint/rule-options.test.ts +3 -3
  59. package/src/lint/rule-registry.test.ts +1 -1
  60. package/src/lint/rules/composite-scope.ts +1 -1
  61. package/src/serializer-walker.ts +2 -1
  62. package/src/spell/discovery.ts +183 -0
  63. package/src/spell/index.ts +3 -0
  64. package/src/spell/prompt.ts +133 -0
  65. package/src/spell/types.ts +89 -0
  66. package/src/state/digest.ts +88 -0
  67. package/src/state/git.ts +317 -0
  68. package/src/state/index.ts +4 -0
  69. package/src/state/snapshot.ts +179 -0
  70. package/src/state/types.ts +59 -0
  71. package/src/toml-emit.ts +182 -0
  72. package/src/toml-parse.ts +370 -0
  73. package/src/toml-utils.ts +60 -0
  74. package/src/toml.ts +5 -602
  75. package/src/types.ts +2 -1
  76. package/src/utils.test.ts +16 -3
  77. package/src/utils.ts +31 -1
  78. package/src/validation.test.ts +11 -0
  79. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  80. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  81. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  82. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  83. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  84. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  85. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  86. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  87. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -0,0 +1,230 @@
1
+ import { resolve } from "node:path";
2
+ import { build } from "../../build";
3
+ import { takeSnapshot } from "../../state/snapshot";
4
+ import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchState } from "../../state/git";
5
+ import { computeBuildDigest, diffDigests } from "../../state/digest";
6
+ import { loadChantConfig } from "../../config";
7
+ import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
8
+ import type { CommandContext } from "../registry";
9
+ import type { StateSnapshot } from "../../state/types";
10
+
11
+ /**
12
+ * chant state snapshot <environment> [lexicon]
13
+ */
14
+ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
15
+ const { args, plugins } = ctx;
16
+ const environment = args.extraPositional;
17
+ const lexiconFilter = args.extraPositional2;
18
+
19
+ if (!environment) {
20
+ console.error(formatError({ message: "Environment is required: chant state snapshot <environment> [lexicon]" }));
21
+ return 1;
22
+ }
23
+
24
+ // Validate environment against config
25
+ const projectPath = resolve(".");
26
+ const { config } = await loadChantConfig(projectPath);
27
+ if (config.environments && !config.environments.includes(environment)) {
28
+ console.error(formatError({
29
+ message: `Unknown environment "${environment}"`,
30
+ hint: `Defined environments: ${config.environments.join(", ")}`,
31
+ }));
32
+ return 1;
33
+ }
34
+
35
+ // Filter plugins if lexicon specified
36
+ const targetPlugins = lexiconFilter
37
+ ? plugins.filter((p) => p.name === lexiconFilter)
38
+ : plugins;
39
+ const targetSerializers = targetPlugins.map((p) => p.serializer);
40
+
41
+ // Build first to get entity names and build output
42
+ const buildResult = await build(projectPath, targetSerializers);
43
+ if (buildResult.errors.length > 0) {
44
+ console.error(formatError({ message: "Build failed — fix errors before taking a snapshot" }));
45
+ return 1;
46
+ }
47
+
48
+ const pluginsWithDescribe = targetPlugins.filter((p) => p.describeResources);
49
+ if (pluginsWithDescribe.length === 0) {
50
+ console.error(formatError({
51
+ message: "No plugins implement describeResources",
52
+ hint: lexiconFilter ? `Lexicon "${lexiconFilter}" does not support state snapshots` : undefined,
53
+ }));
54
+ return 1;
55
+ }
56
+
57
+ const result = await takeSnapshot(environment, pluginsWithDescribe, buildResult);
58
+
59
+ for (const w of result.warnings) {
60
+ console.error(formatWarning({ message: w }));
61
+ }
62
+ for (const e of result.errors) {
63
+ console.error(formatError({ message: e }));
64
+ }
65
+
66
+ if (result.snapshots.length > 0) {
67
+ const counts = result.snapshots
68
+ .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
69
+ .join(" ");
70
+ console.error(formatSuccess(`Snapshot saved to chant/state (${counts})`));
71
+ }
72
+
73
+ return result.errors.length > 0 && result.snapshots.length === 0 ? 1 : 0;
74
+ }
75
+
76
+ /**
77
+ * chant state show <environment> [lexicon]
78
+ */
79
+ export async function runStateShow(ctx: CommandContext): Promise<number> {
80
+ const environment = ctx.args.extraPositional;
81
+ const lexiconFilter = ctx.args.extraPositional2;
82
+
83
+ if (!environment) {
84
+ console.error(formatError({ message: "Environment is required: chant state show <environment> [lexicon]" }));
85
+ return 1;
86
+ }
87
+
88
+ // Fetch from remote first
89
+ await fetchState();
90
+
91
+ if (lexiconFilter) {
92
+ const content = await readSnapshot(environment, lexiconFilter);
93
+ if (!content) {
94
+ console.error(formatError({ message: `No snapshot found for ${environment}/${lexiconFilter}` }));
95
+ return 1;
96
+ }
97
+
98
+ const snapshot: StateSnapshot = JSON.parse(content);
99
+ printSnapshotTable(snapshot);
100
+ } else {
101
+ const snapshots = await readEnvironmentSnapshots(environment);
102
+ if (snapshots.size === 0) {
103
+ console.error(formatError({ message: `No snapshots found for environment "${environment}"` }));
104
+ return 1;
105
+ }
106
+
107
+ for (const [lexicon, content] of snapshots) {
108
+ const snapshot: StateSnapshot = JSON.parse(content);
109
+ console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources — ${snapshot.timestamp}`);
110
+ printSnapshotTable(snapshot);
111
+ }
112
+ }
113
+
114
+ return 0;
115
+ }
116
+
117
+ /**
118
+ * chant state diff <environment> [lexicon]
119
+ */
120
+ export async function runStateDiff(ctx: CommandContext): Promise<number> {
121
+ const { args, plugins, serializers } = ctx;
122
+ const environment = args.extraPositional;
123
+ const lexiconFilter = args.extraPositional2;
124
+
125
+ if (!environment) {
126
+ console.error(formatError({ message: "Environment is required: chant state diff <environment> [lexicon]" }));
127
+ return 1;
128
+ }
129
+
130
+ // Filter serializers to target lexicon before building
131
+ const targetSerializers = lexiconFilter
132
+ ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
133
+ : serializers;
134
+
135
+ // Build to get current digest
136
+ const projectPath = resolve(".");
137
+ const buildResult = await build(projectPath, targetSerializers);
138
+ if (buildResult.errors.length > 0) {
139
+ console.error(formatError({ message: "Build failed — fix errors before diffing" }));
140
+ return 1;
141
+ }
142
+
143
+ const currentDigest = computeBuildDigest(buildResult);
144
+
145
+ // Fetch and read previous snapshot
146
+ await fetchState();
147
+
148
+ const lexicons = lexiconFilter
149
+ ? [lexiconFilter]
150
+ : Array.from(buildResult.manifest.lexicons);
151
+
152
+ for (const lexicon of lexicons) {
153
+ const content = await readSnapshot(environment, lexicon);
154
+ let previousDigest = undefined;
155
+ if (content) {
156
+ const snapshot: StateSnapshot = JSON.parse(content);
157
+ previousDigest = snapshot.digest;
158
+ }
159
+
160
+ const diff = diffDigests(currentDigest, previousDigest);
161
+
162
+ console.log(`\n${formatBold(lexicon)}`);
163
+ console.log("RESOURCE".padEnd(20) + "STATUS".padEnd(12) + "TYPE");
164
+ console.log("-".repeat(60));
165
+
166
+ for (const name of diff.added) {
167
+ console.log(name.padEnd(20) + "added".padEnd(12) + (currentDigest.resources[name]?.type ?? ""));
168
+ }
169
+ for (const name of diff.changed) {
170
+ console.log(name.padEnd(20) + "changed".padEnd(12) + (currentDigest.resources[name]?.type ?? ""));
171
+ }
172
+ for (const name of diff.removed) {
173
+ console.log(name.padEnd(20) + "removed".padEnd(12) + (previousDigest?.resources[name]?.type ?? ""));
174
+ }
175
+ for (const name of diff.unchanged) {
176
+ console.log(name.padEnd(20) + "unchanged".padEnd(12) + (currentDigest.resources[name]?.type ?? ""));
177
+ }
178
+ }
179
+
180
+ return 0;
181
+ }
182
+
183
+ /**
184
+ * chant state log [environment]
185
+ */
186
+ export async function runStateLog(ctx: CommandContext): Promise<number> {
187
+ const environment = ctx.args.extraPositional;
188
+
189
+ await fetchState();
190
+
191
+ const entries = await listSnapshots({ environment });
192
+ if (entries.length === 0) {
193
+ console.error(formatError({ message: "No state snapshots found" }));
194
+ return 1;
195
+ }
196
+
197
+ for (const entry of entries) {
198
+ const date = entry.date.split("T")[0];
199
+ console.log(`${entry.commit.slice(0, 7)} ${date} ${entry.message}`);
200
+ }
201
+
202
+ return 0;
203
+ }
204
+
205
+ /**
206
+ * Fallback for unknown state subcommands.
207
+ */
208
+ export async function runStateUnknown(ctx: CommandContext): Promise<number> {
209
+ console.error(formatError({
210
+ message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
211
+ hint: "Available: chant state snapshot, chant state show, chant state diff, chant state log",
212
+ }));
213
+ return 1;
214
+ }
215
+
216
+ function printSnapshotTable(snapshot: StateSnapshot): void {
217
+ console.log("RESOURCE".padEnd(20) + "TYPE".padEnd(28) + "PHYSICAL ID".padEnd(44) + "STATUS");
218
+ console.log("-".repeat(100));
219
+
220
+ for (const [name, meta] of Object.entries(snapshot.resources)) {
221
+ const physicalId = meta.physicalId ?? "";
222
+ const truncId = physicalId.length > 40 ? physicalId.slice(0, 37) + "..." : physicalId;
223
+ console.log(
224
+ name.padEnd(20) +
225
+ meta.type.padEnd(28) +
226
+ truncId.padEnd(44) +
227
+ meta.status
228
+ );
229
+ }
230
+ }
@@ -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
  }
package/src/cli/main.ts CHANGED
@@ -12,6 +12,8 @@ import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDe
12
12
  import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
14
  import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
15
+ import { runStateSnapshot, runStateShow, runStateDiff, runStateLog, runStateUnknown } from "./handlers/state";
16
+ import { runSpellAdd, runSpellRm, runSpellList, runSpellShow, runSpellCast, runSpellDone, runGraph, runSpellUnknown } from "./handlers/spell";
15
17
 
16
18
  /**
17
19
  * Parse command line arguments
@@ -91,6 +93,21 @@ Commands:
91
93
  list List discovered entities
92
94
  import Import external template into TypeScript
93
95
 
96
+ Spells:
97
+ spell add <name> Create a new spell
98
+ spell rm <name> Remove a spell
99
+ spell list List all spells with status
100
+ spell show <name> Show spell details
101
+ spell cast <name> Generate bootstrap prompt for agent
102
+ spell done <name> <N> Mark task N as done
103
+ graph Show spell dependency graph
104
+
105
+ State:
106
+ state snapshot <env> Query API, save metadata to orphan branch
107
+ state show <env> Show latest state snapshot
108
+ state diff <env> Compare current build against last snapshot
109
+ state log [env] History of state snapshots
110
+
94
111
  Lexicon development:
95
112
  dev generate Generate lexicon artifacts (+ validate + coverage)
96
113
  dev publish Package lexicon for distribution
@@ -177,11 +194,28 @@ const registry: CommandDef[] = [
177
194
  { name: "dev onboard", handler: runDevOnboard },
178
195
  { name: "dev check-lexicon", handler: runDevCheckLexicon },
179
196
 
197
+ // Spell subcommands
198
+ { name: "spell add", handler: runSpellAdd },
199
+ { name: "spell rm", handler: runSpellRm },
200
+ { name: "spell list", handler: runSpellList },
201
+ { name: "spell show", handler: runSpellShow },
202
+ { name: "spell cast", handler: runSpellCast },
203
+ { name: "spell done", handler: runSpellDone },
204
+ { name: "graph", handler: runGraph },
205
+
206
+ // State subcommands
207
+ { name: "state snapshot", requiresPlugins: true, handler: runStateSnapshot },
208
+ { name: "state show", handler: runStateShow },
209
+ { name: "state diff", requiresPlugins: true, handler: runStateDiff },
210
+ { name: "state log", handler: runStateLog },
211
+
180
212
  // Serve subcommands
181
213
  { name: "serve lsp", requiresPlugins: true, handler: runServeLsp },
182
214
  { name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
183
215
 
184
216
  // Fallback for unknown subcommands (must come after compound entries)
217
+ { name: "spell", handler: runSpellUnknown },
218
+ { name: "state", handler: runStateUnknown },
185
219
  { name: "dev", handler: runDevUnknown },
186
220
  { name: "serve", handler: runServeUnknown },
187
221
  ];
@@ -216,9 +250,9 @@ async function main(): Promise<void> {
216
250
  process.exit(1);
217
251
  }
218
252
 
219
- // For compound commands (e.g. "dev generate"), args.path is the subcommand,
220
- // so the project path shifts to extraPositional. For simple commands, use args.path.
221
- const projectPath = match.compound ? (args.extraPositional ?? ".") : args.path;
253
+ // For compound commands (e.g. "spell cast"), args.path is the subcommand,
254
+ // so always use "." as the project path. For simple commands, use args.path.
255
+ const projectPath = match.compound ? (args.extraPositional || ".") : args.path;
222
256
  const plugins = match.def.requiresPlugins
223
257
  ? await loadPluginsOrExit(projectPath)
224
258
  : [];
@@ -0,0 +1,227 @@
1
+ import type { ResourceDefinition } from "./types";
2
+ import { getContext } from "./resources/context";
3
+ import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
4
+ import { discoverSpells } from "../../spell/discovery";
5
+ import { generatePrompt } from "../../spell/prompt";
6
+ import { getRuntime } from "../../runtime-adapter";
7
+
8
+ type PluginResourceEntry = { definition: ResourceDefinition; handler: () => Promise<string> };
9
+
10
+ /**
11
+ * Core resource definitions (always present regardless of plugins)
12
+ */
13
+ export const coreResourceDefinitions: ResourceDefinition[] = [
14
+ {
15
+ uri: "chant://context",
16
+ name: "chant Context",
17
+ description: "Lexicon-specific instructions and patterns for chant development",
18
+ mimeType: "text/markdown",
19
+ },
20
+ {
21
+ uri: "chant://examples/list",
22
+ name: "Examples List",
23
+ description: "List of available chant examples",
24
+ mimeType: "application/json",
25
+ },
26
+ {
27
+ uri: "chant://spells",
28
+ name: "Spells",
29
+ description: "List all spells with status, tasks, and lexicon",
30
+ mimeType: "application/json",
31
+ },
32
+ {
33
+ uri: "chant://spell/{name}",
34
+ name: "Spell details",
35
+ description: "Show spell definition and status",
36
+ mimeType: "application/json",
37
+ },
38
+ {
39
+ uri: "chant://spell/{name}/prompt",
40
+ name: "Spell bootstrap prompt",
41
+ description: "Bootstrap prompt for agent consumption",
42
+ mimeType: "text/markdown",
43
+ },
44
+ {
45
+ uri: "chant://state/{environment}",
46
+ name: "State (all lexicons)",
47
+ description: "All lexicon snapshots for an environment",
48
+ mimeType: "application/json",
49
+ },
50
+ {
51
+ uri: "chant://state/{environment}/{lexicon}",
52
+ name: "State (single lexicon)",
53
+ description: "Single lexicon snapshot for an environment",
54
+ mimeType: "application/json",
55
+ },
56
+ ];
57
+
58
+ /**
59
+ * Build the full resources list merging core + plugin resources
60
+ */
61
+ export function buildResourcesList(
62
+ pluginResources: Map<string, PluginResourceEntry>,
63
+ ): { resources: ResourceDefinition[] } {
64
+ const resources = [...coreResourceDefinitions];
65
+ for (const { definition } of pluginResources.values()) {
66
+ resources.push(definition);
67
+ }
68
+ return { resources };
69
+ }
70
+
71
+ /**
72
+ * Collect example resources from plugins whose URI contains "examples/"
73
+ */
74
+ export function collectExamples(
75
+ pluginResources: Map<string, PluginResourceEntry>,
76
+ ): Array<{ name: string; description: string }> {
77
+ const examples: Array<{ name: string; description: string }> = [];
78
+ for (const [uri, { definition }] of pluginResources.entries()) {
79
+ if (uri.includes("/examples/")) {
80
+ const name = uri.replace(/^chant:\/\/[^/]+\/examples\//, "");
81
+ examples.push({ name, description: definition.description });
82
+ }
83
+ }
84
+ return examples;
85
+ }
86
+
87
+ /**
88
+ * Handle resources/read request — checks plugin resources after core
89
+ */
90
+ export async function handleResourcesRead(
91
+ params: Record<string, unknown>,
92
+ pluginResources: Map<string, PluginResourceEntry>,
93
+ ): Promise<unknown> {
94
+ const uri = params.uri as string;
95
+
96
+ if (uri === "chant://context") {
97
+ return {
98
+ contents: [
99
+ {
100
+ uri,
101
+ mimeType: "text/markdown",
102
+ text: getContext(),
103
+ },
104
+ ],
105
+ };
106
+ }
107
+
108
+ if (uri === "chant://examples/list") {
109
+ return {
110
+ contents: [
111
+ {
112
+ uri,
113
+ mimeType: "application/json",
114
+ text: JSON.stringify(collectExamples(pluginResources)),
115
+ },
116
+ ],
117
+ };
118
+ }
119
+
120
+ // Spell resources
121
+ if (uri === "chant://spells") {
122
+ const { spells } = await discoverSpells();
123
+ const list = Array.from(spells.entries()).map(([name, s]) => ({
124
+ name,
125
+ status: s.status,
126
+ tasks: `${s.definition.tasks.filter((t) => t.done).length}/${s.definition.tasks.length}`,
127
+ lexicon: s.definition.lexicon ?? null,
128
+ overview: s.definition.overview,
129
+ }));
130
+ return {
131
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(list, null, 2) }],
132
+ };
133
+ }
134
+
135
+ if (uri.startsWith("chant://spell/") && uri.endsWith("/prompt")) {
136
+ const name = uri.replace("chant://spell/", "").replace("/prompt", "");
137
+ const { spells } = await discoverSpells();
138
+ const spell = spells.get(name);
139
+ if (!spell) throw new Error(`Spell "${name}" not found`);
140
+ const rt = getRuntime();
141
+ const gitRootResult = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
142
+ const gitRoot = gitRootResult.stdout.trim();
143
+ const prompt = await generatePrompt(spell.definition, { gitRoot });
144
+ return {
145
+ contents: [{ uri, mimeType: "text/markdown", text: prompt }],
146
+ };
147
+ }
148
+
149
+ if (uri.startsWith("chant://spell/")) {
150
+ const name = uri.replace("chant://spell/", "");
151
+ const { spells } = await discoverSpells();
152
+ const spell = spells.get(name);
153
+ if (!spell) throw new Error(`Spell "${name}" not found`);
154
+ return {
155
+ contents: [{
156
+ uri,
157
+ mimeType: "application/json",
158
+ text: JSON.stringify({
159
+ ...spell.definition,
160
+ status: spell.status,
161
+ filePath: spell.filePath,
162
+ }, null, 2),
163
+ }],
164
+ };
165
+ }
166
+
167
+ // State resources: chant://state/{environment} and chant://state/{environment}/{lexicon}
168
+ if (uri.startsWith("chant://state/")) {
169
+ const parts = uri.replace("chant://state/", "").split("/");
170
+ const environment = parts[0];
171
+ const lexicon = parts[1];
172
+
173
+ if (lexicon) {
174
+ const content = await readSnapshot(environment, lexicon);
175
+ if (!content) throw new Error(`No snapshot found for ${environment}/${lexicon}`);
176
+ return {
177
+ contents: [{ uri, mimeType: "application/json", text: content }],
178
+ };
179
+ } else {
180
+ const snapshots = await readEnvironmentSnapshots(environment);
181
+ const result: Record<string, unknown> = {};
182
+ for (const [lex, content] of snapshots) {
183
+ result[lex] = JSON.parse(content);
184
+ }
185
+ return {
186
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
187
+ };
188
+ }
189
+ }
190
+
191
+ if (uri.startsWith("chant://examples/")) {
192
+ // Look up example in plugin resources
193
+ const name = uri.replace("chant://examples/", "");
194
+ for (const [pluginUri, pluginResource] of pluginResources.entries()) {
195
+ if (pluginUri.endsWith(`/examples/${name}`)) {
196
+ const text = await pluginResource.handler();
197
+ return {
198
+ contents: [
199
+ {
200
+ uri,
201
+ mimeType: pluginResource.definition.mimeType ?? "text/typescript",
202
+ text,
203
+ },
204
+ ],
205
+ };
206
+ }
207
+ }
208
+ throw new Error(`Example not found: ${name}`);
209
+ }
210
+
211
+ // Check plugin resources
212
+ const pluginResource = pluginResources.get(uri);
213
+ if (pluginResource) {
214
+ const text = await pluginResource.handler();
215
+ return {
216
+ contents: [
217
+ {
218
+ uri,
219
+ mimeType: pluginResource.definition.mimeType ?? "text/plain",
220
+ text,
221
+ },
222
+ ],
223
+ };
224
+ }
225
+
226
+ throw new Error(`Unknown resource: ${uri}`);
227
+ }
@@ -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
  });