@asrunjs/asrun 0.2.0 → 0.5.3

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 (103) hide show
  1. package/.claude/commands/explore-case.md +18 -15
  2. package/.claude/commands/gen-spec.md +16 -13
  3. package/.claude/commands/heal-spec.md +26 -23
  4. package/.claude/commands/make-case.md +11 -8
  5. package/.claude/skills/exploring-cases/SKILL.md +146 -132
  6. package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
  7. package/.claude/skills/exploring-cases/references/readiness.md +146 -137
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
  9. package/.claude/skills/generating-specs/SKILL.md +219 -207
  10. package/.claude/skills/generating-specs/references/conventions.md +148 -148
  11. package/.claude/skills/making-cases/SKILL.md +72 -64
  12. package/.claude/skills/making-cases/references/normalizing.md +39 -38
  13. package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
  14. package/AGENTS.md +169 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +221 -410
  17. package/dist/lib/build-artifacts.d.ts +8 -0
  18. package/dist/lib/build-artifacts.js +10 -0
  19. package/dist/lib/case-doc.d.ts +311 -0
  20. package/dist/lib/case-doc.js +1117 -0
  21. package/dist/lib/cases.d.ts +2 -15
  22. package/dist/lib/cases.js +5 -78
  23. package/dist/lib/command.d.ts +24 -0
  24. package/dist/lib/command.js +90 -0
  25. package/dist/lib/fixtures.js +2 -2
  26. package/dist/lib/login-card.d.ts +22 -0
  27. package/dist/lib/login-card.js +183 -0
  28. package/dist/lib/node-cli.d.ts +6 -2
  29. package/dist/lib/node-cli.js +16 -6
  30. package/dist/lib/profiles.d.ts +25 -1
  31. package/dist/lib/profiles.js +54 -1
  32. package/dist/lib/run-filters.js +10 -2
  33. package/dist/lib/session.d.ts +13 -1
  34. package/dist/lib/session.js +73 -7
  35. package/dist/lib/targets.d.ts +5 -13
  36. package/dist/lib/targets.js +4 -29
  37. package/dist/lib/values.js +2 -2
  38. package/dist/scripts/build.js +2 -1
  39. package/dist/scripts/check-cases.js +74 -6
  40. package/dist/scripts/doctor.js +115 -4
  41. package/dist/scripts/explore.js +22 -1
  42. package/dist/scripts/go.mjs +188 -188
  43. package/dist/scripts/init.d.ts +7 -1
  44. package/dist/scripts/init.js +62 -14
  45. package/dist/scripts/lint-specs.js +4 -1
  46. package/dist/scripts/login.js +30 -2
  47. package/dist/ui/server/actions.d.ts +4 -0
  48. package/dist/ui/server/actions.js +25 -12
  49. package/dist/ui/server/ai-models.d.ts +3 -0
  50. package/dist/ui/server/ai-models.js +347 -0
  51. package/dist/ui/server/ai-stream.d.ts +34 -9
  52. package/dist/ui/server/ai-stream.js +283 -77
  53. package/dist/ui/server/app.js +74 -12
  54. package/dist/ui/server/jobs.d.ts +7 -1
  55. package/dist/ui/server/jobs.js +69 -15
  56. package/dist/ui/server/profiles.d.ts +20 -1
  57. package/dist/ui/server/profiles.js +214 -38
  58. package/dist/ui/shared/ai-commands.d.ts +16 -6
  59. package/dist/ui/shared/ai-commands.js +35 -12
  60. package/dist/ui/shared/types.d.ts +114 -0
  61. package/docs/agents/case-format.md +193 -0
  62. package/docs/agents/framework-dev.md +167 -0
  63. package/docs/ai-workflow.md +235 -0
  64. package/docs/cases.md +189 -0
  65. package/docs/commands.md +175 -0
  66. package/docs/install.md +174 -0
  67. package/docs/profiles.md +139 -0
  68. package/docs/theme-guide.html +922 -692
  69. package/docs/troubleshooting.md +101 -0
  70. package/docs/web-ui.md +139 -0
  71. package/package.json +20 -1
  72. package/templates/.claude/commands/explore-case.md +22 -21
  73. package/templates/.claude/commands/gen-spec.md +21 -20
  74. package/templates/.claude/commands/heal-spec.md +31 -30
  75. package/templates/.claude/commands/make-case.md +13 -12
  76. package/templates/.mcp.json +8 -8
  77. package/templates/AGENTS.md +66 -45
  78. package/templates/cases/_template.md +57 -63
  79. package/templates/opencode.jsonc +16 -11
  80. package/templates/profiles.yaml +42 -29
  81. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
  82. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
  83. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
  84. package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
  85. package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
  86. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
  87. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
  88. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
  89. package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
  90. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
  91. package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
  92. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  93. package/ui/dist/help/ai-tools.png +0 -0
  94. package/ui/dist/help/case-explore.png +0 -0
  95. package/ui/dist/help/case-generate.png +0 -0
  96. package/ui/dist/help/cases-memo.png +0 -0
  97. package/ui/dist/help/profile-form.png +0 -0
  98. package/ui/dist/help/profiles-login.png +0 -0
  99. package/ui/dist/help/reports.png +0 -0
  100. package/ui/dist/help/run-tests.png +0 -0
  101. package/ui/dist/index.html +22 -16
  102. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  103. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -1,188 +1,188 @@
