@intentius/chant 0.1.6 → 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.
- package/package.json +1 -1
- package/src/cli/commands/build.ts +17 -0
- package/src/cli/handlers/graph.ts +23 -0
- package/src/cli/handlers/run-client.ts +134 -0
- package/src/cli/handlers/run-report.ts +160 -0
- package/src/cli/handlers/run.ts +453 -0
- package/src/cli/main.test.ts +64 -0
- package/src/cli/main.ts +28 -18
- package/src/cli/mcp/op-tools.ts +204 -0
- package/src/cli/mcp/resource-handlers.ts +69 -50
- package/src/cli/mcp/resources/context.ts +27 -0
- package/src/cli/mcp/server.test.ts +176 -3
- package/src/cli/mcp/server.ts +7 -3
- package/src/cli/mcp/state-tools.ts +0 -51
- package/src/cli/registry.ts +2 -0
- package/src/composite.ts +10 -5
- package/src/index.ts +1 -2
- package/src/op/discover.test.ts +43 -0
- package/src/op/discover.ts +89 -0
- package/src/op/index.ts +3 -1
- package/src/op/types.ts +0 -6
- package/src/cli/handlers/spell.ts +0 -396
- package/src/spell/discovery.ts +0 -183
- package/src/spell/index.ts +0 -3
- package/src/spell/prompt.ts +0 -133
- package/src/spell/types.ts +0 -89
|
@@ -6,8 +6,6 @@ import { build } from "../../build";
|
|
|
6
6
|
import { computeBuildDigest, diffDigests } from "../../state/digest";
|
|
7
7
|
import { takeSnapshot } from "../../state/snapshot";
|
|
8
8
|
import type { StateSnapshot } from "../../state/types";
|
|
9
|
-
import { discoverSpells } from "../../spell/discovery";
|
|
10
|
-
|
|
11
9
|
export interface ToolRegistration {
|
|
12
10
|
definition: ToolDefinition;
|
|
13
11
|
handler: ToolHandler;
|
|
@@ -87,52 +85,3 @@ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
|
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/**
|
|
91
|
-
* Create spell-done tool definition and handler
|
|
92
|
-
*/
|
|
93
|
-
export function createSpellDoneTool(): ToolRegistration {
|
|
94
|
-
return {
|
|
95
|
-
definition: {
|
|
96
|
-
name: "spell-done",
|
|
97
|
-
description: "Mark a spell task as done",
|
|
98
|
-
inputSchema: {
|
|
99
|
-
type: "object",
|
|
100
|
-
properties: {
|
|
101
|
-
name: { type: "string", description: "Spell name" },
|
|
102
|
-
taskNumber: { type: "number", description: "Task number (1-based)" },
|
|
103
|
-
},
|
|
104
|
-
required: ["name", "taskNumber"],
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
handler: async (params) => {
|
|
108
|
-
const { readFileSync, writeFileSync } = await import("node:fs");
|
|
109
|
-
const { spells } = await discoverSpells();
|
|
110
|
-
const name = params.name as string;
|
|
111
|
-
const taskNumber = params.taskNumber as number;
|
|
112
|
-
const spell = spells.get(name);
|
|
113
|
-
if (!spell) return `Spell "${name}" not found`;
|
|
114
|
-
if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
|
|
115
|
-
return `Invalid task number ${taskNumber}`;
|
|
116
|
-
}
|
|
117
|
-
const task = spell.definition.tasks[taskNumber - 1];
|
|
118
|
-
if (task.done) return `Task ${taskNumber} is already done`;
|
|
119
|
-
|
|
120
|
-
const content = readFileSync(spell.filePath, "utf-8");
|
|
121
|
-
let count = 0;
|
|
122
|
-
const rewritten = content.replace(
|
|
123
|
-
/task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
|
|
124
|
-
(match, desc, opts) => {
|
|
125
|
-
count++;
|
|
126
|
-
if (count !== taskNumber) return match;
|
|
127
|
-
if (opts && opts.includes("done:")) {
|
|
128
|
-
return match.replace(/done:\s*false/, "done: true");
|
|
129
|
-
}
|
|
130
|
-
return `task(${desc}, { done: true })`;
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
|
|
134
|
-
writeFileSync(spell.filePath, rewritten);
|
|
135
|
-
return `Task ${taskNumber} marked done: "${task.description}"`;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
package/src/cli/registry.ts
CHANGED
package/src/composite.ts
CHANGED
|
@@ -95,11 +95,16 @@ export function Composite<P, M extends CompositeMembers = CompositeMembers>(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// Define `members` and `_definition` as non-enumerable so spreading a
|
|
99
|
+
// composite instance (`...someComposite`) only exposes the actual member
|
|
100
|
+
// resources, not the framework's bookkeeping properties. Without this, a
|
|
101
|
+
// parent composite that does `...childResult` ends up with a `members` key
|
|
102
|
+
// pointing at the child's CompositeMembers record — not a Declarable —
|
|
103
|
+
// which then trips the parent's own member validation.
|
|
104
|
+
const instance = {} as CompositeInstance<M>;
|
|
105
|
+
Object.defineProperty(instance, COMPOSITE_MARKER, { value: true, enumerable: false });
|
|
106
|
+
Object.defineProperty(instance, "members", { value: members, enumerable: false });
|
|
107
|
+
Object.defineProperty(instance, "_definition", { value: definition, enumerable: false });
|
|
103
108
|
|
|
104
109
|
return Object.assign(instance, members) as CompositeInstance<M> & M;
|
|
105
110
|
}) as CompositeDefinition<P, M>;
|
package/src/index.ts
CHANGED
|
@@ -60,8 +60,7 @@ export * from "./lsp/types";
|
|
|
60
60
|
export * from "./lsp/lexicon-providers";
|
|
61
61
|
export * from "./mcp/types";
|
|
62
62
|
export * from "./state/index";
|
|
63
|
-
export * from "./spell/index";
|
|
64
63
|
// Op builders — use explicit exports to avoid collision with the core `build` function
|
|
65
64
|
export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack,
|
|
66
65
|
gitlabPipeline, stateSnapshot, shell, teardown, OpResource } from "./op/index";
|
|
67
|
-
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep
|
|
66
|
+
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./op/index";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { discoverOps } from "./discover";
|
|
3
|
+
|
|
4
|
+
// Mock getRuntime to return git root pointing at the repo root
|
|
5
|
+
vi.mock("../runtime-adapter", () => ({
|
|
6
|
+
getRuntime: () => ({
|
|
7
|
+
spawn: async (cmd: string[]) => {
|
|
8
|
+
if (cmd[0] === "git" && cmd[1] === "rev-parse") {
|
|
9
|
+
// Return the actual repo root so the test can find the example op file
|
|
10
|
+
const { execFile } = await import("node:child_process");
|
|
11
|
+
const { promisify } = await import("node:util");
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
|
|
14
|
+
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
|
15
|
+
}
|
|
16
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe("discoverOps", () => {
|
|
22
|
+
test("discovers alb-deploy.op.ts from examples/", async () => {
|
|
23
|
+
const { ops, errors } = await discoverOps();
|
|
24
|
+
expect(errors).toHaveLength(0);
|
|
25
|
+
expect(ops.has("alb-deploy")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("discovered Op has correct config shape", async () => {
|
|
29
|
+
const { ops } = await discoverOps();
|
|
30
|
+
const op = ops.get("alb-deploy");
|
|
31
|
+
expect(op).toBeDefined();
|
|
32
|
+
expect(op!.config.name).toBe("alb-deploy");
|
|
33
|
+
expect(Array.isArray(op!.config.phases)).toBe(true);
|
|
34
|
+
expect(op!.config.phases.length).toBeGreaterThan(0);
|
|
35
|
+
expect(typeof op!.config.overview).toBe("string");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("filePath points to the .op.ts source file", async () => {
|
|
39
|
+
const { ops } = await discoverOps();
|
|
40
|
+
const op = ops.get("alb-deploy");
|
|
41
|
+
expect(op!.filePath).toMatch(/alb-deploy\.op\.ts$/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getRuntime } from "../runtime-adapter";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OpConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface DiscoveredOp {
|
|
7
|
+
config: OpConfig;
|
|
8
|
+
filePath: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OpDiscoveryResult {
|
|
12
|
+
ops: Map<string, DiscoveredOp>;
|
|
13
|
+
errors: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function findGitRoot(cwd?: string): Promise<string> {
|
|
17
|
+
const rt = getRuntime();
|
|
18
|
+
const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
|
|
19
|
+
if (result.exitCode !== 0) throw new Error("Not in a git repository");
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function collectOpFiles(dir: string): Promise<string[]> {
|
|
24
|
+
const files: string[] = [];
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const fullPath = join(dir, entry.name);
|
|
33
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "dist") {
|
|
34
|
+
files.push(...await collectOpFiles(fullPath));
|
|
35
|
+
} else if (
|
|
36
|
+
entry.isFile() &&
|
|
37
|
+
entry.name.endsWith(".op.ts") &&
|
|
38
|
+
!entry.name.endsWith(".test.ts") &&
|
|
39
|
+
!entry.name.endsWith(".spec.ts")
|
|
40
|
+
) {
|
|
41
|
+
files.push(fullPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Discover all Op definitions from *.op.ts files under the git root.
|
|
49
|
+
*/
|
|
50
|
+
export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryResult> {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
const ops = new Map<string, DiscoveredOp>();
|
|
53
|
+
|
|
54
|
+
const gitRoot = await findGitRoot(opts?.cwd);
|
|
55
|
+
const files = await collectOpFiles(gitRoot);
|
|
56
|
+
|
|
57
|
+
const nameToFile = new Map<string, string>();
|
|
58
|
+
|
|
59
|
+
for (const filePath of files) {
|
|
60
|
+
try {
|
|
61
|
+
const mod = await import(filePath);
|
|
62
|
+
const entity = mod.default;
|
|
63
|
+
|
|
64
|
+
if (!entity || typeof entity !== "object") {
|
|
65
|
+
errors.push(`${filePath}: default export is not an object`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const config = entity.props as OpConfig | undefined;
|
|
70
|
+
|
|
71
|
+
if (!config || typeof config.name !== "string" || !Array.isArray(config.phases)) {
|
|
72
|
+
errors.push(`${filePath}: default export is not a valid Op (missing name or phases)`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (nameToFile.has(config.name)) {
|
|
77
|
+
errors.push(`Duplicate Op name "${config.name}" in ${filePath} and ${nameToFile.get(config.name)}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
nameToFile.set(config.name, filePath);
|
|
82
|
+
ops.set(config.name, { config, filePath });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { ops, errors };
|
|
89
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack,
|
|
2
2
|
gitlabPipeline, stateSnapshot, shell, teardown } from "./builders";
|
|
3
3
|
export { OpResource } from "./resource";
|
|
4
|
-
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep
|
|
4
|
+
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
|
|
5
|
+
export { discoverOps } from "./discover";
|
|
6
|
+
export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
package/src/op/types.ts
CHANGED
|
@@ -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
|
-
}
|