explorbot 0.2.2 → 0.2.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 (101) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +52 -37
  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 +39 -8
  6. package/boat/doc-collector/src/cli.ts +1 -0
  7. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  8. package/boat/doc-collector/src/state-diagram.ts +61 -14
  9. package/boat/prima/bin/prima-cli.ts +5 -0
  10. package/boat/prima/package.json +16 -0
  11. package/boat/prima/src/cli.ts +222 -0
  12. package/boat/prima/src/envelope.ts +141 -0
  13. package/boat/prima/src/prima.ts +705 -0
  14. package/boat/prima/src/pw-parser.ts +17 -0
  15. package/boat/prima/src/pw-registry.ts +75 -0
  16. package/dist/bin/explorbot-cli.js +44 -31
  17. package/dist/boat/api-tester/src/apibot.js +3 -2
  18. package/dist/boat/api-tester/src/cli.js +2 -2
  19. package/dist/boat/api-tester/src/config.js +36 -8
  20. package/dist/boat/doc-collector/src/cli.js +1 -0
  21. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  22. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  23. package/dist/boat/prima/bin/prima-cli.js +4 -0
  24. package/dist/boat/prima/src/cli.js +200 -0
  25. package/dist/boat/prima/src/envelope.js +116 -0
  26. package/dist/boat/prima/src/prima.js +635 -0
  27. package/dist/boat/prima/src/pw-parser.js +18 -0
  28. package/dist/boat/prima/src/pw-registry.js +66 -0
  29. package/dist/models.json +3 -0
  30. package/dist/package.json +6 -2
  31. package/dist/src/action.d.ts +5 -2
  32. package/dist/src/action.js +5 -5
  33. package/dist/src/ai/captain/mixin.js +3 -4
  34. package/dist/src/ai/captain/web-mode.js +1 -1
  35. package/dist/src/ai/navigator.d.ts +4 -0
  36. package/dist/src/ai/navigator.js +11 -6
  37. package/dist/src/ai/planner.d.ts +1 -0
  38. package/dist/src/ai/planner.js +6 -0
  39. package/dist/src/ai/researcher.js +1 -1
  40. package/dist/src/ai/task-agent.js +1 -1
  41. package/dist/src/ai/tester.d.ts +1 -0
  42. package/dist/src/ai/tester.js +13 -0
  43. package/dist/src/application-spec-contract.d.ts +8 -0
  44. package/dist/src/application-spec-contract.js +8 -0
  45. package/dist/src/application-spec.d.ts +15 -0
  46. package/dist/src/application-spec.js +71 -0
  47. package/dist/src/browser-server.d.ts +12 -6
  48. package/dist/src/browser-server.js +74 -19
  49. package/dist/src/commands/clean-command.js +2 -7
  50. package/dist/src/commands/init-command.d.ts +5 -0
  51. package/dist/src/commands/init-command.js +119 -1
  52. package/dist/src/commands/navigate-command.js +1 -1
  53. package/dist/src/commands/research-command.js +1 -1
  54. package/dist/src/commands/sites-command.d.ts +6 -0
  55. package/dist/src/commands/sites-command.js +23 -0
  56. package/dist/src/components/InitWizard.d.ts +10 -0
  57. package/dist/src/components/InitWizard.js +133 -0
  58. package/dist/src/components/InputReadline.d.ts +1 -0
  59. package/dist/src/components/InputReadline.js +7 -4
  60. package/dist/src/config.d.ts +24 -5
  61. package/dist/src/config.js +146 -37
  62. package/dist/src/explorbot.d.ts +9 -0
  63. package/dist/src/explorbot.js +24 -5
  64. package/dist/src/explorer.d.ts +4 -1
  65. package/dist/src/explorer.js +40 -6
  66. package/dist/src/global-config.d.ts +22 -0
  67. package/dist/src/global-config.js +117 -0
  68. package/dist/src/knowledge-tracker.d.ts +5 -1
  69. package/dist/src/knowledge-tracker.js +14 -1
  70. package/dist/src/utils/cli-name.js +6 -2
  71. package/dist/src/utils/test-files.js +1 -2
  72. package/dist/src/utils/url-matcher.d.ts +1 -0
  73. package/dist/src/utils/url-matcher.js +9 -0
  74. package/models.json +3 -0
  75. package/package.json +6 -2
  76. package/src/action.ts +9 -5
  77. package/src/ai/captain/mixin.ts +3 -3
  78. package/src/ai/captain/web-mode.ts +1 -1
  79. package/src/ai/navigator.ts +12 -7
  80. package/src/ai/planner.ts +7 -0
  81. package/src/ai/researcher.ts +1 -1
  82. package/src/ai/task-agent.ts +1 -1
  83. package/src/ai/tester.ts +15 -0
  84. package/src/application-spec-contract.ts +10 -0
  85. package/src/application-spec.ts +87 -0
  86. package/src/browser-server.ts +74 -19
  87. package/src/commands/clean-command.ts +1 -6
  88. package/src/commands/init-command.ts +146 -1
  89. package/src/commands/navigate-command.ts +1 -1
  90. package/src/commands/research-command.ts +1 -1
  91. package/src/commands/sites-command.ts +27 -0
  92. package/src/components/InitWizard.tsx +166 -0
  93. package/src/components/InputReadline.tsx +8 -4
  94. package/src/config.ts +162 -39
  95. package/src/explorbot.ts +30 -5
  96. package/src/explorer.ts +45 -7
  97. package/src/global-config.ts +148 -0
  98. package/src/knowledge-tracker.ts +17 -1
  99. package/src/utils/cli-name.ts +5 -2
  100. package/src/utils/test-files.ts +1 -2
  101. package/src/utils/url-matcher.ts +10 -0
