summer-engine 1.4.0 → 2.0.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/.claude-plugin/plugin.json +18 -10
- package/.codex-plugin/plugin.json +36 -8
- package/.cursor-plugin/plugin.json +19 -11
- package/.opencode/INSTALL.md +69 -0
- package/.opencode/plugins/summer.js +9 -4
- package/CHANGELOG.md +31 -1
- package/README.md +268 -86
- package/dist/bin/summer.js +0 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +189 -0
- package/dist/commands/create.js +55 -17
- package/dist/commands/debug.d.ts +2 -0
- package/dist/commands/debug.js +127 -0
- package/dist/commands/list.js +24 -5
- package/dist/commands/project.d.ts +2 -0
- package/dist/commands/project.js +170 -0
- package/dist/commands/scene.d.ts +2 -0
- package/dist/commands/scene.js +290 -0
- package/dist/lib/banner.js +2 -2
- package/dist/lib/log-filters.d.ts +53 -0
- package/dist/lib/log-filters.js +130 -0
- package/dist/lib/plugin-manifests.test.d.ts +1 -0
- package/dist/lib/plugin-manifests.test.js +134 -0
- package/dist/lib/remote-templates.d.ts +25 -0
- package/dist/lib/remote-templates.js +86 -0
- package/dist/lib/skills-registry.d.ts +33 -1
- package/dist/lib/skills-registry.js +56 -1
- package/dist/lib/telemetry.d.ts +13 -0
- package/dist/lib/telemetry.js +63 -0
- package/dist/lib/with-engine.d.ts +14 -0
- package/dist/lib/with-engine.js +109 -0
- package/dist/mcp/tools/asset-tools.js +12 -13
- package/dist/mcp/tools/debug-tools.js +32 -9
- package/dist/mcp/tools/with-engine.js +4 -0
- package/gemini-extension.json +2 -2
- package/hooks/pre-commit-doctor.sh +0 -0
- package/hooks/run-hook.cmd +0 -0
- package/hooks/session-start.sh +0 -0
- package/marketplace.json +2 -2
- package/package.json +3 -3
- package/skills/README.md +1 -1
- package/skills/_meta/skill-create/SKILL.md +1 -1
- package/skills/_meta/skill-improve/SKILL.md +1 -1
- package/skills/_meta/skill-test/SKILL.md +1 -1
- package/skills/_meta/using-summer/SKILL.md +130 -0
- package/skills/_tests/specs/design-npc.md +43 -12
- package/skills/_tests/specs/fps-controller.md +33 -20
- package/skills/_tests/specs/host-authoritative-state.md +104 -0
- package/skills/_tests/specs/vfx.md +56 -38
- package/skills/ai-and-npcs/design-npc/SKILL.md +264 -122
- package/skills/asset-pipeline/asset-strategy/SKILL.md +1 -1
- package/skills/catalog.yaml +21 -4
- package/skills/character-controllers/fps-controller/SKILL.md +260 -79
- package/skills/debugging/debug/SKILL.md +102 -82
- package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +332 -0
- package/skills/rendering-and-lighting/3d-lighting/SKILL.md +1 -1
- package/skills/scene-and-project/browse-templates/SKILL.md +105 -0
- package/skills/scene-and-project/make-game/SKILL.md +159 -346
- package/skills/scene-and-project/new-project/SKILL.md +106 -0
- package/skills/scene-and-project/scene-composition/SKILL.md +1 -1
- package/skills/scripting-patterns/gdscript-patterns/SKILL.md +1 -1
- package/skills/ui-and-ux/ui-basics/SKILL.md +1 -1
- package/skills/visual-effects/vfx/SKILL.md +233 -134
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { withEngineClient } from "../lib/with-engine.js";
|
|
5
|
+
import { outputResult, outputError, parseValue, readJsonArg, isJsonMode, } from "../lib/format.js";
|
|
6
|
+
async function readMainSceneFromProject(projectPath) {
|
|
7
|
+
if (!projectPath)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const text = await readFile(join(projectPath, "project.godot"), "utf-8");
|
|
11
|
+
const match = text.match(/run\/main_scene="([^"]+)"/);
|
|
12
|
+
return match?.[1] ?? null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const sceneCommand = new Command("scene").description("Scene editing commands (requires running engine)");
|
|
19
|
+
// --- Tier 1 ---
|
|
20
|
+
sceneCommand
|
|
21
|
+
.command("tree")
|
|
22
|
+
.description("Get the full scene tree of the currently open scene")
|
|
23
|
+
.action(async (_opts, cmd) => {
|
|
24
|
+
const fmt = isJsonMode(cmd);
|
|
25
|
+
try {
|
|
26
|
+
const result = await withEngineClient((c) => c.getSceneState());
|
|
27
|
+
outputResult(result, fmt);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
sceneCommand
|
|
34
|
+
.command("open <path>")
|
|
35
|
+
.description("Open a scene file in the editor (e.g. res://main.tscn)")
|
|
36
|
+
.action(async (path, _opts, cmd) => {
|
|
37
|
+
const fmt = isJsonMode(cmd);
|
|
38
|
+
try {
|
|
39
|
+
const result = await withEngineClient((c) => c.executeOps([{ op: "OpenScene", path }]));
|
|
40
|
+
outputResult(result, fmt);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
sceneCommand
|
|
47
|
+
.command("add <parent> <type> <name>")
|
|
48
|
+
.description("Add a node to the scene tree (e.g. summer scene add ./World MeshInstance3D Floor)")
|
|
49
|
+
.action(async (parent, type, name, _opts, cmd) => {
|
|
50
|
+
const fmt = isJsonMode(cmd);
|
|
51
|
+
try {
|
|
52
|
+
const result = await withEngineClient((c) => c.executeOps([{ op: "AddNode", parent, type, name }]));
|
|
53
|
+
outputResult(result, fmt);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
sceneCommand
|
|
60
|
+
.command("prop <path> <key> <value>")
|
|
61
|
+
.description('Set a property on a node (e.g. summer scene prop ./Player position "Vector3(0,5,0)")')
|
|
62
|
+
.action(async (path, key, value, _opts, cmd) => {
|
|
63
|
+
const fmt = isJsonMode(cmd);
|
|
64
|
+
try {
|
|
65
|
+
const result = await withEngineClient((c) => c.executeOps([{ op: "SetProp", path, key, value: parseValue(value) }]));
|
|
66
|
+
outputResult(result, fmt);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
sceneCommand
|
|
73
|
+
.command("save [path]")
|
|
74
|
+
.description("Save the current scene (optionally save-as to a new path)")
|
|
75
|
+
.action(async (path, _opts, cmd) => {
|
|
76
|
+
const fmt = isJsonMode(cmd);
|
|
77
|
+
try {
|
|
78
|
+
const op = { op: "SaveScene" };
|
|
79
|
+
if (path)
|
|
80
|
+
op.path = path;
|
|
81
|
+
const result = await withEngineClient((c) => c.executeOps([op]));
|
|
82
|
+
outputResult(result, fmt);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
sceneCommand
|
|
89
|
+
.command("inspect <path>")
|
|
90
|
+
.description("Get all editable properties of a node (e.g. summer scene inspect Player)")
|
|
91
|
+
.action(async (path, _opts, cmd) => {
|
|
92
|
+
const fmt = isJsonMode(cmd);
|
|
93
|
+
try {
|
|
94
|
+
const result = await withEngineClient((c) => c.inspectNode(path));
|
|
95
|
+
outputResult(result, fmt);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
sceneCommand
|
|
102
|
+
.command("batch <json>")
|
|
103
|
+
.description('Execute multiple ops in one undo step. Pass JSON array or "-" for stdin')
|
|
104
|
+
.action(async (jsonArg, _opts, cmd) => {
|
|
105
|
+
const fmt = isJsonMode(cmd);
|
|
106
|
+
try {
|
|
107
|
+
const ops = (await readJsonArg(jsonArg));
|
|
108
|
+
const result = await withEngineClient((c) => c.executeOps(ops, { groupUndo: true }));
|
|
109
|
+
outputResult(result, fmt);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// --- Tier 2 ---
|
|
116
|
+
sceneCommand
|
|
117
|
+
.command("remove <path>")
|
|
118
|
+
.description("Remove a node and all its children from the scene tree")
|
|
119
|
+
.action(async (path, _opts, cmd) => {
|
|
120
|
+
const fmt = isJsonMode(cmd);
|
|
121
|
+
try {
|
|
122
|
+
const result = await withEngineClient((c) => c.executeOps([{ op: "RemoveNode", path }]));
|
|
123
|
+
outputResult(result, fmt);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
sceneCommand
|
|
130
|
+
.command("rprop <nodePath> <resourceProperty> <subProperty> <value>")
|
|
131
|
+
.description("Set a nested resource property (e.g. summer scene rprop ./Player/Col shape size \"Vector3(1,2,1)\")")
|
|
132
|
+
.action(async (nodePath, resourceProperty, subProperty, value, _opts, cmd) => {
|
|
133
|
+
const fmt = isJsonMode(cmd);
|
|
134
|
+
try {
|
|
135
|
+
const result = await withEngineClient((c) => c.executeOps([
|
|
136
|
+
{
|
|
137
|
+
op: "SetResourceProperty",
|
|
138
|
+
nodePath,
|
|
139
|
+
resourceProperty,
|
|
140
|
+
subProperty,
|
|
141
|
+
value: parseValue(value),
|
|
142
|
+
},
|
|
143
|
+
]));
|
|
144
|
+
outputResult(result, fmt);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
sceneCommand
|
|
151
|
+
.command("instantiate <parent> <scene>")
|
|
152
|
+
.description("Add an existing scene/model as a child (e.g. summer scene instantiate ./World res://player.tscn)")
|
|
153
|
+
.option("--name <name>", "Override the instance name")
|
|
154
|
+
.action(async (parent, scene, opts, cmd) => {
|
|
155
|
+
const fmt = isJsonMode(cmd);
|
|
156
|
+
try {
|
|
157
|
+
const op = {
|
|
158
|
+
op: "InstantiateScene",
|
|
159
|
+
parent,
|
|
160
|
+
scene,
|
|
161
|
+
};
|
|
162
|
+
if (opts.name)
|
|
163
|
+
op.name = opts.name;
|
|
164
|
+
const result = await withEngineClient((c) => c.executeOps([op]));
|
|
165
|
+
outputResult(result, fmt);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
sceneCommand
|
|
172
|
+
.command("create <path>")
|
|
173
|
+
.description("Create a new empty scene file")
|
|
174
|
+
.option("--root-name <name>", "Root node name", "Main")
|
|
175
|
+
.option("--yes", "Skip safety confirmation")
|
|
176
|
+
.action(async (path, opts, cmd) => {
|
|
177
|
+
const fmt = isJsonMode(cmd);
|
|
178
|
+
if (!opts.yes) {
|
|
179
|
+
outputError("Scene creation uses a temporary mutation strategy. Pass --yes to confirm.", fmt);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const result = await withEngineClient(async (client) => {
|
|
183
|
+
const health = (await client.health());
|
|
184
|
+
const currentScene = typeof health.scene === "string" && health.scene.length > 0
|
|
185
|
+
? health.scene
|
|
186
|
+
: null;
|
|
187
|
+
const projectPath = typeof health.project_path === "string"
|
|
188
|
+
? health.project_path
|
|
189
|
+
: undefined;
|
|
190
|
+
const mainScene = await readMainSceneFromProject(projectPath);
|
|
191
|
+
const templateScene = currentScene || mainScene;
|
|
192
|
+
if (!templateScene) {
|
|
193
|
+
throw new Error("No scene open and could not resolve main scene. Run `summer project context` first.");
|
|
194
|
+
}
|
|
195
|
+
await client.executeOps([{ op: "OpenScene", path: templateScene }]);
|
|
196
|
+
const tree = (await client.getSceneState());
|
|
197
|
+
const children = tree.data?.children ?? tree.children ?? [];
|
|
198
|
+
const removeOps = children
|
|
199
|
+
.map((c) => c.path)
|
|
200
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
201
|
+
.map((p) => ({ op: "RemoveNode", path: `./${p}` }));
|
|
202
|
+
if (removeOps.length > 0) {
|
|
203
|
+
await client.executeOps(removeOps, { groupUndo: true });
|
|
204
|
+
}
|
|
205
|
+
await client.executeOps([
|
|
206
|
+
{ op: "SetProp", path: ".", key: "name", value: opts.rootName },
|
|
207
|
+
]);
|
|
208
|
+
await client.executeOps([{ op: "SaveScene", path }]);
|
|
209
|
+
if (currentScene && currentScene !== path) {
|
|
210
|
+
await client.executeOps([{ op: "OpenScene", path: currentScene }]);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
created: path,
|
|
215
|
+
rootName: opts.rootName,
|
|
216
|
+
templateScene,
|
|
217
|
+
restoredScene: currentScene,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
outputResult(result, fmt);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
sceneCommand
|
|
227
|
+
.command("replace <path>")
|
|
228
|
+
.description("Replace a node with a different type or scene")
|
|
229
|
+
.option("--type <type>", "New node type (e.g. RigidBody3D)")
|
|
230
|
+
.option("--scene <scene>", "Scene to replace with (e.g. res://enemies/boss.tscn)")
|
|
231
|
+
.action(async (path, opts, cmd) => {
|
|
232
|
+
const fmt = isJsonMode(cmd);
|
|
233
|
+
try {
|
|
234
|
+
const op = { op: "ReplaceNode", path };
|
|
235
|
+
if (opts.type)
|
|
236
|
+
op.type = opts.type;
|
|
237
|
+
if (opts.scene)
|
|
238
|
+
op.scene = opts.scene;
|
|
239
|
+
const result = await withEngineClient((c) => c.executeOps([op]));
|
|
240
|
+
outputResult(result, fmt);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
sceneCommand
|
|
247
|
+
.command("signal <emitter> <signal> <receiver> <method>")
|
|
248
|
+
.description("Connect a signal between two nodes (e.g. summer scene signal ./HitArea body_entered ./Player _on_hit)")
|
|
249
|
+
.action(async (emitter, signal, receiver, method, _opts, cmd) => {
|
|
250
|
+
const fmt = isJsonMode(cmd);
|
|
251
|
+
try {
|
|
252
|
+
const result = await withEngineClient((c) => c.executeOps([
|
|
253
|
+
{ op: "ConnectSignal", emitter, signal, receiver, method },
|
|
254
|
+
]));
|
|
255
|
+
outputResult(result, fmt);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
sceneCommand
|
|
262
|
+
.command("select <nodePath>")
|
|
263
|
+
.description("Select a node in the editor inspector")
|
|
264
|
+
.option("--scene <scenePath>", "Open this scene first, then select")
|
|
265
|
+
.action(async (nodePath, opts, cmd) => {
|
|
266
|
+
const fmt = isJsonMode(cmd);
|
|
267
|
+
try {
|
|
268
|
+
const op = { op: "SelectNode", nodePath };
|
|
269
|
+
if (opts.scene)
|
|
270
|
+
op.scenePath = opts.scene;
|
|
271
|
+
const result = await withEngineClient((c) => c.executeOps([op]));
|
|
272
|
+
outputResult(result, fmt);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
sceneCommand
|
|
279
|
+
.command("inspect-resource <path>")
|
|
280
|
+
.description("Get all properties of a resource (material, mesh, shape, etc.)")
|
|
281
|
+
.action(async (path, _opts, cmd) => {
|
|
282
|
+
const fmt = isJsonMode(cmd);
|
|
283
|
+
try {
|
|
284
|
+
const result = await withEngineClient((c) => c.inspectResource(path));
|
|
285
|
+
outputResult(result, fmt);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
outputError(err instanceof Error ? err.message : String(err), fmt);
|
|
289
|
+
}
|
|
290
|
+
});
|
package/dist/lib/banner.js
CHANGED
|
@@ -84,10 +84,10 @@ export function getWelcome(version) {
|
|
|
84
84
|
];
|
|
85
85
|
const footerLines = [
|
|
86
86
|
pad("", 69),
|
|
87
|
-
pad(` ${sunC2}summer
|
|
87
|
+
pad(` ${sunC2}summer${reset} · ${dim}local${reset}`, 69),
|
|
88
88
|
pad(` ${dim}Docs: https://summerengine.com/docs/mcp${reset}`, 69)
|
|
89
89
|
];
|
|
90
|
-
const title = ` Summer
|
|
90
|
+
const title = ` Summer v${version} `;
|
|
91
91
|
const titleLen = stripAnsi(title).length;
|
|
92
92
|
const totalDashes = 69 - titleLen;
|
|
93
93
|
const leftDashes = Math.floor(totalDashes / 2);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processors for engine log/diagnostic payloads before they are returned
|
|
3
|
+
* to the AI model. The engine API stays lossless; the AI-facing surface
|
|
4
|
+
* trims, dedupes, and prioritizes.
|
|
5
|
+
*
|
|
6
|
+
* Two passes:
|
|
7
|
+
* - dedupe(): collapse consecutive identical messages into one with a
|
|
8
|
+
* `(×N)` count suffix so a 200-frame `null check` flood reads as one line.
|
|
9
|
+
* - errorsOnly(): drop info/std lines, keep errors and warnings.
|
|
10
|
+
*/
|
|
11
|
+
export type LogEntry = {
|
|
12
|
+
message?: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
severity?: string;
|
|
16
|
+
level?: string;
|
|
17
|
+
timestamp?: string;
|
|
18
|
+
[k: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
export interface FilterOptions {
|
|
21
|
+
/** Drop everything that isn't an error or warning. Default: false. */
|
|
22
|
+
errorsOnly?: boolean;
|
|
23
|
+
/** Drop everything that isn't an error. Default: false. */
|
|
24
|
+
errorsOnlyStrict?: boolean;
|
|
25
|
+
/** Hard cap on returned entries after dedupe. Default: 100. */
|
|
26
|
+
maxEntries?: number;
|
|
27
|
+
/** Skip dedupe — return everything as-is. Default: false. */
|
|
28
|
+
noDedupe?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface FilterSummary {
|
|
31
|
+
totalIn: number;
|
|
32
|
+
totalOut: number;
|
|
33
|
+
duplicatesCollapsed: number;
|
|
34
|
+
droppedByLevel: number;
|
|
35
|
+
truncated: number;
|
|
36
|
+
}
|
|
37
|
+
export interface FilterResult<T extends LogEntry = LogEntry> {
|
|
38
|
+
entries: T[];
|
|
39
|
+
summary: FilterSummary;
|
|
40
|
+
}
|
|
41
|
+
export declare function filterAndDedupe<T extends LogEntry>(entries: T[], opts?: FilterOptions): FilterResult<T>;
|
|
42
|
+
/**
|
|
43
|
+
* Walks a parsed engine response and applies filterAndDedupe to the first
|
|
44
|
+
* recognized log-bearing array. Mutates a clone — the input is not modified.
|
|
45
|
+
*
|
|
46
|
+
* Returns a tuple of [shapedResult, summary] where shapedResult is the same
|
|
47
|
+
* shape as input but with the array replaced and a `_filter` summary
|
|
48
|
+
* appended at the same level so the model can see what was hidden.
|
|
49
|
+
*/
|
|
50
|
+
export declare function shapeEngineLogResponse(raw: unknown, opts?: FilterOptions): {
|
|
51
|
+
result: unknown;
|
|
52
|
+
summary: FilterSummary | null;
|
|
53
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const ERROR_TYPES = new Set(["error", "fatal", "critical"]);
|
|
2
|
+
const WARNING_TYPES = new Set(["warning", "warn"]);
|
|
3
|
+
const NOISE_TYPES = new Set(["info", "debug", "std", "stdout", "trace", "verbose"]);
|
|
4
|
+
function entryKey(e) {
|
|
5
|
+
// Identical message + same type counts as a duplicate.
|
|
6
|
+
// Timestamps differ by frame, so they're excluded from the key.
|
|
7
|
+
const msg = (e.message ?? e.text ?? "").trim();
|
|
8
|
+
const type = (e.type ?? e.severity ?? e.level ?? "").toLowerCase();
|
|
9
|
+
return `${type}::${msg}`;
|
|
10
|
+
}
|
|
11
|
+
function entryLevel(e) {
|
|
12
|
+
const t = (e.type ?? e.severity ?? e.level ?? "").toLowerCase();
|
|
13
|
+
if (ERROR_TYPES.has(t))
|
|
14
|
+
return "error";
|
|
15
|
+
if (WARNING_TYPES.has(t))
|
|
16
|
+
return "warning";
|
|
17
|
+
if (NOISE_TYPES.has(t))
|
|
18
|
+
return "noise";
|
|
19
|
+
return "other";
|
|
20
|
+
}
|
|
21
|
+
export function filterAndDedupe(entries, opts = {}) {
|
|
22
|
+
const { errorsOnly = false, errorsOnlyStrict = false, maxEntries = 100, noDedupe = false } = opts;
|
|
23
|
+
const summary = {
|
|
24
|
+
totalIn: entries.length,
|
|
25
|
+
totalOut: 0,
|
|
26
|
+
duplicatesCollapsed: 0,
|
|
27
|
+
droppedByLevel: 0,
|
|
28
|
+
truncated: 0,
|
|
29
|
+
};
|
|
30
|
+
// Pass 1: level filter
|
|
31
|
+
let kept = entries;
|
|
32
|
+
if (errorsOnlyStrict || errorsOnly) {
|
|
33
|
+
kept = entries.filter((e) => {
|
|
34
|
+
const level = entryLevel(e);
|
|
35
|
+
if (errorsOnlyStrict)
|
|
36
|
+
return level === "error";
|
|
37
|
+
return level === "error" || level === "warning";
|
|
38
|
+
});
|
|
39
|
+
summary.droppedByLevel = entries.length - kept.length;
|
|
40
|
+
}
|
|
41
|
+
// Pass 2: dedupe consecutive identical entries
|
|
42
|
+
let deduped;
|
|
43
|
+
if (noDedupe) {
|
|
44
|
+
deduped = kept;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
deduped = [];
|
|
48
|
+
let lastKey = null;
|
|
49
|
+
let lastIdx = -1;
|
|
50
|
+
let runCount = 0;
|
|
51
|
+
for (const e of kept) {
|
|
52
|
+
const key = entryKey(e);
|
|
53
|
+
if (key === lastKey && lastIdx >= 0) {
|
|
54
|
+
runCount += 1;
|
|
55
|
+
const tagged = { ...deduped[lastIdx] };
|
|
56
|
+
tagged.duplicate_count = runCount;
|
|
57
|
+
const baseMsg = (tagged.message ?? tagged.text ?? "");
|
|
58
|
+
const stripped = baseMsg.replace(/\s*\(×\d+\)\s*$/, "");
|
|
59
|
+
if (tagged.message !== undefined)
|
|
60
|
+
tagged.message = `${stripped} (×${runCount})`;
|
|
61
|
+
else if (tagged.text !== undefined)
|
|
62
|
+
tagged.text = `${stripped} (×${runCount})`;
|
|
63
|
+
deduped[lastIdx] = tagged;
|
|
64
|
+
summary.duplicatesCollapsed += 1;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
deduped.push(e);
|
|
68
|
+
lastKey = key;
|
|
69
|
+
lastIdx = deduped.length - 1;
|
|
70
|
+
runCount = 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Pass 3: truncate
|
|
75
|
+
let final = deduped;
|
|
76
|
+
if (deduped.length > maxEntries) {
|
|
77
|
+
final = deduped.slice(0, maxEntries);
|
|
78
|
+
summary.truncated = deduped.length - maxEntries;
|
|
79
|
+
}
|
|
80
|
+
summary.totalOut = final.length;
|
|
81
|
+
return { entries: final, summary };
|
|
82
|
+
}
|
|
83
|
+
const ENTRY_KEYS = ["messages", "errors", "entries", "items", "lines", "output"];
|
|
84
|
+
/**
|
|
85
|
+
* Walks a parsed engine response and applies filterAndDedupe to the first
|
|
86
|
+
* recognized log-bearing array. Mutates a clone — the input is not modified.
|
|
87
|
+
*
|
|
88
|
+
* Returns a tuple of [shapedResult, summary] where shapedResult is the same
|
|
89
|
+
* shape as input but with the array replaced and a `_filter` summary
|
|
90
|
+
* appended at the same level so the model can see what was hidden.
|
|
91
|
+
*/
|
|
92
|
+
export function shapeEngineLogResponse(raw, opts = {}) {
|
|
93
|
+
if (!raw || typeof raw !== "object")
|
|
94
|
+
return { result: raw, summary: null };
|
|
95
|
+
const cloned = JSON.parse(JSON.stringify(raw));
|
|
96
|
+
const summary = applyToFirstArray(cloned, opts);
|
|
97
|
+
return { result: cloned, summary };
|
|
98
|
+
}
|
|
99
|
+
function applyToFirstArray(node, opts) {
|
|
100
|
+
if (!node || typeof node !== "object")
|
|
101
|
+
return null;
|
|
102
|
+
if (Array.isArray(node)) {
|
|
103
|
+
for (const item of node) {
|
|
104
|
+
if (item && typeof item === "object") {
|
|
105
|
+
const inner = applyToFirstArray(item, opts);
|
|
106
|
+
if (inner)
|
|
107
|
+
return inner;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const obj = node;
|
|
113
|
+
for (const key of ENTRY_KEYS) {
|
|
114
|
+
const arr = obj[key];
|
|
115
|
+
if (Array.isArray(arr) && arr.length > 0 && typeof arr[0] === "object") {
|
|
116
|
+
const { entries, summary } = filterAndDedupe(arr, opts);
|
|
117
|
+
obj[key] = entries;
|
|
118
|
+
obj["_filter"] = summary;
|
|
119
|
+
return summary;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const v of Object.values(obj)) {
|
|
123
|
+
if (v && typeof v === "object") {
|
|
124
|
+
const inner = applyToFirstArray(v, opts);
|
|
125
|
+
if (inner)
|
|
126
|
+
return inner;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that every agent plugin manifest references real, on-disk skills,
|
|
3
|
+
* and that every skill claimed HAVE in catalog.yaml actually exists.
|
|
4
|
+
*
|
|
5
|
+
* Catches broken-path bugs before they ship. Add a manifest, add a path here.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const repoRoot = join(__dirname, "..", "..");
|
|
13
|
+
const MANIFESTS = [
|
|
14
|
+
{
|
|
15
|
+
name: ".claude-plugin/plugin.json",
|
|
16
|
+
path: join(repoRoot, ".claude-plugin", "plugin.json"),
|
|
17
|
+
skillsField: [],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: ".cursor-plugin/plugin.json",
|
|
21
|
+
path: join(repoRoot, ".cursor-plugin", "plugin.json"),
|
|
22
|
+
skillsField: [],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: ".codex-plugin/plugin.json",
|
|
26
|
+
path: join(repoRoot, ".codex-plugin", "plugin.json"),
|
|
27
|
+
skillsField: [],
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
function loadSkillsField(manifestPath) {
|
|
31
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
32
|
+
const json = JSON.parse(raw);
|
|
33
|
+
if (json.skills === undefined) {
|
|
34
|
+
throw new Error(`${manifestPath}: missing "skills" field`);
|
|
35
|
+
}
|
|
36
|
+
return json.skills;
|
|
37
|
+
}
|
|
38
|
+
function listSkillDirs(skillsRoot) {
|
|
39
|
+
const entries = readdirSync(skillsRoot);
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const cat of entries) {
|
|
42
|
+
const catPath = join(skillsRoot, cat);
|
|
43
|
+
if (!statSync(catPath).isDirectory())
|
|
44
|
+
continue;
|
|
45
|
+
if (cat === "_shared" || cat === "_tests")
|
|
46
|
+
continue;
|
|
47
|
+
for (const skill of readdirSync(catPath)) {
|
|
48
|
+
const skillPath = join(catPath, skill);
|
|
49
|
+
if (!statSync(skillPath).isDirectory())
|
|
50
|
+
continue;
|
|
51
|
+
if (existsSync(join(skillPath, "SKILL.md"))) {
|
|
52
|
+
out.push(`./skills/${cat}/${skill}/`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
describe("agent plugin manifests", () => {
|
|
59
|
+
for (const m of MANIFESTS) {
|
|
60
|
+
describe(m.name, () => {
|
|
61
|
+
it("exists and parses as JSON", () => {
|
|
62
|
+
expect(existsSync(m.path), `${m.name} not found`).toBe(true);
|
|
63
|
+
m.skillsField = loadSkillsField(m.path);
|
|
64
|
+
});
|
|
65
|
+
it("every referenced skill path resolves to a SKILL.md on disk", () => {
|
|
66
|
+
const skills = m.skillsField;
|
|
67
|
+
if (typeof skills === "string") {
|
|
68
|
+
// Directory pointer mode (e.g. "./skills/")
|
|
69
|
+
const dir = join(repoRoot, skills);
|
|
70
|
+
expect(existsSync(dir), `${m.name}: skills dir ${skills} missing`).toBe(true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const broken = [];
|
|
74
|
+
for (const rel of skills) {
|
|
75
|
+
const skillMd = join(repoRoot, rel, "SKILL.md");
|
|
76
|
+
if (!existsSync(skillMd))
|
|
77
|
+
broken.push(rel);
|
|
78
|
+
}
|
|
79
|
+
expect(broken, `${m.name} references missing skills:\n${broken.join("\n")}`).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
it("does not duplicate skill paths", () => {
|
|
82
|
+
const skills = m.skillsField;
|
|
83
|
+
if (typeof skills === "string")
|
|
84
|
+
return;
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const dupes = [];
|
|
87
|
+
for (const rel of skills) {
|
|
88
|
+
if (seen.has(rel))
|
|
89
|
+
dupes.push(rel);
|
|
90
|
+
seen.add(rel);
|
|
91
|
+
}
|
|
92
|
+
expect(dupes, `${m.name} duplicates: ${dupes.join(", ")}`).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
describe("skills directory <-> manifest coverage", () => {
|
|
97
|
+
it("every on-disk skill is listed in .claude-plugin/plugin.json", () => {
|
|
98
|
+
const onDisk = listSkillDirs(join(repoRoot, "skills"));
|
|
99
|
+
const claudeManifest = JSON.parse(readFileSync(join(repoRoot, ".claude-plugin", "plugin.json"), "utf-8"));
|
|
100
|
+
const listed = new Set(claudeManifest.skills);
|
|
101
|
+
const orphans = onDisk.filter((p) => !listed.has(p));
|
|
102
|
+
expect(orphans, `On-disk skills missing from .claude-plugin/plugin.json (add or move under skills/_shared|_tests):\n${orphans.join("\n")}`).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("skill SKILL.md frontmatter", () => {
|
|
107
|
+
it("every shipped SKILL.md has name and Use-when-style description", () => {
|
|
108
|
+
const skillsRoot = join(repoRoot, "skills");
|
|
109
|
+
const skillDirs = listSkillDirs(skillsRoot);
|
|
110
|
+
const failures = [];
|
|
111
|
+
for (const rel of skillDirs) {
|
|
112
|
+
const file = join(repoRoot, rel, "SKILL.md");
|
|
113
|
+
const text = readFileSync(file, "utf-8");
|
|
114
|
+
const match = text.match(/^---\s*\n([\s\S]+?)\n---/);
|
|
115
|
+
if (!match) {
|
|
116
|
+
failures.push(`${rel}: no frontmatter`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const fm = match[1];
|
|
120
|
+
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
121
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m);
|
|
122
|
+
if (!nameMatch || !nameMatch[1].trim())
|
|
123
|
+
failures.push(`${rel}: missing name`);
|
|
124
|
+
if (!descMatch || !descMatch[1].trim())
|
|
125
|
+
failures.push(`${rel}: missing description`);
|
|
126
|
+
const desc = descMatch?.[1] ?? "";
|
|
127
|
+
// Auto-trigger discipline: description must contain "Use when"
|
|
128
|
+
if (desc && !/\bUse when\b/i.test(desc)) {
|
|
129
|
+
failures.push(`${rel}: description must contain "Use when ..." (got: "${desc.slice(0, 80)}...")`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
expect(failures, `Skill frontmatter problems:\n${failures.join("\n")}`).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface RemoteTemplate {
|
|
2
|
+
/** Slug used by `summer create <slug>`. The `template-` prefix is stripped. */
|
|
3
|
+
slug: string;
|
|
4
|
+
/** Full GitHub repo name including the `template-` prefix. */
|
|
5
|
+
repo: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** Default branch — used when cloning. */
|
|
8
|
+
branch: string;
|
|
9
|
+
url: string;
|
|
10
|
+
cloneUrl: string;
|
|
11
|
+
stars: number;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function fetchRemoteTemplates(opts?: {
|
|
15
|
+
force?: boolean;
|
|
16
|
+
}): Promise<RemoteTemplate[]>;
|
|
17
|
+
export interface CloneOptions {
|
|
18
|
+
/** Where to put the project. Must not already exist. */
|
|
19
|
+
targetDir: string;
|
|
20
|
+
/** If true, drop the .git directory after clone so the user starts with a clean history. */
|
|
21
|
+
detach?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function cloneTemplate(template: RemoteTemplate, opts: CloneOptions): void;
|
|
24
|
+
/** Quick match: exact slug, then prefix, then substring. Returns null if ambiguous or none. */
|
|
25
|
+
export declare function matchTemplate(query: string, templates: RemoteTemplate[]): RemoteTemplate | null;
|