@letta-ai/letta-code 0.27.17 → 0.27.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +7 -0
  2. package/dist/app-server-client.js +11 -1
  3. package/dist/app-server-client.js.map +3 -3
  4. package/dist/types/app-server-client.d.ts +4 -1
  5. package/dist/types/app-server-client.d.ts.map +1 -1
  6. package/dist/types/types/protocol.d.ts +2 -1
  7. package/dist/types/types/protocol.d.ts.map +1 -1
  8. package/dist/types/types/protocol_v2.d.ts +17 -3
  9. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  10. package/docs/examples/mods/README.md +60 -0
  11. package/docs/examples/mods/learning/memory-citations.env.json +165 -0
  12. package/docs/examples/mods/learning/uv-pip-install.env.json +199 -0
  13. package/docs/examples/mods/memory-citations.ts +347 -0
  14. package/letta.js +125665 -122383
  15. package/package.json +4 -2
  16. package/scripts/mod-learning/learn-mod.ts +306 -0
  17. package/skills/{context_doctor → context-doctor}/SKILL.md +1 -1
  18. package/skills/creating-mods/SKILL.md +10 -7
  19. package/skills/creating-mods/references/architecture.md +15 -4
  20. package/skills/creating-mods/references/commands.md +2 -1
  21. package/skills/creating-mods/references/events.md +141 -10
  22. package/skills/creating-mods/references/plan-mode.md +1 -1
  23. package/skills/creating-mods/references/ui.md +61 -23
  24. package/skills/customizing-statusline/SKILL.md +13 -14
  25. package/skills/customizing-statusline/references/api.md +74 -86
  26. package/skills/customizing-statusline/references/examples.md +79 -51
  27. package/skills/customizing-statusline/references/migration.md +38 -19
  28. package/skills/generating-mod-envs/SKILL.md +130 -0
  29. package/skills/generating-mod-envs/assets/mod-learning-env.template.json +57 -0
  30. package/skills/generating-mod-envs/scripts/validate-mod-env.ts +261 -0
@@ -1,6 +1,6 @@
1
1
  # Statusline Migration
2
2
 
3
- Use this reference when migrating legacy command statuslines, standalone `.sh` statusline scripts, or shell PS1 prompts.
3
+ Use this reference when migrating legacy command statuslines, standalone `.sh` statusline scripts, or shell PS1 prompts into `~/.letta/mods/statusline.tsx`.
4
4
 
5
5
  ## Legacy Letta command statusline
6
6
 
@@ -40,10 +40,10 @@ When migrating:
40
40
 
41
41
  - Preserve old config and referenced files unless the user explicitly asks to delete them.
42
42
  - If `command` references a `.sh` file, read it before writing the new mod.
43
- - Translate polling (`refreshIntervalMs`) to `setInterval`.
44
- - Translate direct command output into cached status plus synchronous rendering.
45
- - If the command output used `\x1e` to split left/right output, convert it to internal full-row layout with `Box`; do not create a new left/right API.
46
- - Treat old prompt customization separately. The new statusline controls the bottom row, not necessarily the input prompt.
43
+ - Translate polling (`refreshIntervalMs`) to `setInterval` + `panel.update()`.
44
+ - Translate direct command output into a cached closure variable plus synchronous rendering.
45
+ - If the command output used `\x1e` to split left/right output, convert it to `row(left, right, width)`; there is no separate left/right API.
46
+ - Treat old prompt customization separately. The statusline controls the primary row, not necessarily the input prompt.
47
47
 
48
48
  Old model:
49
49
 
@@ -59,17 +59,36 @@ import { promisify } from "node:util";
59
59
 
60
60
  const execFileAsync = promisify(execFile);
61
61
 
