explorbot 0.2.2 → 0.2.4

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 (214) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +59 -38
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +43 -10
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  7. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  8. package/boat/doc-collector/src/cli.ts +1 -0
  9. package/boat/doc-collector/src/config.ts +4 -2
  10. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  11. package/boat/doc-collector/src/state-diagram.ts +61 -14
  12. package/boat/prima/bin/prima-cli.ts +5 -0
  13. package/boat/prima/package.json +16 -0
  14. package/boat/prima/src/activity-line.ts +33 -0
  15. package/boat/prima/src/cli.ts +251 -0
  16. package/boat/prima/src/envelope.ts +169 -0
  17. package/boat/prima/src/prima.ts +1124 -0
  18. package/boat/prima/src/pw-parser.ts +27 -0
  19. package/boat/prima/src/pw-registry.ts +74 -0
  20. package/boat/prima/src/session-log.ts +126 -0
  21. package/dist/bin/explorbot-cli.js +51 -32
  22. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  23. package/dist/boat/api-tester/src/apibot.js +3 -2
  24. package/dist/boat/api-tester/src/cli.js +2 -2
  25. package/dist/boat/api-tester/src/config.js +40 -10
  26. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  27. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  28. package/dist/boat/doc-collector/src/cli.js +1 -0
  29. package/dist/boat/doc-collector/src/config.js +4 -2
  30. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  31. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  32. package/dist/boat/prima/bin/prima-cli.js +4 -0
  33. package/dist/boat/prima/src/activity-line.js +30 -0
  34. package/dist/boat/prima/src/cli.js +220 -0
  35. package/dist/boat/prima/src/envelope.js +142 -0
  36. package/dist/boat/prima/src/prima.js +1031 -0
  37. package/dist/boat/prima/src/pw-parser.js +30 -0
  38. package/dist/boat/prima/src/pw-registry.js +65 -0
  39. package/dist/boat/prima/src/session-log.js +108 -0
  40. package/dist/models.json +3 -0
  41. package/dist/package.json +8 -3
  42. package/dist/rules/navigator/verification-actions.md +20 -0
  43. package/dist/src/action-result.d.ts +7 -0
  44. package/dist/src/action-result.js +4 -0
  45. package/dist/src/action.d.ts +7 -2
  46. package/dist/src/action.js +41 -6
  47. package/dist/src/ai/captain/mixin.js +3 -4
  48. package/dist/src/ai/captain/web-mode.js +7 -4
  49. package/dist/src/ai/captain.js +2 -0
  50. package/dist/src/ai/navigator.d.ts +11 -0
  51. package/dist/src/ai/navigator.js +21 -12
  52. package/dist/src/ai/pilot.d.ts +4 -0
  53. package/dist/src/ai/pilot.js +48 -2
  54. package/dist/src/ai/planner.d.ts +1 -0
  55. package/dist/src/ai/planner.js +6 -0
  56. package/dist/src/ai/provider.js +2 -2
  57. package/dist/src/ai/rerunner.js +2 -1
  58. package/dist/src/ai/researcher.js +2 -2
  59. package/dist/src/ai/rules.js +17 -10
  60. package/dist/src/ai/task-agent.js +5 -2
  61. package/dist/src/ai/tester.d.ts +1 -0
  62. package/dist/src/ai/tester.js +33 -20
  63. package/dist/src/ai/tools.d.ts +14 -0
  64. package/dist/src/ai/tools.js +113 -36
  65. package/dist/src/application-spec-contract.d.ts +8 -0
  66. package/dist/src/application-spec-contract.js +8 -0
  67. package/dist/src/application-spec.d.ts +15 -0
  68. package/dist/src/application-spec.js +71 -0
  69. package/dist/src/browser-server.d.ts +12 -6
  70. package/dist/src/browser-server.js +74 -19
  71. package/dist/src/commands/clean-command.js +2 -7
  72. package/dist/src/commands/init-command.d.ts +5 -0
  73. package/dist/src/commands/init-command.js +119 -1
  74. package/dist/src/commands/navigate-command.js +1 -1
  75. package/dist/src/commands/research-command.js +1 -1
  76. package/dist/src/commands/sites-command.d.ts +6 -0
  77. package/dist/src/commands/sites-command.js +23 -0
  78. package/dist/src/components/InitWizard.d.ts +10 -0
  79. package/dist/src/components/InitWizard.js +133 -0
  80. package/dist/src/components/InputReadline.d.ts +1 -0
  81. package/dist/src/components/InputReadline.js +7 -4
  82. package/dist/src/config.d.ts +25 -5
  83. package/dist/src/config.js +158 -40
  84. package/dist/src/execution-controller.d.ts +2 -0
  85. package/dist/src/execution-controller.js +6 -0
  86. package/dist/src/explorbot.d.ts +11 -1
  87. package/dist/src/explorbot.js +27 -6
  88. package/dist/src/explorer.d.ts +4 -1
  89. package/dist/src/explorer.js +42 -9
  90. package/dist/src/global-config.d.ts +22 -0
  91. package/dist/src/global-config.js +117 -0
  92. package/dist/src/knowledge-tracker.d.ts +5 -1
  93. package/dist/src/knowledge-tracker.js +14 -1
  94. package/dist/src/playwright-recorder.js +30 -0
  95. package/dist/src/remote.d.ts +54 -0
  96. package/dist/src/remote.js +229 -0
  97. package/dist/src/reporter.d.ts +1 -0
  98. package/dist/src/reporter.js +7 -1
  99. package/dist/src/state-manager.d.ts +2 -1
  100. package/dist/src/stats.d.ts +1 -0
  101. package/dist/src/stats.js +1 -0
  102. package/dist/src/test-plan.d.ts +2 -0
  103. package/dist/src/test-plan.js +7 -0
  104. package/dist/src/utils/aria.d.ts +2 -8
  105. package/dist/src/utils/aria.js +69 -40
  106. package/dist/src/utils/cli-name.js +6 -2
  107. package/dist/src/utils/html.js +1 -0
  108. package/dist/src/utils/logger.d.ts +6 -0
  109. package/dist/src/utils/logger.js +24 -0
  110. package/dist/src/utils/page-readiness.js +18 -1
  111. package/dist/src/utils/test-files.js +1 -2
  112. package/dist/src/utils/url-matcher.d.ts +1 -0
  113. package/dist/src/utils/url-matcher.js +12 -0
  114. package/dist/src/utils/web-element.d.ts +2 -0
  115. package/dist/src/utils/web-element.js +8 -0
  116. package/dist/src/utils/web-sandbox.d.ts +1 -1
  117. package/dist/src/utils/web-sandbox.js +2 -3
  118. package/docs/api-testing/basics.md +90 -0
  119. package/docs/api-testing/planning.md +57 -0
  120. package/docs/api-testing/running-tests.md +55 -0
  121. package/docs/assets/cloud-report.png +0 -0
  122. package/docs/assets/html-report.png +0 -0
  123. package/docs/assets/langfuse-trace.png +0 -0
  124. package/docs/assets/successful-explore-run.png +0 -0
  125. package/docs/basics/getting-started.md +140 -0
  126. package/docs/basics/prerequisites.md +63 -0
  127. package/docs/basics/providers.md +362 -0
  128. package/docs/basics/running.md +78 -0
  129. package/docs/contributing/ai-integration-tests.md +57 -0
  130. package/docs/contributing/contributing.md +90 -0
  131. package/docs/contributing/demo-videos.md +36 -0
  132. package/docs/contributing/npm-package.md +138 -0
  133. package/docs/contributing/observability.md +227 -0
  134. package/docs/contributing/regression-tests.md +103 -0
  135. package/docs/contributing/testing.md +95 -0
  136. package/docs/doc-collection/basics.md +128 -0
  137. package/docs/doc-collection/crawling.md +67 -0
  138. package/docs/doc-collection/interactive-mode.md +99 -0
  139. package/docs/index.json +86 -0
  140. package/docs/reference/commands.md +994 -0
  141. package/docs/reference/configuration.md +569 -0
  142. package/docs/reference/scripting.md +303 -0
  143. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  144. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  145. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  146. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  147. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  148. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  149. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  150. package/docs/web-testing/agents.md +158 -0
  151. package/docs/web-testing/automated-tests.md +134 -0
  152. package/docs/web-testing/basics.md +91 -0
  153. package/docs/web-testing/customization.md +131 -0
  154. package/docs/web-testing/hooks.md +238 -0
  155. package/docs/web-testing/page-interaction.md +84 -0
  156. package/docs/web-testing/planner.md +122 -0
  157. package/docs/web-testing/rerun.md +164 -0
  158. package/docs/web-testing/researcher.md +380 -0
  159. package/docs/workflow/agentic-usage.md +233 -0
  160. package/docs/workflow/application-spec.md +73 -0
  161. package/docs/workflow/ci.md +202 -0
  162. package/docs/workflow/knowledge.md +310 -0
  163. package/docs/workflow/planning-styles.md +67 -0
  164. package/docs/workflow/reporting.md +133 -0
  165. package/docs/workflow/test-plans.md +90 -0
  166. package/models.json +3 -0
  167. package/package.json +8 -3
  168. package/rules/navigator/verification-actions.md +20 -0
  169. package/src/action-result.ts +11 -0
  170. package/src/action.ts +47 -7
  171. package/src/ai/captain/mixin.ts +3 -3
  172. package/src/ai/captain/web-mode.ts +7 -4
  173. package/src/ai/captain.ts +3 -0
  174. package/src/ai/navigator.ts +26 -15
  175. package/src/ai/pilot.ts +53 -2
  176. package/src/ai/planner.ts +7 -0
  177. package/src/ai/provider.ts +2 -4
  178. package/src/ai/rerunner.ts +2 -1
  179. package/src/ai/researcher.ts +2 -2
  180. package/src/ai/rules.ts +17 -10
  181. package/src/ai/task-agent.ts +4 -2
  182. package/src/ai/tester.ts +32 -18
  183. package/src/ai/tools.ts +132 -36
  184. package/src/application-spec-contract.ts +10 -0
  185. package/src/application-spec.ts +87 -0
  186. package/src/browser-server.ts +74 -19
  187. package/src/commands/clean-command.ts +1 -6
  188. package/src/commands/init-command.ts +146 -1
  189. package/src/commands/navigate-command.ts +1 -1
  190. package/src/commands/research-command.ts +1 -1
  191. package/src/commands/sites-command.ts +27 -0
  192. package/src/components/InitWizard.tsx +166 -0
  193. package/src/components/InputReadline.tsx +8 -4
  194. package/src/config.ts +177 -43
  195. package/src/execution-controller.ts +8 -0
  196. package/src/explorbot.ts +34 -7
  197. package/src/explorer.ts +47 -9
  198. package/src/global-config.ts +148 -0
  199. package/src/knowledge-tracker.ts +17 -1
  200. package/src/playwright-recorder.ts +23 -0
  201. package/src/remote.ts +238 -0
  202. package/src/reporter.ts +7 -1
  203. package/src/state-manager.ts +2 -1
  204. package/src/stats.ts +1 -0
  205. package/src/test-plan.ts +9 -0
  206. package/src/utils/aria.ts +65 -45
  207. package/src/utils/cli-name.ts +5 -2
  208. package/src/utils/html.ts +1 -0
  209. package/src/utils/logger.ts +24 -1
  210. package/src/utils/page-readiness.ts +24 -1
  211. package/src/utils/test-files.ts +1 -2
  212. package/src/utils/url-matcher.ts +13 -0
  213. package/src/utils/web-element.ts +9 -0
  214. package/src/utils/web-sandbox.ts +3 -4
