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.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +52 -37
- package/boat/api-tester/src/apibot.ts +4 -2
- package/boat/api-tester/src/cli.ts +2 -2
- package/boat/api-tester/src/config.ts +39 -8
- package/boat/doc-collector/src/cli.ts +1 -0
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- package/boat/doc-collector/src/state-diagram.ts +61 -14
- package/boat/prima/bin/prima-cli.ts +5 -0
- package/boat/prima/package.json +16 -0
- package/boat/prima/src/cli.ts +222 -0
- package/boat/prima/src/envelope.ts +141 -0
- package/boat/prima/src/prima.ts +705 -0
- package/boat/prima/src/pw-parser.ts +17 -0
- package/boat/prima/src/pw-registry.ts +75 -0
- package/dist/bin/explorbot-cli.js +44 -31
- package/dist/boat/api-tester/src/apibot.js +3 -2
- package/dist/boat/api-tester/src/cli.js +2 -2
- package/dist/boat/api-tester/src/config.js +36 -8
- package/dist/boat/doc-collector/src/cli.js +1 -0
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- package/dist/boat/doc-collector/src/state-diagram.js +57 -13
- package/dist/boat/prima/bin/prima-cli.js +4 -0
- package/dist/boat/prima/src/cli.js +200 -0
- package/dist/boat/prima/src/envelope.js +116 -0
- package/dist/boat/prima/src/prima.js +635 -0
- package/dist/boat/prima/src/pw-parser.js +18 -0
- package/dist/boat/prima/src/pw-registry.js +66 -0
- package/dist/models.json +3 -0
- package/dist/package.json +6 -2
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +5 -5
- package/dist/src/ai/captain/mixin.js +3 -4
- package/dist/src/ai/captain/web-mode.js +1 -1
- package/dist/src/ai/navigator.d.ts +4 -0
- package/dist/src/ai/navigator.js +11 -6
- package/dist/src/ai/planner.d.ts +1 -0
- package/dist/src/ai/planner.js +6 -0
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +13 -0
- package/dist/src/application-spec-contract.d.ts +8 -0
- package/dist/src/application-spec-contract.js +8 -0
- package/dist/src/application-spec.d.ts +15 -0
- package/dist/src/application-spec.js +71 -0
- package/dist/src/browser-server.d.ts +12 -6
- package/dist/src/browser-server.js +74 -19
- package/dist/src/commands/clean-command.js +2 -7
- package/dist/src/commands/init-command.d.ts +5 -0
- package/dist/src/commands/init-command.js +119 -1
- package/dist/src/commands/navigate-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/sites-command.d.ts +6 -0
- package/dist/src/commands/sites-command.js +23 -0
- package/dist/src/components/InitWizard.d.ts +10 -0
- package/dist/src/components/InitWizard.js +133 -0
- package/dist/src/components/InputReadline.d.ts +1 -0
- package/dist/src/components/InputReadline.js +7 -4
- package/dist/src/config.d.ts +24 -5
- package/dist/src/config.js +146 -37
- package/dist/src/explorbot.d.ts +9 -0
- package/dist/src/explorbot.js +24 -5
- package/dist/src/explorer.d.ts +4 -1
- package/dist/src/explorer.js +40 -6
- package/dist/src/global-config.d.ts +22 -0
- package/dist/src/global-config.js +117 -0
- package/dist/src/knowledge-tracker.d.ts +5 -1
- package/dist/src/knowledge-tracker.js +14 -1
- package/dist/src/utils/cli-name.js +6 -2
- package/dist/src/utils/test-files.js +1 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +9 -0
- package/models.json +3 -0
- package/package.json +6 -2
- package/src/action.ts +9 -5
- package/src/ai/captain/mixin.ts +3 -3
- package/src/ai/captain/web-mode.ts +1 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner.ts +7 -0
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +15 -0
- package/src/application-spec-contract.ts +10 -0
- package/src/application-spec.ts +87 -0
- package/src/browser-server.ts +74 -19
- package/src/commands/clean-command.ts +1 -6
- package/src/commands/init-command.ts +146 -1
- package/src/commands/navigate-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/sites-command.ts +27 -0
- package/src/components/InitWizard.tsx +166 -0
- package/src/components/InputReadline.tsx +8 -4
- package/src/config.ts +162 -39
- package/src/explorbot.ts +30 -5
- package/src/explorer.ts +45 -7
- package/src/global-config.ts +148 -0
- package/src/knowledge-tracker.ts +17 -1
- package/src/utils/cli-name.ts +5 -2
- package/src/utils/test-files.ts +1 -2
- package/src/utils/url-matcher.ts +10 -0
|
@@ -0,0 +1,17 @@
|
|
|
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 `I.usePlaywrightTo('pw', async (playwright) => (${expr.trim()})(playwright))`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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: { workspaceDir: string; title?: string }): { match?: PwServerDescriptor; candidates: PwServerDescriptor[] } {
|
|
24
|
+
const workspaceDir = path.resolve(opts.workspaceDir);
|
|
25
|
+
const candidates = descriptors.filter((descriptor) => path.resolve(descriptor.workspaceDir) === workspaceDir);
|
|
26
|
+
if (!candidates.length) return { candidates };
|
|
27
|
+
|
|
28
|
+
if (opts.title) {
|
|
29
|
+
const titled = candidates.find((descriptor) => descriptor.title === opts.title);
|
|
30
|
+
if (titled) return { match: titled, candidates };
|
|
31
|
+
return { candidates };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const preferred = candidates.find((descriptor) => descriptor.title === DEFAULT_TITLE);
|
|
35
|
+
if (preferred) return { match: preferred, candidates };
|
|
36
|
+
if (candidates.length === 1) return { match: candidates[0], candidates };
|
|
37
|
+
return { candidates };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listFiles(dir: string): string[] {
|
|
41
|
+
try {
|
|
42
|
+
return readdirSync(dir);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseDescriptor(file: string): PwServerDescriptor | null {
|
|
49
|
+
let data: any;
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!data?.endpoint || !data?.title || !data?.workspaceDir) return null;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
file,
|
|
60
|
+
title: data.title,
|
|
61
|
+
endpoint: data.endpoint,
|
|
62
|
+
workspaceDir: data.workspaceDir,
|
|
63
|
+
browserName: data.browser?.browserName || DEFAULT_BROWSER,
|
|
64
|
+
playwrightLib: data.playwrightLib || '',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PwServerDescriptor {
|
|
69
|
+
file: string;
|
|
70
|
+
title: string;
|
|
71
|
+
endpoint: string;
|
|
72
|
+
workspaceDir: string;
|
|
73
|
+
browserName: string;
|
|
74
|
+
playwrightLib: string;
|
|
75
|
+
}
|
|
@@ -36,6 +36,7 @@ function buildExplorBotOptions(from, options) {
|
|
|
36
36
|
headless: options.headless,
|
|
37
37
|
incognito: options.incognito,
|
|
38
38
|
session: options.session,
|
|
39
|
+
applicationSpec: options.spec,
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
function addCommonOptions(cmd) {
|
|
@@ -47,6 +48,7 @@ function addCommonOptions(cmd) {
|
|
|
47
48
|
.option('-s, --show', 'Show browser window')
|
|
48
49
|
.option('--headless', 'Run browser in headless mode')
|
|
49
50
|
.option('--incognito', 'Run without recording experiences')
|
|
51
|
+
.option('--spec <path>', 'Use a Docbot application spec directory or index.md')
|
|
50
52
|
.option('--session [file]', 'Save/restore browser session from file');
|
|
51
53
|
}
|
|
52
54
|
async function startTUI(explorBot) {
|
|
@@ -341,6 +343,13 @@ program
|
|
|
341
343
|
process.exit(1);
|
|
342
344
|
}
|
|
343
345
|
});
|
|
346
|
+
program
|
|
347
|
+
.command('sites')
|
|
348
|
+
.description('List sites registered in the global installation')
|
|
349
|
+
.action(async () => {
|
|
350
|
+
const { SitesCommand } = await import('../src/commands/sites-command.js');
|
|
351
|
+
await new SitesCommand(new ExplorBot()).execute('');
|
|
352
|
+
});
|
|
344
353
|
addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
|
|
345
354
|
try {
|
|
346
355
|
const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
|
|
@@ -373,17 +382,29 @@ addCommonOptions(program
|
|
|
373
382
|
});
|
|
374
383
|
program
|
|
375
384
|
.command('init')
|
|
376
|
-
.description('Initialize a
|
|
377
|
-
.option('-c, --config-path <path>', 'Path for the config file'
|
|
385
|
+
.description('Initialize configuration for a project or for this machine')
|
|
386
|
+
.option('-c, --config-path <path>', 'Path for the config file')
|
|
378
387
|
.option('-f, --force', 'Overwrite existing config file')
|
|
379
388
|
.option('-p, --path <path>', 'Working directory for initialization')
|
|
389
|
+
.option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
|
|
390
|
+
.option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
|
|
391
|
+
.option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
|
|
380
392
|
.action(async (options) => {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
393
|
+
try {
|
|
394
|
+
const { runInit } = await import('../src/commands/init-command.js');
|
|
395
|
+
await runInit({
|
|
396
|
+
configPath: options.configPath,
|
|
397
|
+
force: options.force,
|
|
398
|
+
path: options.path,
|
|
399
|
+
global: options.global,
|
|
400
|
+
provider: options.provider,
|
|
401
|
+
apiKey: options.apiKey,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
387
408
|
});
|
|
388
409
|
program
|
|
389
410
|
.command('clean [target]')
|
|
@@ -626,7 +647,7 @@ program
|
|
|
626
647
|
};
|
|
627
648
|
const explorBot = new ExplorBot(mainOptions);
|
|
628
649
|
await explorBot.start();
|
|
629
|
-
await explorBot.
|
|
650
|
+
await explorBot.visit(url);
|
|
630
651
|
const { ContextCommand } = await import('../src/commands/context-command.js');
|
|
631
652
|
const argParts = [];
|
|
632
653
|
if (options.full)
|
|
@@ -650,7 +671,7 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
|
|
|
650
671
|
try {
|
|
651
672
|
const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
|
|
652
673
|
await explorBot.start();
|
|
653
|
-
await explorBot.
|
|
674
|
+
await explorBot.visit(url);
|
|
654
675
|
const action = explorBot.getExplorer().action();
|
|
655
676
|
await action.execute(command);
|
|
656
677
|
log('Command executed successfully');
|
|
@@ -671,10 +692,11 @@ browserCmd
|
|
|
671
692
|
.description('Launch a persistent browser server')
|
|
672
693
|
.option('-s, --show', 'Launch browser in headed mode (visible window)')
|
|
673
694
|
.option('--headless', 'Launch browser in headless mode')
|
|
695
|
+
.option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
|
|
674
696
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
675
697
|
.option('-p, --path <path>', 'Working directory path')
|
|
676
698
|
.action(async (options) => {
|
|
677
|
-
const { launchServer, removeEndpointFile } = await import('../src/browser-server.js');
|
|
699
|
+
const { launchServer, removeEndpointFile, keepServerRunning } = await import('../src/browser-server.js');
|
|
678
700
|
await ConfigParser.getInstance().loadConfig({
|
|
679
701
|
config: options.config,
|
|
680
702
|
path: options.path,
|
|
@@ -688,45 +710,34 @@ browserCmd
|
|
|
688
710
|
const server = await launchServer({
|
|
689
711
|
browser: config.playwright.browser,
|
|
690
712
|
show,
|
|
713
|
+
}, options.instance);
|
|
714
|
+
await keepServerRunning(async () => {
|
|
715
|
+
await server.close();
|
|
716
|
+
removeEndpointFile(options.instance);
|
|
691
717
|
});
|
|
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
718
|
});
|
|
702
719
|
browserCmd
|
|
703
720
|
.command('stop')
|
|
704
721
|
.description('Stop a running browser server')
|
|
722
|
+
.option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
|
|
705
723
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
706
724
|
.option('-p, --path <path>', 'Working directory path')
|
|
707
725
|
.action(async (options) => {
|
|
708
|
-
const {
|
|
726
|
+
const { stopServer } = await import('../src/browser-server.js');
|
|
709
727
|
await ConfigParser.getInstance().loadConfig({
|
|
710
728
|
config: options.config,
|
|
711
729
|
path: options.path,
|
|
712
730
|
});
|
|
713
|
-
|
|
714
|
-
if (!endpoint) {
|
|
731
|
+
if (!(await stopServer(options.instance))) {
|
|
715
732
|
console.log('No running browser server found.');
|
|
716
733
|
process.exit(0);
|
|
717
734
|
}
|
|
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
735
|
console.log('Browser server stopped.');
|
|
726
736
|
});
|
|
727
737
|
browserCmd
|
|
728
738
|
.command('status')
|
|
729
739
|
.description('Check if a browser server is running')
|
|
740
|
+
.option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
|
|
730
741
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
731
742
|
.option('-p, --path <path>', 'Working directory path')
|
|
732
743
|
.action(async (options) => {
|
|
@@ -735,7 +746,7 @@ browserCmd
|
|
|
735
746
|
config: options.config,
|
|
736
747
|
path: options.path,
|
|
737
748
|
});
|
|
738
|
-
const endpoint = await getAliveEndpoint();
|
|
749
|
+
const endpoint = await getAliveEndpoint(options.instance);
|
|
739
750
|
if (endpoint) {
|
|
740
751
|
console.log(`Browser server is running at: ${endpoint}`);
|
|
741
752
|
}
|
|
@@ -790,8 +801,10 @@ program
|
|
|
790
801
|
});
|
|
791
802
|
import { createApiCommands } from "../boat/api-tester/src/cli.js";
|
|
792
803
|
import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
|
|
804
|
+
import { createPrimaCommands } from "../boat/prima/src/cli.js";
|
|
793
805
|
program.addCommand(createApiCommands('api'));
|
|
794
806
|
program.addCommand(createDocsCommands('docs'));
|
|
807
|
+
program.addCommand(createPrimaCommands('prima'));
|
|
795
808
|
const envHelp = () => {
|
|
796
809
|
const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
|
|
797
810
|
const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
|
|
@@ -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(
|
|
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;
|
|
@@ -9,11 +9,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
10
10
|
import path, { resolve } from 'node:path';
|
|
11
11
|
import { parseEnv } from 'node:util';
|
|
12
|
-
import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
|
|
12
|
+
import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from "../../../src/config.js";
|
|
13
|
+
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
13
14
|
export class ApibotConfigParser {
|
|
14
15
|
static instance;
|
|
15
16
|
config = null;
|
|
16
17
|
configPath = null;
|
|
18
|
+
site = null;
|
|
17
19
|
constructor() { }
|
|
18
20
|
static getInstance() {
|
|
19
21
|
if (!ApibotConfigParser.instance) {
|
|
@@ -34,6 +36,7 @@ export class ApibotConfigParser {
|
|
|
34
36
|
if (options?.path) {
|
|
35
37
|
process.chdir(resolve(options.path));
|
|
36
38
|
}
|
|
39
|
+
ApibotConfigParser.loadEnv(globalEnvPath());
|
|
37
40
|
ApibotConfigParser.loadEnv('.env');
|
|
38
41
|
const resolvedPath = options?.config || this.findConfigFile();
|
|
39
42
|
if (!resolvedPath) {
|
|
@@ -59,7 +62,12 @@ export class ApibotConfigParser {
|
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
this.config = this.mergeWithDefaults(loadedConfig);
|
|
65
|
+
await resolveConfigModels(this.config.ai);
|
|
62
66
|
this.configPath = resolvedPath;
|
|
67
|
+
this.site = null;
|
|
68
|
+
if (isGlobalConfigPath(resolvedPath)) {
|
|
69
|
+
this.enterGlobalMode(this.config, options?.endpoint);
|
|
70
|
+
}
|
|
63
71
|
this.validateConfig(this.config);
|
|
64
72
|
return this.config;
|
|
65
73
|
}
|
|
@@ -79,10 +87,23 @@ export class ApibotConfigParser {
|
|
|
79
87
|
}
|
|
80
88
|
getOutputDir() {
|
|
81
89
|
const config = this.getConfig();
|
|
90
|
+
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
91
|
+
}
|
|
92
|
+
getProjectRoot() {
|
|
93
|
+
if (this.site)
|
|
94
|
+
return this.site.dir;
|
|
82
95
|
const configPath = this.getConfigPath();
|
|
83
96
|
if (!configPath)
|
|
84
97
|
throw new Error('Config path not found');
|
|
85
|
-
return path.
|
|
98
|
+
return path.dirname(configPath);
|
|
99
|
+
}
|
|
100
|
+
resolveEndpointPath(endpoint) {
|
|
101
|
+
if (!this.site)
|
|
102
|
+
return endpoint;
|
|
103
|
+
const resolved = resolveSiteTarget(endpoint, this.site.url);
|
|
104
|
+
if (resolved.baseUrl !== this.site.url)
|
|
105
|
+
return endpoint;
|
|
106
|
+
return resolved.path;
|
|
86
107
|
}
|
|
87
108
|
getPlansDir() {
|
|
88
109
|
return path.join(this.getOutputDir(), 'plans');
|
|
@@ -92,21 +113,26 @@ export class ApibotConfigParser {
|
|
|
92
113
|
}
|
|
93
114
|
getKnowledgeDir() {
|
|
94
115
|
const config = this.getConfig();
|
|
95
|
-
|
|
96
|
-
if (!configPath)
|
|
97
|
-
throw new Error('Config path not found');
|
|
98
|
-
return path.join(path.dirname(configPath), config.dirs?.knowledge || 'knowledge');
|
|
116
|
+
return path.join(this.getProjectRoot(), config.dirs?.knowledge || 'knowledge');
|
|
99
117
|
}
|
|
100
118
|
ensureDirectory(dirPath) {
|
|
101
119
|
if (!existsSync(dirPath)) {
|
|
102
120
|
mkdirSync(dirPath, { recursive: true });
|
|
103
121
|
}
|
|
104
122
|
}
|
|
123
|
+
enterGlobalMode(config, endpoint) {
|
|
124
|
+
const site = resolveSiteTarget(endpoint);
|
|
125
|
+
this.site = registerSite(site.baseUrl);
|
|
126
|
+
config.dirs = { output: 'output', knowledge: 'knowledge' };
|
|
127
|
+
config.api = { ...config.api, baseEndpoint: site.baseUrl };
|
|
128
|
+
if (process.env.EXPLORBOT_API_SPEC)
|
|
129
|
+
config.api.spec = [process.env.EXPLORBOT_API_SPEC];
|
|
130
|
+
}
|
|
105
131
|
async loadEnvConfig() {
|
|
106
132
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
107
133
|
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
|
108
134
|
if (!provider && !modelSpec) {
|
|
109
|
-
throw new
|
|
135
|
+
throw new ConfigMissingError(missingConfigMessage('apibot.config.js'));
|
|
110
136
|
}
|
|
111
137
|
if (modelSpec && !provider && !modelSpec.includes('/')) {
|
|
112
138
|
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
@@ -149,7 +175,9 @@ export class ApibotConfigParser {
|
|
|
149
175
|
if (existsSync(fullPath))
|
|
150
176
|
return fullPath;
|
|
151
177
|
}
|
|
152
|
-
|
|
178
|
+
if (envConfigRequested())
|
|
179
|
+
return null;
|
|
180
|
+
return findGlobalConfig();
|
|
153
181
|
}
|
|
154
182
|
async loadConfigModule(configPath) {
|
|
155
183
|
const ext = configPath.split('.').pop();
|
|
@@ -47,6 +47,7 @@ export function createDocsCommands(name = 'docs') {
|
|
|
47
47
|
console.log(`Skipped ${result.skipped.length} page(s)`);
|
|
48
48
|
console.log(`Spec index: ${result.indexPath}`);
|
|
49
49
|
console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
|
|
50
|
+
console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
|
|
50
51
|
await bot.stop();
|
|
51
52
|
process.exit(0);
|
|
52
53
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import { APPLICATION_SPEC_FORMAT, APPLICATION_SPEC_VERSION } from "../../../src/application-spec-contract.js";
|
|
3
4
|
import { normalizeInlineText } from "../../../src/utils/strings.js";
|
|
5
|
+
import { buildStateGraph, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph } from "./state-diagram.js";
|
|
4
6
|
function renderPageDocumentation(state, documentation, screenshots = []) {
|
|
5
7
|
const lines = [];
|
|
6
8
|
lines.push(`# ${state.url}`);
|
|
@@ -25,6 +27,13 @@ function renderPageDocumentation(state, documentation, screenshots = []) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
const interactions = documentation.interactions;
|
|
30
|
+
const pageStateDiagram = renderPageStateDiagram(state.title || state.url || 'page', state.url || '', interactions || []);
|
|
31
|
+
if (pageStateDiagram) {
|
|
32
|
+
lines.push('## State Map');
|
|
33
|
+
lines.push('');
|
|
34
|
+
lines.push(`\`\`\`mermaid\n${pageStateDiagram}\n\`\`\``);
|
|
35
|
+
lines.push('');
|
|
36
|
+
}
|
|
28
37
|
if (interactions && interactions.length > 0) {
|
|
29
38
|
lines.push('## State Transitions');
|
|
30
39
|
lines.push('');
|
|
@@ -83,7 +92,11 @@ function renderPageDocumentation(state, documentation, screenshots = []) {
|
|
|
83
92
|
}
|
|
84
93
|
lines.push('');
|
|
85
94
|
}
|
|
86
|
-
return `${lines.join('\n').trimEnd()}\n
|
|
95
|
+
return matter.stringify(`${lines.join('\n').trimEnd()}\n`, {
|
|
96
|
+
url: state.url,
|
|
97
|
+
format: APPLICATION_SPEC_FORMAT,
|
|
98
|
+
version: APPLICATION_SPEC_VERSION,
|
|
99
|
+
});
|
|
87
100
|
}
|
|
88
101
|
function renderSpecIndex(outputDir, startPath, pages, skipped, maxPages) {
|
|
89
102
|
const lines = [];
|
|
@@ -97,9 +110,10 @@ function renderSpecIndex(outputDir, startPath, pages, skipped, maxPages) {
|
|
|
97
110
|
lines.push(`Max pages: ${maxPages}`);
|
|
98
111
|
lines.push('');
|
|
99
112
|
const graph = buildStateGraph(outputDir, pages);
|
|
113
|
+
const mermaid = renderMermaidFromGraph(graph, true);
|
|
100
114
|
lines.push('## State Transitions');
|
|
101
115
|
lines.push('');
|
|
102
|
-
lines.push(`\`\`\`mermaid\n${
|
|
116
|
+
lines.push(`\`\`\`mermaid\n${mermaid}\n\`\`\``);
|
|
103
117
|
lines.push('');
|
|
104
118
|
const stateMap = renderStateMapFromGraph(graph);
|
|
105
119
|
if (stateMap) {
|
|
@@ -60,8 +60,11 @@ function buildStateGraph(outputDir, pages) {
|
|
|
60
60
|
if (!targetId) {
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
if (adjacency.get(sourceId)?.has(targetId)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (createsCycle(sourceId, targetId, adjacency)) {
|
|
67
|
+
const pairKey = `${sourceId}>${targetId}`;
|
|
65
68
|
if (drawnBack.has(pairKey)) {
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
@@ -69,9 +72,6 @@ function buildStateGraph(outputDir, pages) {
|
|
|
69
72
|
edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: true });
|
|
70
73
|
continue;
|
|
71
74
|
}
|
|
72
|
-
if (adjacency.get(sourceId)?.has(targetId) || createsCycle(sourceId, targetId, adjacency)) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
75
|
adjacency.get(sourceId)?.add(targetId);
|
|
76
76
|
edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: false });
|
|
77
77
|
}
|
|
@@ -90,8 +90,8 @@ function buildStateGraph(outputDir, pages) {
|
|
|
90
90
|
function renderMermaidBody(outputDir, pages) {
|
|
91
91
|
return renderMermaidFromGraph(buildStateGraph(outputDir, pages));
|
|
92
92
|
}
|
|
93
|
-
function renderMermaidFromGraph(graph) {
|
|
94
|
-
const lines = ['flowchart TD'];
|
|
93
|
+
function renderMermaidFromGraph(graph, compact = false) {
|
|
94
|
+
const lines = [compact ? 'flowchart LR' : 'flowchart TD'];
|
|
95
95
|
if (graph.pages.length === 0) {
|
|
96
96
|
lines.push(' empty["No documented states"]');
|
|
97
97
|
return lines.join('\n');
|
|
@@ -102,6 +102,12 @@ function renderMermaidFromGraph(graph) {
|
|
|
102
102
|
if (!children || children.length === 0) {
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
|
+
if (compact) {
|
|
106
|
+
for (const child of children) {
|
|
107
|
+
lines.push(` ${renderNodeLine(child)}`);
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
105
111
|
lines.push(` subgraph sg_${page.id} ["${escapeMermaidLabel(page.label)} — transient states"]`);
|
|
106
112
|
for (const child of children) {
|
|
107
113
|
lines.push(` ${renderNodeLine(child)}`);
|
|
@@ -109,11 +115,13 @@ function renderMermaidFromGraph(graph) {
|
|
|
109
115
|
lines.push(' end');
|
|
110
116
|
}
|
|
111
117
|
for (const edge of graph.edges) {
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
arrow
|
|
118
|
+
const arrow = edge.isBack ? '-.->' : '-->';
|
|
119
|
+
if (compact) {
|
|
120
|
+
lines.push(` ${edge.source} ${arrow} ${edge.target}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
|
|
115
124
|
}
|
|
116
|
-
lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
|
|
117
125
|
}
|
|
118
126
|
lines.push(' classDef page fill:#dbeafe,stroke:#2563eb,color:#0f172a;');
|
|
119
127
|
lines.push(' classDef dialog fill:#ffedd5,stroke:#ea580c,color:#0f172a;');
|
|
@@ -153,6 +161,42 @@ function renderStateMapFromGraph(graph) {
|
|
|
153
161
|
}
|
|
154
162
|
return rows.join('\n');
|
|
155
163
|
}
|
|
164
|
+
function renderPageStateDiagram(label, url, interactions) {
|
|
165
|
+
const targets = new Map();
|
|
166
|
+
let index = 0;
|
|
167
|
+
for (const interaction of interactions) {
|
|
168
|
+
const targetState = interaction.targetState;
|
|
169
|
+
if (!targetState) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const key = `${targetState.kind}:${targetState.label}:${normalizeUrl(targetState.url)}`;
|
|
173
|
+
if (targets.has(key)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
targets.set(key, {
|
|
177
|
+
node: { id: `target${index++}`, kind: targetState.kind, label: targetState.label, subLabel: targetState.kind },
|
|
178
|
+
action: interaction.action,
|
|
179
|
+
screenshot: interaction.screenshot,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (targets.size === 0) {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
const lines = ['flowchart LR'];
|
|
186
|
+
lines.push(` ${renderNodeLine({ id: 'self', kind: 'page', label, subLabel: url })}`);
|
|
187
|
+
for (const target of targets.values()) {
|
|
188
|
+
lines.push(` ${renderNodeLine(target.node)}`);
|
|
189
|
+
}
|
|
190
|
+
for (const target of targets.values()) {
|
|
191
|
+
lines.push(` self -->|"${escapeMermaidLabel(target.action)}"| ${target.node.id}`);
|
|
192
|
+
}
|
|
193
|
+
for (const target of targets.values()) {
|
|
194
|
+
if (target.screenshot) {
|
|
195
|
+
lines.push(` click ${target.node.id} "${target.screenshot.relativePath}" "${escapeMermaidLabel(target.screenshot.title)}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
156
200
|
function renderNodeLine(node) {
|
|
157
201
|
const label = `${escapeMermaidLabel(node.label)}<br/>${escapeMermaidLabel(node.subLabel)}`;
|
|
158
202
|
if (node.kind === 'dialog' || node.kind === 'modal') {
|
|
@@ -183,7 +227,7 @@ function createsCycle(sourceId, targetId, adjacency) {
|
|
|
183
227
|
return false;
|
|
184
228
|
}
|
|
185
229
|
function escapeMermaidLabel(value) {
|
|
186
|
-
return normalizeInlineText(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('|', '|');
|
|
230
|
+
return normalizeInlineText(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('#', '#').replaceAll('<', '<').replaceAll('>', '>').replaceAll('|', '|');
|
|
187
231
|
}
|
|
188
232
|
function escapeTable(value) {
|
|
189
233
|
return normalizeInlineText(value).replaceAll('|', '\\|');
|
|
@@ -197,4 +241,4 @@ function classForKind(kind) {
|
|
|
197
241
|
}
|
|
198
242
|
return 'dialog';
|
|
199
243
|
}
|
|
200
|
-
export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderStateMapFromGraph };
|
|
244
|
+
export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph };
|