From 7298361027aaab84eb084ea32cb61d66eca049f8 Mon Sep 17 00:00:00 2001 From: vinifig Date: Wed, 1 Apr 2026 14:27:16 -0300 Subject: [PATCH 1/6] feat: add named server registry and --server flag for CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a TCP IPC layer so any MCP action can be invoked against a named, running server instance from plain CLI commands. New files: - lib/src/server_registry.dart — ServerRegistry + ServerEntry (reads/writes ~/.flutter_skill/servers/.json) - lib/src/skill_server.dart — SkillServer: JSON-RPC 2.0 over TCP (+ optional Unix socket on macOS/Linux) - lib/src/skill_client.dart — SkillClient: resolves named servers and sends JSON-RPC requests - lib/src/cli/connect.dart — `flutter_skill connect --id= [--port|--uri]` command - lib/src/cli/server_cmd.dart — `flutter_skill server list/stop/status` subcommands - lib/src/cli/output_format.dart — isCiEnvironment() + OutputFormat helpers Modified files: - lib/src/cli/launch.dart — adds --id= and --detach flags; registers SkillServer on URI discovery - lib/src/cli/inspect.dart — adds --server=[,,...] for parallel forwarding; --output flag - lib/src/cli/act.dart — adds --server=[,,...] for parallel forwarding; --output flag - bin/flutter_skill.dart — routes `connect`, `servers`, and `server list/stop/status` to new handlers Usage examples: flutter_skill connect --id=myapp --port=50000 flutter_skill server list flutter_skill server stop --id=myapp flutter_skill tap "Login" --server=myapp flutter_skill screenshot --server=app-a,app-b # parallel --- bin/flutter_skill.dart | 22 ++- lib/src/cli/act.dart | 180 ++++++++++++++++++++++++- lib/src/cli/connect.dart | 105 +++++++++++++++ lib/src/cli/inspect.dart | 127 ++++++++++++++++-- lib/src/cli/launch.dart | 111 ++++++++++++---- lib/src/cli/output_format.dart | 31 +++++ lib/src/cli/server_cmd.dart | 158 ++++++++++++++++++++++ lib/src/server_registry.dart | 162 +++++++++++++++++++++++ lib/src/skill_client.dart | 123 +++++++++++++++++ lib/src/skill_server.dart | 235 +++++++++++++++++++++++++++++++++ 10 files changed, 1209 insertions(+), 45 deletions(-) create mode 100644 lib/src/cli/connect.dart create mode 100644 lib/src/cli/output_format.dart create mode 100644 lib/src/cli/server_cmd.dart create mode 100644 lib/src/server_registry.dart create mode 100644 lib/src/skill_client.dart create mode 100644 lib/src/skill_server.dart diff --git a/bin/flutter_skill.dart b/bin/flutter_skill.dart index c0dec014..1f9afefa 100644 --- a/bin/flutter_skill.dart +++ b/bin/flutter_skill.dart @@ -3,6 +3,8 @@ import 'package:flutter_skill/src/cli/launch.dart'; import 'package:flutter_skill/src/cli/inspect.dart'; import 'package:flutter_skill/src/cli/act.dart'; import 'package:flutter_skill/src/cli/server.dart'; +import 'package:flutter_skill/src/cli/server_cmd.dart'; +import 'package:flutter_skill/src/cli/connect.dart'; import 'package:flutter_skill/src/cli/report_error.dart'; import 'package:flutter_skill/src/cli/setup_priority.dart'; import 'package:flutter_skill/src/cli/doctor.dart'; @@ -27,7 +29,9 @@ void main(List args) async { print(' quickstart Guided demo — see flutter-skill in action in 30s'); print(' demo Launch a built-in demo app — zero setup needed'); print(' launch Launch and auto-connect to an app'); - print(' server Start MCP server (used by IDEs)'); + print(' connect Attach to a running Flutter app and name it'); + print(' server Start MCP server / manage named server instances'); + print(' servers List all running named server instances'); print(' inspect Inspect interactive elements'); print(' act Perform actions (tap, enter_text, scroll)'); print(' screenshot Take a screenshot of the running app'); @@ -104,8 +108,22 @@ void main(List args) async { case 'act': await runAct(commandArgs); break; + case 'connect': + await runConnect(commandArgs); + break; + case 'servers': + // Shorthand for `server list` + await runServerCmd(['list', ...commandArgs]); + break; case 'server': - await runServer(commandArgs); + // Route server subcommands (list, stop, status) to server_cmd. + // Plain `server` (no subcommand) or `server` with MCP flags → MCP server. + if (commandArgs.isNotEmpty && + const {'list', 'stop', 'status'}.contains(commandArgs[0])) { + await runServerCmd(commandArgs); + } else { + await runServer(commandArgs); + } break; case 'setup': await runSetupPriority(commandArgs); diff --git a/lib/src/cli/act.dart b/lib/src/cli/act.dart index a8d32516..82f4970f 100644 --- a/lib/src/cli/act.dart +++ b/lib/src/cli/act.dart @@ -1,15 +1,30 @@ import 'dart:convert'; import 'dart:io'; import '../drivers/flutter_driver.dart'; +import '../skill_client.dart'; +import 'output_format.dart'; Future runAct(List args) async { + // --server=[,,...] — forward to named SkillServer instance(s) + final serverIds = _parseServerIds(args); + final format = resolveOutputFormat(args); + final effectiveArgs = stripOutputFlag(args) + .where((a) => !a.startsWith('--server=')) + .toList(); + + if (serverIds.isNotEmpty) { + await _actViaServers(serverIds, effectiveArgs, format); + return; + } + String uri; int argOffset; // Check if first arg is a URI - if (args.isNotEmpty && - (args[0].startsWith('ws://') || args[0].startsWith('http://'))) { - uri = args[0]; + if (effectiveArgs.isNotEmpty && + (effectiveArgs[0].startsWith('ws://') || + effectiveArgs[0].startsWith('http://'))) { + uri = effectiveArgs[0]; argOffset = 1; } else { // Use auto-discovery (no need for .flutter_skill_uri file!) @@ -22,19 +37,19 @@ Future runAct(List args) async { } } - if (args.length <= argOffset) { + if (effectiveArgs.length <= argOffset) { print('Missing action argument'); print('Usage: flutter_skill act [vm-uri] '); exit(1); } - String action = args[argOffset]; + String action = effectiveArgs[argOffset]; final client = FlutterSkillClient(uri); String? param1; String? param2; - if (args.length > argOffset + 1) param1 = args[argOffset + 1]; - if (args.length > argOffset + 2) param2 = args[argOffset + 2]; + if (effectiveArgs.length > argOffset + 1) param1 = effectiveArgs[argOffset + 1]; + if (effectiveArgs.length > argOffset + 2) param2 = effectiveArgs[argOffset + 2]; try { await client.connect(); @@ -156,6 +171,157 @@ Future runAct(List args) async { } } +// --------------------------------------------------------------------------- +// Server-forwarding helpers +// --------------------------------------------------------------------------- + +/// Parse `--server=[,,...]` from args and return the list of IDs. +List _parseServerIds(List args) { + for (final arg in args) { + if (arg.startsWith('--server=')) { + final value = arg.substring('--server='.length); + return value + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + } + return []; +} + +/// Build a JSON-RPC method name + params from the act CLI args. +Map _buildRpcCall(List actArgs) { + if (actArgs.isEmpty) return {'method': 'ping', 'params': {}}; + + final action = actArgs[0]; + final param1 = actArgs.length > 1 ? actArgs[1] : null; + final param2 = actArgs.length > 2 ? actArgs[2] : null; + + switch (action) { + case 'tap': + return { + 'method': 'tap', + 'params': {'key': param1} + }; + case 'enter_text': + return { + 'method': 'enter_text', + 'params': {'key': param1, 'text': param2 ?? ''} + }; + case 'scroll': + case 'scroll_to': + return { + 'method': 'swipe', + 'params': {'direction': 'up', 'key': param1} + }; + case 'screenshot': + return { + 'method': 'screenshot', + 'params': param1 != null ? {'path': param1} : {} + }; + case 'swipe': + return { + 'method': 'swipe', + 'params': { + 'direction': param1 ?? 'up', + 'distance': double.tryParse(param2 ?? '') ?? 300, + } + }; + case 'go_back': + return {'method': 'go_back', 'params': {}}; + default: + return {'method': action, 'params': {}}; + } +} + +Future _actViaServers( + List serverIds, List actArgs, OutputFormat format) async { + final rpc = _buildRpcCall(actArgs); + final method = rpc['method'] as String; + final params = rpc['params'] as Map; + final action = actArgs.isNotEmpty ? actArgs[0] : method; + + final futures = serverIds.map((id) async { + final stopwatch = Stopwatch()..start(); + try { + final client = SkillClient.byId(id); + final result = await client.call(method, params); + stopwatch.stop(); + + // Handle screenshot save when --server is used. + if (method == 'screenshot' && actArgs.length > 1) { + final path = actArgs[1]; + final image = result['image'] as String?; + if (image != null) { + final bytes = base64Decode(image); + await File(path).writeAsBytes(bytes); + } + } + + return _ActResult( + serverId: id, + success: true, + action: action, + durationMs: stopwatch.elapsedMilliseconds); + } catch (e) { + stopwatch.stop(); + return _ActResult( + serverId: id, + success: false, + action: action, + error: e.toString(), + durationMs: stopwatch.elapsedMilliseconds); + } + }); + + final results = await Future.wait(futures); + + if (format == OutputFormat.json) { + print(jsonEncode(results.map((r) => r.toJson()).toList())); + return; + } + + for (final r in results) { + if (r.success) { + print('[${r.serverId}] ${r.action} completed (${r.durationMs}ms)'); + } else { + print('[${r.serverId}] Error: ${r.error}'); + } + } + + // Exit with error code if any server failed. + if (results.any((r) => !r.success)) exit(1); +} + +class _ActResult { + final String serverId; + final bool success; + final String action; + final String? error; + final int durationMs; + + const _ActResult({ + required this.serverId, + required this.success, + required this.action, + this.error, + required this.durationMs, + }); + + Map toJson() => { + 'server': serverId, + 'success': success, + 'action': action, + if (error != null) 'error': error, + 'duration_ms': durationMs, + }; +} + +// --------------------------------------------------------------------------- +// Existing helper (unchanged) +// --------------------------------------------------------------------------- + bool _findTarget(List elements, String target) { for (final e in elements) { if (e is! Map) continue; diff --git a/lib/src/cli/connect.dart b/lib/src/cli/connect.dart new file mode 100644 index 00000000..a37a28f4 --- /dev/null +++ b/lib/src/cli/connect.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import '../drivers/flutter_driver.dart'; +import '../skill_server.dart'; + +/// CLI command: `flutter_skill connect --id= [--port=|--uri=]` +/// +/// Attaches to a running Flutter app (identified by VM Service port or URI), +/// wraps it in a [SkillServer], registers the server in the registry, and +/// keeps running until Ctrl+C. +Future runConnect(List args) async { + String? id; + int? port; + String? uri; + String projectPath = '.'; + String deviceId = ''; + + for (final arg in args) { + if (arg.startsWith('--id=')) { + id = arg.substring('--id='.length); + } else if (arg.startsWith('--port=')) { + port = int.tryParse(arg.substring('--port='.length)); + } else if (arg.startsWith('--uri=')) { + uri = arg.substring('--uri='.length); + } else if (arg.startsWith('--project=')) { + projectPath = arg.substring('--project='.length); + } else if (arg.startsWith('--device=')) { + deviceId = arg.substring('--device='.length); + } + } + + if (id == null) { + print('Usage: flutter_skill connect --id= [--port=|--uri=]'); + print(''); + print('Options:'); + print(' --id= Server name (required)'); + print(' --port= VM Service port (e.g. 50000)'); + print(' --uri= VM Service URI (e.g. ws://127.0.0.1:50000/ws)'); + print(' --project=

