@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
@@ -0,0 +1,396 @@
1
+ import { resolve, join } from "node:path";
2
+ import { writeFileSync, unlinkSync, readFileSync } from "node:fs";
3
+ import { mkdirSync, existsSync } from "node:fs";
4
+ import { getRuntime } from "../../runtime-adapter";
5
+ import { discoverSpells } from "../../spell/discovery";
6
+ import { generatePrompt } from "../../spell/prompt";
7
+ import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
8
+ import { loadPlugin } from "../plugins";
9
+ import type { CommandContext } from "../registry";
10
+
11
+ /**
12
+ * Find the git root directory.
13
+ */
14
+ async function findGitRoot(): Promise<string> {
15
+ const rt = getRuntime();
16
+ const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"]);
17
+ if (result.exitCode !== 0) throw new Error("Not in a git repository");
18
+ return result.stdout.trim();
19
+ }
20
+
21
+ /**
22
+ * chant spell add <name>
23
+ */
24
+ export async function runSpellAdd(ctx: CommandContext): Promise<number> {
25
+ const name = ctx.args.extraPositional;
26
+ if (!name) {
27
+ console.error(formatError({ message: "Name is required: chant spell add <name>" }));
28
+ return 1;
29
+ }
30
+
31
+ // Validate name format
32
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64) {
33
+ console.error(formatError({ message: `Invalid spell name: "${name}" (must be kebab-case, max 64 chars)` }));
34
+ return 1;
35
+ }
36
+
37
+ const gitRoot = await findGitRoot();
38
+ const spellsDir = join(gitRoot, "spells");
39
+ const filePath = join(spellsDir, `${name}.spell.ts`);
40
+
41
+ if (existsSync(filePath)) {
42
+ console.error(formatError({ message: `Spell "${name}" already exists at ${filePath}` }));
43
+ return 1;
44
+ }
45
+
46
+ mkdirSync(spellsDir, { recursive: true });
47
+
48
+ const template = `import { spell, task } from "@intentius/chant";
49
+
50
+ export default spell({
51
+ name: "${name}",
52
+ overview: "",
53
+ tasks: [
54
+ task(""),
55
+ ],
56
+ });
57
+ `;
58
+
59
+ writeFileSync(filePath, template);
60
+ console.error(formatSuccess(`Created ${filePath}`));
61
+ return 0;
62
+ }
63
+
64
+ /**
65
+ * chant spell rm <name>
66
+ */
67
+ export async function runSpellRm(ctx: CommandContext): Promise<number> {
68
+ const name = ctx.args.extraPositional;
69
+ if (!name) {
70
+ console.error(formatError({ message: "Name is required: chant spell rm <name>" }));
71
+ return 1;
72
+ }
73
+
74
+ const gitRoot = await findGitRoot();
75
+ const filePath = join(gitRoot, "spells", `${name}.spell.ts`);
76
+
77
+ if (!existsSync(filePath)) {
78
+ console.error(formatError({ message: `Spell "${name}" not found at ${filePath}` }));
79
+ return 1;
80
+ }
81
+
82
+ // Check for dependents (unless --force)
83
+ if (!ctx.args.force) {
84
+ const { spells } = await discoverSpells();
85
+ const dependents = [];
86
+ for (const [depName, spell] of spells) {
87
+ if (spell.definition.depends?.includes(name)) {
88
+ dependents.push(depName);
89
+ }
90
+ }
91
+ if (dependents.length > 0) {
92
+ console.error(formatWarning({
93
+ message: `Spell "${name}" is depended on by: ${dependents.join(", ")}`,
94
+ hint: "Use --force to delete anyway",
95
+ }));
96
+ return 1;
97
+ }
98
+ }
99
+
100
+ unlinkSync(filePath);
101
+ console.error(formatSuccess(`Removed ${filePath}`));
102
+ return 0;
103
+ }
104
+
105
+ /**
106
+ * chant spell list
107
+ */
108
+ export async function runSpellList(ctx: CommandContext): Promise<number> {
109
+ const { spells, errors } = await discoverSpells();
110
+
111
+ for (const err of errors) {
112
+ console.error(formatError({ message: err }));
113
+ }
114
+
115
+ if (spells.size === 0) {
116
+ console.error(formatWarning({ message: "No spells found" }));
117
+ return 0;
118
+ }
119
+
120
+ // Filter by --ready flag if present (using extraPositional as a hack)
121
+ const readyOnly = ctx.args.format === "ready";
122
+
123
+ console.log(
124
+ "NAME".padEnd(20) +
125
+ "STATUS".padEnd(10) +
126
+ "TASKS".padEnd(10) +
127
+ "LEXICON".padEnd(12) +
128
+ "OVERVIEW"
129
+ );
130
+
131
+ for (const [name, spell] of spells) {
132
+ if (readyOnly && spell.status !== "ready") continue;
133
+
134
+ const def = spell.definition;
135
+ const doneCount = def.tasks.filter((t) => t.done).length;
136
+ const tasksStr = `[${doneCount}/${def.tasks.length}]`;
137
+ const lexicon = def.lexicon ?? "";
138
+ const overview = def.overview.length > 40
139
+ ? def.overview.slice(0, 37) + "..."
140
+ : def.overview;
141
+
142
+ console.log(
143
+ name.padEnd(20) +
144
+ spell.status.padEnd(10) +
145
+ tasksStr.padEnd(10) +
146
+ lexicon.padEnd(12) +
147
+ overview
148
+ );
149
+ }
150
+
151
+ return 0;
152
+ }
153
+
154
+ /**
155
+ * chant spell show <name>
156
+ */
157
+ export async function runSpellShow(ctx: CommandContext): Promise<number> {
158
+ const name = ctx.args.extraPositional;
159
+ if (!name) {
160
+ console.error(formatError({ message: "Name is required: chant spell show <name>" }));
161
+ return 1;
162
+ }
163
+
164
+ const { spells, errors } = await discoverSpells();
165
+ const spell = spells.get(name);
166
+
167
+ if (!spell) {
168
+ // Try to reconstruct from git history
169
+ const rt = getRuntime();
170
+ const result = await rt.spawn([
171
+ "git", "log", "--all", "--format=%H", "--diff-filter=D",
172
+ "--", `spells/${name}.spell.ts`,
173
+ ]);
174
+ if (result.exitCode === 0 && result.stdout.trim()) {
175
+ const commit = result.stdout.trim().split("\n")[0];
176
+ const showResult = await rt.spawn([
177
+ "git", "show", `${commit}^:spells/${name}.spell.ts`,
178
+ ]);
179
+ if (showResult.exitCode === 0) {
180
+ console.log(`(Reconstructed from git history, commit ${commit.slice(0, 7)})\n`);
181
+ console.log(showResult.stdout);
182
+ return 0;
183
+ }
184
+ }
185
+
186
+ console.error(formatError({ message: `Spell "${name}" not found` }));
187
+ return 1;
188
+ }
189
+
190
+ const def = spell.definition;
191
+ const doneCount = def.tasks.filter((t) => t.done).length;
192
+
193
+ console.log(formatBold(def.name));
194
+ console.log(`Status: ${spell.status} [${doneCount}/${def.tasks.length}]`);
195
+ if (def.lexicon) console.log(`Lexicon: ${def.lexicon}`);
196
+ console.log(`\n${def.overview}\n`);
197
+
198
+ console.log("Tasks:");
199
+ def.tasks.forEach((t, i) => {
200
+ const check = t.done ? "[x]" : "[ ]";
201
+ console.log(` ${i + 1}. ${check} ${t.description}`);
202
+ });
203
+
204
+ if (def.depends && def.depends.length > 0) {
205
+ console.log(`\nDepends: ${def.depends.join(", ")}`);
206
+ }
207
+
208
+ if (def.afterAll && def.afterAll.length > 0) {
209
+ console.log(`\nAfter all: ${def.afterAll.join(", ")}`);
210
+ }
211
+
212
+ return 0;
213
+ }
214
+
215
+ /**
216
+ * chant spell cast <name> — generate bootstrap prompt
217
+ */
218
+ export async function runSpellCast(ctx: CommandContext): Promise<number> {
219
+ const name = ctx.args.extraPositional;
220
+ if (!name) {
221
+ console.error(formatError({ message: "Name is required: chant spell cast <name>" }));
222
+ return 1;
223
+ }
224
+
225
+ const { spells, errors } = await discoverSpells();
226
+
227
+ for (const err of errors) {
228
+ console.error(formatError({ message: err }));
229
+ }
230
+
231
+ const spell = spells.get(name);
232
+ if (!spell) {
233
+ console.error(formatError({ message: `Spell "${name}" not found` }));
234
+ return 1;
235
+ }
236
+
237
+ // Warn if blocked or done (unless --force)
238
+ if (spell.status === "blocked" && !ctx.args.force) {
239
+ console.error(formatWarning({
240
+ message: `Spell "${name}" is blocked by incomplete dependencies`,
241
+ hint: "Use --force to proceed anyway",
242
+ }));
243
+ return 1;
244
+ }
245
+ if (spell.status === "done" && !ctx.args.force) {
246
+ console.error(formatWarning({
247
+ message: `Spell "${name}" is already done`,
248
+ hint: "Use --force to proceed anyway",
249
+ }));
250
+ return 1;
251
+ }
252
+
253
+ const gitRoot = await findGitRoot();
254
+
255
+ // Load the spell's lexicon plugin directly (not all project lexicons)
256
+ const plugins: import("../../lexicon").LexiconPlugin[] = [];
257
+ if (spell.definition.lexicon) {
258
+ try {
259
+ const plugin = await loadPlugin(spell.definition.lexicon);
260
+ if (plugin.init) await plugin.init();
261
+ plugins.push(plugin);
262
+ } catch {
263
+ console.error(formatWarning({
264
+ message: `Lexicon "${spell.definition.lexicon}" could not be loaded — skills will not be inlined`,
265
+ hint: `Install @intentius/chant-lexicon-${spell.definition.lexicon}`,
266
+ }));
267
+ }
268
+ }
269
+
270
+ const prompt = await generatePrompt(spell.definition, {
271
+ gitRoot,
272
+ plugins: plugins.length > 0 ? plugins : undefined,
273
+ });
274
+
275
+ console.log(prompt);
276
+ return 0;
277
+ }
278
+
279
+ /**
280
+ * chant spell done <name> <task-number>
281
+ *
282
+ * Rewrites task() call in the source file to mark it as done.
283
+ */
284
+ export async function runSpellDone(ctx: CommandContext): Promise<number> {
285
+ const name = ctx.args.extraPositional;
286
+ const taskNumStr = ctx.args.extraPositional2;
287
+
288
+ if (!name || !taskNumStr) {
289
+ console.error(formatError({ message: "Usage: chant spell done <name> <task-number>" }));
290
+ return 1;
291
+ }
292
+
293
+ const taskNum = parseInt(taskNumStr, 10);
294
+ if (isNaN(taskNum) || taskNum < 1) {
295
+ console.error(formatError({ message: `Invalid task number: ${taskNumStr}` }));
296
+ return 1;
297
+ }
298
+
299
+ const { spells } = await discoverSpells();
300
+ const spell = spells.get(name);
301
+ if (!spell) {
302
+ console.error(formatError({ message: `Spell "${name}" not found` }));
303
+ return 1;
304
+ }
305
+
306
+ if (taskNum > spell.definition.tasks.length) {
307
+ console.error(formatError({
308
+ message: `Task ${taskNum} does not exist (spell has ${spell.definition.tasks.length} tasks)`,
309
+ }));
310
+ return 1;
311
+ }
312
+
313
+ const task = spell.definition.tasks[taskNum - 1];
314
+ if (task.done) {
315
+ console.error(formatWarning({ message: `Task ${taskNum} is already done` }));
316
+ return 0;
317
+ }
318
+
319
+ // Rewrite the source file
320
+ const content = readFileSync(spell.filePath, "utf-8");
321
+ const rewritten = markTaskDone(content, taskNum);
322
+
323
+ if (rewritten === content) {
324
+ console.error(formatError({ message: `Could not find task ${taskNum} in source file` }));
325
+ return 1;
326
+ }
327
+
328
+ writeFileSync(spell.filePath, rewritten);
329
+ console.error(formatSuccess(`Task ${taskNum} marked done: "${task.description}"`));
330
+ return 0;
331
+ }
332
+
333
+ /**
334
+ * Regex-based rewrite: find the Nth task() call and add { done: true }.
335
+ */
336
+ function markTaskDone(source: string, taskNum: number): string {
337
+ let count = 0;
338
+ // Match task("...") or task("...", { done: false })
339
+ return source.replace(
340
+ /task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
341
+ (match, desc, opts) => {
342
+ count++;
343
+ if (count !== taskNum) return match;
344
+
345
+ // Already has opts — replace done: false with done: true or add done: true
346
+ if (opts && opts.includes("done:")) {
347
+ return match.replace(/done:\s*false/, "done: true");
348
+ }
349
+ return `task(${desc}, { done: true })`;
350
+ },
351
+ );
352
+ }
353
+
354
+ /**
355
+ * chant graph — show dependency graph
356
+ */
357
+ export async function runGraph(ctx: CommandContext): Promise<number> {
358
+ const { spells, errors } = await discoverSpells();
359
+
360
+ for (const err of errors) {
361
+ console.error(formatError({ message: err }));
362
+ }
363
+
364
+ if (spells.size === 0) {
365
+ console.error(formatWarning({ message: "No spells found" }));
366
+ return 0;
367
+ }
368
+
369
+ let hasEdges = false;
370
+ for (const [name, spell] of spells) {
371
+ const deps = spell.definition.depends;
372
+ if (deps && deps.length > 0) {
373
+ for (const dep of deps) {
374
+ console.log(`${dep} → ${name}`);
375
+ hasEdges = true;
376
+ }
377
+ }
378
+ }
379
+
380
+ if (!hasEdges) {
381
+ console.log("No dependencies");
382
+ }
383
+
384
+ return 0;
385
+ }
386
+
387
+ /**
388
+ * Fallback for unknown spell subcommands.
389
+ */
390
+ export async function runSpellUnknown(ctx: CommandContext): Promise<number> {
391
+ console.error(formatError({
392
+ message: `Unknown spell subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
393
+ hint: "Available: chant spell add, chant spell rm, chant spell list, chant spell show, chant spell cast, chant spell done",
394
+ }));
395
+ return 1;
396
+ }
@@ -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.path;
222
256
  const plugins = match.def.requiresPlugins
223
257
  ? await loadPluginsOrExit(projectPath)
224
258
  : [];