flow-walker-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ import { execFileSync, execSync } from 'node:child_process';
2
+ import type { SnapshotElement, ScreenSnapshot } from './types.ts';
3
+ import { FlowWalkerError, ErrorCodes } from './errors.ts';
4
+
5
+ /** Package name for the app under test — used to bring app to foreground */
6
+ const DEFAULT_PACKAGE = 'com.friend.ios.dev';
7
+
8
+ /**
9
+ * Thin wrapper around the agent-flutter CLI.
10
+ * All device interaction goes through this bridge.
11
+ */
12
+ export class AgentBridge {
13
+ private bin: string;
14
+ private timeout: number;
15
+ private lastUri?: string;
16
+ private lastBundleId?: string;
17
+
18
+ constructor(agentFlutterPath: string = 'agent-flutter', timeout: number = 30000) {
19
+ this.bin = agentFlutterPath;
20
+ this.timeout = timeout;
21
+ }
22
+
23
+ /** Connect to a Flutter app by VM Service URI */
24
+ connect(uri: string): void {
25
+ this.lastUri = uri;
26
+ this.exec(['connect', uri]);
27
+ }
28
+
29
+ /** Connect to a Flutter app by bundle ID */
30
+ connectBundle(bundleId: string): void {
31
+ this.lastBundleId = bundleId;
32
+ this.exec(['connect', '--bundle-id', bundleId]);
33
+ }
34
+
35
+ /** Store URI for reconnection without connecting (for skip-connect mode) */
36
+ setUri(uri: string): void {
37
+ this.lastUri = uri;
38
+ }
39
+
40
+ /** Reconnect using the last connection parameters */
41
+ reconnect(): boolean {
42
+ try {
43
+ try { this.exec(['disconnect']); } catch { /* ignore */ }
44
+ if (this.lastUri) {
45
+ this.exec(['connect', this.lastUri]);
46
+ } else if (this.lastBundleId) {
47
+ this.exec(['connect', '--bundle-id', this.lastBundleId]);
48
+ } else {
49
+ return false;
50
+ }
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Disconnect from the current app */
58
+ disconnect(): void {
59
+ this.exec(['disconnect']);
60
+ }
61
+
62
+ /** Take a snapshot of interactive elements, with auto-reconnect on failure */
63
+ snapshot(): ScreenSnapshot {
64
+ let raw: string;
65
+ try {
66
+ raw = this.exec(['snapshot', '-i', '--json']);
67
+ } catch {
68
+ // Try reconnecting once
69
+ if (!this.reconnect()) throw new FlowWalkerError(ErrorCodes.DEVICE_ERROR, 'Snapshot failed and reconnect failed', 'Check device connection: adb devices');
70
+ raw = this.exec(['snapshot', '-i', '--json']);
71
+ }
72
+ const parsed = JSON.parse(raw);
73
+
74
+ // agent-flutter returns { elements: [...] } or an array directly
75
+ const rawElements = Array.isArray(parsed) ? parsed : (parsed.elements || []);
76
+
77
+ const elements: SnapshotElement[] = rawElements.map((el: Record<string, unknown>) => ({
78
+ ref: String(el.ref || ''),
79
+ type: String(el.type || ''),
80
+ text: String(el.text || el.label || ''),
81
+ flutterType: el.flutterType ? String(el.flutterType) : undefined,
82
+ enabled: el.enabled !== false,
83
+ bounds: el.bounds as SnapshotElement['bounds'],
84
+ }));
85
+
86
+ return { elements, raw };
87
+ }
88
+
89
+ /** Press an element by ref */
90
+ press(ref: string): string {
91
+ return this.exec(['press', ref, '--json']);
92
+ }
93
+
94
+ /** Scroll in a direction */
95
+ scroll(direction: string): string {
96
+ return this.exec(['scroll', direction]);
97
+ }
98
+
99
+ /** Fill text into an element by ref */
100
+ fill(ref: string, text: string): string {
101
+ return this.exec(['fill', ref, text]);
102
+ }
103
+
104
+ /** Navigate back */
105
+ back(): string {
106
+ return this.exec(['back', '--json']);
107
+ }
108
+
109
+ /** Get all visible text from UIAutomator accessibility layer */
110
+ text(): string[] {
111
+ try {
112
+ const raw = this.exec(['text', '--json']);
113
+ const parsed = JSON.parse(raw);
114
+ return Array.isArray(parsed) ? parsed : [];
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ /** Check if specific text is visible on screen */
121
+ textVisible(query: string): boolean {
122
+ try {
123
+ this.exec(['text', query]);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Get connection status */
131
+ status(): string {
132
+ return this.exec(['status', '--json']);
133
+ }
134
+
135
+ /** Bring app to foreground via ADB am start */
136
+ bringToForeground(packageName: string = DEFAULT_PACKAGE): boolean {
137
+ try {
138
+ const adbArgs = this.adbDeviceArgs();
139
+ execFileSync('adb', [
140
+ ...adbArgs, 'shell', 'am', 'start', '-n',
141
+ `${packageName}/${packageName.replace('.dev', '')}.MainActivity`,
142
+ ], { encoding: 'utf8', timeout: 5000 });
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /** Check if the current foreground app matches our package */
150
+ isAppInForeground(packageName: string = DEFAULT_PACKAGE): boolean {
151
+ try {
152
+ const adbArgs = this.adbDeviceArgs().join(' ');
153
+ const result = execSync(
154
+ `adb ${adbArgs} shell "dumpsys window displays | grep mCurrentFocus"`,
155
+ { encoding: 'utf8', timeout: 5000 },
156
+ );
157
+ // mCurrentFocus=null means window transition — treat as in foreground
158
+ if (result.includes('null')) return true;
159
+ return result.includes(packageName);
160
+ } catch {
161
+ // If the grep/dumpsys fails, assume in foreground to avoid false negatives
162
+ return true;
163
+ }
164
+ }
165
+
166
+ /** Get ADB device args from AGENT_FLUTTER_DEVICE env var */
167
+ private adbDeviceArgs(): string[] {
168
+ const device = process.env.AGENT_FLUTTER_DEVICE;
169
+ return device ? ['-s', device] : [];
170
+ }
171
+
172
+ private exec(args: string[]): string {
173
+ try {
174
+ const result = execFileSync(this.bin, args, {
175
+ encoding: 'utf8',
176
+ timeout: this.timeout,
177
+ env: { ...process.env, AGENT_FLUTTER_JSON: '1' },
178
+ });
179
+ return result.trim();
180
+ } catch (err: unknown) {
181
+ const error = err as { stderr?: string; message?: string };
182
+ throw new FlowWalkerError(
183
+ ErrorCodes.COMMAND_FAILED,
184
+ `agent-flutter ${args[0]} failed: ${error.stderr || error.message}`,
185
+ `Run: agent-flutter doctor`,
186
+ );
187
+ }
188
+ }
189
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,102 @@
1
+ // Screenshot, video, and logcat capture helpers
2
+
3
+ import { execSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process';
4
+ import { existsSync, mkdirSync } from 'node:fs';
5
+
6
+ const PIPE_STDIO: StdioOptions = ['pipe', 'pipe', 'pipe'];
7
+
8
+ function adbArgs(): string[] {
9
+ const addr = process.env.ANDROID_ADB_SERVER_ADDRESS;
10
+ const port = process.env.ANDROID_ADB_SERVER_PORT;
11
+ const device = process.env.AGENT_FLUTTER_DEVICE;
12
+ const args: string[] = [];
13
+ if (addr) args.push('-H', addr);
14
+ if (port) args.push('-P', port);
15
+ if (device) args.push('-s', device);
16
+ return args;
17
+ }
18
+
19
+ function adb(shellArgs: string[], timeout = 10000): string {
20
+ const args = [...adbArgs(), ...shellArgs];
21
+ return execSync(`adb ${args.join(' ')}`, {
22
+ encoding: 'utf-8',
23
+ timeout,
24
+ stdio: PIPE_STDIO,
25
+ }).trim();
26
+ }
27
+
28
+ /** Take a screenshot via ADB and pull to local path */
29
+ export function screenshot(localPath: string): boolean {
30
+ try {
31
+ const devicePath = '/sdcard/fw-screenshot.png';
32
+ adb(['shell', 'screencap', '-p', devicePath]);
33
+ adb(['pull', devicePath, localPath]);
34
+ adb(['shell', 'rm', devicePath]);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** Start ADB screen recording. Returns handle to stop it later. */
42
+ export function startRecording(devicePath: string = '/sdcard/fw-recording.mp4'): {
43
+ process: ChildProcess;
44
+ devicePath: string;
45
+ } {
46
+ const args = [...adbArgs(), 'shell', 'screenrecord', '--time-limit', '180', devicePath];
47
+ const proc = spawn('adb', args, { stdio: 'ignore', detached: true });
48
+ return { process: proc, devicePath };
49
+ }
50
+
51
+ /** Stop recording and pull video to local path */
52
+ export function stopRecording(
53
+ handle: { process: ChildProcess; devicePath: string },
54
+ localPath: string,
55
+ ): boolean {
56
+ try {
57
+ // Send SIGINT to stop recording
58
+ handle.process.kill('SIGINT');
59
+ // Wait a moment for file to finalize
60
+ execSync('sleep 2', { stdio: 'ignore' });
61
+ adb(['pull', handle.devicePath, localPath], 30000);
62
+ adb(['shell', 'rm', handle.devicePath]);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /** Start logcat capture. Returns handle to stop it later. */
70
+ export function startLogcat(): { process: ChildProcess; lines: string[] } {
71
+ const args = [...adbArgs(), 'logcat', '-v', 'time', '-s', 'flutter'];
72
+ // Clear first
73
+ try { adb(['logcat', '-c']); } catch { /* ignore */ }
74
+ const proc = spawn('adb', args, { stdio: ['ignore', 'pipe', 'ignore'] });
75
+ const lines: string[] = [];
76
+ proc.stdout?.on('data', (data: Buffer) => {
77
+ lines.push(...data.toString().split('\n').filter(Boolean));
78
+ });
79
+ return { process: proc, lines };
80
+ }
81
+
82
+ /** Stop logcat capture and return collected lines */
83
+ export function stopLogcat(handle: { process: ChildProcess; lines: string[] }): string[] {
84
+ handle.process.kill('SIGTERM');
85
+ return handle.lines;
86
+ }
87
+
88
+ /** Get device model name */
89
+ export function getDeviceName(): string {
90
+ try {
91
+ return adb(['shell', 'getprop', 'ro.product.model']);
92
+ } catch {
93
+ return process.env.AGENT_FLUTTER_DEVICE ?? 'unknown';
94
+ }
95
+ }
96
+
97
+ /** Ensure output directory exists */
98
+ export function ensureDir(dir: string): void {
99
+ if (!existsSync(dir)) {
100
+ mkdirSync(dir, { recursive: true });
101
+ }
102
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node --experimental-strip-types
2
+ import { parseArgs } from 'node:util';
3
+ import { readFileSync } from 'node:fs';
4
+ import type { WalkerConfig } from './types.ts';
5
+ import { walk } from './walker.ts';
6
+ import { parseFlowFile } from './flow-parser.ts';
7
+ import { runFlow, dryRunFlow, type RunOptions } from './runner.ts';
8
+ import { generateReport, type ReportOptions } from './reporter.ts';
9
+ import { validateRunResult, type RunResult } from './run-schema.ts';
10
+ import { FlowWalkerError, ErrorCodes, formatError } from './errors.ts';
11
+ import { validateFlowPath, validateOutputDir, validateUri, validateBundleId, validateRunDir } from './validate.ts';
12
+ import { COMMAND_SCHEMAS, SCHEMA_VERSION, getCommandSchema, getSchemaEnvelope } from './command-schema.ts';
13
+ import { pushReport, getRunData } from './push.ts';
14
+
15
+ const DEFAULT_BLOCKLIST = 'delete,sign out,remove,reset,unpair,logout,clear all';
16
+
17
+ /** Resolve JSON output mode: --no-json > --json > env > TTY detection */
18
+ function resolveJsonMode(flags: Record<string, unknown>): boolean {
19
+ if (flags['no-json']) return false;
20
+ if (flags['json']) return true;
21
+ if (process.env.FLOW_WALKER_JSON === '1') return true;
22
+ if (!process.stdout.isTTY) return true;
23
+ return false;
24
+ }
25
+
26
+ function printUsage(): void {
27
+ console.log(`flow-walker — Auto-discover app flows, execute YAML test flows, generate HTML reports
28
+
29
+ Usage:
30
+ flow-walker walk [options] Auto-explore app and generate YAML flows
31
+ flow-walker run <flow.yaml> Execute a YAML flow and produce run.json
32
+ flow-walker report <run-dir> Generate HTML report from run results
33
+ flow-walker push <run-dir> Upload report and return shareable URL
34
+ flow-walker get <run-id> Fetch run data from hosted service
35
+ flow-walker schema [command] Show command schema for agent discovery
36
+
37
+ Run: flow-walker schema for machine-readable command descriptions.
38
+ `);
39
+ }
40
+
41
+ async function main(): Promise<void> {
42
+ const { values, positionals } = parseArgs({
43
+ allowPositionals: true,
44
+ options: {
45
+ 'app-uri': { type: 'string' },
46
+ 'bundle-id': { type: 'string' },
47
+ 'max-depth': { type: 'string', default: '5' },
48
+ 'output-dir': { type: 'string' },
49
+ 'output': { type: 'string' },
50
+ 'blocklist': { type: 'string', default: DEFAULT_BLOCKLIST },
51
+ 'agent-flutter-path': { type: 'string' },
52
+ 'json': { type: 'boolean', default: false },
53
+ 'no-json': { type: 'boolean', default: false },
54
+ 'dry-run': { type: 'boolean', default: false },
55
+ 'skip-connect': { type: 'boolean', default: false },
56
+ 'no-video': { type: 'boolean', default: false },
57
+ 'no-logs': { type: 'boolean', default: false },
58
+ 'help': { type: 'boolean', default: false },
59
+ 'version': { type: 'boolean', default: false },
60
+ },
61
+ });
62
+
63
+ const subcommand = positionals[0];
64
+ const json = resolveJsonMode(values);
65
+
66
+ // Env var overrides
67
+ const agentPath = (values['agent-flutter-path'] as string | undefined)
68
+ ?? process.env.FLOW_WALKER_AGENT_PATH
69
+ ?? 'agent-flutter';
70
+ const dryRun = (values['dry-run'] as boolean) || process.env.FLOW_WALKER_DRY_RUN === '1';
71
+
72
+ if (values.version) {
73
+ if (json) {
74
+ console.log(JSON.stringify({ version: SCHEMA_VERSION }));
75
+ } else {
76
+ console.log(`flow-walker ${SCHEMA_VERSION}`);
77
+ }
78
+ process.exit(0);
79
+ }
80
+
81
+ if (values.help || !subcommand) {
82
+ if (json && subcommand) {
83
+ // --help --json → schema for that command
84
+ const schema = getCommandSchema(subcommand);
85
+ if (schema) {
86
+ console.log(JSON.stringify(schema));
87
+ process.exit(0);
88
+ }
89
+ }
90
+ printUsage();
91
+ process.exit(subcommand ? 0 : 1);
92
+ }
93
+
94
+ try {
95
+ if (subcommand === 'walk') {
96
+ await handleWalk(values, positionals, json, agentPath, dryRun);
97
+ } else if (subcommand === 'run') {
98
+ await handleRun(values, positionals, json, agentPath, dryRun);
99
+ } else if (subcommand === 'report') {
100
+ await handleReport(values, positionals, json);
101
+ } else if (subcommand === 'push') {
102
+ await handlePush(values, positionals, json);
103
+ } else if (subcommand === 'get') {
104
+ await handleGet(values, positionals, json);
105
+ } else if (subcommand === 'schema') {
106
+ handleSchema(positionals);
107
+ } else {
108
+ throw new FlowWalkerError(
109
+ ErrorCodes.INVALID_ARGS,
110
+ `Unknown subcommand: ${subcommand}`,
111
+ 'Available: walk, run, report, push, get, schema. Run: flow-walker schema',
112
+ );
113
+ }
114
+ } catch (err) {
115
+ console.error(formatError(err, json));
116
+ process.exit(2);
117
+ }
118
+ }
119
+
120
+ async function handleWalk(
121
+ values: Record<string, unknown>,
122
+ _positionals: string[],
123
+ json: boolean,
124
+ agentPath: string,
125
+ dryRun: boolean,
126
+ ): Promise<void> {
127
+ // Validate inputs
128
+ if (values['app-uri']) validateUri(values['app-uri'] as string);
129
+ if (values['bundle-id']) validateBundleId(values['bundle-id'] as string);
130
+
131
+ if (!values['app-uri'] && !values['bundle-id'] && !values['skip-connect']) {
132
+ throw new FlowWalkerError(
133
+ ErrorCodes.INVALID_ARGS,
134
+ 'Either --app-uri, --bundle-id, or --skip-connect is required',
135
+ 'Run: flow-walker schema walk',
136
+ );
137
+ }
138
+
139
+ const outputDir = (values['output-dir'] as string | undefined)
140
+ ?? process.env.FLOW_WALKER_OUTPUT_DIR
141
+ ?? './flows/';
142
+ validateOutputDir(outputDir);
143
+
144
+ const config: WalkerConfig = {
145
+ appUri: values['app-uri'] as string | undefined,
146
+ bundleId: values['bundle-id'] as string | undefined,
147
+ maxDepth: parseInt(values['max-depth'] as string, 10),
148
+ outputDir,
149
+ blocklist: (values['blocklist'] as string).split(',').map(s => s.trim()),
150
+ json,
151
+ dryRun,
152
+ agentFlutterPath: agentPath,
153
+ skipConnect: values['skip-connect'] as boolean,
154
+ };
155
+
156
+ const result = await walk(config);
157
+
158
+ if (json) {
159
+ console.log(JSON.stringify({ type: 'result', ...result }));
160
+ } else {
161
+ console.log(`\nDone. ${result.screensFound} screens, ${result.flowsGenerated} flows, ${result.elementsSkipped} skipped.`);
162
+ }
163
+
164
+ process.exit(0);
165
+ }
166
+
167
+ async function handleRun(
168
+ values: Record<string, unknown>,
169
+ positionals: string[],
170
+ json: boolean,
171
+ agentPath: string,
172
+ dryRun: boolean,
173
+ ): Promise<void> {
174
+ const flowPath = positionals[1];
175
+ if (!flowPath) {
176
+ throw new FlowWalkerError(
177
+ ErrorCodes.INVALID_ARGS,
178
+ 'Flow YAML path is required',
179
+ 'Usage: flow-walker run <flow.yaml>. Run: flow-walker schema run',
180
+ );
181
+ }
182
+
183
+ validateFlowPath(flowPath);
184
+
185
+ const outputDir = (values['output-dir'] as string | undefined)
186
+ ?? process.env.FLOW_WALKER_OUTPUT_DIR
187
+ ?? './run-output/';
188
+ validateOutputDir(outputDir);
189
+
190
+ const flow = parseFlowFile(flowPath);
191
+
192
+ // Dry-run mode: parse and resolve without executing
193
+ if (dryRun) {
194
+ const dryResult = await dryRunFlow(flow, agentPath);
195
+ console.log(JSON.stringify(dryResult, null, json ? undefined : 2));
196
+ process.exit(0);
197
+ }
198
+
199
+ const options: RunOptions = {
200
+ outputDir,
201
+ noVideo: values['no-video'] as boolean,
202
+ noLogs: values['no-logs'] as boolean,
203
+ json,
204
+ agentFlutterPath: agentPath,
205
+ };
206
+
207
+ if (!json) {
208
+ console.log(`Running flow: ${flow.name} (${flow.steps.length} steps)`);
209
+ }
210
+
211
+ const result = await runFlow(flow, options);
212
+
213
+ if (json) {
214
+ console.log(JSON.stringify(result));
215
+ } else {
216
+ const icon = result.result === 'pass' ? '✓' : '✗';
217
+ console.log(`\n${icon} Flow "${result.flow}" ${result.result.toUpperCase()} (${(result.duration / 1000).toFixed(1)}s)`);
218
+ console.log(` Run ID: ${result.id}`);
219
+ console.log(` Output: ${outputDir}/${result.id}/`);
220
+ }
221
+
222
+ process.exit(result.result === 'pass' ? 0 : 1);
223
+ }
224
+
225
+ async function handleReport(
226
+ values: Record<string, unknown>,
227
+ positionals: string[],
228
+ json: boolean,
229
+ ): Promise<void> {
230
+ const runDir = positionals[1];
231
+ if (!runDir) {
232
+ throw new FlowWalkerError(
233
+ ErrorCodes.INVALID_ARGS,
234
+ 'Run directory is required',
235
+ 'Usage: flow-walker report <run-dir>. Run: flow-walker schema report',
236
+ );
237
+ }
238
+
239
+ validateRunDir(runDir);
240
+
241
+ const raw = readFileSync(`${runDir}/run.json`, 'utf-8');
242
+ const data = JSON.parse(raw);
243
+ if (!validateRunResult(data)) {
244
+ throw new FlowWalkerError(
245
+ ErrorCodes.FLOW_PARSE_ERROR,
246
+ 'Invalid run.json format',
247
+ 'Ensure run.json was produced by flow-walker run',
248
+ );
249
+ }
250
+ const runResult: RunResult = data;
251
+
252
+ const reportOptions: ReportOptions = {
253
+ noVideo: values['no-video'] as boolean,
254
+ output: values['output'] as string | undefined,
255
+ };
256
+
257
+ const outputPath = generateReport(runResult, runDir, reportOptions);
258
+
259
+ if (json) {
260
+ console.log(JSON.stringify({ report: outputPath }));
261
+ } else {
262
+ console.log(`Report generated: ${outputPath}`);
263
+ }
264
+
265
+ process.exit(0);
266
+ }
267
+
268
+ async function handlePush(
269
+ _values: Record<string, unknown>,
270
+ positionals: string[],
271
+ json: boolean,
272
+ ): Promise<void> {
273
+ const runDir = positionals[1];
274
+ if (!runDir) {
275
+ throw new FlowWalkerError(
276
+ ErrorCodes.INVALID_ARGS,
277
+ 'Run directory is required',
278
+ 'Usage: flow-walker push <run-dir>. Run: flow-walker schema push',
279
+ );
280
+ }
281
+
282
+ validateRunDir(runDir);
283
+
284
+ const apiUrl = process.env.FLOW_WALKER_API_URL;
285
+ const result = await pushReport(runDir, { apiUrl });
286
+
287
+ if (json) {
288
+ console.log(JSON.stringify(result));
289
+ } else {
290
+ console.log(`\nReport uploaded successfully.`);
291
+ console.log(` JSON: ${result.url}`);
292
+ console.log(` HTML: ${result.htmlUrl}`);
293
+ console.log(` ID: ${result.id}`);
294
+ console.log(` Expires: ${result.expiresAt}`);
295
+ }
296
+
297
+ process.exit(0);
298
+ }
299
+
300
+ async function handleGet(
301
+ _values: Record<string, unknown>,
302
+ positionals: string[],
303
+ json: boolean,
304
+ ): Promise<void> {
305
+ const runId = positionals[1];
306
+ if (!runId) {
307
+ throw new FlowWalkerError(
308
+ ErrorCodes.INVALID_ARGS,
309
+ 'Run ID is required',
310
+ 'Usage: flow-walker get <run-id>. Run: flow-walker schema get',
311
+ );
312
+ }
313
+
314
+ if (!/^[A-Za-z0-9_-]{6,20}$/.test(runId)) {
315
+ throw new FlowWalkerError(
316
+ ErrorCodes.INVALID_INPUT,
317
+ 'Invalid run ID format',
318
+ 'Run IDs are 6-20 characters: letters, digits, hyphens, underscores',
319
+ );
320
+ }
321
+
322
+ const apiUrl = process.env.FLOW_WALKER_API_URL;
323
+ const data = await getRunData(runId, { apiUrl });
324
+
325
+ if (json) {
326
+ console.log(JSON.stringify(data));
327
+ } else {
328
+ console.log(JSON.stringify(data, null, 2));
329
+ }
330
+
331
+ process.exit(0);
332
+ }
333
+
334
+ function handleSchema(positionals: string[]): void {
335
+ const commandName = positionals[1];
336
+ if (commandName) {
337
+ const schema = getCommandSchema(commandName);
338
+ if (!schema) {
339
+ throw new FlowWalkerError(
340
+ ErrorCodes.INVALID_ARGS,
341
+ `Unknown command: ${commandName}`,
342
+ `Available: ${COMMAND_SCHEMAS.map(s => s.name).join(', ')}`,
343
+ );
344
+ }
345
+ console.log(JSON.stringify(schema, null, 2));
346
+ } else {
347
+ console.log(JSON.stringify(getSchemaEnvelope(), null, 2));
348
+ }
349
+ process.exit(0);
350
+ }
351
+
352
+ main();