1
- // One-liner for a fresh machine: install everything, prepare the workspace, then start the web UI and open it.
2
- // Plain Node on purpose: it may run before the dependencies are installed.
3
- //
4
- // pnpm add -D @asrunjs/asrun (or github:wizcas/asrun#v0.1.0)
5
- // pnpm exec asrun init
6
- // pnpm go
7
- import {spawn, spawnSync} from 'node:child_process';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
-
11
- const FRAMEWORK = path.resolve(import.meta.dirname, '..');
12
- const PACKAGE = '@asrunjs/asrun';
13
- const MARKERS = ['.asrun.json', 'profiles.yaml'];
14
- const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
15
- const url = `http://127.0.0.1:${port}`;
16
- const openBrowser = !process.argv.includes('--no-open');
17
- const shell = process.platform === 'win32';
18
-
19
- // `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
20
- function loadRootEnv(file) {
21
- try {
22
- for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
23
- if (process.env.ASRUN_ROOT !== undefined) return;
24
- const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
25
- if (match) {
26
- process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
27
- }
28
- }
29
- } catch {
30
- // no .env: nothing to do
31
- }
32
- }
33
- loadRootEnv(path.join(process.cwd(), '.env'));
34
- if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
35
-
36
- // The workspace: the project that owns profiles.yaml, cases/ and tests/. Inside the framework repository
37
- // (which has none of those) the demo workspace is used, so the framework itself can be developed this way.
38
- function findWorkspace(from) {
39
- for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
40
- if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
41
- if (path.dirname(dir) === dir) return null;
42
- }
43
- }
44
- const demo = path.join(FRAMEWORK, 'examples', 'demo');
45
- const ROOT = (process.env.ASRUN_ROOT && path.resolve(process.env.ASRUN_ROOT)) || findWorkspace(process.cwd()) || demo;
46
- if (ROOT === FRAMEWORK && !fs.existsSync(demo)) {
47
- console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
48
- process.exit(1);
49
- }
50
-
51
- const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
52
- const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
53
- const fail = (text) => {
54
- console.error(`\x1b[31m${text}\x1b[0m`);
55
- process.exit(1);
56
- };
57
- const run = (command, args, options = {}) =>
58
- spawnSync(command, args, {cwd: ROOT, stdio: 'inherit', shell, ...options}).status === 0;
59
- const exists = (command) => spawnSync(command, ['--version'], {stdio: 'ignore', shell}).status === 0;
60
-
61
- /** The JavaScript entry point of a package's command-line tool, without relying on node_modules/.bin shims. */
62
- function packageBin(name, command = name) {
63
- for (const base of [FRAMEWORK, path.dirname(path.dirname(FRAMEWORK)), ROOT]) {
64
- const dir = path.join(base, 'node_modules', name);
65
- const manifest = path.join(dir, 'package.json');
66
- if (!fs.existsSync(manifest)) continue;
67
- const bin = JSON.parse(fs.readFileSync(manifest, 'utf8')).bin;
68
- const entry = typeof bin === 'string' ? bin : bin?.[command];
69
- if (entry) return path.join(dir, entry);
70
- }
71
- return null;
72
- }
73
-
74
- /** The installed command of this workspace, or the framework's own bin while developing it here. */
75
- const bin = packageBin(PACKAGE) ?? path.join(FRAMEWORK, 'bin', 'asrun.mjs');
76
- /** The workspace's copy of the package, if any. */
77
- const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
78
- const frameworkRelative = installed
79
- ? './' + path.relative(ROOT, path.join(ROOT, 'node_modules', PACKAGE)).split(path.sep).join('/')
80
- : (() => {
81
- const relative = path.relative(ROOT, FRAMEWORK).split(path.sep).join('/');
82
- return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
83
- })();
84
-
85
- /** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
86
- function ensurePnpmWorkspace() {
87
- if (frameworkRelative.startsWith('..')) return 'skipped';
88
- const file = path.join(ROOT, 'pnpm-workspace.yaml');
89
- const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
90
- if (!fs.existsSync(file)) {
91
- fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
92
- return 'created';
93
- }
94
- const text = fs.readFileSync(file, 'utf8');
95
- if (text.includes('allowBuilds')) return 'kept';
96
- fs.writeFileSync(file, `${text.replace(/\n*$/, '\n')}\n${block}`);
97
- return 'updated';
98
- }
99
-
100
- async function uiIsUp() {
101
- try {
102
- return (await fetch(`${url}/api/info`, {signal: AbortSignal.timeout(1500)})).ok;
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
- function open(target) {
109
- const [command, args] =
110
- process.platform === 'darwin'
111
- ? ['open', [target]]
112
- : process.platform === 'win32'
113
- ? ['cmd', ['/c', 'start', '', target]]
114
- : ['xdg-open', [target]];
115
- spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
116
- }
117
-
118
- // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
119
- function ensureFfmpeg() {
120
- // ffmpeg does not know `--version`, only `-version`.
121
- if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
122
- const installer =
123
- process.platform === 'darwin' && exists('brew')
124
- ? ['brew', ['install', 'ffmpeg']]
125
- : process.platform === 'win32' && exists('winget')
126
- ? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
127
- : null;
128
- if (!installer) {
129
- return warn('ffmpeg not found; the bundled ffmpeg-static is used instead. To install it yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
130
- }
131
- console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
132
- if (run(...installer)) console.log('ffmpeg: installed');
133
- else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
134
- }
135
-
136
- step(1, 'Checking this machine');
137
- const nodeMajor = Number(process.versions.node.split('.')[0]);
138
- if (nodeMajor < 22) fail(`Node 22 or newer is required (this is ${process.versions.node}). Install it from https://nodejs.org and run "pnpm go" again.`);
139
- console.log(`Node ${process.versions.node}: ok`);
140
- console.log(`workspace: ${ROOT}`);
141
- console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
142
- ensureFfmpeg();
143
- if (!exists('claude') && !exists('opencode')) {
144
- warn('Neither "claude" (Claude Code) nor "opencode" found. Everything works except the AI steps (make cases, review, generate specs).');
145
- }
146
-
147
- step(2, 'Installing dependencies and the browser (a minute the first time, seconds after that)');
148
- const workspaceFile = ensurePnpmWorkspace();
149
- if (workspaceFile === 'created') console.log('pnpm-workspace.yaml: created (allowBuilds)');
150
- else if (workspaceFile === 'updated') console.log('pnpm-workspace.yaml: updated (allowBuilds)');
151
- const installRoot = frameworkRelative.startsWith('..') ? FRAMEWORK : ROOT;
152
- if (!run('pnpm', ['install'], {cwd: installRoot})) fail('Install failed. Fix the error above and run "pnpm go" again.');
153
- const playwright = packageBin('@playwright/test', 'playwright');
154
- if (playwright) {
155
- if (!run(process.execPath, [playwright, 'install', 'chromium'])) warn('Could not install the Chromium browser; run "pnpm exec playwright install chromium".');
156
- } else {
157
- warn('@playwright/test is not installed yet. Run this command again after the workspace files are in place.');
158
- }
159
-
160
- step(3, 'Preparing the workspace');
161
- // `--root` names the workspace, but the command runs where the person is: that is where `.env` belongs.
162
- if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()})) {
163
- fail('Could not initialize the workspace. Run "pnpm asrun init" and read the message.');
164
- }
165
- run(process.execPath, [bin, 'doctor']);
166
-
167
- step(4, `Starting the web UI at ${url}`);
168
- if (await uiIsUp()) {
169
- console.log('The UI is already running.');
170
- if (openBrowser) open(url);
171
- process.exit(0);
172
- }
173
-
174
- const env = {...process.env, ASRUN_ROOT: ROOT};
175
- const child = spawn(process.execPath, [bin, 'ui'], {cwd: ROOT, stdio: 'inherit', env, shell});
176
- // Ctrl+C reaches the UI server directly (same terminal); we only wait for it to shut down.
177
- process.on('SIGINT', () => {});
178
- process.on('SIGTERM', () => child.kill('SIGTERM'));
179
- child.on('exit', (code) => process.exit(code ?? 0));
180
-
181
- for (let i = 0; i < 240 && child.exitCode === null; i++) {
182
- if (await uiIsUp()) {
183
- console.log(`\n\x1b[1;32mReady: ${url}\x1b[0m (Ctrl+C to stop)`);
184
- if (openBrowser) open(url);
185
- break;
186
- }
187
- await new Promise((resolve) => setTimeout(resolve, 500));
188
- }
1
+ // One-liner for a fresh machine: install everything, prepare the workspace, then start the web UI and open it.
2
+ // Plain Node on purpose: it may run before the dependencies are installed.
3
+ //
4
+ // pnpm add -D @asrunjs/asrun (or github:wizcas/asrun#<tag>)
5
+ // pnpm exec asrun init
6
+ // pnpm go
7
+ import {spawn, spawnSync} from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const FRAMEWORK = path.resolve(import.meta.dirname, '..');
12
+ const PACKAGE = '@asrunjs/asrun';
13
+ const MARKERS = ['.asrun.json', 'profiles.yaml'];
14
+ const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
15
+ const url = `http://127.0.0.1:${port}`;
16
+ const openBrowser = !process.argv.includes('--no-open');
17
+ const shell = process.platform === 'win32';
18
+
19
+ // `.env` next to the command, or the framework's own `.env`; a real environment variable always wins.
20
+ function loadRootEnv(file) {
21
+ try {
22
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
23
+ if (process.env.ASRUN_ROOT !== undefined) return;
24
+ const match = /^(?:export\s+)?ASRUN_ROOT\s*=\s*(.*)$/.exec(raw.trim());
25
+ if (match) {
26
+ process.env.ASRUN_ROOT = path.resolve(path.dirname(file), match[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
27
+ }
28
+ }
29
+ } catch {
30
+ // no .env: nothing to do
31
+ }
32
+ }
33
+ loadRootEnv(path.join(process.cwd(), '.env'));
34
+ if (process.env.ASRUN_ROOT === undefined) loadRootEnv(path.join(FRAMEWORK, '.env'));
35
+
36
+ // The workspace: the project that owns profiles.yaml, cases/ and tests/. Inside the framework repository
37
+ // (which has none of those) the demo workspace is used, so the framework itself can be developed this way.
38
+ function findWorkspace(from) {
39
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
40
+ if (MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) return dir;
41
+ if (path.dirname(dir) === dir) return null;
42
+ }
43
+ }
44
+ const demo = path.join(FRAMEWORK, 'examples', 'demo');
45
+ const ROOT = (process.env.ASRUN_ROOT && path.resolve(process.env.ASRUN_ROOT)) || findWorkspace(process.cwd()) || demo;
46
+ if (ROOT === FRAMEWORK && !fs.existsSync(demo)) {
47
+ console.error('No workspace found: run this command in the project that uses asrun, or set ASRUN_ROOT.');
48
+ process.exit(1);
49
+ }
50
+
51
+ const step = (n, text) => console.log(`\n\x1b[1;36m[${n}/4] ${text}\x1b[0m`);
52
+ const warn = (text) => console.log(`\x1b[33m! ${text}\x1b[0m`);
53
+ const fail = (text) => {
54
+ console.error(`\x1b[31m${text}\x1b[0m`);
55
+ process.exit(1);
56
+ };
57
+ const run = (command, args, options = {}) =>
58
+ spawnSync(command, args, {cwd: ROOT, stdio: 'inherit', shell, ...options}).status === 0;
59
+ const exists = (command) => spawnSync(command, ['--version'], {stdio: 'ignore', shell}).status === 0;
60
+
61
+ /** The JavaScript entry point of a package's command-line tool, without relying on node_modules/.bin shims. */
62
+ function packageBin(name, command = name) {
63
+ for (const base of [FRAMEWORK, path.dirname(path.dirname(FRAMEWORK)), ROOT]) {
64
+ const dir = path.join(base, 'node_modules', name);
65
+ const manifest = path.join(dir, 'package.json');
66
+ if (!fs.existsSync(manifest)) continue;
67
+ const bin = JSON.parse(fs.readFileSync(manifest, 'utf8')).bin;
68
+ const entry = typeof bin === 'string' ? bin : bin?.[command];
69
+ if (entry) return path.join(dir, entry);
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** The installed command of this workspace, or the framework's own bin while developing it here. */
75
+ const bin = packageBin(PACKAGE) ?? path.join(FRAMEWORK, 'bin', 'asrun.mjs');
76
+ /** The workspace's copy of the package, if any. */
77
+ const installed = fs.existsSync(path.join(ROOT, 'node_modules', PACKAGE, 'package.json'));
78
+ const frameworkRelative = installed
79
+ ? './' + path.relative(ROOT, path.join(ROOT, 'node_modules', PACKAGE)).split(path.sep).join('/')
80
+ : (() => {
81
+ const relative = path.relative(ROOT, FRAMEWORK).split(path.sep).join('/');
82
+ return relative === '' ? '.' : relative.startsWith('.') ? relative : `./${relative}`;
83
+ })();
84
+
85
+ /** pnpm only installs dependencies normally; its build scripts need an explicit approval in pnpm-workspace.yaml. */
86
+ function ensurePnpmWorkspace() {
87
+ if (frameworkRelative.startsWith('..')) return 'skipped';
88
+ const file = path.join(ROOT, 'pnpm-workspace.yaml');
89
+ const block = 'allowBuilds:\n esbuild: true\n ffmpeg-static: true\n';
90
+ if (!fs.existsSync(file)) {
91
+ fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx, ffmpeg-static) compile a binary on install.\n${block}`);
92
+ return 'created';
93
+ }
94
+ const text = fs.readFileSync(file, 'utf8');
95
+ if (text.includes('allowBuilds')) return 'kept';
96
+ fs.writeFileSync(file, `${text.replace(/\n*$/, '\n')}\n${block}`);
97
+ return 'updated';
98
+ }
99
+
100
+ async function uiIsUp() {
101
+ try {
102
+ return (await fetch(`${url}/api/info`, {signal: AbortSignal.timeout(1500)})).ok;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function open(target) {
109
+ const [command, args] =
110
+ process.platform === 'darwin'
111
+ ? ['open', [target]]
112
+ : process.platform === 'win32'
113
+ ? ['cmd', ['/c', 'start', '', target]]
114
+ : ['xdg-open', [target]];
115
+ spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
116
+ }
117
+
118
+ // ffmpeg joins the videos of a test's browser tabs into one. Never fatal: the optional ffmpeg-static package is the fallback.
119
+ function ensureFfmpeg() {
120
+ // ffmpeg does not know `--version`, only `-version`.
121
+ if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
122
+ const installer =
123
+ process.platform === 'darwin' && exists('brew')
124
+ ? ['brew', ['install', 'ffmpeg']]
125
+ : process.platform === 'win32' && exists('winget')
126
+ ? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
127
+ : null;
128
+ if (!installer) {
129
+ return warn('ffmpeg not found; the bundled ffmpeg-static is used instead. To install it yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
130
+ }
131
+ console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
132
+ if (run(...installer)) console.log('ffmpeg: installed');
133
+ else warn('Could not install ffmpeg; the bundled ffmpeg-static is used instead.');
134
+ }
135
+
136
+ step(1, 'Checking this machine');
137
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
138
+ if (nodeMajor < 22) fail(`Node 22 or newer is required (this is ${process.versions.node}). Install it from https://nodejs.org and run "pnpm go" again.`);
139
+ console.log(`Node ${process.versions.node}: ok`);
140
+ console.log(`workspace: ${ROOT}`);
141
+ console.log(`framework: ${FRAMEWORK}${installed ? ' (installed)' : ' (development checkout)'}`);
142
+ ensureFfmpeg();
143
+ if (!exists('claude') && !exists('opencode')) {
144
+ warn('Neither "claude" (Claude Code) nor "opencode" found. Everything works except the AI steps (make cases, review, generate specs).');
145
+ }
146
+
147
+ step(2, 'Installing dependencies and the browser (a minute the first time, seconds after that)');
148
+ const workspaceFile = ensurePnpmWorkspace();
149
+ if (workspaceFile === 'created') console.log('pnpm-workspace.yaml: created (allowBuilds)');
150
+ else if (workspaceFile === 'updated') console.log('pnpm-workspace.yaml: updated (allowBuilds)');
151
+ const installRoot = frameworkRelative.startsWith('..') ? FRAMEWORK : ROOT;
152
+ if (!run('pnpm', ['install'], {cwd: installRoot})) fail('Install failed. Fix the error above and run "pnpm go" again.');
153
+ const playwright = packageBin('@playwright/test', 'playwright');
154
+ if (playwright) {
155
+ if (!run(process.execPath, [playwright, 'install', 'chromium'])) warn('Could not install the Chromium browser; run "pnpm exec playwright install chromium".');
156
+ } else {
157
+ warn('@playwright/test is not installed yet. Run this command again after the workspace files are in place.');
158
+ }
159
+
160
+ step(3, 'Preparing the workspace');
161
+ // `--root` names the workspace, but the command runs where the person is: that is where `.env` belongs.
162
+ if (!run(process.execPath, [bin, 'init', '--root', ROOT], {cwd: process.cwd()})) {
163
+ fail('Could not initialize the workspace. Run "pnpm asrun init" and read the message.');
164
+ }
165
+ run(process.execPath, [bin, 'doctor']);
166
+
167
+ step(4, `Starting the web UI at ${url}`);
168
+ if (await uiIsUp()) {
169
+ console.log('The UI is already running.');
170
+ if (openBrowser) open(url);
171
+ process.exit(0);
172
+ }
173
+
174
+ const env = {...process.env, ASRUN_ROOT: ROOT};
175
+ const child = spawn(process.execPath, [bin, 'ui'], {cwd: ROOT, stdio: 'inherit', env, shell});
176
+ // Ctrl+C reaches the UI server directly (same terminal); we only wait for it to shut down.
177
+ process.on('SIGINT', () => {});
178
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
179
+ child.on('exit', (code) => process.exit(code ?? 0));
180
+
181
+ for (let i = 0; i < 240 && child.exitCode === null; i++) {
182
+ if (await uiIsUp()) {
183
+ console.log(`\n\x1b[1;32mReady: ${url}\x1b[0m (Ctrl+C to stop)`);
184
+ if (openBrowser) open(url);
185
+ break;
186
+ }
187
+ await new Promise((resolve) => setTimeout(resolve, 500));
188
+ }
@@ -7,8 +7,14 @@ export declare const GENERATED: {
7
7
  export declare function frameworkPath(root: string): string;
8
8
  /** True when this workspace has the package installed (or is the framework's own checkout). */
9
9
  export declare function isInstalled(root: string): boolean;
10
- /** Fills the `{{framework}}` placeholder of a template. */
10
+ /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
11
11
  export declare function renderTemplate(name: string, root: string): string;
12
+ /**
13
+ * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
14
+ * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
15
+ * Kept relative: the file is committed and must not name a machine.
16
+ */
17
+ export declare function playwrightBin(root: string): string;
12
18
  export declare function frameworkVersion(): {
13
19
  version: string;
14
20
  commit: string;
@@ -6,13 +6,15 @@ import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
6
6
  * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
7
7
  * `--update` refreshes only the files generated from the framework templates.
8
8
  */
9
- const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
-
11
- --root <dir> the workspace to initialize (default: the current folder)
12
- --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*)
13
- --force <path> overwrite one file, e.g. --force playwright.config.ts (may be repeated)
14
- --no-env do not write .env (ASRUN_ROOT)
15
- --install [spec] also add the dependency (default "asrun"; e.g. --install github:wizcas/asrun#v0.1.0)`;
9
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
+
11
+ --root <dir> the workspace to initialize (default: the current folder)
12
+ --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
13
+ .opencode/commands/*)
14
+ --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
15
+ --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
16
+ --no-env do not write .env (ASRUN_ROOT)
17
+ --install [spec] also add the dependency (default "asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
16
18
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
17
19
  export const GENERATED = [
18
20
  { file: 'playwright.config.ts', template: 'playwright.config.ts' },
@@ -21,12 +23,18 @@ export const GENERATED = [
21
23
  { file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
22
24
  { file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
23
25
  { file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
26
+ // OpenCode does not read `.claude/commands` (only `.claude/skills`), so the same commands live here too.
27
+ { file: '.opencode/commands/make-case.md', template: '.claude/commands/make-case.md' },
28
+ { file: '.opencode/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
29
+ { file: '.opencode/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
30
+ { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
24
31
  ];
25
32
  /** Files `init` creates once; after that the project owns them and may edit them freely. */
26
33
  const ONCE = [
27
34
  { file: 'AGENTS.md', template: 'AGENTS.md' },
28
35
  { file: 'CLAUDE.md', template: 'CLAUDE.md' },
29
36
  { file: '.mcp.json', template: '.mcp.json' },
37
+ { file: 'opencode.jsonc', template: 'opencode.jsonc' },
30
38
  { file: '.claude/settings.json', template: '.claude/settings.json' },
31
39
  { file: 'tsconfig.json', template: 'tsconfig.json' },
32
40
  { file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
@@ -71,9 +79,22 @@ export function frameworkPath(root) {
71
79
  export function isInstalled(root) {
72
80
  return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
73
81
  }
74
- /** Fills the `{{framework}}` placeholder of a template. */
82
+ /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
75
83
  export function renderTemplate(name, root) {
76
- return readTemplate(name).replaceAll('{{framework}}', frameworkPath(root));
84
+ return readTemplate(name)
85
+ .replaceAll('{{framework}}', frameworkPath(root))
86
+ .replaceAll('{{playwright}}', playwrightBin(root));
87
+ }
88
+ /**
89
+ * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
90
+ * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
91
+ * Kept relative: the file is committed and must not name a machine.
92
+ */
93
+ export function playwrightBin(root) {
94
+ if (fs.existsSync(path.join(root, 'node_modules', '.bin', 'playwright')))
95
+ return './node_modules/.bin/playwright';
96
+ const framework = frameworkPath(root);
97
+ return framework.startsWith('..') ? `${framework}/node_modules/.bin/playwright` : './node_modules/.bin/playwright';
77
98
  }
78
99
  export function frameworkVersion() {
79
100
  let version = '0.0.0';
@@ -183,7 +204,10 @@ export async function main(args) {
183
204
  const installAt = args.indexOf('--install');
184
205
  const install = installAt >= 0;
185
206
  const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
186
- const forced = args.flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []));
207
+ const forced = args
208
+ .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
209
+ .map((file) => file.split('\\').join('/'));
210
+ const resetRules = args.includes('--reset-rules');
187
211
  const skipEnv = args.includes('--no-env');
188
212
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
189
213
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
@@ -195,6 +219,20 @@ export async function main(args) {
195
219
  const kept = [];
196
220
  const notes = [];
197
221
  const framework = frameworkPath(root);
222
+ // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
223
+ // `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need `--reset-rules` on top.
224
+ const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
225
+ const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
226
+ for (const file of forced) {
227
+ if (!owned.includes(file)) {
228
+ console.error(`--force ${file}: init only writes the files it owns (${owned.join(', ')}).`);
229
+ return 1;
230
+ }
231
+ if (projectRules(file) && !resetRules) {
232
+ console.error(`--force ${file}: this file belongs to the project. Add --reset-rules to replace it with the template.`);
233
+ return 1;
234
+ }
235
+ }
198
236
  const place = (entry, overwrite) => {
199
237
  const target = path.join(root, entry.file);
200
238
  if (fs.existsSync(target) && !overwrite) {
@@ -231,10 +269,20 @@ export async function main(args) {
231
269
  notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
232
270
  }
233
271
  }
234
- if (fs.existsSync(path.join(root, '.mcp.json'))) {
235
- const mcp = fs.readFileSync(path.join(root, '.mcp.json'), 'utf8');
236
- if (!mcp.includes('playwright-test'))
237
- notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
272
+ if (fs.existsSync(path.join(root, '.mcp.json')) && !fs.readFileSync(path.join(root, '.mcp.json'), 'utf8').includes('playwright-test')) {
273
+ notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
274
+ }
275
+ if (fs.existsSync(path.join(root, 'opencode.jsonc')) && !fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8').includes('playwright-test')) {
276
+ notes.push('Add the "playwright-test" MCP server to opencode.jsonc (see templates/opencode.jsonc).');
277
+ }
278
+ if (fs.existsSync(path.join(root, 'opencode.jsonc'))) {
279
+ const text = fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8');
280
+ // A commented-out "model" (the template has one) does not set anything: only a real key counts.
281
+ const withoutComments = text.replace(/^\s*\/\/.*$/gm, '');
282
+ if (!/["']?model["']?\s*:/.test(withoutComments)) {
283
+ notes.push('opencode.jsonc sets no "model": a one-shot "opencode run" then uses OpenCode\'s built-in free model, which has a ' +
284
+ 'daily usage limit. Pick a model in the AsRun web UI (top bar), or set "model" there.');
285
+ }
238
286
  }
239
287
  if (fs.existsSync(path.join(root, '.claude', 'settings.json'))) {
240
288
  const settings = fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8');
@@ -128,8 +128,11 @@ function lint(file) {
128
128
  TARGET_REF.lastIndex = 0;
129
129
  return findings;
130
130
  }
131
+ // A case id is written with `/` (`conduit/header-navigation`), while a path on Windows has `\`: without the
132
+ // normalization this filter matched nothing and reported "0 spec(s)" for a spec that was right there.
133
+ const casePath = (file) => file.replace(/\\/g, '/');
131
134
  const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
132
- const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => file.endsWith(`${id}.spec.ts`)));
135
+ const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => casePath(file).endsWith(`${casePath(id)}.spec.ts`)));
133
136
  const findings = files.flatMap(lint);
134
137
  for (const f of findings)
135
138
  console.log(`${f.level.toUpperCase().padEnd(4)} ${f.file}:${f.line} [${f.rule}] ${f.message}`);
@@ -6,6 +6,7 @@ import { getProfile, listProfiles, profileLabel } from '../lib/profiles.js';
6
6
  import { parseCliTarget } from '../lib/cli.js';
7
7
  import { authFile } from '../lib/paths.js';
8
8
  import { checkSession } from '../lib/session.js';
9
+ import { showLoginCard, showLoginCardSaved, waitForLoginCard } from '../lib/login-card.js';
9
10
  const parsed = parseCliTarget(process.argv.slice(2));
10
11
  if (!parsed.profileId || parsed.help) {
11
12
  console.log('Usage: pnpm signin <profile-id>\n\nProfiles:');
@@ -23,14 +24,41 @@ const file = authFile(profile.id);
23
24
  fs.mkdirSync(path.dirname(file), { recursive: true });
24
25
  const browser = await chromium.launch({ headless: false });
25
26
  const context = await browser.newContext({ viewport: null });
27
+ // The card goes in before the first page, so it is there on the login page and again after every navigation.
28
+ showLoginCard(context, `Log in as usual, including the 2FA code. When you can see the logged-in page, press "Done, save the session": ` +
29
+ `the session is saved and this window closes.`);
26
30
  const page = await context.newPage();
27
31
  await page.goto(new URL('/login/', profile.baseURL).href);
28
32
  console.log(`\nA browser window opened for "${profileLabel(profile)}" (${profile.baseURL}).`);
29
33
  console.log('1. Log in as usual, including the 2FA code.');
30
- console.log('2. Wait until you can see the logged-in page.');
34
+ console.log('2. When you can see the logged-in page, press "Done, save the session" on the card in that window.');
35
+ console.log(' The card sits in the bottom-left corner; drag it if it is in the way.');
31
36
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
32
- await rl.question('3. Come back here and press Enter to save the session... ');
37
+ const stopWaiting = new AbortController();
38
+ const card = waitForLoginCard(context, page, stopWaiting.signal);
39
+ const typed = rl
40
+ .question(' Or press Enter here (in the terminal, or the button on the job in the UI): ')
41
+ .then(() => 'enter');
42
+ const answer = await Promise.race([
43
+ card.then((outcome) => ({ via: 'card', outcome })),
44
+ typed.then(() => ({ via: 'enter', outcome: 'done' })),
45
+ ]);
46
+ stopWaiting.abort();
33
47
  rl.close();
48
+ if (answer.via === 'card' && answer.outcome !== 'done') {
49
+ await browser.close().catch(() => { });
50
+ console.error(answer.outcome === 'cancel'
51
+ ? `\nNothing was saved: the login was cancelled on the card. Run \`pnpm signin ${profile.id}\` again when you are ready.`
52
+ : '\nNothing was saved: the browser window was closed before the session was saved. ' +
53
+ `Run \`pnpm signin ${profile.id}\` again.`);
54
+ process.exit(1);
55
+ }
56
+ if (answer.via === 'card') {
57
+ console.log('\nSaving the session from the card...');
58
+ // The person is still looking at the window: say that the session is safe before it disappears.
59
+ await showLoginCardSaved(context, 'Session saved. Closing this window...');
60
+ await new Promise((resolve) => setTimeout(resolve, 1200));
61
+ }
34
62
  await context.storageState({ path: file });
35
63
  await browser.close();
36
64
  fs.chmodSync(file, 0o600);
@@ -24,6 +24,8 @@ export declare const startJobSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
24
24
  env: z.ZodString;
25
25
  force: z.ZodOptional<z.ZodBoolean>;
26
26
  attended: z.ZodOptional<z.ZodBoolean>;
27
+ thinking: z.ZodOptional<z.ZodBoolean>;
28
+ model: z.ZodOptional<z.ZodString>;
27
29
  profiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
30
  hints: z.ZodOptional<z.ZodString>;
29
31
  report: z.ZodOptional<z.ZodString>;
@@ -38,6 +40,8 @@ export declare const startJobSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
38
40
  opencode: "opencode";
39
41
  }>;
40
42
  memo: z.ZodString;
43
+ model: z.ZodOptional<z.ZodString>;
44
+ thinking: z.ZodOptional<z.ZodBoolean>;
41
45
  }, z.core.$strip>, z.ZodObject<{
42
46
  action: z.ZodLiteral<"run">;
43
47
  env: z.ZodString;