state-scenarios-cli 0.0.1 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jag Reehal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # state-scenarios-cli
2
+
3
+ ```sh
4
+ npx state-scenarios validate scenarios --rules rules.ts
5
+ npx state-scenarios list scenarios --base-url http://localhost:5173 --json
6
+ npx state-scenarios shoot scenarios --base-url http://localhost:5173 --out shots
7
+ npx state-scenarios suggest scenarios --src src --schema scenario.schema.json
8
+ ```
9
+
10
+ - `shoot` opens every scenario in Chromium, waits until it's ready, and saves a screenshot. It fails on unmocked requests, uncaught exceptions and `console.error` calls. It needs `@playwright/test`.
11
+ - `suggest` asks Claude, or another model behind `ANTHROPIC_BASE_URL` via `--model`, which UI states lack scenarios, and drafts them for review.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
5
+ import { join, relative, resolve } from "path";
6
+ import { pathToFileURL } from "url";
7
+ import { parseArgs } from "util";
8
+ import { createCatalog, scenarioJsonSchema } from "state-scenarios";
9
+ var HELP = `Usage: state-scenarios <command> <scenarios-dir> [options]
10
+
11
+ validate Check every scenario parses, resolves and passes --rules
12
+ list Print scenarios with links (--base-url) for people and agents; --json for machines
13
+ shoot Open each scenario in Chromium, wait for its ready selector, screenshot it,
14
+ and report requests it didn't mock. --base-url (required), --out, --only a,b, --no-strict
15
+ suggest Ask Claude which UI states lack scenarios and draft them into --out (default <dir>/proposed).
16
+ --src dir[,dir] (required), --schema file, --max n, --model (default $STATE_SCENARIOS_MODEL or claude-opus-5-5).
17
+ Needs ANTHROPIC_API_KEY or \`ant auth login\`; ANTHROPIC_BASE_URL points it at a compatible proxy.
18
+ --env-file file loads those variables first.
19
+
20
+ Common: --rules file module exporting \`rules\` (ScenarioRule[])`;
21
+ var MAX_SOURCE_CHARS = 8e5;
22
+ var { positionals, values } = parseArgs({
23
+ allowPositionals: true,
24
+ options: {
25
+ rules: { type: "string" },
26
+ "base-url": { type: "string" },
27
+ out: { type: "string" },
28
+ only: { type: "string" },
29
+ "no-strict": { type: "boolean" },
30
+ json: { type: "boolean" },
31
+ src: { type: "string" },
32
+ schema: { type: "string" },
33
+ max: { type: "string" },
34
+ model: { type: "string" },
35
+ "env-file": { type: "string" },
36
+ help: { type: "boolean", short: "h" }
37
+ }
38
+ });
39
+ var [command, dir] = positionals;
40
+ if (values.help || !command || !dir) {
41
+ console.log(HELP);
42
+ process.exit(command ? 1 : 0);
43
+ }
44
+ try {
45
+ const rules = values.rules ? (await import(pathToFileURL(resolve(values.rules)).href)).rules : [];
46
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
47
+ const catalog = createCatalog(
48
+ files.map((f) => JSON.parse(readFileSync(join(dir, f), "utf8"))),
49
+ { rules }
50
+ );
51
+ switch (command) {
52
+ case "validate": {
53
+ const problems = catalog.check();
54
+ for (const p of problems) console.error(`\u2717 ${p}
55
+ `);
56
+ console.log(`${catalog.list.length - problems.length}/${catalog.list.length} scenarios valid`);
57
+ process.exit(problems.length ? 1 : 0);
58
+ }
59
+ case "list": {
60
+ const base = values["base-url"];
61
+ const rows = catalog.list.map((s) => ({
62
+ name: s.name,
63
+ description: s.description ?? "",
64
+ tags: s.tags ?? [],
65
+ ready: s.ready ?? null,
66
+ url: base ? `${base.replace(/\/$/, "")}${catalog.resolve(s).path ?? "/"}?scenario=${encodeURIComponent(s.name)}` : null
67
+ }));
68
+ if (values.json) console.log(JSON.stringify(rows, null, 2));
69
+ else {
70
+ for (const r of rows) {
71
+ console.log(`${r.name.padEnd(32)} ${r.description}${r.url ? `
72
+ ${" ".repeat(33)}${r.url}` : ""}`);
73
+ }
74
+ }
75
+ break;
76
+ }
77
+ case "shoot": {
78
+ const baseURL = values["base-url"];
79
+ if (!baseURL) throw new Error("shoot needs --base-url, e.g. http://localhost:5173");
80
+ const out = values.out ?? "scenario-shots";
81
+ const only = values.only?.split(",");
82
+ mkdirSync(out, { recursive: true });
83
+ const { chromium } = await import("@playwright/test");
84
+ const { openScenario, unhandledRequests } = await import("state-scenarios/playwright");
85
+ const browser = await chromium.launch();
86
+ const report = [];
87
+ for (const s of catalog.list.filter((s2) => !only || only.includes(s2.name))) {
88
+ const context = await browser.newContext({ baseURL });
89
+ const page = await context.newPage();
90
+ page.setDefaultTimeout(1e4);
91
+ const consoleErrors = [];
92
+ const pageErrors = [];
93
+ page.on("console", (m) => {
94
+ if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) {
95
+ consoleErrors.push(m.text());
96
+ }
97
+ });
98
+ page.on("pageerror", (err) => pageErrors.push(err.message));
99
+ let error = null;
100
+ try {
101
+ await openScenario(page, s.name, { path: catalog.resolve(s).path, strict: !values["no-strict"] });
102
+ } catch (err) {
103
+ error = err instanceof Error ? err.message.split("\n")[0] : String(err);
104
+ }
105
+ const screenshot = join(out, `${s.name}.png`);
106
+ await page.screenshot({ path: screenshot, fullPage: true });
107
+ const unhandled = await unhandledRequests(page);
108
+ const ok = !error && !unhandled.length && !consoleErrors.length && !pageErrors.length;
109
+ report.push({ name: s.name, ok, screenshot, unhandled, consoleErrors, pageErrors, error });
110
+ await context.close();
111
+ }
112
+ await browser.close();
113
+ if (values.json) console.log(JSON.stringify(report, null, 2));
114
+ else {
115
+ for (const r of report) {
116
+ console.log(`${r.ok ? "\u2713" : "\u2717"} ${r.name.padEnd(32)} ${r.screenshot}`);
117
+ if (r.error) console.log(` error: ${r.error}`);
118
+ for (const u of r.unhandled) console.log(` unmocked: ${u}`);
119
+ for (const e of r.pageErrors) console.log(` uncaught: ${e}`);
120
+ for (const e of r.consoleErrors) console.log(` console.error: ${e}`);
121
+ }
122
+ }
123
+ process.exit(report.every((r) => r.ok) ? 0 : 1);
124
+ }
125
+ case "suggest": {
126
+ if (!values.src) throw new Error("suggest needs --src, e.g. --src src/pages,src/components");
127
+ const sources = values.src.split(",").flatMap((d) => walk(d));
128
+ const size = sources.reduce((n, f) => n + f.content.length, 0);
129
+ if (size > MAX_SOURCE_CHARS) {
130
+ throw new Error(
131
+ `--src is ${size.toLocaleString()} characters; narrow it to the screens you want covered`
132
+ );
133
+ }
134
+ const jsonSchema = values.schema ? JSON.parse(readFileSync(values.schema, "utf8")) : scenarioJsonSchema();
135
+ if (values["env-file"]) process.loadEnvFile(values["env-file"]);
136
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
137
+ const model = values.model ?? process.env.STATE_SCENARIOS_MODEL ?? void 0;
138
+ const { suggestScenarios } = await import("./suggest.js");
139
+ console.error(
140
+ `Reading ${sources.length} files (${size.toLocaleString()} chars) and ${catalog.list.length} scenarios with ${model ?? "claude-opus-5-5"}\u2026`
141
+ );
142
+ const result = await suggestScenarios(
143
+ { sources, catalog, jsonSchema, max: Number(values.max ?? 8), model },
144
+ {
145
+ client: new Anthropic({
146
+ baseURL: process.env.ANTHROPIC_BASE_URL || void 0,
147
+ // Some Anthropic-compatible proxies require a session id and a user agent.
148
+ defaultHeaders: {
149
+ "x-opencode-session": `state-scenarios-${Date.now()}`,
150
+ "User-Agent": "state-scenarios/0.0 (suggest)"
151
+ }
152
+ })
153
+ }
154
+ );
155
+ console.log("\nCandidate UI states (not exhaustive)\n");
156
+ for (const s of result.states) {
157
+ const mark = s.covered_by ? "\u2713" : "\u2717";
158
+ console.log(
159
+ `${mark} ${`${s.area} / ${s.state}`.padEnd(56)} ${s.covered_by ?? `uncovered (${s.priority})`}`
160
+ );
161
+ }
162
+ const covered = result.states.filter((s) => s.covered_by).length;
163
+ console.log(
164
+ `
165
+ ${result.states.length} states detected, ${covered} have scenarios, ${result.states.length - covered} uncovered`
166
+ );
167
+ const out = values.out ?? join(dir, "proposed");
168
+ if (result.accepted.length) mkdirSync(out, { recursive: true });
169
+ for (const { scenario, covers } of result.accepted) {
170
+ const file = join(out, `${scenario.name}.json`);
171
+ const $schema = values.schema ? relative(out, values.schema) : void 0;
172
+ writeFileSync(file, `${JSON.stringify({ $schema, ...scenario }, null, 2)}
173
+ `);
174
+ console.log(`+ ${file} (${covers})`);
175
+ }
176
+ for (const r of result.rejected) console.log(`- rejected proposal for ${r.covers}: ${r.error}`);
177
+ if (result.accepted.length) {
178
+ console.log(`
179
+ Review the drafts, then move the ones you want into ${dir}.`);
180
+ }
181
+ break;
182
+ }
183
+ default:
184
+ console.error(`Unknown command "${command}"
185
+
186
+ ${HELP}`);
187
+ process.exit(1);
188
+ }
189
+ } catch (err) {
190
+ const message = err instanceof Error ? err.message : String(err);
191
+ const hint = /authentication/i.test(message) ? "\nSet ANTHROPIC_API_KEY, or run `ant auth login`." : "";
192
+ console.error(`state-scenarios ${command}: ${message}${hint}`);
193
+ process.exit(1);
194
+ }
195
+ function walk(path) {
196
+ if (statSync(path).isDirectory()) {
197
+ return readdirSync(path).filter((f) => !["node_modules", "dist", "scenarios"].includes(f)).flatMap((f) => walk(join(path, f)));
198
+ }
199
+ const isSource = /\.(tsx?|jsx?|vue|svelte)$/.test(path) && !/\.(d|test|spec)\.\w+$/.test(path);
200
+ return isSource ? [{ path, content: readFileSync(path, "utf8") }] : [];
201
+ }
202
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';\nimport { join, relative, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { createCatalog, scenarioJsonSchema, type ScenarioRule } from 'state-scenarios';\n\nconst HELP = `Usage: state-scenarios <command> <scenarios-dir> [options]\n\n validate Check every scenario parses, resolves and passes --rules\n list Print scenarios with links (--base-url) for people and agents; --json for machines\n shoot Open each scenario in Chromium, wait for its ready selector, screenshot it,\n and report requests it didn't mock. --base-url (required), --out, --only a,b, --no-strict\n suggest Ask Claude which UI states lack scenarios and draft them into --out (default <dir>/proposed).\n --src dir[,dir] (required), --schema file, --max n, --model (default $STATE_SCENARIOS_MODEL or claude-opus-5-5).\n Needs ANTHROPIC_API_KEY or \\`ant auth login\\`; ANTHROPIC_BASE_URL points it at a compatible proxy.\n --env-file file loads those variables first.\n\nCommon: --rules file module exporting \\`rules\\` (ScenarioRule[])`;\n\nconst MAX_SOURCE_CHARS = 800_000;\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n rules: { type: 'string' },\n 'base-url': { type: 'string' },\n out: { type: 'string' },\n only: { type: 'string' },\n 'no-strict': { type: 'boolean' },\n json: { type: 'boolean' },\n src: { type: 'string' },\n schema: { type: 'string' },\n max: { type: 'string' },\n model: { type: 'string' },\n 'env-file': { type: 'string' },\n help: { type: 'boolean', short: 'h' },\n },\n});\n\nconst [command, dir] = positionals;\n\nif (values.help || !command || !dir) {\n console.log(HELP);\n process.exit(command ? 1 : 0);\n}\n\ntry {\n const rules: ScenarioRule[] = values.rules\n ? (await import(pathToFileURL(resolve(values.rules)).href)).rules\n : [];\n\n const files = readdirSync(dir).filter((f) => f.endsWith('.json'));\n\n const catalog = createCatalog(\n files.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))),\n { rules },\n );\n\n switch (command) {\n case 'validate': {\n const problems = catalog.check();\n\n for (const p of problems) console.error(`✗ ${p}\\n`);\n console.log(`${catalog.list.length - problems.length}/${catalog.list.length} scenarios valid`);\n process.exit(problems.length ? 1 : 0);\n }\n\n case 'list': {\n const base = values['base-url'];\n\n const rows = catalog.list.map((s) => ({\n name: s.name,\n description: s.description ?? '',\n tags: s.tags ?? [],\n ready: s.ready ?? null,\n url: base\n ? `${base.replace(/\\/$/, '')}${catalog.resolve(s).path ?? '/'}?scenario=${\n encodeURIComponent(s.name)\n }`\n : null,\n }));\n\n if (values.json) console.log(JSON.stringify(rows, null, 2));\n else {for (const r of rows) {\n console.log(`${r.name.padEnd(32)} ${r.description}${r.url ? `\\n${' '.repeat(33)}${r.url}` : ''}`);\n }}\n\n break;\n }\n\n case 'shoot': {\n const baseURL = values['base-url'];\n\n if (!baseURL) throw new Error('shoot needs --base-url, e.g. http://localhost:5173');\n const out = values.out ?? 'scenario-shots';\n const only = values.only?.split(',');\n mkdirSync(out, { recursive: true });\n\n const { chromium } = await import('@playwright/test');\n const { openScenario, unhandledRequests } = await import('state-scenarios/playwright');\n const browser = await chromium.launch();\n const report = [];\n\n for (const s of catalog.list.filter((s) => !only || only.includes(s.name))) {\n const context = await browser.newContext({ baseURL });\n const page = await context.newPage();\n page.setDefaultTimeout(10_000);\n // Uncaught exceptions and console errors both fail the run. Browser network logs\n // (\"Failed to load resource\") are excluded: error scenarios cause them on purpose,\n // and requests the scenario didn't mock are reported as `unhandled` instead.\n const consoleErrors: string[] = [];\n const pageErrors: string[] = [];\n page.on('console', (m) => {\n if (m.type() === 'error' && !m.text().startsWith('Failed to load resource')) {\n consoleErrors.push(m.text());\n }\n });\n page.on('pageerror', (err) => pageErrors.push(err.message));\n let error: string | null = null;\n\n try {\n await openScenario(page, s.name, { path: catalog.resolve(s).path, strict: !values['no-strict'] });\n } catch (err) {\n error = err instanceof Error ? err.message.split('\\n')[0] : String(err);\n }\n\n const screenshot = join(out, `${s.name}.png`);\n await page.screenshot({ path: screenshot, fullPage: true });\n const unhandled = await unhandledRequests(page);\n const ok = !error && !unhandled.length && !consoleErrors.length && !pageErrors.length;\n report.push({ name: s.name, ok, screenshot, unhandled, consoleErrors, pageErrors, error });\n await context.close();\n }\n\n await browser.close();\n\n if (values.json) console.log(JSON.stringify(report, null, 2));\n else {\n for (const r of report) {\n console.log(`${r.ok ? '✓' : '✗'} ${r.name.padEnd(32)} ${r.screenshot}`);\n\n if (r.error) console.log(` error: ${r.error}`);\n\n for (const u of r.unhandled) console.log(` unmocked: ${u}`);\n\n for (const e of r.pageErrors) console.log(` uncaught: ${e}`);\n\n for (const e of r.consoleErrors) console.log(` console.error: ${e}`);\n }\n }\n\n process.exit(report.every((r) => r.ok) ? 0 : 1);\n }\n\n case 'suggest': {\n if (!values.src) throw new Error('suggest needs --src, e.g. --src src/pages,src/components');\n const sources = values.src.split(',').flatMap((d) => walk(d));\n const size = sources.reduce((n, f) => n + f.content.length, 0);\n\n if (size > MAX_SOURCE_CHARS) {\n throw new Error(\n `--src is ${size.toLocaleString()} characters; narrow it to the screens you want covered`,\n );\n }\n\n const jsonSchema = values.schema\n ? JSON.parse(readFileSync(values.schema, 'utf8'))\n : scenarioJsonSchema();\n\n if (values['env-file']) process.loadEnvFile(values['env-file']);\n const { default: Anthropic } = await import('@anthropic-ai/sdk');\n const model = values.model ?? process.env.STATE_SCENARIOS_MODEL ?? undefined;\n const { suggestScenarios } = await import('./suggest.js');\n console.error(\n `Reading ${sources.length} files (${size.toLocaleString()} chars) and ${catalog.list.length} scenarios with ${\n model ?? 'claude-opus-5-5'\n }…`,\n );\n\n const result = await suggestScenarios(\n { sources, catalog, jsonSchema, max: Number(values.max ?? 8), model },\n {\n client: new Anthropic({\n baseURL: process.env.ANTHROPIC_BASE_URL || undefined,\n // Some Anthropic-compatible proxies require a session id and a user agent.\n defaultHeaders: {\n 'x-opencode-session': `state-scenarios-${Date.now()}`,\n 'User-Agent': 'state-scenarios/0.0 (suggest)',\n },\n }),\n },\n );\n\n console.log('\\nCandidate UI states (not exhaustive)\\n');\n\n for (const s of result.states) {\n const mark = s.covered_by ? '✓' : '✗';\n console.log(\n `${mark} ${`${s.area} / ${s.state}`.padEnd(56)} ${s.covered_by ?? `uncovered (${s.priority})`}`,\n );\n }\n\n const covered = result.states.filter((s) => s.covered_by).length;\n console.log(\n `\\n${result.states.length} states detected, ${covered} have scenarios, ${\n result.states.length - covered\n } uncovered`,\n );\n\n const out = values.out ?? join(dir, 'proposed');\n\n if (result.accepted.length) mkdirSync(out, { recursive: true });\n\n for (const { scenario, covers } of result.accepted) {\n const file = join(out, `${scenario.name}.json`);\n const $schema = values.schema ? relative(out, values.schema) : undefined;\n writeFileSync(file, `${JSON.stringify({ $schema, ...scenario }, null, 2)}\\n`);\n console.log(`+ ${file} (${covers})`);\n }\n\n for (const r of result.rejected) console.log(`- rejected proposal for ${r.covers}: ${r.error}`);\n\n if (result.accepted.length) {\n console.log(`\\nReview the drafts, then move the ones you want into ${dir}.`);\n }\n\n break;\n }\n\n default:\n console.error(`Unknown command \"${command}\"\\n\\n${HELP}`);\n process.exit(1);\n }\n} catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const hint = /authentication/i.test(message) ? '\\nSet ANTHROPIC_API_KEY, or run `ant auth login`.' : '';\n console.error(`state-scenarios ${command}: ${message}${hint}`);\n process.exit(1);\n}\n\nfunction walk(path: string): { path: string; content: string; }[] {\n if (statSync(path).isDirectory()) {\n return readdirSync(path)\n .filter((f) => !['node_modules', 'dist', 'scenarios'].includes(f))\n .flatMap((f) => walk(join(path, f)));\n }\n\n const isSource = /\\.(tsx?|jsx?|vue|svelte)$/.test(path) && !/\\.(d|test|spec)\\.\\w+$/.test(path);\n\n return isSource ? [{ path, content: readFileSync(path, 'utf8') }] : [];\n}\n"],"mappings":"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","names":["s"]}
@@ -0,0 +1,97 @@
1
+ import type { MessageCreateParamsNonStreaming } from '@anthropic-ai/sdk/resources/beta/messages';
2
+ import { type Catalog, type Scenario } from 'state-scenarios';
3
+ import { z } from 'zod';
4
+ export declare const DEFAULT_MODEL = "claude-opus-5-5";
5
+ export declare const SuggestionSchema: z.ZodObject<{
6
+ states: z.ZodArray<z.ZodObject<{
7
+ area: z.ZodString;
8
+ state: z.ZodString;
9
+ evidence: z.ZodString;
10
+ covered_by: z.ZodNullable<z.ZodString>;
11
+ priority: z.ZodEnum<{
12
+ high: "high";
13
+ low: "low";
14
+ medium: "medium";
15
+ }>;
16
+ }, z.core.$strip>>;
17
+ proposals: z.ZodArray<z.ZodObject<{
18
+ scenario_json: z.ZodString;
19
+ covers: z.ZodString;
20
+ rationale: z.ZodString;
21
+ }, z.core.$strip>>;
22
+ }, z.core.$strip>;
23
+ export type Suggestion = z.output<typeof SuggestionSchema>;
24
+ export interface SourceFile {
25
+ path: string;
26
+ content: string;
27
+ }
28
+ /** The one SDK call suggest makes. The Anthropic client satisfies it; tests can fake it. */
29
+ export interface SuggestClient {
30
+ beta: {
31
+ messages: {
32
+ parse(params: MessageCreateParamsNonStreaming): PromiseLike<SuggestReply>;
33
+ };
34
+ };
35
+ }
36
+ interface SuggestReply {
37
+ stop_reason: string | null;
38
+ stop_details?: {
39
+ explanation?: string | null;
40
+ } | null;
41
+ /** Decoded with SuggestionSchema either way; structured output only makes it likelier to pass. */
42
+ parsed_output?: unknown;
43
+ content: ReadonlyArray<{
44
+ type: string;
45
+ text?: string;
46
+ }>;
47
+ }
48
+ export interface SuggestResult {
49
+ states: Suggestion['states'];
50
+ accepted: {
51
+ scenario: Scenario;
52
+ covers: string;
53
+ rationale: string;
54
+ }[];
55
+ rejected: {
56
+ json: string;
57
+ covers: string;
58
+ error: string;
59
+ }[];
60
+ }
61
+ /**
62
+ * Ask Claude which UI states the code can produce and which scenarios are missing,
63
+ * then keep only proposals that validate, resolve and pass the catalog's rules.
64
+ */
65
+ export declare function suggestScenarios({ sources, catalog, jsonSchema, max, model, }: {
66
+ sources: SourceFile[];
67
+ catalog: Catalog;
68
+ jsonSchema: object;
69
+ max?: number;
70
+ model?: string;
71
+ }, { client }: {
72
+ client: SuggestClient;
73
+ }): Promise<SuggestResult>;
74
+ /** What the model needs to know about a scenario, without full fixture bodies. */
75
+ export declare function summarize(s: Scenario): {
76
+ name: string;
77
+ description: string | undefined;
78
+ tags: string[] | undefined;
79
+ extends: string | string[] | undefined;
80
+ url: Record<string, string | null> | undefined;
81
+ ready: string | undefined;
82
+ state: string[] | undefined;
83
+ network: {
84
+ method: "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT";
85
+ path: string;
86
+ query: Record<string, string> | undefined;
87
+ remove: true | undefined;
88
+ responses: {
89
+ status: number;
90
+ delay: number | "infinite" | undefined;
91
+ body: string | number | boolean | {
92
+ [key: string]: z.JSONType;
93
+ } | null | undefined;
94
+ }[];
95
+ }[] | undefined;
96
+ };
97
+ export {};
@@ -0,0 +1,140 @@
1
+ // src/suggest.ts
2
+ import { BadRequestError } from "@anthropic-ai/sdk";
3
+ import { betaZodOutputFormat } from "@anthropic-ai/sdk/helpers/beta/zod";
4
+ import Ajv2020 from "ajv/dist/2020.js";
5
+ import { parseScenario } from "state-scenarios";
6
+ import { z } from "zod";
7
+ var DEFAULT_MODEL = "claude-opus-5-5";
8
+ var SuggestionSchema = z.object({
9
+ states: z.array(
10
+ z.object({
11
+ area: z.string().describe('Screen or component, e.g. "country list"'),
12
+ state: z.string().describe('The observable state, e.g. "empty after filtering"'),
13
+ evidence: z.string().describe("file:line, or the code that produces this state"),
14
+ covered_by: z.string().nullable().describe("Name of an existing scenario that shows it, or null"),
15
+ priority: z.enum(["high", "medium", "low"])
16
+ })
17
+ ),
18
+ proposals: z.array(
19
+ z.object({
20
+ scenario_json: z.string().describe("One complete scenario, as a JSON string"),
21
+ covers: z.string().describe('Which detected state this shows, as "area / state"'),
22
+ rationale: z.string()
23
+ })
24
+ )
25
+ });
26
+ var SYSTEM = `You find user-visible UI states in a web app's source code and map them to scenarios. A scenario is a JSON file that puts the running app into a state by mocking API responses, setting URL params, and seeding client state.
27
+
28
+ Report candidate observable states: loading, empty, error, recovery, partial or boundary data (pagination edges, long text, missing optional fields), permission or flag variants, and combinations of these. You will not find every state, and the report should not imply you did; prioritise states a real user could plausibly hit. For each state, cite evidence from the code, and name the existing scenario that already shows it, or null.
29
+
30
+ Then propose scenarios for the most valuable uncovered states. Each proposal must validate against the provided JSON Schema. It should "extends" an existing scenario rather than repeat that scenario's fixtures, mock only endpoints the code calls, and set "ready" to a Playwright selector for text the UI renders in that state, taken from the source. Use new kebab-case names.`;
31
+ async function suggestScenarios({
32
+ sources,
33
+ catalog,
34
+ jsonSchema,
35
+ max = 8,
36
+ model = DEFAULT_MODEL
37
+ }, { client }) {
38
+ const prompt = [
39
+ `<scenario_json_schema>
40
+ ${JSON.stringify(jsonSchema, null, 2)}
41
+ </scenario_json_schema>`,
42
+ `<existing_scenarios>
43
+ ${JSON.stringify(catalog.list.map(summarize), null, 2)}
44
+ </existing_scenarios>`,
45
+ ...sources.map((f) => `<source path="${f.path}">
46
+ ${f.content}
47
+ </source>`),
48
+ `List the candidate UI states, then propose at most ${max} new scenarios for the most valuable uncovered ones.`,
49
+ `Reply with only a JSON object matching this schema:
50
+ ${JSON.stringify(z.toJSONSchema(SuggestionSchema))}`
51
+ ].join("\n\n");
52
+ const isClaude = model.startsWith("claude-");
53
+ const params = {
54
+ model,
55
+ max_tokens: 16e3,
56
+ system: SYSTEM,
57
+ messages: [{ role: "user", content: prompt }],
58
+ // Claude only. Server-side fallback: if a safety classifier declines, the API retries on another model.
59
+ ...isClaude && {
60
+ betas: ["server-side-fallback-2026-07-01"],
61
+ fallbacks: "default",
62
+ output_config: { effort: "high" }
63
+ }
64
+ };
65
+ let response;
66
+ try {
67
+ response = await client.beta.messages.parse({
68
+ ...params,
69
+ output_config: { ...params.output_config, format: betaZodOutputFormat(SuggestionSchema) }
70
+ });
71
+ } catch (err) {
72
+ if (!(err instanceof BadRequestError)) throw err;
73
+ response = await client.beta.messages.parse(params);
74
+ }
75
+ if (response.stop_reason === "refusal") {
76
+ throw new Error(`${model} declined: ${response.stop_details?.explanation ?? "no explanation given"}`);
77
+ }
78
+ if (response.stop_reason === "max_tokens") {
79
+ throw new Error("Response hit max_tokens; narrow --src or lower --max");
80
+ }
81
+ const output = parseSuggestion(response);
82
+ const matchesSchema = new Ajv2020({ strict: false }).compile(jsonSchema);
83
+ const result = { states: output.states, accepted: [], rejected: [] };
84
+ const taken = new Set(catalog.list.map((s) => s.name));
85
+ for (const { scenario_json: json, covers, rationale } of output.proposals) {
86
+ try {
87
+ const data = JSON.parse(json);
88
+ if (!matchesSchema(data)) {
89
+ throw new Error(`Does not match the JSON Schema: ${new Ajv2020().errorsText(matchesSchema.errors)}`);
90
+ }
91
+ const scenario = parseScenario(data, "proposal");
92
+ if (taken.has(scenario.name)) throw new Error(`Name "${scenario.name}" is already taken`);
93
+ catalog.resolve(scenario);
94
+ taken.add(scenario.name);
95
+ result.accepted.push({ scenario, covers, rationale });
96
+ } catch (err) {
97
+ result.rejected.push({ json, covers, error: err instanceof Error ? err.message : String(err) });
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+ function parseSuggestion(reply) {
103
+ const text = reply.content.map((block) => block.type === "text" ? block.text : "").join("");
104
+ const json = text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
105
+ const result = SuggestionSchema.safeParse(reply.parsed_output ?? (json ? JSON.parse(json) : null));
106
+ if (!result.success) {
107
+ throw new Error(`Reply does not match the suggestion schema:
108
+ ${z.prettifyError(result.error)}`);
109
+ }
110
+ return result.data;
111
+ }
112
+ function summarize(s) {
113
+ return {
114
+ name: s.name,
115
+ description: s.description,
116
+ tags: s.tags,
117
+ extends: s.extends,
118
+ url: s.url,
119
+ ready: s.ready,
120
+ state: s.state && Object.keys(s.state),
121
+ network: s.network?.map((e) => ({
122
+ method: e.method,
123
+ path: e.path,
124
+ query: e.query,
125
+ remove: e.remove,
126
+ responses: (e.sequence ?? (e.response ? [e.response] : [])).map((r) => ({
127
+ status: r.status,
128
+ delay: r.delay,
129
+ body: Array.isArray(r.body) ? { items: r.body.length, first: r.body[0] } : r.body
130
+ }))
131
+ }))
132
+ };
133
+ }
134
+ export {
135
+ DEFAULT_MODEL,
136
+ SuggestionSchema,
137
+ suggestScenarios,
138
+ summarize
139
+ };
140
+ //# sourceMappingURL=suggest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/suggest.ts"],"sourcesContent":["import { BadRequestError } from '@anthropic-ai/sdk';\nimport { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod';\nimport type { MessageCreateParamsNonStreaming } from '@anthropic-ai/sdk/resources/beta/messages';\nimport Ajv2020 from 'ajv/dist/2020.js';\nimport { type Catalog, parseScenario, type Scenario, type ScenarioInput } from 'state-scenarios';\nimport { z } from 'zod';\n\nexport const DEFAULT_MODEL = 'claude-opus-5-5';\n\nexport const SuggestionSchema = z.object({\n states: z.array(\n z.object({\n area: z.string().describe('Screen or component, e.g. \"country list\"'),\n state: z.string().describe('The observable state, e.g. \"empty after filtering\"'),\n evidence: z.string().describe('file:line, or the code that produces this state'),\n covered_by: z.string().nullable().describe('Name of an existing scenario that shows it, or null'),\n priority: z.enum(['high', 'medium', 'low']),\n }),\n ),\n proposals: z.array(\n z.object({\n scenario_json: z.string().describe('One complete scenario, as a JSON string'),\n covers: z.string().describe('Which detected state this shows, as \"area / state\"'),\n rationale: z.string(),\n }),\n ),\n});\n\nexport type Suggestion = z.output<typeof SuggestionSchema>;\n\nexport interface SourceFile {\n path: string;\n content: string;\n}\n\n/** The one SDK call suggest makes. The Anthropic client satisfies it; tests can fake it. */\nexport interface SuggestClient {\n beta: { messages: { parse(params: MessageCreateParamsNonStreaming): PromiseLike<SuggestReply>; }; };\n}\n\ninterface SuggestReply {\n stop_reason: string | null;\n stop_details?: { explanation?: string | null; } | null;\n /** Decoded with SuggestionSchema either way; structured output only makes it likelier to pass. */\n parsed_output?: unknown;\n content: ReadonlyArray<{ type: string; text?: string; }>;\n}\n\nexport interface SuggestResult {\n states: Suggestion['states'];\n accepted: { scenario: Scenario; covers: string; rationale: string; }[];\n rejected: { json: string; covers: string; error: string; }[];\n}\n\nconst SYSTEM =\n `You find user-visible UI states in a web app's source code and map them to scenarios. A scenario is a JSON file that puts the running app into a state by mocking API responses, setting URL params, and seeding client state.\n\nReport candidate observable states: loading, empty, error, recovery, partial or boundary data (pagination edges, long text, missing optional fields), permission or flag variants, and combinations of these. You will not find every state, and the report should not imply you did; prioritise states a real user could plausibly hit. For each state, cite evidence from the code, and name the existing scenario that already shows it, or null.\n\nThen propose scenarios for the most valuable uncovered states. Each proposal must validate against the provided JSON Schema. It should \"extends\" an existing scenario rather than repeat that scenario's fixtures, mock only endpoints the code calls, and set \"ready\" to a Playwright selector for text the UI renders in that state, taken from the source. Use new kebab-case names.`;\n\n/**\n * Ask Claude which UI states the code can produce and which scenarios are missing,\n * then keep only proposals that validate, resolve and pass the catalog's rules.\n */\nexport async function suggestScenarios(\n {\n sources,\n catalog,\n jsonSchema,\n max = 8,\n model = DEFAULT_MODEL,\n }: { sources: SourceFile[]; catalog: Catalog; jsonSchema: object; max?: number; model?: string; },\n { client }: { client: SuggestClient; },\n): Promise<SuggestResult> {\n const prompt = [\n `<scenario_json_schema>\\n${JSON.stringify(jsonSchema, null, 2)}\\n</scenario_json_schema>`,\n `<existing_scenarios>\\n${JSON.stringify(catalog.list.map(summarize), null, 2)}\\n</existing_scenarios>`,\n ...sources.map((f) => `<source path=\"${f.path}\">\\n${f.content}\\n</source>`),\n `List the candidate UI states, then propose at most ${max} new scenarios for the most valuable uncovered ones.`,\n `Reply with only a JSON object matching this schema:\\n${\n JSON.stringify(z.toJSONSchema(SuggestionSchema))\n }`,\n ].join('\\n\\n');\n\n const isClaude = model.startsWith('claude-');\n\n const params = {\n model,\n max_tokens: 16000,\n system: SYSTEM,\n messages: [{ role: 'user' as const, content: prompt }],\n // Claude only. Server-side fallback: if a safety classifier declines, the API retries on another model.\n ...(isClaude && {\n betas: ['server-side-fallback-2026-07-01'],\n fallbacks: 'default' as const,\n output_config: { effort: 'high' as const },\n }),\n };\n\n let response;\n\n try {\n response = await client.beta.messages.parse({\n ...params,\n output_config: { ...params.output_config, format: betaZodOutputFormat(SuggestionSchema) },\n });\n } catch (err) {\n // Anthropic-compatible proxies serving other models don't all support structured\n // outputs; the prompt asks for JSON anyway, so retry and parse the text.\n if (!(err instanceof BadRequestError)) throw err;\n response = await client.beta.messages.parse(params);\n }\n\n if (response.stop_reason === 'refusal') {\n throw new Error(`${model} declined: ${response.stop_details?.explanation ?? 'no explanation given'}`);\n }\n\n if (response.stop_reason === 'max_tokens') {\n throw new Error('Response hit max_tokens; narrow --src or lower --max');\n }\n\n const output = parseSuggestion(response);\n\n // The supplied schema can be stricter than the built-in one (e.g. adapter state shapes).\n const matchesSchema = new Ajv2020({ strict: false }).compile<ScenarioInput>(jsonSchema);\n const result: SuggestResult = { states: output.states, accepted: [], rejected: [] };\n const taken = new Set(catalog.list.map((s) => s.name));\n\n for (const { scenario_json: json, covers, rationale } of output.proposals) {\n try {\n const data = JSON.parse(json);\n\n if (!matchesSchema(data)) {\n throw new Error(`Does not match the JSON Schema: ${new Ajv2020().errorsText(matchesSchema.errors)}`);\n }\n\n const scenario = parseScenario(data, 'proposal');\n\n if (taken.has(scenario.name)) throw new Error(`Name \"${scenario.name}\" is already taken`);\n catalog.resolve(scenario); // parents exist, rules pass\n taken.add(scenario.name);\n result.accepted.push({ scenario, covers, rationale });\n } catch (err) {\n result.rejected.push({ json, covers, error: err instanceof Error ? err.message : String(err) });\n }\n }\n\n return result;\n}\n\n/** Decode the reply: structured output when the API gave it, else JSON pulled out of the text. */\nfunction parseSuggestion(reply: SuggestReply): Suggestion {\n const text = reply.content.map((block) => (block.type === 'text' ? block.text : '')).join('');\n const json = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1);\n const result = SuggestionSchema.safeParse(reply.parsed_output ?? (json ? JSON.parse(json) : null));\n\n if (!result.success) {\n throw new Error(`Reply does not match the suggestion schema:\\n${z.prettifyError(result.error)}`);\n }\n\n return result.data;\n}\n\n/** What the model needs to know about a scenario, without full fixture bodies. */\nexport function summarize(s: Scenario) {\n return {\n name: s.name,\n description: s.description,\n tags: s.tags,\n extends: s.extends,\n url: s.url,\n ready: s.ready,\n state: s.state && Object.keys(s.state),\n network: s.network?.map((e) => ({\n method: e.method,\n path: e.path,\n query: e.query,\n remove: e.remove,\n responses: (e.sequence ?? (e.response ? [e.response] : [])).map((r) => ({\n status: r.status,\n delay: r.delay,\n body: Array.isArray(r.body) ? { items: r.body.length, first: r.body[0] } : r.body,\n })),\n })),\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "state-scenarios-cli",
3
- "version": "0.0.1",
4
- "description": "Name placeholder",
5
- "license": "MIT"
3
+ "version": "0.2.0",
4
+ "description": "state-scenarios CLI: validate, list, screenshot (shoot) and AI-suggest scenarios.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jagreehal/state-scenarios",
9
+ "directory": "packages/state-scenarios-cli"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "bin": {
16
+ "state-scenarios": "./dist/cli.js"
17
+ },
18
+ "exports": {
19
+ "./suggest": {
20
+ "types": "./dist/suggest.d.ts",
21
+ "source": "./src/suggest.ts",
22
+ "import": "./dist/suggest.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@anthropic-ai/sdk": "^0.131.0",
30
+ "ajv": "^8.20.0",
31
+ "state-scenarios": "^0.2.0",
32
+ "zod": "4.6.5"
33
+ },
34
+ "peerDependencies": {
35
+ "@playwright/test": ">=1.40"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@playwright/test": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@playwright/test": "1.63.0",
44
+ "@types/node": "^24.0.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "7.0.2"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup && tsc -p tsconfig.json --noEmit false --declaration --emitDeclarationOnly --outDir dist",
50
+ "dev": "tsup --watch",
51
+ "typecheck": "tsc --noEmit"
52
+ }
6
53
  }