@@ -0,0 +1,27 @@
1
+ const FUNCTION_SHAPE = /^(async\s+)?(function\b|\()/;
2
+
3
+ export function isFunctionExpression(expr: string): { valid: boolean; error?: string } {
4
+ const trimmed = expr.trim();
5
+ if (!trimmed) return { valid: false, error: 'empty expression; pass a function like ({ page }) => ...' };
6
+ if (!FUNCTION_SHAPE.test(trimmed)) return { valid: false, error: 'expression must be a function like ({ page }) => ... destructuring the playwright objects it needs' };
7
+ try {
8
+ new Function(`return (${trimmed})`);
9
+ } catch (e) {
10
+ return { valid: false, error: `not a valid function expression: ${(e as Error).message}` };
11
+ }
12
+ return { valid: true };
13
+ }
14
+
15
+ export function toCodeceptWrapper(expr: string): string {
16
+ return `return I.usePlaywrightTo('pw', async (playwright) => (${expr.trim()})(playwright))`;
17
+ }
18
+
19
+ export function takePwValue(held: unknown): string | undefined {
20
+ if (held === undefined || held === null) return undefined;
21
+ if (typeof held === 'string') return held;
22
+ try {
23
+ return JSON.stringify(held, null, 2);
24
+ } catch {
25
+ return String(held);
26
+ }
27
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const DEFAULT_TITLE = 'default';
6
+ const DEFAULT_BROWSER = 'chromium';
7
+
8
+ export function registryDir(): string {
9
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright', 'b');
10
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ms-playwright', 'b');
11
+ return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'ms-playwright', 'b');
12
+ }
13
+
14
+ export function readDescriptors(dir = registryDir()): PwServerDescriptor[] {
15
+ const descriptors: PwServerDescriptor[] = [];
16
+ for (const fileName of listFiles(dir)) {
17
+ const descriptor = parseDescriptor(path.join(dir, fileName));
18
+ if (descriptor) descriptors.push(descriptor);
19
+ }
20
+ return descriptors;
21
+ }
22
+
23
+ export function selectDescriptor(descriptors: PwServerDescriptor[], opts: { title?: string } = {}): { match?: PwServerDescriptor; candidates: PwServerDescriptor[] } {
24
+ const candidates = descriptors;
25
+ if (!candidates.length) return { candidates };
26
+
27
+ if (opts.title) {
28
+ const titled = candidates.find((descriptor) => descriptor.title === opts.title);
29
+ if (titled) return { match: titled, candidates };
30
+ return { candidates };
31
+ }
32
+
33
+ const preferred = candidates.find((descriptor) => descriptor.title === DEFAULT_TITLE);
34
+ if (preferred) return { match: preferred, candidates };
35
+ if (candidates.length === 1) return { match: candidates[0], candidates };
36
+ return { candidates };
37
+ }
38
+
39
+ function listFiles(dir: string): string[] {
40
+ try {
41
+ return readdirSync(dir);
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function parseDescriptor(file: string): PwServerDescriptor | null {
48
+ let data: any;
49
+ try {
50
+ data = JSON.parse(readFileSync(file, 'utf8'));
51
+ } catch {
52
+ return null;
53
+ }
54
+
55
+ if (!data?.endpoint || !data?.title) return null;
56
+
57
+ return {
58
+ file,
59
+ title: data.title,
60
+ endpoint: data.endpoint,
61
+ workspaceDir: data.workspaceDir || '',
62
+ browserName: data.browser?.browserName || DEFAULT_BROWSER,
63
+ playwrightLib: data.playwrightLib || '',
64
+ };
65
+ }
66
+
67
+ export interface PwServerDescriptor {
68
+ file: string;
69
+ title: string;
70
+ endpoint: string;
71
+ workspaceDir: string;
72
+ browserName: string;
73
+ playwrightLib: string;
74
+ }
@@ -0,0 +1,126 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { outputPath } from '../../../src/config.ts';
4
+ import { safeFilename } from '../../../src/utils/strings.ts';
5
+ import { type EnvelopeData, renderEnvelope } from './envelope.ts';
6
+
7
+ const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
8
+
9
+ export function sessionsDir(): string {
10
+ return outputPath('prima', 'sessions');
11
+ }
12
+
13
+ export function sessionFile(key: string): string {
14
+ return path.join(sessionsDir(), `${safeFilename(key)}.jsonl`);
15
+ }
16
+
17
+ export function latestSessionFile(): string | null {
18
+ const dir = sessionsDir();
19
+ if (!existsSync(dir)) return null;
20
+
21
+ const files = readdirSync(dir)
22
+ .filter((name) => name.endsWith('.jsonl'))
23
+ .map((name) => path.join(dir, name))
24
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
25
+
26
+ return files[0] || null;
27
+ }
28
+
29
+ export function recordCommand(file: string, session: SessionRun, envelope: EnvelopeData, durationMs: number): void {
30
+ mkdirSync(path.dirname(file), { recursive: true });
31
+ if (recordedEndpoint(file) !== session.endpoint) writeFileSync(file, jsonLine({ action: 'createRun', endpoint: session.endpoint, params: { title: session.title } }), 'utf-8');
32
+ appendFileSync(file, jsonLine({ action: 'addTest', testId: commandTest(envelope, durationMs) }), 'utf-8');
33
+ }
34
+
35
+ export function readSession(file: string): { title: string; tests: any[] } {
36
+ const entries = readEntries(file);
37
+ const run = entries.find((entry) => entry.action === 'createRun');
38
+ const tests = entries.filter((entry) => entry.action === 'addTest').map((entry) => entry.testId);
39
+ return { title: run?.params?.title || 'prima session', tests };
40
+ }
41
+
42
+ function commandTest(envelope: EnvelopeData, durationMs: number): any {
43
+ let status = 'failed';
44
+ if (envelope.ok) status = 'passed';
45
+
46
+ const test: any = {
47
+ rid: envelope.status,
48
+ title: envelope.command,
49
+ suite_title: 'Prima session',
50
+ status,
51
+ file: '<prima>',
52
+ description: [`page: ${envelope.page.url}`, envelope.stepFiles && `artifacts: ${envelope.stepFiles}`].filter(Boolean).join('\n'),
53
+ code: (envelope.used || []).join('\n'),
54
+ steps: commandSteps(envelope),
55
+ logs: renderEnvelope(envelope),
56
+ time: Math.round(durationMs),
57
+ };
58
+
59
+ if (envelope.failure) test.error = { message: envelope.failure.error };
60
+ return test;
61
+ }
62
+
63
+ function commandSteps(envelope: EnvelopeData): any[] {
64
+ const steps: any[] = [];
65
+
66
+ for (const step of envelope.steps || []) {
67
+ const entry: any = { category: 'user', title: step.label, status: 'passed', duration: 0, log: step.proof };
68
+ if (!step.ok) {
69
+ entry.status = 'failed';
70
+ entry.error = step.proof;
71
+ }
72
+ steps.push(entry);
73
+ }
74
+
75
+ for (const expectation of envelope.expectations || []) {
76
+ const entry: any = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
77
+ if (expectation.status === 'failed') entry.error = 'the run did not reach this outcome';
78
+ steps.push(entry);
79
+ }
80
+
81
+ for (const assertion of envelope.assertions || []) {
82
+ const entry: any = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
83
+ if (!assertion.passed) {
84
+ entry.status = 'failed';
85
+ entry.error = 'the assertion did not hold on the page';
86
+ }
87
+ steps.push(entry);
88
+ }
89
+
90
+ return steps;
91
+ }
92
+
93
+ function recordedEndpoint(file: string): string | null {
94
+ const [first] = readEntries(file);
95
+ return first?.endpoint || null;
96
+ }
97
+
98
+ function readEntries(file: string): any[] {
99
+ if (!existsSync(file)) return [];
100
+
101
+ const entries: any[] = [];
102
+ for (const line of readFileSync(file, 'utf-8').split('\n')) {
103
+ if (!line.trim()) continue;
104
+ const entry = parseLine(line);
105
+ if (entry) entries.push(entry);
106
+ }
107
+ return entries;
108
+ }
109
+
110
+ function parseLine(line: string): any {
111
+ try {
112
+ return JSON.parse(line);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function jsonLine(entry: unknown): string {
119
+ return `${JSON.stringify(entry)}\n`;
120
+ }
121
+
122
+ export interface SessionRun {
123
+ key: string;
124
+ endpoint: string;
125
+ title: string;
126
+ }
@@ -11,6 +11,7 @@ import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
12
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot } from '../src/explorbot.js';
14
+ import { remote } from '../src/remote.js';
14
15
  import { Stats } from '../src/stats.js';
15
16
  import { Plan } from '../src/test-plan.js';
16
17
  import { getCliName } from "../src/utils/cli-name.js";
@@ -23,7 +24,8 @@ const cli = getCliName();
23
24
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../package.json');
24
25
  const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
25
26
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
26
- if (!process.env.EXPLORBOT_NO_BANNER) {
27
+ remote.registerOption(program);
28
+ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima')) {
27
29
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
28
30
  }
29
31
  function buildExplorBotOptions(from, options) {
@@ -36,6 +38,7 @@ function buildExplorBotOptions(from, options) {
36
38
  headless: options.headless,
37
39
  incognito: options.incognito,
38
40
  session: options.session,
41
+ applicationSpec: options.spec,
39
42
  };
40
43
  }
41
44
  function addCommonOptions(cmd) {
@@ -47,6 +50,7 @@ function addCommonOptions(cmd) {
47
50
  .option('-s, --show', 'Show browser window')
48
51
  .option('--headless', 'Run browser in headless mode')
49
52
  .option('--incognito', 'Run without recording experiences')
53
+ .option('--spec <path>', 'Use a Docbot application spec directory or index.md')
50
54
  .option('--session [file]', 'Save/restore browser session from file');
51
55
  }
52
56
  async function startTUI(explorBot) {
@@ -72,6 +76,10 @@ async function startTUI(explorBot) {
72
76
  });
73
77
  }
74
78
  async function showStatsAndExit(code) {
79
+ if (remote.isAttached()) {
80
+ await remote.close(code);
81
+ process.exit(code);
82
+ }
75
83
  if (Stats.hasActivity()) {
76
84
  await new Promise((resolve) => {
77
85
  const { unmount } = render(React.createElement(StatusPane, {
@@ -341,6 +349,13 @@ program
341
349
  process.exit(1);
342
350
  }
343
351
  });
352
+ program
353
+ .command('sites')
354
+ .description('List sites registered in the global installation')
355
+ .action(async () => {
356
+ const { SitesCommand } = await import('../src/commands/sites-command.js');
357
+ await new SitesCommand(new ExplorBot()).execute('');
358
+ });
344
359
  addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
345
360
  try {
346
361
  const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
@@ -373,17 +388,29 @@ addCommonOptions(program
373
388
  });
374
389
  program
375
390
  .command('init')
376
- .description('Initialize a new project with configuration')
377
- .option('-c, --config-path <path>', 'Path for the config file', './explorbot.config.js')
391
+ .description('Initialize configuration for a project or for this machine')
392
+ .option('-c, --config-path <path>', 'Path for the config file')
378
393
  .option('-f, --force', 'Overwrite existing config file')
379
394
  .option('-p, --path <path>', 'Working directory for initialization')
395
+ .option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
396
+ .option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
397
+ .option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
380
398
  .action(async (options) => {
381
- const { runInitCommand } = await import('../src/commands/init-command.js');
382
- runInitCommand({
383
- configPath: options.configPath,
384
- force: options.force,
385
- path: options.path,
386
- });
399
+ try {
400
+ const { runInit } = await import('../src/commands/init-command.js');
401
+ await runInit({
402
+ configPath: options.configPath,
403
+ force: options.force,
404
+ path: options.path,
405
+ global: options.global,
406
+ provider: options.provider,
407
+ apiKey: options.apiKey,
408
+ });
409
+ }
410
+ catch (error) {
411
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
412
+ process.exit(1);
413
+ }
387
414
  });
388
415
  program
389
416
  .command('clean [target]')
@@ -626,7 +653,7 @@ program
626
653
  };
627
654
  const explorBot = new ExplorBot(mainOptions);
628
655
  await explorBot.start();
629
- await explorBot.agentNavigator().visit(url);
656
+ await explorBot.visit(url);
630
657
  const { ContextCommand } = await import('../src/commands/context-command.js');
631
658
  const argParts = [];
632
659
  if (options.full)
@@ -650,7 +677,7 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
650
677
  try {
651
678
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
652
679
  await explorBot.start();
653
- await explorBot.agentNavigator().visit(url);
680
+ await explorBot.visit(url);
654
681
  const action = explorBot.getExplorer().action();
655
682
  await action.execute(command);
656
683
  log('Command executed successfully');
@@ -671,10 +698,11 @@ browserCmd
671
698
  .description('Launch a persistent browser server')
672
699
  .option('-s, --show', 'Launch browser in headed mode (visible window)')
673
700
  .option('--headless', 'Launch browser in headless mode')
701
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
674
702
  .option('-c, --config <path>', 'Path to configuration file')
675
703
  .option('-p, --path <path>', 'Working directory path')
676
704
  .action(async (options) => {
677
- const { launchServer, removeEndpointFile } = await import('../src/browser-server.js');
705
+ const { launchServer, removeEndpointFile, keepServerRunning } = await import('../src/browser-server.js');
678
706
  await ConfigParser.getInstance().loadConfig({
679
707
  config: options.config,
680
708
  path: options.path,
@@ -688,45 +716,34 @@ browserCmd
688
716
  const server = await launchServer({
689
717
  browser: config.playwright.browser,
690
718
  show,
719
+ }, options.instance);
720
+ await keepServerRunning(async () => {
721
+ await server.close();
722
+ removeEndpointFile(options.instance);
691
723
  });
692
- console.log('Browser server is running. Press Ctrl+C to stop.');
693
- const cleanup = () => {
694
- console.log('\nStopping browser server...');
695
- server.close();
696
- removeEndpointFile();
697
- process.exit(0);
698
- };
699
- process.on('SIGINT', cleanup);
700
- process.on('SIGTERM', cleanup);
701
724
  });
702
725
  browserCmd
703
726
  .command('stop')
704
727
  .description('Stop a running browser server')
728
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
705
729
  .option('-c, --config <path>', 'Path to configuration file')
706
730
  .option('-p, --path <path>', 'Working directory path')
707
731
  .action(async (options) => {
708
- const { getAliveEndpoint, removeEndpointFile } = await import('../src/browser-server.js');
732
+ const { stopServer } = await import('../src/browser-server.js');
709
733
  await ConfigParser.getInstance().loadConfig({
710
734
  config: options.config,
711
735
  path: options.path,
712
736
  });
713
- const endpoint = await getAliveEndpoint();
714
- if (!endpoint) {
737
+ if (!(await stopServer(options.instance))) {
715
738
  console.log('No running browser server found.');
716
739
  process.exit(0);
717
740
  }
718
- try {
719
- const { chromium } = await import('playwright-core');
720
- const browser = await chromium.connect(endpoint, { timeout: 3000 });
721
- await browser.close();
722
- }
723
- catch { }
724
- removeEndpointFile();
725
741
  console.log('Browser server stopped.');
726
742
  });
727
743
  browserCmd
728
744
  .command('status')
729
745
  .description('Check if a browser server is running')
746
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
730
747
  .option('-c, --config <path>', 'Path to configuration file')
731
748
  .option('-p, --path <path>', 'Working directory path')
732
749
  .action(async (options) => {
@@ -735,7 +752,7 @@ browserCmd
735
752
  config: options.config,
736
753
  path: options.path,
737
754
  });
738
- const endpoint = await getAliveEndpoint();
755
+ const endpoint = await getAliveEndpoint(options.instance);
739
756
  if (endpoint) {
740
757
  console.log(`Browser server is running at: ${endpoint}`);
741
758
  }
@@ -790,8 +807,10 @@ program
790
807
  });
791
808
  import { createApiCommands } from "../boat/api-tester/src/cli.js";
792
809
  import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
810
+ import { createPrimaCommands } from "../boat/prima/src/cli.js";
793
811
  program.addCommand(createApiCommands('api'));
794
812
  program.addCommand(createDocsCommands('docs'));
813
+ program.addCommand(createPrimaCommands('prima'));
795
814
  const envHelp = () => {
796
815
  const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
797
816
  const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import { remote } from "../../../src/remote.js";
2
3
  import { createApiCommands } from "../src/cli.js";
3
4
  const program = createApiCommands('apibot');
5
+ remote.registerOption(program);
4
6
  program.parse();
@@ -30,7 +30,7 @@ export class ApiBot {
30
30
  }
31
31
  }
32
32
  async start() {
33
- this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path });
33
+ this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path, endpoint: this.options.endpoint });
34
34
  this.provider = new AIProvider(this.config.ai);
35
35
  await this.provider.validateConnection();
36
36
  this.apiClient = new ApiClient(this.config.api.baseEndpoint, this.config.api.headers || {}, {
@@ -86,11 +86,12 @@ export class ApiBot {
86
86
  agentCurler() {
87
87
  return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState }) => new Curler(ai, apiClient, requestState, this.reporter)));
88
88
  }
89
- async plan(endpoint, opts = {}) {
89
+ async plan(target, opts = {}) {
90
90
  if (opts.fresh) {
91
91
  this.currentPlan = undefined;
92
92
  this.agents.chief = undefined;
93
93
  }
94
+ const endpoint = this.configParser.resolveEndpointPath(target);
94
95
  const chief = this.agentChief();
95
96
  const specDefinition = this.getEndpointDefinition(endpoint);
96
97
  this.currentPlan = await chief.plan(endpoint, { style: opts.style, specDefinition });
@@ -40,7 +40,7 @@ export function createApiCommands(name = 'api') {
40
40
  addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint').option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/').option('--fresh', 'Start planning from scratch')).action(async (endpoint, options) => {
41
41
  setPreserveConsoleLogs(true);
42
42
  try {
43
- const bot = new ApiBot(buildOptions(options));
43
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
44
44
  await bot.start();
45
45
  await bot.plan(endpoint, { style: options.style, fresh: options.fresh });
46
46
  const plan = bot.getCurrentPlan();
@@ -104,7 +104,7 @@ export function createApiCommands(name = 'api') {
104
104
  addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
105
105
  setPreserveConsoleLogs(true);
106
106
  try {
107
- const bot = new ApiBot(buildOptions(options));
107
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
108
108
  await bot.start();
109
109
  const styles = Object.keys(getStyles());
110
110
  let totalPassed = 0;
@@ -8,12 +8,15 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
11
12
  import { parseEnv } from 'node:util';
12
- import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
13
+ import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from "../../../src/config.js";
14
+ import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
13
15
  export class ApibotConfigParser {
14
16
  static instance;
15
17
  config = null;
16
18
  configPath = null;
19
+ site = null;
17
20
  constructor() { }
18
21
  static getInstance() {
19
22
  if (!ApibotConfigParser.instance) {
@@ -34,6 +37,7 @@ export class ApibotConfigParser {
34
37
  if (options?.path) {
35
38
  process.chdir(resolve(options.path));
36
39
  }
40
+ ApibotConfigParser.loadEnv(globalEnvPath());
37
41
  ApibotConfigParser.loadEnv('.env');
38
42
  const resolvedPath = options?.config || this.findConfigFile();
39
43
  if (!resolvedPath) {
@@ -59,7 +63,12 @@ export class ApibotConfigParser {
59
63
  };
60
64
  }
61
65
  this.config = this.mergeWithDefaults(loadedConfig);
66
+ await resolveConfigModels(this.config.ai);
62
67
  this.configPath = resolvedPath;
68
+ this.site = null;
69
+ if (isGlobalConfigPath(resolvedPath)) {
70
+ this.enterGlobalMode(this.config, options?.endpoint);
71
+ }
63
72
  this.validateConfig(this.config);
64
73
  return this.config;
65
74
  }
@@ -79,10 +88,23 @@ export class ApibotConfigParser {
79
88
  }
80
89
  getOutputDir() {
81
90
  const config = this.getConfig();
91
+ return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
92
+ }
93
+ getProjectRoot() {
94
+ if (this.site)
95
+ return this.site.dir;
82
96
  const configPath = this.getConfigPath();
83
97
  if (!configPath)
84
98
  throw new Error('Config path not found');
85
- return path.join(path.dirname(configPath), config.dirs?.output || 'output');
99
+ return path.dirname(configPath);
100
+ }
101
+ resolveEndpointPath(endpoint) {
102
+ if (!this.site)
103
+ return endpoint;
104
+ const resolved = resolveSiteTarget(endpoint, this.site.url);
105
+ if (resolved.baseUrl !== this.site.url)
106
+ return endpoint;
107
+ return resolved.path;
86
108
  }
87
109
  getPlansDir() {
88
110
  return path.join(this.getOutputDir(), 'plans');
@@ -92,21 +114,26 @@ export class ApibotConfigParser {
92
114
  }
93
115
  getKnowledgeDir() {
94
116
  const config = this.getConfig();
95
- const configPath = this.getConfigPath();
96
- if (!configPath)
97
- throw new Error('Config path not found');
98
- return path.join(path.dirname(configPath), config.dirs?.knowledge || 'knowledge');
117
+ return path.join(this.getProjectRoot(), config.dirs?.knowledge || 'knowledge');
99
118
  }
100
119
  ensureDirectory(dirPath) {
101
120
  if (!existsSync(dirPath)) {
102
121
  mkdirSync(dirPath, { recursive: true });
103
122
  }
104
123
  }
124
+ enterGlobalMode(config, endpoint) {
125
+ const site = resolveSiteTarget(endpoint);
126
+ this.site = registerSite(site.baseUrl);
127
+ config.dirs = { output: 'output', knowledge: 'knowledge' };
128
+ config.api = { ...config.api, baseEndpoint: site.baseUrl };
129
+ if (process.env.EXPLORBOT_API_SPEC)
130
+ config.api.spec = [process.env.EXPLORBOT_API_SPEC];
131
+ }
105
132
  async loadEnvConfig() {
106
133
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
107
134
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
108
135
  if (!provider && !modelSpec) {
109
- throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
136
+ throw new ConfigMissingError(missingConfigMessage('apibot.config.js'));
110
137
  }
111
138
  if (modelSpec && !provider && !modelSpec.includes('/')) {
112
139
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
@@ -149,13 +176,16 @@ export class ApibotConfigParser {
149
176
  if (existsSync(fullPath))
150
177
  return fullPath;
151
178
  }
152
- return null;
179
+ if (envConfigRequested())
180
+ return null;
181
+ return findGlobalConfig();
153
182
  }
154
183
  async loadConfigModule(configPath) {
155
184
  const ext = configPath.split('.').pop();
185
+ const moduleUrl = pathToFileURL(resolve(configPath)).href;
156
186
  if (ext === 'ts') {
157
187
  try {
158
- return await import(__rewriteRelativeImportExtension(configPath));
188
+ return await import(__rewriteRelativeImportExtension(moduleUrl));
159
189
  }
160
190
  catch {
161
191
  const require = (await import('node:module')).createRequire(import.meta.url);
@@ -163,7 +193,7 @@ export class ApibotConfigParser {
163
193
  }
164
194
  }
165
195
  if (ext === 'js' || ext === 'mjs') {
166
- return await import(__rewriteRelativeImportExtension(configPath));
196
+ return await import(__rewriteRelativeImportExtension(moduleUrl));
167
197
  }
168
198
  const content = readFileSync(configPath, 'utf8');
169
199
  return JSON.parse(content);
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import { remote } from "../../../src/remote.js";
2
3
  import { createDocsCommands } from "../src/cli.js";
3
4
  const program = createDocsCommands('doc-collector');
5
+ remote.registerOption(program);
4
6
  program.parse();