package/README.md CHANGED
@@ -138,7 +138,7 @@ EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
138
138
  npx explorbot explore /admin/users --max-tests 3
139
139
  ```
140
140
 
141
- Output lands in a temp directory and nothing is written to your project. See [Agentic Usage](docs/workflow/agentic-usage.md).
141
+ Output lands in a per-host state directory, `~/.explorbot/state/<host>/`, so runs against the same app collect in one place and nothing is written to your project. Set `EXPLORBOT_EPHEMERAL=1` to keep nothing between runs. See [Agentic Usage](docs/workflow/agentic-usage.md).
142
142
 
143
143
  ## Teaching Explorbot
144
144
 
@@ -40,6 +40,7 @@ interface CLIOptions {
40
40
  headless?: boolean;
41
41
  incognito?: boolean;
42
42
  session?: string | boolean;
43
+ spec?: string;
43
44
  }
44
45
 
45
46
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
@@ -52,6 +53,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
52
53
  headless: options.headless,
53
54
  incognito: options.incognito,
54
55
  session: options.session,
56
+ applicationSpec: options.spec,
55
57
  } as ExplorBotOptions;
56
58
  }
57
59
 
@@ -64,6 +66,7 @@ function addCommonOptions(cmd: Command): Command {
64
66
  .option('-s, --show', 'Show browser window')
65
67
  .option('--headless', 'Run browser in headless mode')
66
68
  .option('--incognito', 'Run without recording experiences')
69
+ .option('--spec <path>', 'Use a Docbot application spec directory or index.md')
67
70
  .option('--session [file]', 'Save/restore browser session from file');
68
71
  }
69
72
 
@@ -386,6 +389,14 @@ program
386
389
  }
387
390
  });
388
391
 
392
+ program
393
+ .command('sites')
394
+ .description('List sites registered in the global installation')
395
+ .action(async () => {
396
+ const { SitesCommand } = await import('../src/commands/sites-command.js');
397
+ await new SitesCommand(new ExplorBot()).execute('');
398
+ });
399
+
389
400
  addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
390
401
  try {
391
402
  const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
@@ -421,17 +432,28 @@ addCommonOptions(
421
432
 
422
433
  program
423
434
  .command('init')
424
- .description('Initialize a new project with configuration')
425
- .option('-c, --config-path <path>', 'Path for the config file', './explorbot.config.js')
435
+ .description('Initialize configuration for a project or for this machine')
436
+ .option('-c, --config-path <path>', 'Path for the config file')
426
437
  .option('-f, --force', 'Overwrite existing config file')
427
438
  .option('-p, --path <path>', 'Working directory for initialization')
439
+ .option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
440
+ .option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
441
+ .option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
428
442
  .action(async (options) => {
429
- const { runInitCommand } = await import('../src/commands/init-command.js');
430
- runInitCommand({
431
- configPath: options.configPath,
432
- force: options.force,
433
- path: options.path,
434
- });
443
+ try {
444
+ const { runInit } = await import('../src/commands/init-command.js');
445
+ await runInit({
446
+ configPath: options.configPath,
447
+ force: options.force,
448
+ path: options.path,
449
+ global: options.global,
450
+ provider: options.provider,
451
+ apiKey: options.apiKey,
452
+ });
453
+ } catch (error) {
454
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
455
+ process.exit(1);
456
+ }
435
457
  });
436
458
 
437
459
  program
@@ -694,7 +716,7 @@ program
694
716
  const explorBot = new ExplorBot(mainOptions);
695
717
  await explorBot.start();
696
718
 
697
- await explorBot.agentNavigator().visit(url);
719
+ await explorBot.visit(url);
698
720
 
699
721
  const { ContextCommand } = await import('../src/commands/context-command.js');
700
722
  const argParts: string[] = [];
@@ -716,7 +738,7 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
716
738
  try {
717
739
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
718
740
  await explorBot.start();
719
- await explorBot.agentNavigator().visit(url);
741
+ await explorBot.visit(url);
720
742
 
721
743
  const action = explorBot.getExplorer().action();
722
744
  await action.execute(command);
@@ -740,10 +762,11 @@ browserCmd
740
762
  .description('Launch a persistent browser server')
741
763
  .option('-s, --show', 'Launch browser in headed mode (visible window)')
742
764
  .option('--headless', 'Launch browser in headless mode')
765
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
743
766
  .option('-c, --config <path>', 'Path to configuration file')
744
767
  .option('-p, --path <path>', 'Working directory path')
745
768
  .action(async (options) => {
746
- const { launchServer, removeEndpointFile } = await import('../src/browser-server.js');
769
+ const { launchServer, removeEndpointFile, keepServerRunning } = await import('../src/browser-server.js');
747
770
  await ConfigParser.getInstance().loadConfig({
748
771
  config: options.config,
749
772
  path: options.path,
@@ -754,55 +777,45 @@ browserCmd
754
777
  if (options.show !== undefined) show = true;
755
778
  if (options.headless !== undefined) show = false;
756
779
 
757
- const server = await launchServer({
758
- browser: config.playwright.browser,
759
- show,
760
- });
761
-
762
- console.log('Browser server is running. Press Ctrl+C to stop.');
763
-
764
- const cleanup = () => {
765
- console.log('\nStopping browser server...');
766
- server.close();
767
- removeEndpointFile();
768
- process.exit(0);
769
- };
780
+ const server = await launchServer(
781
+ {
782
+ browser: config.playwright.browser,
783
+ show,
784
+ },
785
+ options.instance
786
+ );
770
787
 
771
- process.on('SIGINT', cleanup);
772
- process.on('SIGTERM', cleanup);
788
+ await keepServerRunning(async () => {
789
+ await server.close();
790
+ removeEndpointFile(options.instance);
791
+ });
773
792
  });
774
793
 
775
794
  browserCmd
776
795
  .command('stop')
777
796
  .description('Stop a running browser server')
797
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
778
798
  .option('-c, --config <path>', 'Path to configuration file')
779
799
  .option('-p, --path <path>', 'Working directory path')
780
800
  .action(async (options) => {
781
- const { getAliveEndpoint, removeEndpointFile } = await import('../src/browser-server.js');
801
+ const { stopServer } = await import('../src/browser-server.js');
782
802
  await ConfigParser.getInstance().loadConfig({
783
803
  config: options.config,
784
804
  path: options.path,
785
805
  });
786
806
 
787
- const endpoint = await getAliveEndpoint();
788
- if (!endpoint) {
807
+ if (!(await stopServer(options.instance))) {
789
808
  console.log('No running browser server found.');
790
809
  process.exit(0);
791
810
  }
792
811
 
793
- try {
794
- const { chromium } = await import('playwright-core');
795
- const browser = await chromium.connect(endpoint, { timeout: 3000 });
796
- await browser.close();
797
- } catch {}
798
-
799
- removeEndpointFile();
800
812
  console.log('Browser server stopped.');
801
813
  });
802
814
 
803
815
  browserCmd
804
816
  .command('status')
805
817
  .description('Check if a browser server is running')
818
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
806
819
  .option('-c, --config <path>', 'Path to configuration file')
807
820
  .option('-p, --path <path>', 'Working directory path')
808
821
  .action(async (options) => {
@@ -812,7 +825,7 @@ browserCmd
812
825
  path: options.path,
813
826
  });
814
827
 
815
- const endpoint = await getAliveEndpoint();
828
+ const endpoint = await getAliveEndpoint(options.instance);
816
829
  if (endpoint) {
817
830
  console.log(`Browser server is running at: ${endpoint}`);
818
831
  } else {
@@ -871,8 +884,10 @@ program
871
884
 
872
885
  import { createApiCommands } from '../boat/api-tester/src/cli.ts';
873
886
  import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
887
+ import { createPrimaCommands } from '../boat/prima/src/cli.ts';
874
888
  program.addCommand(createApiCommands('api'));
875
889
  program.addCommand(createDocsCommands('docs'));
890
+ program.addCommand(createPrimaCommands('prima'));
876
891
 
877
892
  const envHelp = () => {
878
893
  const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
@@ -33,7 +33,7 @@ export class ApiBot {
33
33
  }
34
34
 
35
35
  async start(): Promise<void> {
36
- this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path });
36
+ this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path, endpoint: this.options.endpoint });
37
37
  this.provider = new AIProvider(this.config.ai);
38
38
  await this.provider.validateConnection();
39
39
 
@@ -101,12 +101,13 @@ export class ApiBot {
101
101
  return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState }) => new Curler(ai, apiClient, requestState, this.reporter)));
102
102
  }
103
103
 
104
- async plan(endpoint: string, opts: { style?: string; fresh?: boolean } = {}): Promise<Plan> {
104
+ async plan(target: string, opts: { style?: string; fresh?: boolean } = {}): Promise<Plan> {
105
105
  if (opts.fresh) {
106
106
  this.currentPlan = undefined;
107
107
  this.agents.chief = undefined;
108
108
  }
109
109
 
110
+ const endpoint = this.configParser.resolveEndpointPath(target);
110
111
  const chief = this.agentChief();
111
112
  const specDefinition = this.getEndpointDefinition(endpoint);
112
113
  this.currentPlan = await chief.plan(endpoint, { style: opts.style, specDefinition });
@@ -198,6 +199,7 @@ interface ApibotOptions {
198
199
  verbose?: boolean;
199
200
  config?: string;
200
201
  path?: string;
202
+ endpoint?: string;
201
203
  }
202
204
 
203
205
  export type { ApibotOptions };
@@ -49,7 +49,7 @@ export function createApiCommands(name = 'api'): Command {
49
49
  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) => {
50
50
  setPreserveConsoleLogs(true);
51
51
  try {
52
- const bot = new ApiBot(buildOptions(options));
52
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
53
53
  await bot.start();
54
54
 
55
55
  await bot.plan(endpoint, { style: options.style, fresh: options.fresh });
@@ -122,7 +122,7 @@ export function createApiCommands(name = 'api'): Command {
122
122
  addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
123
123
  setPreserveConsoleLogs(true);
124
124
  try {
125
- const bot = new ApiBot(buildOptions(options));
125
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
126
126
  await bot.start();
127
127
 
128
128
  const styles = Object.keys(getStyles());
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
- import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
4
+ import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
5
+ import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
5
6
 
6
7
  export type { AIConfig };
7
8
 
@@ -26,6 +27,7 @@ export class ApibotConfigParser {
26
27
  private static instance: ApibotConfigParser;
27
28
  private config: ApibotConfig | null = null;
28
29
  private configPath: string | null = null;
30
+ private site: SiteRecord | null = null;
29
31
 
30
32
  private constructor() {}
31
33
 
@@ -42,7 +44,7 @@ export class ApibotConfigParser {
42
44
  Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
43
45
  }
44
46
 
45
- async loadConfig(options?: { config?: string; path?: string }): Promise<ApibotConfig> {
47
+ async loadConfig(options?: { config?: string; path?: string; endpoint?: string }): Promise<ApibotConfig> {
46
48
  if (this.config && !options?.config && !options?.path) return this.config;
47
49
 
48
50
  const originalCwd = process.cwd();
@@ -50,6 +52,7 @@ export class ApibotConfigParser {
50
52
  process.chdir(resolve(options.path));
51
53
  }
52
54
 
55
+ ApibotConfigParser.loadEnv(globalEnvPath());
53
56
  ApibotConfigParser.loadEnv('.env');
54
57
 
55
58
  const resolvedPath = options?.config || this.findConfigFile();
@@ -78,7 +81,14 @@ export class ApibotConfigParser {
78
81
  }
79
82
 
80
83
  this.config = this.mergeWithDefaults(loadedConfig);
84
+ await resolveConfigModels(this.config.ai);
81
85
  this.configPath = resolvedPath;
86
+ this.site = null;
87
+
88
+ if (isGlobalConfigPath(resolvedPath)) {
89
+ this.enterGlobalMode(this.config, options?.endpoint);
90
+ }
91
+
82
92
  this.validateConfig(this.config);
83
93
 
84
94
  return this.config;
@@ -100,9 +110,22 @@ export class ApibotConfigParser {
100
110
 
101
111
  getOutputDir(): string {
102
112
  const config = this.getConfig();
113
+ return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
114
+ }
115
+
116
+ getProjectRoot(): string {
117
+ if (this.site) return this.site.dir;
103
118
  const configPath = this.getConfigPath();
104
119
  if (!configPath) throw new Error('Config path not found');
105
- return path.join(path.dirname(configPath), config.dirs?.output || 'output');
120
+ return path.dirname(configPath);
121
+ }
122
+
123
+ resolveEndpointPath(endpoint: string): string {
124
+ if (!this.site) return endpoint;
125
+
126
+ const resolved = resolveSiteTarget(endpoint, this.site.url);
127
+ if (resolved.baseUrl !== this.site.url) return endpoint;
128
+ return resolved.path;
106
129
  }
107
130
 
108
131
  getPlansDir(): string {
@@ -115,9 +138,7 @@ export class ApibotConfigParser {
115
138
 
116
139
  getKnowledgeDir(): string {
117
140
  const config = this.getConfig();
118
- const configPath = this.getConfigPath();
119
- if (!configPath) throw new Error('Config path not found');
120
- return path.join(path.dirname(configPath), config.dirs?.knowledge || 'knowledge');
141
+ return path.join(this.getProjectRoot(), config.dirs?.knowledge || 'knowledge');
121
142
  }
122
143
 
123
144
  ensureDirectory(dirPath: string): void {
@@ -126,11 +147,20 @@ export class ApibotConfigParser {
126
147
  }
127
148
  }
128
149
 
150
+ private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
151
+ const site = resolveSiteTarget(endpoint);
152
+ this.site = registerSite(site.baseUrl);
153
+
154
+ config.dirs = { output: 'output', knowledge: 'knowledge' };
155
+ config.api = { ...config.api, baseEndpoint: site.baseUrl };
156
+ if (process.env.EXPLORBOT_API_SPEC) config.api.spec = [process.env.EXPLORBOT_API_SPEC];
157
+ }
158
+
129
159
  private async loadEnvConfig(): Promise<ApibotConfig> {
130
160
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
131
161
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
132
162
  if (!provider && !modelSpec) {
133
- throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
163
+ throw new ConfigMissingError(missingConfigMessage('apibot.config.js'));
134
164
  }
135
165
  if (modelSpec && !provider && !modelSpec.includes('/')) {
136
166
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
@@ -177,7 +207,8 @@ export class ApibotConfigParser {
177
207
  if (existsSync(fullPath)) return fullPath;
178
208
  }
179
209
 
180
- return null;
210
+ if (envConfigRequested()) return null;
211
+ return findGlobalConfig();
181
212
  }
182
213
 
183
214
  private async loadConfigModule(configPath: string): Promise<any> {
@@ -55,6 +55,7 @@ export function createDocsCommands(name = 'docs'): Command {
55
55
  console.log(`Skipped ${result.skipped.length} page(s)`);
56
56
  console.log(`Spec index: ${result.indexPath}`);
57
57
  console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
58
+ console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
58
59
 
59
60
  await bot.stop();
60
61
  process.exit(0);
@@ -1,9 +1,11 @@
1
1
  import path from 'node:path';
2
+ import matter from 'gray-matter';
3
+ import { APPLICATION_SPEC_FORMAT, APPLICATION_SPEC_VERSION } from '../../../src/application-spec-contract.ts';
2
4
  import { type WebPageState } from '../../../src/state-manager.ts';
5
+ import { normalizeInlineText } from '../../../src/utils/strings.ts';
3
6
  import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
4
7
  import type { DocumentationScreenshot } from './screenshots.ts';
5
- import { buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph, type DocumentedPage, type SkippedPage } from './state-diagram.ts';
6
- import { normalizeInlineText } from '../../../src/utils/strings.ts';
8
+ import { type DocumentedPage, type SkippedPage, buildStateGraph, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph } from './state-diagram.ts';
7
9
 
8
10
  function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
9
11
  const lines: string[] = [];
@@ -33,6 +35,13 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
33
35
  }
34
36
 
35
37
  const interactions = documentation.interactions;
38
+ const pageStateDiagram = renderPageStateDiagram(state.title || state.url || 'page', state.url || '', interactions || []);
39
+ if (pageStateDiagram) {
40
+ lines.push('## State Map');
41
+ lines.push('');
42
+ lines.push(`\`\`\`mermaid\n${pageStateDiagram}\n\`\`\``);
43
+ lines.push('');
44
+ }
36
45
  if (interactions && interactions.length > 0) {
37
46
  lines.push('## State Transitions');
38
47
  lines.push('');
@@ -101,7 +110,11 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
101
110
  lines.push('');
102
111
  }
