@sundaeswap/sprinkles 0.6.1 → 0.7.0
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/dist/cjs/Sprinkle/__tests__/action-integration.test.js +590 -0
- package/dist/cjs/Sprinkle/__tests__/action-integration.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/action-registry.test.js +193 -0
- package/dist/cjs/Sprinkle/__tests__/action-registry.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/action-runner.test.js +304 -0
- package/dist/cjs/Sprinkle/__tests__/action-runner.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/builtin-actions.test.js +1110 -0
- package/dist/cjs/Sprinkle/__tests__/builtin-actions.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/cli-adapter.test.js +722 -0
- package/dist/cjs/Sprinkle/__tests__/cli-adapter.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/mcp-adapter.test.js +713 -0
- package/dist/cjs/Sprinkle/__tests__/mcp-adapter.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/tui-helpers.test.js +334 -0
- package/dist/cjs/Sprinkle/__tests__/tui-helpers.test.js.map +1 -0
- package/dist/cjs/Sprinkle/__tests__/wallet-transaction-actions.test.js +749 -0
- package/dist/cjs/Sprinkle/__tests__/wallet-transaction-actions.test.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/blaze-helper.js +61 -0
- package/dist/cjs/Sprinkle/actions/builtin/blaze-helper.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/index.js +117 -0
- package/dist/cjs/Sprinkle/actions/builtin/index.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/profile-actions.js +202 -0
- package/dist/cjs/Sprinkle/actions/builtin/profile-actions.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/settings-actions.js +87 -0
- package/dist/cjs/Sprinkle/actions/builtin/settings-actions.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/transaction-actions.js +345 -0
- package/dist/cjs/Sprinkle/actions/builtin/transaction-actions.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/builtin/wallet-actions.js +212 -0
- package/dist/cjs/Sprinkle/actions/builtin/wallet-actions.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/cli-adapter.js +372 -0
- package/dist/cjs/Sprinkle/actions/cli-adapter.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/index.js +127 -0
- package/dist/cjs/Sprinkle/actions/index.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/mcp-adapter.js +415 -0
- package/dist/cjs/Sprinkle/actions/mcp-adapter.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/registry.js +92 -0
- package/dist/cjs/Sprinkle/actions/registry.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/runner.js +190 -0
- package/dist/cjs/Sprinkle/actions/runner.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/tui-helpers.js +96 -0
- package/dist/cjs/Sprinkle/actions/tui-helpers.js.map +1 -0
- package/dist/cjs/Sprinkle/actions/types.js +68 -0
- package/dist/cjs/Sprinkle/actions/types.js.map +1 -0
- package/dist/cjs/Sprinkle/index.js +412 -1
- package/dist/cjs/Sprinkle/index.js.map +1 -1
- package/dist/cjs/Sprinkle/prompts.js +12 -7
- package/dist/cjs/Sprinkle/prompts.js.map +1 -1
- package/dist/cjs/Sprinkle/type-guards.js +7 -1
- package/dist/cjs/Sprinkle/type-guards.js.map +1 -1
- package/dist/esm/Sprinkle/__tests__/action-integration.test.js +588 -0
- package/dist/esm/Sprinkle/__tests__/action-integration.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/action-registry.test.js +192 -0
- package/dist/esm/Sprinkle/__tests__/action-registry.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/action-runner.test.js +302 -0
- package/dist/esm/Sprinkle/__tests__/action-runner.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/builtin-actions.test.js +1107 -0
- package/dist/esm/Sprinkle/__tests__/builtin-actions.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/cli-adapter.test.js +720 -0
- package/dist/esm/Sprinkle/__tests__/cli-adapter.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/mcp-adapter.test.js +712 -0
- package/dist/esm/Sprinkle/__tests__/mcp-adapter.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/tui-helpers.test.js +332 -0
- package/dist/esm/Sprinkle/__tests__/tui-helpers.test.js.map +1 -0
- package/dist/esm/Sprinkle/__tests__/wallet-transaction-actions.test.js +747 -0
- package/dist/esm/Sprinkle/__tests__/wallet-transaction-actions.test.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/blaze-helper.js +55 -0
- package/dist/esm/Sprinkle/actions/builtin/blaze-helper.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/index.js +32 -0
- package/dist/esm/Sprinkle/actions/builtin/index.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/profile-actions.js +197 -0
- package/dist/esm/Sprinkle/actions/builtin/profile-actions.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/settings-actions.js +81 -0
- package/dist/esm/Sprinkle/actions/builtin/settings-actions.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/transaction-actions.js +340 -0
- package/dist/esm/Sprinkle/actions/builtin/transaction-actions.js.map +1 -0
- package/dist/esm/Sprinkle/actions/builtin/wallet-actions.js +207 -0
- package/dist/esm/Sprinkle/actions/builtin/wallet-actions.js.map +1 -0
- package/dist/esm/Sprinkle/actions/cli-adapter.js +361 -0
- package/dist/esm/Sprinkle/actions/cli-adapter.js.map +1 -0
- package/dist/esm/Sprinkle/actions/index.js +12 -0
- package/dist/esm/Sprinkle/actions/index.js.map +1 -0
- package/dist/esm/Sprinkle/actions/mcp-adapter.js +407 -0
- package/dist/esm/Sprinkle/actions/mcp-adapter.js.map +1 -0
- package/dist/esm/Sprinkle/actions/registry.js +85 -0
- package/dist/esm/Sprinkle/actions/registry.js.map +1 -0
- package/dist/esm/Sprinkle/actions/runner.js +182 -0
- package/dist/esm/Sprinkle/actions/runner.js.map +1 -0
- package/dist/esm/Sprinkle/actions/tui-helpers.js +91 -0
- package/dist/esm/Sprinkle/actions/tui-helpers.js.map +1 -0
- package/dist/esm/Sprinkle/actions/types.js +61 -0
- package/dist/esm/Sprinkle/actions/types.js.map +1 -0
- package/dist/esm/Sprinkle/index.js +260 -1
- package/dist/esm/Sprinkle/index.js.map +1 -1
- package/dist/esm/Sprinkle/prompts.js +12 -7
- package/dist/esm/Sprinkle/prompts.js.map +1 -1
- package/dist/esm/Sprinkle/type-guards.js +3 -0
- package/dist/esm/Sprinkle/type-guards.js.map +1 -1
- package/dist/types/Sprinkle/actions/builtin/blaze-helper.d.ts +39 -0
- package/dist/types/Sprinkle/actions/builtin/blaze-helper.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/builtin/index.d.ts +26 -0
- package/dist/types/Sprinkle/actions/builtin/index.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/builtin/profile-actions.d.ts +55 -0
- package/dist/types/Sprinkle/actions/builtin/profile-actions.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/builtin/settings-actions.d.ts +32 -0
- package/dist/types/Sprinkle/actions/builtin/settings-actions.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/builtin/transaction-actions.d.ts +70 -0
- package/dist/types/Sprinkle/actions/builtin/transaction-actions.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/builtin/wallet-actions.d.ts +50 -0
- package/dist/types/Sprinkle/actions/builtin/wallet-actions.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/cli-adapter.d.ts +104 -0
- package/dist/types/Sprinkle/actions/cli-adapter.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/index.d.ts +12 -0
- package/dist/types/Sprinkle/actions/index.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/mcp-adapter.d.ts +92 -0
- package/dist/types/Sprinkle/actions/mcp-adapter.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/registry.d.ts +42 -0
- package/dist/types/Sprinkle/actions/registry.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/runner.d.ts +45 -0
- package/dist/types/Sprinkle/actions/runner.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/tui-helpers.d.ts +53 -0
- package/dist/types/Sprinkle/actions/tui-helpers.d.ts.map +1 -0
- package/dist/types/Sprinkle/actions/types.d.ts +76 -0
- package/dist/types/Sprinkle/actions/types.d.ts.map +1 -0
- package/dist/types/Sprinkle/index.d.ts +81 -1
- package/dist/types/Sprinkle/index.d.ts.map +1 -1
- package/dist/types/Sprinkle/prompts.d.ts.map +1 -1
- package/dist/types/Sprinkle/type-guards.d.ts +4 -1
- package/dist/types/Sprinkle/type-guards.d.ts.map +1 -1
- package/dist/types/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +9 -2
- package/src/Sprinkle/__tests__/action-integration.test.ts +558 -0
- package/src/Sprinkle/__tests__/action-registry.test.ts +187 -0
- package/src/Sprinkle/__tests__/action-runner.test.ts +324 -0
- package/src/Sprinkle/__tests__/builtin-actions.test.ts +1022 -0
- package/src/Sprinkle/__tests__/cli-adapter.test.ts +715 -0
- package/src/Sprinkle/__tests__/mcp-adapter.test.ts +718 -0
- package/src/Sprinkle/__tests__/tui-helpers.test.ts +325 -0
- package/src/Sprinkle/__tests__/wallet-transaction-actions.test.ts +695 -0
- package/src/Sprinkle/actions/builtin/blaze-helper.ts +89 -0
- package/src/Sprinkle/actions/builtin/index.ts +86 -0
- package/src/Sprinkle/actions/builtin/profile-actions.ts +229 -0
- package/src/Sprinkle/actions/builtin/settings-actions.ts +99 -0
- package/src/Sprinkle/actions/builtin/transaction-actions.ts +381 -0
- package/src/Sprinkle/actions/builtin/wallet-actions.ts +233 -0
- package/src/Sprinkle/actions/cli-adapter.ts +430 -0
- package/src/Sprinkle/actions/index.ts +32 -0
- package/src/Sprinkle/actions/mcp-adapter.ts +463 -0
- package/src/Sprinkle/actions/registry.ts +97 -0
- package/src/Sprinkle/actions/runner.ts +200 -0
- package/src/Sprinkle/actions/tui-helpers.ts +114 -0
- package/src/Sprinkle/actions/types.ts +91 -0
- package/src/Sprinkle/index.ts +351 -0
- package/src/Sprinkle/prompts.ts +118 -72
- package/src/Sprinkle/type-guards.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sundaeswap/sprinkles",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A TypeScript library for building interactive CLI menus and TUI applications with TypeBox schema validation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -64,7 +64,13 @@
|
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"typescript": "^5.8.3",
|
|
66
66
|
"@blaze-cardano/sdk": "^0.2.33",
|
|
67
|
-
"@blaze-cardano/query": "^0.5.0"
|
|
67
|
+
"@blaze-cardano/query": "^0.5.0",
|
|
68
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"@modelcontextprotocol/sdk": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
68
74
|
},
|
|
69
75
|
"devDependencies": {
|
|
70
76
|
"@babel/cli": "^7.27.2",
|
|
@@ -73,6 +79,7 @@
|
|
|
73
79
|
"@babel/preset-env": "^7.27.2",
|
|
74
80
|
"@babel/preset-typescript": "^7.27.1",
|
|
75
81
|
"@changesets/cli": "^2.27.1",
|
|
82
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
76
83
|
"@types/bun": "latest",
|
|
77
84
|
"@types/node": "^20.0.0",
|
|
78
85
|
"cross-env": "^7.0.3",
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
import { Sprinkle, ActionRegistry, ActionError } from "../index.js";
|
|
7
|
+
import { withProfile } from "./test-helpers.js";
|
|
8
|
+
|
|
9
|
+
// Helper: write a minimal profile file into a tmp storage directory
|
|
10
|
+
function writeTestProfile(
|
|
11
|
+
storagePath: string,
|
|
12
|
+
id: string,
|
|
13
|
+
name: string,
|
|
14
|
+
settings: Record<string, unknown>,
|
|
15
|
+
): void {
|
|
16
|
+
const dir = path.join(storagePath, "profiles");
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
const meta = {
|
|
19
|
+
name,
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
updatedAt: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
path.join(dir, `${id}.json`),
|
|
25
|
+
JSON.stringify({ meta, settings, defaults: {} }, null, 2),
|
|
26
|
+
"utf-8",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Simple settings schema used for integration tests
|
|
31
|
+
const TestSchema = Type.Object({ name: Type.String() });
|
|
32
|
+
|
|
33
|
+
// A reusable valid action
|
|
34
|
+
const greetAction = {
|
|
35
|
+
name: "greet",
|
|
36
|
+
description: "Greets someone",
|
|
37
|
+
inputSchema: Type.Object({ who: Type.String() }),
|
|
38
|
+
outputSchema: Type.Object({ greeting: Type.String() }),
|
|
39
|
+
execute: async (input: { who: string }) => ({
|
|
40
|
+
greeting: `Hello, ${input.who}!`,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("Sprinkle action integration", () => {
|
|
45
|
+
let tmpDir: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sprinkles-action-test-"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// -------------------------------------------------------------------------
|
|
56
|
+
// actionRegistry field
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test("Sprinkle constructor initialises actionRegistry", () => {
|
|
60
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
61
|
+
expect(sprinkle.actionRegistry).toBeInstanceOf(ActionRegistry);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// -------------------------------------------------------------------------
|
|
65
|
+
// registerAction / getAction / listActions
|
|
66
|
+
// -------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
test("registerAction stores an action retrievable via getAction", () => {
|
|
69
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
70
|
+
sprinkle.registerAction(greetAction);
|
|
71
|
+
expect(sprinkle.getAction("greet")).toBe(greetAction);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("listActions returns all registered actions", () => {
|
|
75
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
76
|
+
const actionB = { ...greetAction, name: "farewell" };
|
|
77
|
+
sprinkle.registerAction(greetAction);
|
|
78
|
+
sprinkle.registerAction(actionB);
|
|
79
|
+
expect(sprinkle.listActions()).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("listActions returns empty array when no actions registered", () => {
|
|
83
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
84
|
+
expect(sprinkle.listActions()).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("getAction returns undefined for unknown action", () => {
|
|
88
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
89
|
+
expect(sprinkle.getAction("does-not-exist")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("registerAction throws for duplicate name", () => {
|
|
93
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
94
|
+
sprinkle.registerAction(greetAction);
|
|
95
|
+
expect(() => sprinkle.registerAction(greetAction)).toThrow(
|
|
96
|
+
/already registered/,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("registerAction throws for non-kebab-case name", () => {
|
|
101
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
102
|
+
expect(() =>
|
|
103
|
+
sprinkle.registerAction({ ...greetAction, name: "BadName" }),
|
|
104
|
+
).toThrow(/Invalid action name/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
// runAction
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
test("runAction executes action and returns success result", async () => {
|
|
112
|
+
const sprinkle = withProfile(new Sprinkle(TestSchema, tmpDir));
|
|
113
|
+
sprinkle.settings = { name: "tester" } as any;
|
|
114
|
+
sprinkle.registerAction(greetAction);
|
|
115
|
+
|
|
116
|
+
const result = await sprinkle.runAction("greet", { who: "World" });
|
|
117
|
+
expect(result.success).toBe(true);
|
|
118
|
+
if (result.success) {
|
|
119
|
+
expect(result.data).toEqual({ greeting: "Hello, World!" });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("runAction returns failure result on invalid input", async () => {
|
|
124
|
+
const sprinkle = withProfile(new Sprinkle(TestSchema, tmpDir));
|
|
125
|
+
sprinkle.settings = { name: "tester" } as any;
|
|
126
|
+
sprinkle.registerAction(greetAction);
|
|
127
|
+
|
|
128
|
+
// 'who' field is missing
|
|
129
|
+
const result = await sprinkle.runAction("greet", { wrongField: "oops" });
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
expect(result.error.code).toBe("VALIDATION_ERROR");
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("runAction throws when action name is not registered", async () => {
|
|
137
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
138
|
+
await expect(
|
|
139
|
+
sprinkle.runAction("not-registered", {}),
|
|
140
|
+
).rejects.toThrow(/not registered/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("runAction error message lists available actions when none registered", async () => {
|
|
144
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
145
|
+
await expect(
|
|
146
|
+
sprinkle.runAction("nope", {}),
|
|
147
|
+
).rejects.toThrow(/\(none\)/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("runAction error message lists registered action names", async () => {
|
|
151
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
152
|
+
sprinkle.registerAction(greetAction);
|
|
153
|
+
await expect(
|
|
154
|
+
sprinkle.runAction("nope", {}),
|
|
155
|
+
).rejects.toThrow(/greet/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("runAction provides sprinkle and settings via context", async () => {
|
|
159
|
+
let capturedContext: any;
|
|
160
|
+
const ctxAction = {
|
|
161
|
+
name: "ctx-test",
|
|
162
|
+
description: "Captures context",
|
|
163
|
+
inputSchema: Type.Object({}),
|
|
164
|
+
outputSchema: Type.Object({}),
|
|
165
|
+
execute: async (_input: any, ctx: any) => {
|
|
166
|
+
capturedContext = ctx;
|
|
167
|
+
return {};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const sprinkle = withProfile(new Sprinkle(TestSchema, tmpDir));
|
|
172
|
+
sprinkle.settings = { name: "ctx-user" } as any;
|
|
173
|
+
sprinkle.registerAction(ctxAction);
|
|
174
|
+
|
|
175
|
+
await sprinkle.runAction("ctx-test", {});
|
|
176
|
+
expect(capturedContext.sprinkle).toBe(sprinkle);
|
|
177
|
+
expect(capturedContext.settings).toEqual({ name: "ctx-user" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("runAction propagates ActionError from execute without re-wrapping", async () => {
|
|
181
|
+
const myError = new ActionError("deliberate failure", "DELIBERATE", {
|
|
182
|
+
detail: 42,
|
|
183
|
+
});
|
|
184
|
+
const failAction = {
|
|
185
|
+
name: "fail-action",
|
|
186
|
+
description: "Always fails",
|
|
187
|
+
inputSchema: Type.Object({}),
|
|
188
|
+
outputSchema: Type.Object({}),
|
|
189
|
+
execute: async () => {
|
|
190
|
+
throw myError;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const sprinkle = withProfile(new Sprinkle(TestSchema, tmpDir));
|
|
195
|
+
sprinkle.settings = { name: "x" } as any;
|
|
196
|
+
sprinkle.registerAction(failAction);
|
|
197
|
+
|
|
198
|
+
const result = await sprinkle.runAction("fail-action", {});
|
|
199
|
+
expect(result.success).toBe(false);
|
|
200
|
+
if (!result.success) {
|
|
201
|
+
expect(result.error).toBe(myError);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
// Re-exports from index
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
test("ActionRegistry is re-exported from Sprinkle index", () => {
|
|
210
|
+
expect(ActionRegistry).toBeDefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("ActionError is re-exported from Sprinkle index", () => {
|
|
214
|
+
expect(ActionError).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
// listActionsByCategory
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
test("listActionsByCategory returns empty map when no actions registered", () => {
|
|
222
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
223
|
+
const map = sprinkle.listActionsByCategory();
|
|
224
|
+
expect(map.size).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("listActionsByCategory places uncategorised actions under 'default'", () => {
|
|
228
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
229
|
+
sprinkle.registerAction(greetAction); // no category field
|
|
230
|
+
const map = sprinkle.listActionsByCategory();
|
|
231
|
+
expect(map.has("default")).toBe(true);
|
|
232
|
+
expect(map.get("default")).toHaveLength(1);
|
|
233
|
+
expect(map.get("default")![0]!.name).toBe("greet");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("listActionsByCategory groups categorised actions correctly", () => {
|
|
237
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
238
|
+
const alpha1 = { ...greetAction, name: "alpha-one", category: "alpha" };
|
|
239
|
+
const alpha2 = { ...greetAction, name: "alpha-two", category: "alpha" };
|
|
240
|
+
const beta1 = { ...greetAction, name: "beta-one", category: "beta" };
|
|
241
|
+
sprinkle.registerAction(alpha1);
|
|
242
|
+
sprinkle.registerAction(alpha2);
|
|
243
|
+
sprinkle.registerAction(beta1);
|
|
244
|
+
const map = sprinkle.listActionsByCategory();
|
|
245
|
+
expect(map.get("alpha")).toHaveLength(2);
|
|
246
|
+
expect(map.get("beta")).toHaveLength(1);
|
|
247
|
+
expect(map.has("default")).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("listActionsByCategory mixes categorised and uncategorised actions", () => {
|
|
251
|
+
const sprinkle = new Sprinkle(TestSchema, tmpDir);
|
|
252
|
+
const categorised = { ...greetAction, name: "cat-action", category: "tools" };
|
|
253
|
+
sprinkle.registerAction(greetAction); // uncategorised
|
|
254
|
+
sprinkle.registerAction(categorised);
|
|
255
|
+
const map = sprinkle.listActionsByCategory();
|
|
256
|
+
expect(map.get("default")).toHaveLength(1);
|
|
257
|
+
expect(map.get("tools")).toHaveLength(1);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Sprinkle.run() static entry point
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe("Sprinkle.run() static entry point", () => {
|
|
266
|
+
let tmpDir: string;
|
|
267
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
268
|
+
let errorSpy: ReturnType<typeof spyOn>;
|
|
269
|
+
let exitSpy: ReturnType<typeof spyOn>;
|
|
270
|
+
|
|
271
|
+
beforeEach(() => {
|
|
272
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sprinkles-run-test-"));
|
|
273
|
+
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
274
|
+
errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
275
|
+
exitSpy = spyOn(process, "exit").mockImplementation(() => {
|
|
276
|
+
throw new Error("process.exit called");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
afterEach(() => {
|
|
281
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
282
|
+
logSpy.mockRestore();
|
|
283
|
+
errorSpy.mockRestore();
|
|
284
|
+
exitSpy.mockRestore();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// -------------------------------------------------------------------------
|
|
288
|
+
// help mode
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
test("help mode prints 'No actions registered.' when no actions provided", async () => {
|
|
292
|
+
await Sprinkle.run({
|
|
293
|
+
type: TestSchema,
|
|
294
|
+
storagePath: tmpDir,
|
|
295
|
+
argv: ["--help"],
|
|
296
|
+
});
|
|
297
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
298
|
+
expect(output).toContain("No actions registered.");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("help mode prints 'Available actions:' header when actions are registered", async () => {
|
|
302
|
+
await Sprinkle.run({
|
|
303
|
+
type: TestSchema,
|
|
304
|
+
storagePath: tmpDir,
|
|
305
|
+
actions: [greetAction],
|
|
306
|
+
argv: ["--help"],
|
|
307
|
+
});
|
|
308
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
309
|
+
expect(output).toContain("Available actions:");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("help mode lists registered action name and description", async () => {
|
|
313
|
+
await Sprinkle.run({
|
|
314
|
+
type: TestSchema,
|
|
315
|
+
storagePath: tmpDir,
|
|
316
|
+
actions: [greetAction],
|
|
317
|
+
argv: ["--help"],
|
|
318
|
+
});
|
|
319
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
320
|
+
expect(output).toContain("greet");
|
|
321
|
+
expect(output).toContain("Greets someone");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("help mode prints category header for categorised actions", async () => {
|
|
325
|
+
const catAction = { ...greetAction, name: "do-thing", category: "tools" };
|
|
326
|
+
await Sprinkle.run({
|
|
327
|
+
type: TestSchema,
|
|
328
|
+
storagePath: tmpDir,
|
|
329
|
+
actions: [catAction],
|
|
330
|
+
argv: ["--help"],
|
|
331
|
+
});
|
|
332
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
333
|
+
expect(output).toContain("tools:");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("help mode does not print 'default:' header for uncategorised actions", async () => {
|
|
337
|
+
await Sprinkle.run({
|
|
338
|
+
type: TestSchema,
|
|
339
|
+
storagePath: tmpDir,
|
|
340
|
+
actions: [greetAction],
|
|
341
|
+
argv: ["--help"],
|
|
342
|
+
});
|
|
343
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
344
|
+
expect(output).not.toContain("default:");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("help mode also triggers with -h flag", async () => {
|
|
348
|
+
await Sprinkle.run({
|
|
349
|
+
type: TestSchema,
|
|
350
|
+
storagePath: tmpDir,
|
|
351
|
+
argv: ["-h"],
|
|
352
|
+
});
|
|
353
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
354
|
+
expect(output).toContain("No actions registered.");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// -------------------------------------------------------------------------
|
|
358
|
+
// CLI mode
|
|
359
|
+
// -------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
test("CLI mode executes action and writes JSON result to stdout", async () => {
|
|
362
|
+
writeTestProfile(tmpDir, "default", "Default", { name: "tester" });
|
|
363
|
+
await Sprinkle.run({
|
|
364
|
+
type: TestSchema,
|
|
365
|
+
storagePath: tmpDir,
|
|
366
|
+
actions: [greetAction],
|
|
367
|
+
argv: ["greet", "--who", "World"],
|
|
368
|
+
});
|
|
369
|
+
const calls = logSpy.mock.calls.map((c) => String(c[0]));
|
|
370
|
+
const jsonLine = calls.find((l) => {
|
|
371
|
+
try {
|
|
372
|
+
JSON.parse(l);
|
|
373
|
+
return true;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
expect(jsonLine).toBeDefined();
|
|
379
|
+
const parsed = JSON.parse(jsonLine!);
|
|
380
|
+
expect(parsed.success).toBe(true);
|
|
381
|
+
expect(parsed.data).toEqual({ greeting: "Hello, World!" });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("CLI mode result contains success:false for invalid input", async () => {
|
|
385
|
+
writeTestProfile(tmpDir, "default", "Default", { name: "tester" });
|
|
386
|
+
await expect(
|
|
387
|
+
Sprinkle.run({
|
|
388
|
+
type: TestSchema,
|
|
389
|
+
storagePath: tmpDir,
|
|
390
|
+
actions: [greetAction],
|
|
391
|
+
argv: ["greet"],
|
|
392
|
+
}),
|
|
393
|
+
).rejects.toThrow("process.exit called");
|
|
394
|
+
const calls = errorSpy.mock.calls.map((c) => String(c[0]));
|
|
395
|
+
const jsonLine = calls.find((l) => {
|
|
396
|
+
try { JSON.parse(l); return true; } catch { return false; }
|
|
397
|
+
});
|
|
398
|
+
expect(jsonLine).toBeDefined();
|
|
399
|
+
const parsed = JSON.parse(jsonLine!);
|
|
400
|
+
expect(parsed.success).toBe(false);
|
|
401
|
+
expect(parsed.error.code).toBe("VALIDATION_ERROR");
|
|
402
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("CLI mode --profile flag selects the named profile", async () => {
|
|
406
|
+
writeTestProfile(tmpDir, "alice", "Alice", { name: "alice" });
|
|
407
|
+
writeTestProfile(tmpDir, "bob", "Bob", { name: "bob" });
|
|
408
|
+
|
|
409
|
+
let capturedSettings: any;
|
|
410
|
+
const captureAction = {
|
|
411
|
+
name: "capture",
|
|
412
|
+
description: "Captures settings",
|
|
413
|
+
inputSchema: Type.Object({}),
|
|
414
|
+
outputSchema: Type.Object({ name: Type.String() }),
|
|
415
|
+
execute: async (_input: any, ctx: any) => {
|
|
416
|
+
capturedSettings = ctx.settings;
|
|
417
|
+
return { name: ctx.settings.name };
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
await Sprinkle.run({
|
|
422
|
+
type: TestSchema,
|
|
423
|
+
storagePath: tmpDir,
|
|
424
|
+
actions: [captureAction],
|
|
425
|
+
argv: ["capture", "--profile", "alice"],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(capturedSettings).toBeDefined();
|
|
429
|
+
expect(capturedSettings.name).toBe("alice");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("CLI mode --profile flag selects the correct profile when multiple exist", async () => {
|
|
433
|
+
writeTestProfile(tmpDir, "alice", "Alice", { name: "alice" });
|
|
434
|
+
writeTestProfile(tmpDir, "bob", "Bob", { name: "bob" });
|
|
435
|
+
|
|
436
|
+
const captureAction = {
|
|
437
|
+
name: "capture-two",
|
|
438
|
+
description: "Captures settings",
|
|
439
|
+
inputSchema: Type.Object({}),
|
|
440
|
+
outputSchema: Type.Object({ name: Type.String() }),
|
|
441
|
+
execute: async (_input: any, ctx: any) => ({ name: ctx.settings.name }),
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
await Sprinkle.run({
|
|
445
|
+
type: TestSchema,
|
|
446
|
+
storagePath: tmpDir,
|
|
447
|
+
actions: [captureAction],
|
|
448
|
+
argv: ["capture-two", "--profile", "bob"],
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const calls = logSpy.mock.calls.map((c) => String(c[0]));
|
|
452
|
+
const jsonLine = calls.find((l) => {
|
|
453
|
+
try { JSON.parse(l); return true; } catch { return false; }
|
|
454
|
+
});
|
|
455
|
+
const parsed = JSON.parse(jsonLine!);
|
|
456
|
+
expect(parsed.success).toBe(true);
|
|
457
|
+
expect(parsed.data.name).toBe("bob");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("CLI mode auto-selects the only profile when no --profile flag given", async () => {
|
|
461
|
+
writeTestProfile(tmpDir, "solo", "Solo", { name: "solo-user" });
|
|
462
|
+
|
|
463
|
+
const captureAction = {
|
|
464
|
+
name: "capture-auto",
|
|
465
|
+
description: "Captures settings",
|
|
466
|
+
inputSchema: Type.Object({}),
|
|
467
|
+
outputSchema: Type.Object({ name: Type.String() }),
|
|
468
|
+
execute: async (_input: any, ctx: any) => ({ name: ctx.settings.name }),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
await Sprinkle.run({
|
|
472
|
+
type: TestSchema,
|
|
473
|
+
storagePath: tmpDir,
|
|
474
|
+
actions: [captureAction],
|
|
475
|
+
argv: ["capture-auto"],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const calls = logSpy.mock.calls.map((c) => String(c[0]));
|
|
479
|
+
const jsonLine = calls.find((l) => {
|
|
480
|
+
try { JSON.parse(l); return true; } catch { return false; }
|
|
481
|
+
});
|
|
482
|
+
const parsed = JSON.parse(jsonLine!);
|
|
483
|
+
expect(parsed.success).toBe(true);
|
|
484
|
+
expect(parsed.data.name).toBe("solo-user");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("CLI mode throws when multiple profiles exist and no --profile flag given", async () => {
|
|
488
|
+
writeTestProfile(tmpDir, "alice", "Alice", { name: "alice" });
|
|
489
|
+
writeTestProfile(tmpDir, "bob", "Bob", { name: "bob" });
|
|
490
|
+
|
|
491
|
+
await expect(
|
|
492
|
+
Sprinkle.run({
|
|
493
|
+
type: TestSchema,
|
|
494
|
+
storagePath: tmpDir,
|
|
495
|
+
actions: [greetAction],
|
|
496
|
+
argv: ["greet", "--who", "World"],
|
|
497
|
+
}),
|
|
498
|
+
).rejects.toThrow(/--profile/);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("CLI mode throws when no profiles exist", async () => {
|
|
502
|
+
await expect(
|
|
503
|
+
Sprinkle.run({
|
|
504
|
+
type: TestSchema,
|
|
505
|
+
storagePath: tmpDir,
|
|
506
|
+
actions: [greetAction],
|
|
507
|
+
argv: ["greet", "--who", "World"],
|
|
508
|
+
}),
|
|
509
|
+
).rejects.toThrow(/No profiles found/);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
// TUI mode
|
|
514
|
+
// -------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
test("TUI mode throws when no menu option is provided", async () => {
|
|
517
|
+
await expect(
|
|
518
|
+
Sprinkle.run({
|
|
519
|
+
type: TestSchema,
|
|
520
|
+
storagePath: tmpDir,
|
|
521
|
+
argv: [],
|
|
522
|
+
}),
|
|
523
|
+
).rejects.toThrow(/TUI mode requires a menu/);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("TUI mode throws when --interactive flag is passed without a menu", async () => {
|
|
527
|
+
await expect(
|
|
528
|
+
Sprinkle.run({
|
|
529
|
+
type: TestSchema,
|
|
530
|
+
storagePath: tmpDir,
|
|
531
|
+
argv: ["--interactive"],
|
|
532
|
+
}),
|
|
533
|
+
).rejects.toThrow(/TUI mode requires a menu/);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// -------------------------------------------------------------------------
|
|
537
|
+
// MCP mode
|
|
538
|
+
// -------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
test("MCP mode throws when SDK is not installed", async () => {
|
|
541
|
+
// MCP mode is implemented but requires the optional @modelcontextprotocol/sdk
|
|
542
|
+
// peer dependency. When the SDK is available (as in this dev environment),
|
|
543
|
+
// MCP mode will attempt to start a server. When the SDK is not installed,
|
|
544
|
+
// it should throw a clear error message with install instructions.
|
|
545
|
+
//
|
|
546
|
+
// In CI/dev with the SDK installed, MCP mode will attempt to connect to
|
|
547
|
+
// stdio transport. We test the error path by verifying the mode no longer
|
|
548
|
+
// throws "not yet implemented" -- specific MCP behavior is tested in
|
|
549
|
+
// mcp-adapter.test.ts.
|
|
550
|
+
await expect(
|
|
551
|
+
Sprinkle.run({
|
|
552
|
+
type: TestSchema,
|
|
553
|
+
storagePath: tmpDir,
|
|
554
|
+
argv: ["--mcp"],
|
|
555
|
+
}),
|
|
556
|
+
).rejects.not.toThrow(/not yet implemented/i);
|
|
557
|
+
});
|
|
558
|
+
});
|