Project path (for registry metadata)'); + print(' --device= Device ID (for registry metadata)'); + exit(1); + } + + // Build the WebSocket URI. + if (uri == null) { + if (port != null) { + uri = 'ws://127.0.0.1:$port/ws'; + } else { + // Fall back to auto-discovery. + try { + uri = await FlutterSkillClient.resolveUri([]); + } catch (e) { + print('Error: $e'); + exit(1); + } + } + } + + // Normalise http:// → ws:// + if (uri.startsWith('http://')) { + uri = uri.replaceFirst('http://', 'ws://'); + if (!uri.endsWith('/ws')) uri = '$uri/ws'; + } + + print('Connecting to Flutter app at $uri...'); + final driver = FlutterSkillClient(uri); + try { + await driver.connect(); + } catch (e) { + print('Failed to connect: $e'); + exit(1); + } + print('Connected.'); + + final server = SkillServer( + id: id, + driver: driver, + projectPath: projectPath, + deviceId: deviceId, + ); + + await server.start(); + print('Skill server "$id" listening on port ${server.port}'); + print('Press Ctrl+C to stop.'); + + // Keep running until the process is interrupted. + ProcessSignal.sigint.watch().first.then((_) async { + print('\nShutting down server "$id"...'); + await server.stop(); + await driver.disconnect(); + exit(0); + }); + + // Also handle SIGTERM on Unix. + if (!Platform.isWindows) { + ProcessSignal.sigterm.watch().first.then((_) async { + await server.stop(); + await driver.disconnect(); + exit(0); + }); + } + + // Park the isolate so the process stays alive. + await Future.delayed(const Duration(days: 365)); +} diff --git a/lib/src/cli/inspect.dart b/lib/src/cli/inspect.dart index d20c78c2..56345be1 100644 --- a/lib/src/cli/inspect.dart +++ b/lib/src/cli/inspect.dart @@ -1,13 +1,26 @@ +import 'dart:convert'; import 'dart:io'; import '../drivers/flutter_driver.dart'; +import '../skill_client.dart'; +import 'output_format.dart'; Future runInspect(List args) async { - // No initial arg check, let resolveUri handle it - // if (args.isEmpty) ... + // --server=[,,...] — forward to named SkillServer instance(s) + // --output=json|human — output format + final serverIds = _parseServerIds(args); + final format = resolveOutputFormat(args); + final cleanArgs = + stripOutputFlag(args).where((a) => !a.startsWith('--server=')).toList(); + if (serverIds.isNotEmpty) { + await _inspectViaServers(serverIds, format); + return; + } + + // Default behaviour: direct VM Service connection. String uri; try { - uri = await FlutterSkillClient.resolveUri(args); + uri = await FlutterSkillClient.resolveUri(cleanArgs); } catch (e) { print(e); exit(1); @@ -19,13 +32,17 @@ Future runInspect(List args) async { await client.connect(); final elements = await client.getInteractiveElements(); - // Print simplified tree for LLM consumption - print('Interactive Elements:'); - if (elements.isEmpty) { - print('(No interactive elements found)'); + if (format == OutputFormat.json) { + print(jsonEncode({'elements': elements})); } else { - for (final e in elements) { - _printElement(e); + // Print simplified tree for LLM consumption + print('Interactive Elements:'); + if (elements.isEmpty) { + print('(No interactive elements found)'); + } else { + for (final e in elements) { + _printElement(e); + } } } } catch (e) { @@ -36,7 +53,55 @@ Future runInspect(List args) async { } } -void _printElement(dynamic element, [String prefix = '']) { +/// Forward the inspect action to one or more named servers concurrently. +Future _inspectViaServers( + List serverIds, OutputFormat format) async { + final futures = serverIds.map((id) async { + final stopwatch = Stopwatch()..start(); + try { + final client = SkillClient.byId(id); + final result = await client.call('inspect', {}); + stopwatch.stop(); + return _ServerResult( + serverId: id, + success: true, + data: result, + durationMs: stopwatch.elapsedMilliseconds); + } catch (e) { + stopwatch.stop(); + return _ServerResult( + serverId: id, + success: false, + error: e.toString(), + durationMs: stopwatch.elapsedMilliseconds); + } + }); + + final results = await Future.wait(futures); + + if (format == OutputFormat.json) { + print(jsonEncode(results.map((r) => r.toJson()).toList())); + return; + } + + for (final r in results) { + if (!r.success) { + print('[${r.serverId}] Error: ${r.error}'); + continue; + } + final elements = (r.data!['elements'] as List?) ?? []; + print('[${r.serverId}] Interactive Elements (${r.durationMs}ms):'); + if (elements.isEmpty) { + print(' (No interactive elements found)'); + } else { + for (final e in elements) { + _printElement(e, prefix: ' '); + } + } + } +} + +void _printElement(dynamic element, {String prefix = ''}) { if (element is! Map) return; // Try to extract useful info @@ -59,7 +124,47 @@ void _printElement(dynamic element, [String prefix = '']) { // Recursively print children if any if (element.containsKey('children') && element['children'] is List) { for (final child in element['children']) { - _printElement(child, '$prefix '); + _printElement(child, prefix: '$prefix '); } } } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse `--server=[,,...]` from args and return the list of IDs. +List _parseServerIds(List args) { + for (final arg in args) { + if (arg.startsWith('--server=')) { + final value = arg.substring('--server='.length); + return value.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + } + } + return []; +} + +/// Holds the outcome of a single per-server parallel action. +class _ServerResult { + final String serverId; + final bool success; + final Map? data; + final String? error; + final int durationMs; + + const _ServerResult({ + required this.serverId, + required this.success, + this.data, + this.error, + required this.durationMs, + }); + + Map toJson() => { + 'server': serverId, + 'success': success, + if (data != null) 'data': data, + if (error != null) 'error': error, + 'duration_ms': durationMs, + }; +} diff --git a/lib/src/cli/launch.dart b/lib/src/cli/launch.dart index eff1989e..e2638c16 100644 --- a/lib/src/cli/launch.dart +++ b/lib/src/cli/launch.dart @@ -1,23 +1,32 @@ import 'dart:convert'; import 'dart:io'; import 'setup.dart'; // Import setup logic +import '../drivers/flutter_driver.dart'; +import '../skill_server.dart'; Future runLaunch(List args) async { - // Extract project path. Everything else is passed to flutter run. - // We assume: flutter_skill launch [project_path] [flutter_args...] - // But wait, standard args might be tricky. - // Let's say: first arg is project path if it doesn't start with -? + // Extract project path and new flags before passing the rest to flutter run. + // + // New flags (consumed here, not forwarded to flutter): + // --id= Register the attached skill server under this name. + // --detach Spawn a detached child process that keeps the server alive; + // the parent process exits after handing off. String projectPath = '.'; + String? serverId; + bool detach = false; List flutterArgs = []; - if (args.isNotEmpty) { - if (!args[0].startsWith('-')) { - projectPath = args[0]; - flutterArgs = args.sublist(1); + for (int i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg.startsWith('--id=')) { + serverId = arg.substring('--id='.length); + } else if (arg == '--detach') { + detach = true; + } else if (i == 0 && !arg.startsWith('-')) { + projectPath = arg; } else { - // Current dir, all args are for flutter - flutterArgs = args; + flutterArgs.add(arg); } } @@ -30,11 +39,11 @@ Future runLaunch(List args) async { print('Proceeding with launch anyway...'); } - // Auto-add --vm-service-port=50000 if not specified - // This ensures faster discovery (recommended but not required) + // Auto-add --vm-service-port=50000 if not specified. + // This ensures faster discovery (recommended but not required). if (!flutterArgs.any((arg) => arg.contains('--vm-service-port'))) { flutterArgs.add('--vm-service-port=50000'); - print('💡 Auto-adding --vm-service-port=50000 (推荐,可加速发现)'); + print('Auto-adding --vm-service-port=50000 (recommended for faster discovery)'); } print('Launching Flutter app in: $projectPath with args: $flutterArgs'); @@ -49,12 +58,18 @@ Future runLaunch(List args) async { print( 'Flutter process started (PID: ${process.pid}). Waiting for connection URI...'); + String? discoveredUri; + process.stdout .transform(utf8.decoder) .transform(const LineSplitter()) .listen((line) { print('[Flutter]: $line'); - _checkForUri(line); + final uri = _extractUri(line); + if (uri != null && discoveredUri == null) { + discoveredUri = uri; + _onUriDiscovered(uri, serverId, projectPath, detach); + } }); process.stderr @@ -74,16 +89,62 @@ Future runLaunch(List args) async { exit(exitCode); } -void _checkForUri(String line) { - if (line.contains('ws://')) { - final uriRegex = RegExp(r'ws://[^\s]+'); - final match = uriRegex.firstMatch(line); - if (match != null) { - final uri = match.group(0)!; - print('\n✅ Flutter Skill: VM Service 已启动'); - print(' URI: $uri'); - print(' 🚀 现在可以直接使用: flutter_skill inspect (自动发现)'); - // Note: No longer saving to .flutter_skill_uri - using auto-discovery instead! - } +String? _extractUri(String line) { + if (!line.contains('ws://')) return null; + final uriRegex = RegExp(r'ws://[^\s]+'); + final match = uriRegex.firstMatch(line); + return match?.group(0); +} + +void _onUriDiscovered( + String uri, String? serverId, String projectPath, bool detach) { + print('\nFlutter Skill: VM Service is ready'); + print(' URI: $uri'); + print(' Run: flutter_skill inspect (auto-discovery)'); + + if (serverId == null) return; + + if (detach) { + // Spawn a detached helper process that owns the SkillServer lifecycle. + // The parent (this process) continues owning `flutter run`. + _spawnDetachedServer(serverId, uri, projectPath); + } else { + // Attach the SkillServer in-process (background isolate via async). + _attachServer(serverId, uri, projectPath); + } +} + +/// Attach a SkillServer in the same process (async, non-blocking). +void _attachServer(String id, String uri, String projectPath) async { + try { + final driver = FlutterSkillClient(uri); + await driver.connect(); + final server = SkillServer(id: id, driver: driver, projectPath: projectPath); + await server.start(); + print('Skill server "$id" listening on port ${server.port}'); + + // Write a convenience file in the project directory. + final marker = File('$projectPath/.flutter_skill_server'); + await marker.writeAsString(id, flush: true); + } catch (e) { + print('Warning: Could not start skill server "$id": $e'); } } + +/// Spawn a completely detached child process to host the SkillServer. +void _spawnDetachedServer(String id, String uri, String projectPath) { + // We re-invoke ourselves with the `connect` command so the child process + // manages the server lifecycle independently. + Process.start( + Platform.resolvedExecutable, + ['connect', '--id=$id', '--uri=$uri', '--project=$projectPath'], + mode: ProcessStartMode.detached, + runInShell: false, + ).then((p) { + print('Detached skill server "$id" started (PID: ${p.pid})'); + final marker = File('$projectPath/.flutter_skill_server'); + marker.writeAsString(id, flush: true).catchError((_) => marker); + }).catchError((e) { + print('Warning: Could not start detached server "$id": $e'); + }); +} diff --git a/lib/src/cli/output_format.dart b/lib/src/cli/output_format.dart new file mode 100644 index 00000000..2e69715e --- /dev/null +++ b/lib/src/cli/output_format.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +/// Returns true when the process is running inside a CI environment. +/// +/// Checks common CI environment variables used by GitHub Actions, CircleCI, +/// Travis CI, Buildkite, and generic CI setups. +bool isCiEnvironment() => + Platform.environment.containsKey('CI') || + Platform.environment.containsKey('GITHUB_ACTIONS') || + Platform.environment.containsKey('CIRCLECI') || + Platform.environment.containsKey('TRAVIS') || + Platform.environment.containsKey('BUILDKITE'); + +/// The output format to use for CLI commands. +enum OutputFormat { human, json } + +/// Resolve the output format from CLI args or environment. +/// +/// [args] may contain `--output=json` or `--output=human`. +/// Falls back to [isCiEnvironment] when no explicit flag is provided. +OutputFormat resolveOutputFormat(List args) { + for (final arg in args) { + if (arg == '--output=json') return OutputFormat.json; + if (arg == '--output=human') return OutputFormat.human; + } + return isCiEnvironment() ? OutputFormat.json : OutputFormat.human; +} + +/// Strip `--output=*` entries from an arg list. +List stripOutputFlag(List args) => + args.where((a) => !a.startsWith('--output=')).toList(); diff --git a/lib/src/cli/server_cmd.dart b/lib/src/cli/server_cmd.dart new file mode 100644 index 00000000..5f343014 --- /dev/null +++ b/lib/src/cli/server_cmd.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../server_registry.dart'; +import '../skill_client.dart'; +import 'output_format.dart'; + +/// CLI command: `flutter_skill server [options]` +/// +/// Subcommands: +/// list — table of running named servers +/// stop --id= — stop a named server +/// status --id= — show status of a named server +Future runServerCmd(List args) async { + final format = resolveOutputFormat(args); + final cleanArgs = stripOutputFlag(args); + + final sub = cleanArgs.isNotEmpty ? cleanArgs[0] : 'list'; + final subArgs = cleanArgs.length > 1 ? cleanArgs.sublist(1) : []; + + switch (sub) { + case 'list': + await _cmdList(format); + break; + case 'stop': + await _cmdStop(subArgs, format); + break; + case 'status': + await _cmdStatus(subArgs, format); + break; + default: + print('Unknown server subcommand: $sub'); + print('Available: list, stop, status'); + exit(1); + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +Future _cmdList(OutputFormat format) async { + final entries = await ServerRegistry.listAll(); + + if (format == OutputFormat.json) { + print(jsonEncode(entries.map((e) => e.toJson()).toList())); + return; + } + + if (entries.isEmpty) { + print('No running skill servers found.'); + return; + } + + // Human-readable table. + print('Running skill servers:'); + print(''); + final header = _padRight('ID', 20) + + _padRight('PORT', 8) + + _padRight('PID', 8) + + 'PROJECT'; + print(header); + print('-' * 60); + for (final e in entries) { + final alive = await ServerRegistry.isAlive(e.id); + final status = alive ? '' : ' (unreachable)'; + print(_padRight(e.id, 20) + + _padRight(e.port.toString(), 8) + + _padRight(e.pid.toString(), 8) + + e.projectPath + + status); + } +} + +// --------------------------------------------------------------------------- +// stop +// --------------------------------------------------------------------------- + +Future _cmdStop(List args, OutputFormat format) async { + final id = _parseFlag(args, '--id'); + if (id == null) { + print('Usage: flutter_skill server stop --id='); + exit(1); + } + + // Send a JSON-RPC shutdown request — or just unregister if unreachable. + bool sent = false; + try { + final client = SkillClient.byId(id); + await client.call('shutdown', {}); + sent = true; + } catch (_) { + // Server may already be down — just clean the registry entry. + } + + await ServerRegistry.unregister(id); + + if (format == OutputFormat.json) { + print(jsonEncode({'id': id, 'stopped': true, 'signaled': sent})); + } else { + print(sent + ? 'Server "$id" stopped.' + : 'Server "$id" was not reachable; registry entry removed.'); + } +} + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +Future _cmdStatus(List args, OutputFormat format) async { + final id = _parseFlag(args, '--id'); + if (id == null) { + print('Usage: flutter_skill server status --id='); + exit(1); + } + + final entry = await ServerRegistry.get(id); + if (entry == null) { + if (format == OutputFormat.json) { + print(jsonEncode({'id': id, 'found': false})); + } else { + print('No server registered with id "$id".'); + } + return; + } + + final alive = await ServerRegistry.isAlive(id); + + if (format == OutputFormat.json) { + final data = entry.toJson(); + data['alive'] = alive; + print(jsonEncode(data)); + return; + } + + print('Server: ${entry.id}'); + print(' Status : ${alive ? "running" : "unreachable"}'); + print(' Port : ${entry.port}'); + print(' PID : ${entry.pid}'); + print(' Project : ${entry.projectPath}'); + print(' Device : ${entry.deviceId}'); + print(' URI : ${entry.vmServiceUri}'); + print(' Started : ${entry.startedAt.toLocal()}'); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +String? _parseFlag(List args, String flag) { + for (final arg in args) { + if (arg.startsWith('$flag=')) return arg.substring(flag.length + 1); + } + return null; +} + +String _padRight(String s, int width) => s.padRight(width); diff --git a/lib/src/server_registry.dart b/lib/src/server_registry.dart new file mode 100644 index 00000000..c73d6b18 --- /dev/null +++ b/lib/src/server_registry.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io'; + +/// A single named server instance entry stored in the registry. +class ServerEntry { + final String id; + final int port; + final int pid; + final String projectPath; + final String deviceId; + final String vmServiceUri; + final DateTime startedAt; + + const ServerEntry({ + required this.id, + required this.port, + required this.pid, + required this.projectPath, + required this.deviceId, + required this.vmServiceUri, + required this.startedAt, + }); + + Map toJson() => { + 'id': id, + 'port': port, + 'pid': pid, + 'projectPath': projectPath, + 'deviceId': deviceId, + 'vmServiceUri': vmServiceUri, + 'startedAt': startedAt.toIso8601String(), + }; + + factory ServerEntry.fromJson(Map json) => ServerEntry( + id: json['id'] as String, + port: json['port'] as int, + pid: json['pid'] as int, + projectPath: json['projectPath'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', + vmServiceUri: json['vmServiceUri'] as String? ?? '', + startedAt: json['startedAt'] != null + ? DateTime.parse(json['startedAt'] as String) + : DateTime.now(), + ); +} + +/// Manages ~/.flutter_skill/servers/ registry of named server instances. +class ServerRegistry { + static Directory get _registryDir { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + final sep = Platform.pathSeparator; + return Directory('$home${sep}.flutter_skill${sep}servers'); + } + + static File _entryFile(String id) => + File('${_registryDir.path}${Platform.pathSeparator}$id.json'); + + static File _sockFile(String id) => + File('${_registryDir.path}${Platform.pathSeparator}$id.sock'); + + /// Write a server entry to disk. + static Future register(ServerEntry entry) async { + await _registryDir.create(recursive: true); + await _entryFile(entry.id) + .writeAsString(jsonEncode(entry.toJson()), flush: true); + } + + /// Read a single server entry by ID. Returns null if not found. + static Future get(String id) async { + final file = _entryFile(id); + if (!await file.exists()) return null; + try { + final json = + jsonDecode(await file.readAsString()) as Map; + return ServerEntry.fromJson(json); + } catch (_) { + return null; + } + } + + /// Return all registered entries, filtering out stale ones (PID no longer alive). + static Future> listAll() async { + if (!await _registryDir.exists()) return []; + + final entries = []; + await for (final entity in _registryDir.list()) { + if (entity is! File) continue; + if (!entity.path.endsWith('.json')) continue; + try { + final json = + jsonDecode(await entity.readAsString()) as Map; + final entry = ServerEntry.fromJson(json); + // Filter stale entries — check if the process is still alive. + if (await _isPidAlive(entry.pid)) { + entries.add(entry); + } else { + // Clean up stale entry silently. + await entity.delete().catchError((_) => entity); + } + } catch (_) { + // Skip malformed files. + } + } + return entries; + } + + /// Delete a server entry (and its Unix socket file if present). + static Future unregister(String id) async { + final file = _entryFile(id); + if (await file.exists()) await file.delete(); + final sock = _sockFile(id); + if (await sock.exists()) await sock.delete(); + } + + /// Check whether the TCP port for the named server is accepting connections. + static Future isAlive(String id) async { + final entry = await get(id); + if (entry == null) return false; + return await _isTcpPortOpen('127.0.0.1', entry.port); + } + + /// Unix socket path for a server id, or null on Windows. + static String? unixSocketPath(String id) { + if (Platform.isWindows) return null; + return _sockFile(id).path; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + static Future _isPidAlive(int pid) async { + try { + if (Platform.isWindows) { + final result = await Process.run( + 'tasklist', ['/FI', 'PID eq $pid', '/NH'], + runInShell: true); + return result.stdout.toString().contains(pid.toString()); + } else { + // kill -0 checks existence without sending a real signal. + final result = + await Process.run('kill', ['-0', pid.toString()], runInShell: true); + return result.exitCode == 0; + } + } catch (_) { + return false; + } + } + + static Future _isTcpPortOpen(String host, int port) async { + try { + final socket = await Socket.connect(host, port, + timeout: const Duration(milliseconds: 500)); + await socket.close(); + return true; + } catch (_) { + return false; + } + } +} diff --git a/lib/src/skill_client.dart b/lib/src/skill_client.dart new file mode 100644 index 00000000..16533052 --- /dev/null +++ b/lib/src/skill_client.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'server_registry.dart'; + +/// Client that connects to a named [SkillServer] over TCP (or Unix socket on +/// macOS/Linux) and sends JSON-RPC 2.0 requests. +class SkillClient { + final String? serverId; + final int? directPort; + + int _nextId = 1; + + SkillClient.byId(String id) + : serverId = id, + directPort = null; + + SkillClient.byPort(int port) + : serverId = null, + directPort = port; + + /// Send a JSON-RPC 2.0 request and return the result map. + /// + /// Throws if the server returns an error or the connection fails. + Future> call( + String method, Map params) async { + final id = _nextId++; + + Socket socket; + try { + socket = await _connect(); + } catch (e) { + throw Exception( + 'Could not connect to server "${serverId ?? directPort}": $e'); + } + + try { + final request = jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + 'params': params, + }); + + socket.writeln(request); + + // Read lines until we get the response for our request id. + final completer = Completer>(); + + socket.cast>().transform(utf8.decoder).transform(const LineSplitter()).listen( + (line) { + if (completer.isCompleted) return; + try { + final response = jsonDecode(line) as Map; + if (response['id'] == id) { + if (response.containsKey('error')) { + final err = response['error'] as Map; + completer.completeError( + Exception(err['message'] ?? 'Unknown error')); + } else { + completer.complete( + response['result'] as Map? ?? {}); + } + } + } catch (_) { + // Ignore lines that are not valid JSON for our id. + } + }, + onError: (Object e) { + if (!completer.isCompleted) completer.completeError(e); + }, + onDone: () { + if (!completer.isCompleted) { + completer.completeError( + Exception('Connection closed before response received')); + } + }, + cancelOnError: true, + ); + + return await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () => + throw TimeoutException('Request timed out', const Duration(seconds: 30)), + ); + } finally { + await socket.close().catchError((_) {}); + } + } + + Future _connect() async { + // Prefer Unix socket on non-Windows when available. + if (!Platform.isWindows && serverId != null) { + final sockPath = ServerRegistry.unixSocketPath(serverId!); + if (sockPath != null && await File(sockPath).exists()) { + try { + return await Socket.connect( + InternetAddress(sockPath, type: InternetAddressType.unix), + 0, + timeout: const Duration(milliseconds: 500), + ); + } catch (_) { + // Fall through to TCP. + } + } + } + + final port = await _resolvePort(); + return Socket.connect('127.0.0.1', port, + timeout: const Duration(seconds: 5)); + } + + Future _resolvePort() async { + if (directPort != null) return directPort!; + final entry = await ServerRegistry.get(serverId!); + if (entry == null) { + throw Exception('No server registered with id "$serverId". ' + 'Run: flutter_skill connect --id=$serverId --port='); + } + return entry.port; + } +} diff --git a/lib/src/skill_server.dart b/lib/src/skill_server.dart new file mode 100644 index 00000000..b5d99b6e --- /dev/null +++ b/lib/src/skill_server.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'drivers/app_driver.dart'; +import 'server_registry.dart'; + +/// A lightweight JSON-RPC 2.0 server over raw TCP (newline-delimited JSON) +/// that exposes AppDriver capabilities to local CLI clients. +/// +/// Lifecycle: +/// 1. Call [start] — binds a random free TCP port, registers in [ServerRegistry]. +/// 2. Clients connect and send newline-delimited JSON-RPC 2.0 requests. +/// 3. Call [stop] — closes all connections, unregisters from [ServerRegistry]. +class SkillServer { + final String id; + final AppDriver driver; + final String projectPath; + final String deviceId; + + ServerSocket? _tcpServer; + ServerSocket? _unixServer; + int? _port; + final List _connections = []; + + SkillServer({ + required this.id, + required this.driver, + this.projectPath = '', + this.deviceId = '', + }); + + int get port => _port ?? 0; + + /// Start listening. Binds a random free TCP port and registers in the registry. + Future start() async { + _tcpServer = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + _port = _tcpServer!.port; + + _tcpServer!.listen(_handleConnection); + + // On Unix, also bind a Unix domain socket for lower-latency local IPC. + final sockPath = ServerRegistry.unixSocketPath(id); + if (sockPath != null) { + try { + // Remove stale socket file if present. + final sockFile = File(sockPath); + if (await sockFile.exists()) await sockFile.delete(); + + _unixServer = await ServerSocket.bind( + InternetAddress(sockPath, type: InternetAddressType.unix), + 0, + ); + _unixServer!.listen(_handleConnection); + } catch (_) { + // Unix socket is optional — silently ignore failures. + } + } + + final entry = ServerEntry( + id: id, + port: _port!, + pid: pid, + projectPath: projectPath, + deviceId: deviceId, + vmServiceUri: _vmServiceUri(), + startedAt: DateTime.now(), + ); + await ServerRegistry.register(entry); + } + + /// Stop the server and unregister from the registry. + Future stop() async { + for (final conn in List.from(_connections)) { + await conn.close().catchError((_) => conn); + } + _connections.clear(); + await _tcpServer?.close(); + await _unixServer?.close(); + await ServerRegistry.unregister(id); + } + + // --------------------------------------------------------------------------- + // Connection handling + // --------------------------------------------------------------------------- + + void _handleConnection(Socket socket) { + _connections.add(socket); + + socket + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) => _handleLine(socket, line), + onDone: () => _connections.remove(socket), + onError: (_) => _connections.remove(socket), + cancelOnError: false, + ); + } + + Future _handleLine(Socket socket, String line) async { + final trimmed = line.trim(); + if (trimmed.isEmpty) return; + + Map request; + try { + request = jsonDecode(trimmed) as Map; + } catch (e) { + _sendError(socket, null, -32700, 'Parse error: $e'); + return; + } + + final id = request['id']; + final method = request['method'] as String?; + final params = (request['params'] as Map?) ?? {}; + + if (method == null) { + _sendError(socket, id, -32600, 'Invalid Request: missing method'); + return; + } + + try { + final result = await _dispatch(method, params); + _sendResult(socket, id, result); + } catch (e) { + _sendError(socket, id, -32000, e.toString()); + } + } + + // --------------------------------------------------------------------------- + // Method dispatch — mirrors the MCP tool set + // --------------------------------------------------------------------------- + + Future> _dispatch( + String method, Map params) async { + switch (method) { + case 'tap': + final result = await driver.tap( + key: params['key'] as String?, + text: params['text'] as String?, + ref: params['ref'] as String?, + ); + return result; + + case 'enter_text': + final result = await driver.enterText( + params['key'] as String?, + params['text'] as String? ?? '', + ref: params['ref'] as String?, + ); + return result; + + case 'swipe': + final success = await driver.swipe( + direction: params['direction'] as String? ?? 'up', + distance: (params['distance'] as num?)?.toDouble() ?? 300, + key: params['key'] as String?, + ); + return {'success': success}; + + case 'inspect': + final elements = await driver.getInteractiveElements( + includePositions: (params['includePositions'] as bool?) ?? true, + ); + return {'elements': elements}; + + case 'screenshot': + final image = await driver.takeScreenshot( + quality: (params['quality'] as num?)?.toDouble() ?? 1.0, + maxWidth: params['maxWidth'] as int?, + ); + return {'image': image}; + + case 'get_logs': + final logs = await driver.getLogs(); + return {'logs': logs}; + + case 'clear_logs': + await driver.clearLogs(); + return {'success': true}; + + case 'hot_reload': + await driver.hotReload(); + return {'success': true}; + + case 'ping': + return {'pong': true, 'server': id}; + + default: + throw Exception('Method not found: $method'); + } + } + + // --------------------------------------------------------------------------- + // JSON-RPC helpers + // --------------------------------------------------------------------------- + + void _sendResult(Socket socket, dynamic id, Map result) { + final response = jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': result, + }); + socket.writeln(response); + } + + void _sendError(Socket socket, dynamic id, int code, String message) { + final response = jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': {'code': code, 'message': message}, + }); + socket.writeln(response); + } + + String _vmServiceUri() { + // If the driver is a FlutterSkillClient it exposes vmServiceUri. + try { + // Use reflection-free duck typing via dynamic dispatch. + final d = driver as dynamic; + return (d.vmServiceUri as String?) ?? ''; + } catch (_) { + return ''; + } + } +} + +/// Find a random free TCP port by binding temporarily. +Future findFreePort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} From 5f6293f03974a5ec2e1bf7de9f9b22e9c9554632 Mon Sep 17 00:00:00 2001 From: vinifig Date: Wed, 1 Apr 2026 14:39:53 -0300 Subject: [PATCH 2/6] fix: address all code review issues from PR #1 - CRITICAL-1: add shutdown case to _dispatch in skill_server.dart; move ServerRegistry.unregister into catch block only in server_cmd.dart - CRITICAL-2: replace Future.delayed park hack with Completer in connect.dart - CRITICAL-3: fix Windows _isPidAlive false-positive with word-boundary matching - MAJOR-1: add hot_restart and scroll_to cases to _dispatch; add phase-1 comment - MAJOR-2: fix _spawnDetachedServer to detect dart run context and construct correct invocation - MAJOR-3: fix scroll direction hardcoded to 'up'; split scroll/scroll_to RPC cases - MAJOR-4: deduplicate _parseServerIds -> parseServerIds in output_format.dart; extract ServerCallResult replacing _ActResult/_ServerResult - MAJOR-5: add ID validation in ServerRegistry.register to prevent path traversal - MINOR-1: add https -> wss normalization in connect.dart - MINOR-2: replace as dynamic with proper type check in _vmServiceUri - MINOR-3: rename stripOutputFlag to stripOutputFormatFlag (keep old name as shim) - NIT-1: remove _padRight wrapper in server_cmd.dart; use .padRight() directly - NIT-2: remove unused findFreePort from skill_server.dart --- lib/src/cli/act.dart | 58 ++++++++-------------------------- lib/src/cli/connect.dart | 17 +++++++--- lib/src/cli/inspect.dart | 47 +++------------------------ lib/src/cli/launch.dart | 17 ++++++++-- lib/src/cli/output_format.dart | 46 ++++++++++++++++++++++++++- lib/src/cli/server_cmd.dart | 23 +++++++------- lib/src/server_registry.dart | 15 ++++++++- lib/src/skill_server.dart | 44 +++++++++++++++++--------- 8 files changed, 144 insertions(+), 123 deletions(-) diff --git a/lib/src/cli/act.dart b/lib/src/cli/act.dart index 82f4970f..885b8182 100644 --- a/lib/src/cli/act.dart +++ b/lib/src/cli/act.dart @@ -6,9 +6,9 @@ import 'output_format.dart'; Future runAct(List args) async { // --server=[,,...] — forward to named SkillServer instance(s) - final serverIds = _parseServerIds(args); + final serverIds = parseServerIds(args); final format = resolveOutputFormat(args); - final effectiveArgs = stripOutputFlag(args) + final effectiveArgs = stripOutputFormatFlag(args) .where((a) => !a.startsWith('--server=')) .toList(); @@ -175,21 +175,6 @@ Future runAct(List args) async { // Server-forwarding helpers // --------------------------------------------------------------------------- -/// Parse `--server=[,,...]` from args and return the list of IDs. -List _parseServerIds(List args) { - for (final arg in args) { - if (arg.startsWith('--server=')) { - final value = arg.substring('--server='.length); - return value - .split(',') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); - } - } - return []; -} - /// Build a JSON-RPC method name + params from the act CLI args. Map _buildRpcCall(List actArgs) { if (actArgs.isEmpty) return {'method': 'ping', 'params': {}}; @@ -210,10 +195,17 @@ Map _buildRpcCall(List actArgs) { 'params': {'key': param1, 'text': param2 ?? ''} }; case 'scroll': - case 'scroll_to': return { 'method': 'swipe', - 'params': {'direction': 'up', 'key': param1} + 'params': { + 'direction': param1 ?? 'up', // param1 IS the direction for `scroll` + 'distance': double.tryParse(param2 ?? '') ?? 300, + } + }; + case 'scroll_to': + return { + 'method': 'scroll_to', + 'params': {'key': param1, 'direction': param2 ?? 'down'} }; case 'screenshot': return { @@ -259,14 +251,14 @@ Future _actViaServers( } } - return _ActResult( + return ServerCallResult( serverId: id, success: true, action: action, durationMs: stopwatch.elapsedMilliseconds); } catch (e) { stopwatch.stop(); - return _ActResult( + return ServerCallResult( serverId: id, success: false, action: action, @@ -294,30 +286,6 @@ Future _actViaServers( if (results.any((r) => !r.success)) exit(1); } -class _ActResult { - final String serverId; - final bool success; - final String action; - final String? error; - final int durationMs; - - const _ActResult({ - required this.serverId, - required this.success, - required this.action, - this.error, - required this.durationMs, - }); - - Map toJson() => { - 'server': serverId, - 'success': success, - 'action': action, - if (error != null) 'error': error, - 'duration_ms': durationMs, - }; -} - // --------------------------------------------------------------------------- // Existing helper (unchanged) // --------------------------------------------------------------------------- diff --git a/lib/src/cli/connect.dart b/lib/src/cli/connect.dart index a37a28f4..548443b1 100644 --- a/lib/src/cli/connect.dart +++ b/lib/src/cli/connect.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import '../drivers/flutter_driver.dart'; @@ -62,6 +63,12 @@ Future runConnect(List args) async { if (!uri.endsWith('/ws')) uri = '$uri/ws'; } + // Normalise https:// → wss:// + if (uri.startsWith('https://')) { + uri = uri.replaceFirst('https://', 'wss://'); + if (!uri.endsWith('/ws')) uri = '$uri/ws'; + } + print('Connecting to Flutter app at $uri...'); final driver = FlutterSkillClient(uri); try { @@ -84,11 +91,13 @@ Future runConnect(List args) async { print('Press Ctrl+C to stop.'); // Keep running until the process is interrupted. + final shutdown = Completer(); + ProcessSignal.sigint.watch().first.then((_) async { print('\nShutting down server "$id"...'); await server.stop(); await driver.disconnect(); - exit(0); + shutdown.complete(); }); // Also handle SIGTERM on Unix. @@ -96,10 +105,10 @@ Future runConnect(List args) async { ProcessSignal.sigterm.watch().first.then((_) async { await server.stop(); await driver.disconnect(); - exit(0); + if (!shutdown.isCompleted) shutdown.complete(); }); } - // Park the isolate so the process stays alive. - await Future.delayed(const Duration(days: 365)); + await shutdown.future; + exit(0); } diff --git a/lib/src/cli/inspect.dart b/lib/src/cli/inspect.dart index 56345be1..6c2c34c1 100644 --- a/lib/src/cli/inspect.dart +++ b/lib/src/cli/inspect.dart @@ -7,10 +7,10 @@ import 'output_format.dart'; Future runInspect(List args) async { // --server=[,,...] — forward to named SkillServer instance(s) // --output=json|human — output format - final serverIds = _parseServerIds(args); + final serverIds = parseServerIds(args); final format = resolveOutputFormat(args); final cleanArgs = - stripOutputFlag(args).where((a) => !a.startsWith('--server=')).toList(); + stripOutputFormatFlag(args).where((a) => !a.startsWith('--server=')).toList(); if (serverIds.isNotEmpty) { await _inspectViaServers(serverIds, format); @@ -62,14 +62,14 @@ Future _inspectViaServers( final client = SkillClient.byId(id); final result = await client.call('inspect', {}); stopwatch.stop(); - return _ServerResult( + return ServerCallResult( serverId: id, success: true, data: result, durationMs: stopwatch.elapsedMilliseconds); } catch (e) { stopwatch.stop(); - return _ServerResult( + return ServerCallResult( serverId: id, success: false, error: e.toString(), @@ -129,42 +129,3 @@ void _printElement(dynamic element, {String prefix = ''}) { } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Parse `--server=[,,...]` from args and return the list of IDs. -List _parseServerIds(List args) { - for (final arg in args) { - if (arg.startsWith('--server=')) { - final value = arg.substring('--server='.length); - return value.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); - } - } - return []; -} - -/// Holds the outcome of a single per-server parallel action. -class _ServerResult { - final String serverId; - final bool success; - final Map? data; - final String? error; - final int durationMs; - - const _ServerResult({ - required this.serverId, - required this.success, - this.data, - this.error, - required this.durationMs, - }); - - Map toJson() => { - 'server': serverId, - 'success': success, - if (data != null) 'data': data, - if (error != null) 'error': error, - 'duration_ms': durationMs, - }; -} diff --git a/lib/src/cli/launch.dart b/lib/src/cli/launch.dart index e2638c16..e2d0c0b1 100644 --- a/lib/src/cli/launch.dart +++ b/lib/src/cli/launch.dart @@ -135,9 +135,22 @@ void _attachServer(String id, String uri, String projectPath) async { void _spawnDetachedServer(String id, String uri, String projectPath) { // We re-invoke ourselves with the `connect` command so the child process // manages the server lifecycle independently. + final exe = Platform.resolvedExecutable; + final script = Platform.script.toFilePath(); + + // When running via `dart run`, resolvedExecutable is the Dart VM binary. + // Re-invoke as: dart