62
- const update = async () => {
63
- const { stdout } = await execFileAsync("git", ["branch", "--show-current"], {
64
- cwd: process.cwd(),
62
+ export default function activate(letta) {
63
+ if (!letta.capabilities.ui.panels) return;
64
+
65
+ let branch = "";
66
+
67
+ const panel = letta.ui.openPanel({
68
+ id: "statusline",
69
+ order: 0,
70
+ render: ({ width, row }) => row(branch, "", width),
65
71
  });
66
- letta.ui.setStatus("branch", stdout.trim());
67
- };
68
72
 
69
- letta.ui.setStatuslineRenderer((context) => {
70
- const { Text } = context.components;
71
- return <Text>{context.statuses.branch ?? ""}</Text>;
72
- });
73
+ const update = async () => {
74
+ try {
75
+ const { stdout } = await execFileAsync("git", ["branch", "--show-current"], {
76
+ cwd: process.cwd(),
77
+ });
78
+ branch = stdout.trim();
79
+ } catch {
80
+ branch = "";
81
+ }
82
+ panel.update();
83
+ };
84
+
85
+ void update();
86
+ const timer = setInterval(update, 30_000);
87
+ return () => {
88
+ clearInterval(timer);
89
+ panel.close();
90
+ };
91
+ }
73
92
  ```
74
93
 
75
94
  ## Standalone `.sh` file migration
@@ -79,11 +98,11 @@ If the user provides a `.sh` path:
79
98
  1. Read the script.
80
99
  2. Identify commands, expected stdin JSON, environment variables, and output shape.
81
100
  3. Port shell commands to async setup/update code.
82
- 4. Store results with `letta.ui.setStatus(key, value)`.
83
- 5. Render cached status synchronously.
84
- 6. Preserve graceful fallbacks for missing tools, not-a-git-repo, no PR, etc.
101
+ 4. Store results in closure variables.
102
+ 5. Render cached state synchronously and call `panel.update()` after each refresh.
103
+ 6. Preserve graceful fallbacks for missing tools, not-a-git-repo, no PR, etc. (return `""` to hide the line).
85
104
 
86
- If a script depends heavily on stdin JSON, use `context.rawPayload` as a temporary migration aid, but prefer semantic context fields for new code.
105
+ Map the script's stdin JSON to the render context's semantic fields (`agent`, `model`, `width`); compute anything else in setup/update code.
87
106
 
88
107
  ## Shell PS1 import
89
108
 
@@ -127,4 +146,4 @@ If no PS1 is found and the user did not provide other instructions, ask for one
127
146
  2. a description of what their prompt shows
128
147
  3. the current prompt output as it appears in their terminal
129
148
 
130
- Preserve colors where practical using display components. If the PS1 is too dynamic to port exactly, ask whether to approximate it or port specific commands.
149
+ Preserve colors where practical with `chalk`. If the PS1 is too dynamic to port exactly, ask whether to approximate it or port specific commands.
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: generating-mod-envs
3
+ description: Generates and reviews mod learning env JSON files for Letta Code local mods. Use when asked to teach, learn, or optimize a mod behavior; create, draft, validate, improve, or explain envs for `/mods learn --env`; or design evaluation scenarios, memory fixtures, requiredResultMarkers, requiredTraceMarkers, negative controls, and candidate diversity hints.
4
+ disable-model-invocation: true
5
+ user-invocable: true
6
+ ---
7
+
8
+ # Generating mod learning envs
9
+
10
+ Use this skill to create JSON envs consumed by `/mods learn --env=<path>` or `bun scripts/mod-learning/learn-mod.ts --env <path>`. An env describes the mod behavior to learn and the scenario-suite eval used to score candidates.
11
+
12
+ ## Workflow
13
+
14
+ 1. Define the behavior and eval before writing JSON.
15
+ - What should the mod do? Tool, turn event, tool event, provider, command, status, etc.
16
+ - What would a placebo/no-op mod fail?
17
+ - What unique sentinel strings make success unambiguous?
18
+ 2. Choose a path:
19
+ - Repo example: `docs/examples/mods/learning/<slug>.env.json`
20
+ - Local/private: any user-requested path
21
+ 3. Draft strict JSON. Start from `assets/mod-learning-env.template.json` if useful. No comments or trailing commas.
22
+ 4. Prefer `evaluation.scenarios` with at least:
23
+ - happy path
24
+ - discrimination/exact-target path
25
+ - negative control
26
+ 5. Validate:
27
+
28
+ ```bash
29
+ bun src/skills/builtin/generating-mod-envs/scripts/validate-mod-env.ts path/to/env.json
30
+ ```
31
+
32
+ If this skill is installed outside the source tree, run the same script from this skill directory: `scripts/validate-mod-env.ts`.
33
+
34
+ 6. If asked to run it:
35
+
36
+ ```text
37
+ /mods learn --env=path/to/env.json --model=auto --backend=api --out=/tmp/<slug>-learn
38
+ ```
39
+
40
+ The raw `scripts/mod-learning/learn-mod.ts` dev script detaches by default. Add `--foreground` only when a blocking pass/fail exit code is needed.
41
+
42
+ Use single-line `--flag=value` commands for TUI instructions.
43
+
44
+ ## Env shape
45
+
46
+ Required top-level fields:
47
+
48
+ - `name`: human display name.
49
+ - `slug`: stable kebab-case run/candidate slug.
50
+ - `objective`: one-paragraph target for the generation agent.
51
+ - `requirements`: concrete pass/fail behavior constraints.
52
+ - `evaluation`: either a single prompt eval or a scenario suite.
53
+
54
+ Common optional fields:
55
+
56
+ - `targetModName`: display metadata for the intended mod filename. The harness still chooses the candidate filename from `slug` unless `--candidate-file-name` is passed.
57
+ - `candidateDiversityHints`: strategies assigned across multi-candidate runs.
58
+ - `modApiHints`: concise API reminders that prevent bad generated code.
59
+ - `examples`: small input/expected demos for the generation prompt.
60
+
61
+ Evaluation fields:
62
+
63
+ - `evaluation.outputFormat`: use `stream-json` when checking trace markers.
64
+ - `evaluation.timeoutMs`, `evaluation.maxTurns`: per-scenario defaults.
65
+ - `evaluation.memoryFiles`: files seeded under eval `$MEMORY_DIR`.
66
+ - `evaluation.scenarios[]`: scenario-specific overrides and fixtures.
67
+ - In scenario-suite envs, do not add a top-level `evaluation.prompt` unless that prompt must run for every scenario. Assertion-only scenarios should have `assertions` and no `prompt`; only scenarios that require model behavior should define `scenario.prompt`.
68
+ - `requiredResultMarkers`: literal strings required in the final answer.
69
+ - `requiredTraceMarkers`: literal strings required in raw stdout/stderr.
70
+ - `forbiddenResultMarkers`: final-answer strings that fail the run.
71
+ - `forbiddenTraceMarkers`: raw trace strings that fail the run.
72
+
73
+ ## Quality rules
74
+
75
+ - Design the eval first. A useful env distinguishes success from a no-op mod.
76
+ - Use unique sentinels, e.g. `MY-MOD-CANARY-OK`, not common phrases.
77
+ - Seed `memoryFiles` rather than depending on real user memory or repo files.
78
+ - Include negative controls for non-use. If behavior should be conditional, verify it stays silent when not triggered.
79
+ - Include discrimination scenarios when paths, IDs, or sources matter. Put a tempting wrong sentinel in an irrelevant fixture and forbid it in the final answer.
80
+ - Put load failures in `forbiddenTraceMarkers`, usually:
81
+ - `[mods] failed to load`
82
+ - `[extensions] failed to load`
83
+ - `loaded 0 mod(s)`
84
+ - `loaded 0 extension(s)`
85
+ - For eval-facing tools, require `requiresApproval: false`, `parallelSafe: true`, and a strict no-argument schema when applicable.
86
+ - Avoid over-brittle trace markers. Prefer stable substrings like the tool name plus `"message_type":"tool_return_message"`.
87
+ - Keep requirements behavioral; put fragile implementation details in `modApiHints` only when needed.
88
+
89
+ ## Minimal scenario-suite example
90
+
91
+ ```json
92
+ {
93
+ "name": "Hello tool mod learner demo",
94
+ "slug": "hello-tool",
95
+ "objective": "Learn a trusted local mod that registers a read-only hello_mod_ping tool returning a fixed sentinel.",
96
+ "requirements": [
97
+ "Register a tool named hello_mod_ping.",
98
+ "The tool must accept no parameters, require no approval, be parallelSafe, and return the exact string HELLO-MOD-OK."
99
+ ],
100
+ "candidateDiversityHints": [
101
+ "Use the smallest possible tool-only implementation.",
102
+ "Add explicit defensive checks around the tool schema."
103
+ ],
104
+ "modApiHints": [
105
+ "Use export function activate(letta) or a default export.",
106
+ "Use letta.tools.register({ name, description, parameters, requiresApproval, parallelSafe, run }).",
107
+ "A no-argument tool schema is { \"type\": \"object\", \"properties\": {}, \"additionalProperties\": false }."
108
+ ],
109
+ "evaluation": {
110
+ "outputFormat": "stream-json",
111
+ "timeoutMs": 900000,
112
+ "maxTurns": 6,
113
+ "forbiddenTraceMarkers": ["[mods] failed to load", "loaded 0 mod(s)"],
114
+ "scenarios": [
115
+ {
116
+ "name": "happy-path",
117
+ "prompt": "Call the hello_mod_ping tool, then answer with the exact text HELLO-MOD-OK.",
118
+ "requiredResultMarkers": ["HELLO-MOD-OK"],
119
+ "requiredTraceMarkers": ["hello_mod_ping", "\"message_type\":\"tool_return_message\""]
120
+ },
121
+ {
122
+ "name": "negative-control",
123
+ "prompt": "Answer without calling tools: what is 2 + 2?",
124
+ "requiredResultMarkers": ["4"],
125
+ "forbiddenTraceMarkers": ["hello_mod_ping"]
126
+ }
127
+ ]
128
+ }
129
+ }
130
+ ```
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "<Display name> mod learner demo",
3
+ "slug": "example-mod-env",
4
+ "targetModName": "example-mod-env.ts",
5
+ "objective": "Learn a trusted local mod that <specific behavior>.",
6
+ "requirements": [
7
+ "<Concrete behavior requirement>",
8
+ "<Concrete eval-facing requirement>"
9
+ ],
10
+ "candidateDiversityHints": [
11
+ "<Alternative implementation strategy for candidate 1>",
12
+ "<Alternative implementation strategy for candidate 2>"
13
+ ],
14
+ "modApiHints": [
15
+ "Use export function activate(letta) or a default export.",
16
+ "Use only the trusted local mod API exposed through the letta argument; do not import from repo source files."
17
+ ],
18
+ "examples": [
19
+ {
20
+ "input": "<Short demo input>",
21
+ "expected": "<Expected behavior>"
22
+ }
23
+ ],
24
+ "evaluation": {
25
+ "outputFormat": "stream-json",
26
+ "timeoutMs": 900000,
27
+ "maxTurns": 8,
28
+ "forbiddenTraceMarkers": [
29
+ "[mods] failed to load",
30
+ "[extensions] failed to load",
31
+ "loaded 0 mod(s)",
32
+ "loaded 0 extension(s)"
33
+ ],
34
+ "scenarios": [
35
+ {
36
+ "name": "happy-path",
37
+ "memoryFiles": {
38
+ "reference/<fixture>.md": "# Fixture\n\nThe sentinel is <SENTINEL-OK>.\n"
39
+ },
40
+ "prompt": "<Prompt that triggers the mod and requires the sentinel in the final answer>. The memory directory is available in MEMORY_DIR.",
41
+ "requiredResultMarkers": ["<SENTINEL-OK>"],
42
+ "requiredTraceMarkers": ["<stable trace marker>"],
43
+ "forbiddenResultMarkers": ["<known bad final answer marker>"]
44
+ },
45
+ {
46
+ "name": "negative-control",
47
+ "prompt": "<Prompt where the mod behavior should not trigger>.",
48
+ "requiredResultMarkers": ["<ordinary answer marker>"],
49
+ "forbiddenResultMarkers": ["<mod-only marker>"],
50
+ "forbiddenTraceMarkers": [
51
+ "<tool or event marker that should not appear>"
52
+ ]
53
+ }
54
+ ],
55
+ "prompt": "Run all configured evaluation.scenarios."
56
+ }
57
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ type JsonRecord = Record<string, unknown>;
5
+
6
+ function isRecord(value: unknown): value is JsonRecord {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ function isStringArray(value: unknown): value is string[] {
11
+ return (
12
+ Array.isArray(value) && value.every((item) => typeof item === "string")
13
+ );
14
+ }
15
+
16
+ function assertValid(condition: unknown, message: string): asserts condition {
17
+ if (!condition) throw new Error(message);
18
+ }
19
+
20
+ function optionalString(record: JsonRecord, key: string, path = key): void {
21
+ const value = record[key];
22
+ assertValid(
23
+ value === undefined ||
24
+ (typeof value === "string" && value.trim().length > 0),
25
+ `${path} must be a non-empty string when present`,
26
+ );
27
+ }
28
+
29
+ function optionalStringArray(
30
+ record: JsonRecord,
31
+ key: string,
32
+ path = key,
33
+ ): void {
34
+ const value = record[key];
35
+ assertValid(
36
+ value === undefined || isStringArray(value),
37
+ `${path} must be an array of strings`,
38
+ );
39
+ }
40
+
41
+ function optionalPositiveNumber(
42
+ record: JsonRecord,
43
+ key: string,
44
+ path = key,
45
+ ): void {
46
+ const value = record[key];
47
+ assertValid(
48
+ value === undefined ||
49
+ (typeof value === "number" && Number.isFinite(value) && value > 0),
50
+ `${path} must be a positive number`,
51
+ );
52
+ }
53
+
54
+ function validateMemoryFiles(value: unknown, path: string): void {
55
+ if (value === undefined) return;
56
+ assertValid(isRecord(value), `${path} must be an object`);
57
+ for (const [filePath, content] of Object.entries(value)) {
58
+ assertValid(
59
+ filePath.length > 0 && !filePath.startsWith("/"),
60
+ `${path} paths must be relative`,
61
+ );
62
+ assertValid(
63
+ typeof content === "string",
64
+ `${path}[${filePath}] must be a string`,
65
+ );
66
+ }
67
+ }
68
+
69
+ function validateMarkerFields(record: JsonRecord, path: string): void {
70
+ optionalStringArray(
71
+ record,
72
+ "requiredResultMarkers",
73
+ `${path}.requiredResultMarkers`,
74
+ );
75
+ optionalStringArray(
76
+ record,
77
+ "requiredTraceMarkers",
78
+ `${path}.requiredTraceMarkers`,
79
+ );
80
+ optionalStringArray(
81
+ record,
82
+ "forbiddenResultMarkers",
83
+ `${path}.forbiddenResultMarkers`,
84
+ );
85
+ optionalStringArray(
86
+ record,
87
+ "forbiddenTraceMarkers",
88
+ `${path}.forbiddenTraceMarkers`,
89
+ );
90
+ }
91
+
92
+ function requiredMarkerCount(record: JsonRecord): number {
93
+ return ["requiredResultMarkers", "requiredTraceMarkers"].reduce(
94
+ (count, key) => {
95
+ const value = record[key];
96
+ return count + (Array.isArray(value) ? value.length : 0);
97
+ },
98
+ 0,
99
+ );
100
+ }
101
+
102
+ function assertionCount(record: JsonRecord): number {
103
+ const value = record.assertions;
104
+ return Array.isArray(value) ? value.length : 0;
105
+ }
106
+
107
+ function validateScenario(params: {
108
+ evaluation: JsonRecord;
109
+ index: number;
110
+ scenario: unknown;
111
+ warnings: string[];
112
+ }): void {
113
+ const path = `evaluation.scenarios[${params.index}]`;
114
+ assertValid(isRecord(params.scenario), `${path} must be an object`);
115
+ const scenario = params.scenario;
116
+
117
+ optionalString(scenario, "name", `${path}.name`);
118
+ optionalString(scenario, "prompt", `${path}.prompt`);
119
+ assertValid(
120
+ typeof scenario.prompt === "string" ||
121
+ typeof params.evaluation.prompt === "string" ||
122
+ assertionCount(scenario) > 0 ||
123
+ assertionCount(params.evaluation) > 0,
124
+ `${path}.prompt or ${path}.assertions is required when evaluation.prompt is absent`,
125
+ );
126
+ assertValid(
127
+ scenario.outputFormat === undefined ||
128
+ scenario.outputFormat === "json" ||
129
+ scenario.outputFormat === "stream-json",
130
+ `${path}.outputFormat must be json or stream-json`,
131
+ );
132
+ optionalPositiveNumber(scenario, "timeoutMs", `${path}.timeoutMs`);
133
+ optionalPositiveNumber(scenario, "maxTurns", `${path}.maxTurns`);
134
+ validateMemoryFiles(scenario.memoryFiles, `${path}.memoryFiles`);
135
+ validateMarkerFields(scenario, path);
136
+
137
+ if (
138
+ requiredMarkerCount(scenario) === 0 &&
139
+ requiredMarkerCount(params.evaluation) === 0
140
+ ) {
141
+ params.warnings.push(
142
+ `${path} has no required result/trace markers and no parent required markers`,
143
+ );
144
+ }
145
+ }
146
+
147
+ function validateEnv(env: unknown): string[] {
148
+ assertValid(isRecord(env), "env must be a JSON object");
149
+ assertValid(
150
+ typeof env.name === "string" && env.name.trim(),
151
+ "name is required",
152
+ );
153
+ assertValid(
154
+ typeof env.objective === "string" && env.objective.trim(),
155
+ "objective is required",
156
+ );
157
+ assertValid(
158
+ isStringArray(env.requirements) && env.requirements.length > 0,
159
+ "requirements must be a non-empty array of strings",
160
+ );
161
+
162
+ optionalString(env, "slug");
163
+ optionalString(env, "targetModName");
164
+ optionalStringArray(env, "candidateDiversityHints");
165
+ optionalStringArray(env, "modApiHints");
166
+
167
+ if (env.examples !== undefined) {
168
+ assertValid(Array.isArray(env.examples), "examples must be an array");
169
+ for (const [index, example] of env.examples.entries()) {
170
+ assertValid(isRecord(example), `examples[${index}] must be an object`);
171
+ assertValid(
172
+ typeof example.input === "string" && example.input.trim(),
173
+ `examples[${index}].input is required`,
174
+ );
175
+ assertValid(
176
+ example.expected === undefined || typeof example.expected === "string",
177
+ `examples[${index}].expected must be a string`,
178
+ );
179
+ assertValid(
180
+ example.notes === undefined || typeof example.notes === "string",
181
+ `examples[${index}].notes must be a string`,
182
+ );
183
+ }
184
+ }
185
+
186
+ assertValid(isRecord(env.evaluation), "evaluation is required");
187
+ const evaluation = env.evaluation;
188
+ optionalString(evaluation, "prompt", "evaluation.prompt");
189
+ assertValid(
190
+ evaluation.outputFormat === undefined ||
191
+ evaluation.outputFormat === "json" ||
192
+ evaluation.outputFormat === "stream-json",
193
+ "evaluation.outputFormat must be json or stream-json",
194
+ );
195
+ optionalPositiveNumber(evaluation, "timeoutMs", "evaluation.timeoutMs");
196
+ optionalPositiveNumber(evaluation, "maxTurns", "evaluation.maxTurns");
197
+ validateMemoryFiles(evaluation.memoryFiles, "evaluation.memoryFiles");
198
+ validateMarkerFields(evaluation, "evaluation");
199
+
200
+ const warnings: string[] = [];
201
+ if (evaluation.scenarios !== undefined) {
202
+ assertValid(
203
+ Array.isArray(evaluation.scenarios),
204
+ "evaluation.scenarios must be an array",
205
+ );
206
+ assertValid(
207
+ evaluation.scenarios.length > 0,
208
+ "evaluation.scenarios must not be empty",
209
+ );
210
+ for (const [index, scenario] of evaluation.scenarios.entries()) {
211
+ validateScenario({ evaluation, index, scenario, warnings });
212
+ }
213
+ } else {
214
+ assertValid(
215
+ typeof evaluation.prompt === "string" && evaluation.prompt.trim(),
216
+ "evaluation.prompt is required when no scenarios are configured",
217
+ );
218
+ if (requiredMarkerCount(evaluation) === 0) {
219
+ warnings.push(
220
+ "add at least one requiredResultMarkers or requiredTraceMarkers entry so the eval can fail a placebo mod",
221
+ );
222
+ }
223
+ }
224
+
225
+ if (
226
+ typeof env.slug === "string" &&
227
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(env.slug)
228
+ ) {
229
+ warnings.push(
230
+ "slug should be kebab-case for stable run and candidate paths",
231
+ );
232
+ }
233
+ if (
234
+ evaluation.scenarios !== undefined &&
235
+ Array.isArray(evaluation.scenarios) &&
236
+ evaluation.scenarios.length < 2
237
+ ) {
238
+ warnings.push(
239
+ "prefer at least a happy path and a negative-control scenario",
240
+ );
241
+ }
242
+ return warnings;
243
+ }
244
+
245
+ async function main(): Promise<void> {
246
+ const envPath = process.argv[2];
247
+ if (!envPath || envPath === "--help" || envPath === "-h") {
248
+ console.error("Usage: bun validate-mod-env.ts path/to/env.json");
249
+ process.exit(envPath ? 0 : 1);
250
+ }
251
+
252
+ const env = JSON.parse(await readFile(envPath, "utf8"));
253
+ const warnings = validateEnv(env);
254
+ console.log(`OK ${envPath}`);
255
+ for (const warning of warnings) console.warn(`warning: ${warning}`);
256
+ }
257
+
258
+ main().catch((error) => {
259
+ console.error(error instanceof Error ? error.message : String(error));
260
+ process.exit(1);
261
+ });