103
112
 
104
- return `${lines.join('\n').trimEnd()}\n`;
113
+ return matter.stringify(`${lines.join('\n').trimEnd()}\n`, {
114
+ url: state.url,
115
+ format: APPLICATION_SPEC_FORMAT,
116
+ version: APPLICATION_SPEC_VERSION,
117
+ });
105
118
  }
106
119
 
107
120
  function renderSpecIndex(outputDir: string, startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
@@ -116,9 +129,10 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented
116
129
  lines.push(`Max pages: ${maxPages}`);
117
130
  lines.push('');
118
131
  const graph = buildStateGraph(outputDir, pages);
132
+ const mermaid = renderMermaidFromGraph(graph, true);
119
133
  lines.push('## State Transitions');
120
134
  lines.push('');
121
- lines.push(`\`\`\`mermaid\n${renderMermaidFromGraph(graph)}\n\`\`\``);
135
+ lines.push(`\`\`\`mermaid\n${mermaid}\n\`\`\``);
122
136
  lines.push('');
123
137
  const stateMap = renderStateMapFromGraph(graph);
124
138
  if (stateMap) {
@@ -69,8 +69,12 @@ function buildStateGraph(outputDir: string, pages: DocumentedPage[]): StateGraph
69
69
  continue;
70
70
  }
71
71
 
72
- const pairKey = `${sourceId}>${targetId}`;
73
- if (adjacency.get(targetId)?.has(sourceId)) {
72
+ if (adjacency.get(sourceId)?.has(targetId)) {
73
+ continue;
74
+ }
75
+
76
+ if (createsCycle(sourceId, targetId, adjacency)) {
77
+ const pairKey = `${sourceId}>${targetId}`;
74
78
  if (drawnBack.has(pairKey)) {
75
79
  continue;
76
80
  }
@@ -78,10 +82,6 @@ function buildStateGraph(outputDir: string, pages: DocumentedPage[]): StateGraph
78
82
  edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: true });
79
83
  continue;
80
84
  }
