@intentius/chant 0.1.5 → 0.1.7

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.
@@ -1,396 +0,0 @@
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
- }
@@ -1,183 +0,0 @@
1
- /**
2
- * Spell discovery: find, import, validate, and index spell files.
3
- */
4
- import { getRuntime } from "../runtime-adapter";
5
- import type { SpellDefinition, Status } from "./types";
6
- import { readdir } from "node:fs/promises";
7
- import { join } from "node:path";
8
-
9
- export interface DiscoveredSpell {
10
- definition: SpellDefinition;
11
- filePath: string;
12
- status: Status;
13
- }
14
-
15
- export interface SpellDiscoveryResult {
16
- spells: Map<string, DiscoveredSpell>;
17
- errors: string[];
18
- }
19
-
20
- /**
21
- * Find the git root directory.
22
- */
23
- async function findGitRoot(cwd?: string): Promise<string> {
24
- const rt = getRuntime();
25
- const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
26
- if (result.exitCode !== 0) {
27
- throw new Error("Not in a git repository");
28
- }
29
- return result.stdout.trim();
30
- }
31
-
32
- /**
33
- * Discover all spells from the spells/ directory at the git root.
34
- */
35
- export async function discoverSpells(
36
- opts?: { cwd?: string },
37
- ): Promise<SpellDiscoveryResult> {
38
- const errors: string[] = [];
39
- const spells = new Map<string, DiscoveredSpell>();
40
-
41
- const gitRoot = await findGitRoot(opts?.cwd);
42
- const spellsDir = join(gitRoot, "spells");
43
-
44
- // List *.spell.ts files
45
- let files: string[];
46
- try {
47
- const entries = await readdir(spellsDir);
48
- files = entries.filter((f) => f.endsWith(".spell.ts")).map((f) => join(spellsDir, f));
49
- } catch {
50
- // spells/ directory doesn't exist — that's OK
51
- return { spells, errors };
52
- }
53
-
54
- // Import each file
55
- const fileMap = new Map<string, string>(); // name → filePath for duplicate detection
56
- for (const filePath of files) {
57
- try {
58
- const mod = await import(filePath);
59
- const def = mod.default as SpellDefinition | undefined;
60
-
61
- if (!def) {
62
- errors.push(`File ${filePath} has no default export`);
63
- continue;
64
- }
65
-
66
- // Validate shape
67
- if (!def.name || typeof def.name !== "string") {
68
- errors.push(`File ${filePath}: default export has no valid name`);
69
- continue;
70
- }
71
- if (!def.tasks || !Array.isArray(def.tasks)) {
72
- errors.push(`File ${filePath}: default export has no valid tasks`);
73
- continue;
74
- }
75
-
76
- // Duplicate check
77
- if (fileMap.has(def.name)) {
78
- errors.push(
79
- `Duplicate name "${def.name}" in ${filePath} and ${fileMap.get(def.name)}`,
80
- );
81
- continue;
82
- }
83
-
84
- fileMap.set(def.name, filePath);
85
- spells.set(def.name, {
86
- definition: def,
87
- filePath,
88
- status: "ready", // placeholder — computed after all are loaded
89
- });
90
- } catch (err) {
91
- errors.push(
92
- `${filePath}: ${err instanceof Error ? err.message : String(err)}`,
93
- );
94
- }
95
- }
96
-
97
- // Validate dependencies
98
- for (const [name, spell] of spells) {
99
- const deps = spell.definition.depends;
100
- if (!deps) continue;
101
-
102
- for (const depName of deps) {
103
- if (!spells.has(depName)) {
104
- errors.push(
105
- `Spell "${name}" depends on "${depName}" which does not exist`,
106
- );
107
- }
108
- }
109
- }
110
-
111
- // Detect circular dependencies via topological sort
112
- const circularError = detectCycles(spells);
113
- if (circularError) {
114
- errors.push(circularError);
115
- }
116
-
117
- // Compute status for each spell
118
- computeStatuses(spells);
119
-
120
- return { spells, errors };
121
- }
122
-
123
- /**
124
- * Detect circular dependencies. Returns an error message or null.
125
- */
126
- function detectCycles(spells: Map<string, DiscoveredSpell>): string | null {
127
- const visited = new Set<string>();
128
- const visiting = new Set<string>();
129
-
130
- function visit(name: string, path: string[]): string | null {
131
- if (visiting.has(name)) {
132
- const cycle = [...path.slice(path.indexOf(name)), name];
133
- return `Circular dependency: ${cycle.join(" → ")}`;
134
- }
135
- if (visited.has(name)) return null;
136
-
137
- visiting.add(name);
138
- path.push(name);
139
-
140
- const spell = spells.get(name);
141
- if (spell?.definition.depends) {
142
- for (const dep of spell.definition.depends) {
143
- if (spells.has(dep)) {
144
- const err = visit(dep, path);
145
- if (err) return err;
146
- }
147
- }
148
- }
149
-
150
- visiting.delete(name);
151
- visited.add(name);
152
- path.pop();
153
- return null;
154
- }
155
-
156
- for (const name of spells.keys()) {
157
- const err = visit(name, []);
158
- if (err) return err;
159
- }
160
- return null;
161
- }
162
-
163
- /**
164
- * Compute statuses: blocked / ready / done.
165
- */
166
- function computeStatuses(spells: Map<string, DiscoveredSpell>): void {
167
- for (const [, spell] of spells) {
168
- const allTasksDone = spell.definition.tasks.every((t) => t.done);
169
- if (allTasksDone) {
170
- spell.status = "done";
171
- continue;
172
- }
173
-
174
- const deps = spell.definition.depends ?? [];
175
- const hasIncompleteDep = deps.some((depName) => {
176
- const dep = spells.get(depName);
177
- if (!dep) return true; // dangling dep counts as incomplete
178
- return !dep.definition.tasks.every((t) => t.done);
179
- });
180
-
181
- spell.status = hasIncompleteDep ? "blocked" : "ready";
182
- }
183
- }
@@ -1,3 +0,0 @@
1
- export * from "./types";
2
- export * from "./discovery";
3
- export * from "./prompt";
@@ -1,133 +0,0 @@
1
- /**
2
- * Bootstrap prompt generation for spells.
3
- *
4
- * Resolves context items (static strings, files, commands), assembles the
5
- * full prompt with overview, context, task list, and afterAll instructions.
6
- */
7
- import { readFile } from "node:fs/promises";
8
- import { join } from "node:path";
9
- import { getRuntime } from "../runtime-adapter";
10
- import type { SpellDefinition, ContextItem, Task } from "./types";
11
- import type { LexiconPlugin } from "../lexicon";
12
-
13
- /**
14
- * Resolve a single context item to a string.
15
- */
16
- async function resolveContextItem(
17
- item: string | ContextItem,
18
- gitRoot: string,
19
- ): Promise<string> {
20
- if (typeof item === "string") return item;
21
-
22
- if (item.type === "file") {
23
- const filePath = join(gitRoot, item.value);
24
- try {
25
- const content = await readFile(filePath, "utf-8");
26
- return `--- ${item.value} ---\n${content}`;
27
- } catch {
28
- return `[Context error: ${item.value} not found]`;
29
- }
30
- }
31
-
32
- if (item.type === "cmd") {
33
- const rt = getRuntime();
34
- try {
35
- const result = await rt.spawn(["sh", "-c", item.value], { cwd: gitRoot });
36
- if (result.exitCode !== 0) {
37
- return `[Context error: command "${item.value}" failed with exit code ${result.exitCode}]\n${result.stderr}`;
38
- }
39
- return `--- $ ${item.value} ---\n${result.stdout}`;
40
- } catch (err) {
41
- return `[Context error: command "${item.value}" failed: ${err instanceof Error ? err.message : String(err)}]`;
42
- }
43
- }
44
-
45
- return String(item);
46
- }
47
-
48
- /**
49
- * Format the task list for the prompt.
50
- */
51
- function formatTasks(tasks: Task[]): string {
52
- return tasks
53
- .map((t, i) => {
54
- const check = t.done ? "[x]" : "[ ]";
55
- return `${i + 1}. ${check} ${t.description}`;
56
- })
57
- .join("\n");
58
- }
59
-
60
- /**
61
- * Find relevant skill content from a lexicon plugin.
62
- */
63
- function getLexiconSkillContent(
64
- lexiconName: string,
65
- plugins: LexiconPlugin[],
66
- ): string | null {
67
- const plugin = plugins.find((p) => p.name === lexiconName);
68
- if (!plugin?.skills) return null;
69
- const skills = plugin.skills();
70
- if (skills.length === 0) return null;
71
-
72
- return skills
73
- .map((s) => `### ${s.name}\n\n${s.content}`)
74
- .join("\n\n");
75
- }
76
-
77
- export interface PromptOptions {
78
- gitRoot: string;
79
- plugins?: LexiconPlugin[];
80
- }
81
-
82
- /**
83
- * Generate the bootstrap prompt for a spell.
84
- */
85
- export async function generatePrompt(
86
- spell: SpellDefinition,
87
- opts: PromptOptions,
88
- ): Promise<string> {
89
- const sections: string[] = [];
90
-
91
- // Header
92
- sections.push(`# Spell: ${spell.name}\n`);
93
-
94
- // Overview
95
- sections.push(`## Overview\n\n${spell.overview}\n`);
96
-
97
- // Resolved context
98
- if (spell.context && spell.context.length > 0) {
99
- const resolved = await Promise.all(
100
- spell.context.map((item) => resolveContextItem(item, opts.gitRoot)),
101
- );
102
- sections.push(`## Context\n\n${resolved.join("\n\n")}\n`);
103
- }
104
-
105
- // Lexicon skill guidance
106
- if (spell.lexicon && opts.plugins) {
107
- const skillContent = getLexiconSkillContent(spell.lexicon, opts.plugins);
108
- if (skillContent) {
109
- sections.push(`## ${spell.lexicon} Guidance\n\n${skillContent}\n`);
110
- }
111
- }
112
-
113
- // Task list
114
- sections.push(`## Tasks\n\n${formatTasks(spell.tasks)}\n`);
115
-
116
- // After all
117
- if (spell.afterAll && spell.afterAll.length > 0) {
118
- sections.push(
119
- `## After Completion\n\nAfter all tasks are done, run:\n${spell.afterAll.map((c) => `- \`${c}\``).join("\n")}\n`,
120
- );
121
- }
122
-
123
- // Instructions
124
- sections.push(
125
- `## Instructions\n\n` +
126
- `- Mark tasks done with: \`chant spell done ${spell.name} <task-number>\`\n` +
127
- `- Task numbers are 1-based\n` +
128
- `- Commit with trailer: \`Spell: ${spell.name}\`\n` +
129
- `- Work through tasks in order\n`,
130
- );
131
-
132
- return sections.join("\n");
133
- }