81
-
82
- if (adjacency.get(sourceId)?.has(targetId) || createsCycle(sourceId, targetId, adjacency)) {
83
- continue;
84
- }
85
85
  adjacency.get(sourceId)?.add(targetId);
86
86
  edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: false });
87
87
  }
@@ -104,8 +104,8 @@ function renderMermaidBody(outputDir: string, pages: DocumentedPage[]): string {
104
104
  return renderMermaidFromGraph(buildStateGraph(outputDir, pages));
105
105
  }
106
106
 
107
- function renderMermaidFromGraph(graph: StateGraph): string {
108
- const lines: string[] = ['flowchart TD'];
107
+ function renderMermaidFromGraph(graph: StateGraph, compact = false): string {
108
+ const lines: string[] = [compact ? 'flowchart LR' : 'flowchart TD'];
109
109
  if (graph.pages.length === 0) {
110
110
  lines.push(' empty["No documented states"]');
111
111
  return lines.join('\n');
@@ -117,6 +117,12 @@ function renderMermaidFromGraph(graph: StateGraph): string {
117
117
  if (!children || children.length === 0) {
118
118
  continue;
119
119
  }
120
+ if (compact) {
121
+ for (const child of children) {
122
+ lines.push(` ${renderNodeLine(child)}`);
123
+ }
124
+ continue;
125
+ }
120
126
  lines.push(` subgraph sg_${page.id} ["${escapeMermaidLabel(page.label)} — transient states"]`);
121
127
  for (const child of children) {
122
128
  lines.push(` ${renderNodeLine(child)}`);
@@ -125,11 +131,12 @@ function renderMermaidFromGraph(graph: StateGraph): string {
125
131
  }
126
132
 
127
133
  for (const edge of graph.edges) {
128
- let arrow = '-->';
129
- if (edge.isBack) {
130
- arrow = '-.->';
134
+ const arrow = edge.isBack ? '-.->' : '-->';
135
+ if (compact) {
136
+ lines.push(` ${edge.source} ${arrow} ${edge.target}`);
137
+ } else {
138
+ lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
131
139
  }
132
- lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
133
140
  }
134
141
 
135
142
  lines.push(' classDef page fill:#dbeafe,stroke:#2563eb,color:#0f172a;');
@@ -174,6 +181,46 @@ function renderStateMapFromGraph(graph: StateGraph): string {
174
181
  return rows.join('\n');
175
182
  }
176
183
 
184
+ function renderPageStateDiagram(label: string, url: string, interactions: StateTransition[]): string {
185
+ const targets = new Map<string, { node: StateNode; action: string; screenshot?: { title: string; relativePath: string } }>();
186
+ let index = 0;
187
+ for (const interaction of interactions) {
188
+ const targetState = interaction.targetState;
189
+ if (!targetState) {
190
+ continue;
191
+ }
192
+ const key = `${targetState.kind}:${targetState.label}:${normalizeUrl(targetState.url)}`;
193
+ if (targets.has(key)) {
194
+ continue;
195
+ }
196
+ targets.set(key, {
197
+ node: { id: `target${index++}`, kind: targetState.kind, label: targetState.label, subLabel: targetState.kind },
198
+ action: interaction.action,
199
+ screenshot: interaction.screenshot,
200
+ });
201
+ }
202
+
203
+ if (targets.size === 0) {
204
+ return '';
205
+ }
206
+
207
+ const lines: string[] = ['flowchart LR'];
208
+ lines.push(` ${renderNodeLine({ id: 'self', kind: 'page', label, subLabel: url })}`);
209
+ for (const target of targets.values()) {
210
+ lines.push(` ${renderNodeLine(target.node)}`);
211
+ }
212
+ for (const target of targets.values()) {
213
+ lines.push(` self -->|"${escapeMermaidLabel(target.action)}"| ${target.node.id}`);
214
+ }
215
+ for (const target of targets.values()) {
216
+ if (target.screenshot) {
217
+ lines.push(` click ${target.node.id} "${target.screenshot.relativePath}" "${escapeMermaidLabel(target.screenshot.title)}"`);
218
+ }
219
+ }
220
+
221
+ return lines.join('\n');
222
+ }
223
+
177
224
  function renderNodeLine(node: StateNode): string {
178
225
  const label = `${escapeMermaidLabel(node.label)}<br/>${escapeMermaidLabel(node.subLabel)}`;
179
226
  if (node.kind === 'dialog' || node.kind === 'modal') {
@@ -207,7 +254,7 @@ function createsCycle(sourceId: string, targetId: string, adjacency: Map<string,
207
254
  }
208
255
 
209
256
  function escapeMermaidLabel(value: string): string {
210
- return normalizeInlineText(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('|', '&#124;');
257
+ return normalizeInlineText(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('#', '&#35;').replaceAll('<', '&#60;').replaceAll('>', '&#62;').replaceAll('|', '&#124;');
211
258
  }
212
259
 
213
260
  function escapeTable(value: string): string {
@@ -277,5 +324,5 @@ interface StateGraph {
277
324
  classAssignment: Map<StateClass, string[]>;
278
325
  }
279
326
 
280
- export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderStateMapFromGraph };
327
+ export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph };
281
328
  export type { DocumentedPage, SkippedPage, StateGraph, StateNode, StateEdge, StateClick };
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ import { createPrimaCommands } from '../src/cli.ts';
3
+
4
+ const program = createPrimaCommands('prima');
5
+ program.parse();
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "prima",
3
+ "version": "1.0.0",
4
+ "description": "High-level browser driver CLI for orchestrating agents",
5
+ "type": "module",
6
+ "bin": { "prima": "./bin/prima-cli.ts" },
7
+ "scripts": {
8
+ "format": "biome format --write .",
9
+ "lint:fix": "biome lint --write .",
10
+ "check:fix": "biome check --write ."
11
+ },
12
+ "dependencies": {
13
+ "commander": "^14.0.1",
14
+ "dedent": "^1.6.0"
15
+ }
16
+ }