add Dart core package — IPC envelopes, daemon, ping round-trip
First real content for the `clide` Dart package at the repo root.
One AOT-compiled binary (ADR 0005) with two modes:
* `clide --daemon` long-running unix-socket server; listens on
`$XDG_RUNTIME_DIR/clide-$USER.sock` with stale-socket
reclaim, accepts JSON-lines request/response traffic, clean
SIGTERM shutdown unlinks the socket file.
* `clide <subcommand>` one-shot; opens the socket, sends a
request, writes the response JSON to stdout, exits with the
dispatcher's error code per ADR 0006 (0/1/2/3/4). Unknown
subcommands forward to the daemon so extensions can register
their own without CLI changes.
Tier 0 handlers: `ping` (returns pong + version + UTC ts) and
`version`. Both are covered by `test/ipc/` + `test/daemon/`; the
subprocess test builds `bin/clide`, starts it, pings it, SIGTERMs
it, and asserts the socket file disappears.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
// clide — CLI + daemon entry point.
|
||||
//
|
||||
// One binary, two modes (per ADR 0005):
|
||||
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
|
||||
// sends a request, prints the response, exits with the ADR-0006
|
||||
// exit code.
|
||||
// * `clide --daemon` — long-running; owns the socket, dispatches
|
||||
// requests. Tier 0 ships `ping` and `version`; feature subsystems
|
||||
// (pane, git, pql, etc.) register handlers as they land.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
|
||||
Future<void> main(List<String> argv) async {
|
||||
if (argv.isEmpty) {
|
||||
_printHelp(stdout);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (argv.first == '--daemon') {
|
||||
await _runDaemon(argv.sublist(1));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (argv.first) {
|
||||
case '--version':
|
||||
case 'version':
|
||||
await _runCli('version', const [], exitOnOk: true);
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help':
|
||||
_printHelp(stdout);
|
||||
exit(0);
|
||||
case 'ping':
|
||||
await _runCli('ping', argv.sublist(1), exitOnOk: true);
|
||||
default:
|
||||
// Unknown-to-the-CLI commands still go over IPC — the daemon is
|
||||
// authoritative about what's registered. Lets extensions add
|
||||
// subcommands without the CLI caring.
|
||||
await _runCli(argv.first, argv.sublist(1), exitOnOk: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _printHelp(IOSink sink) {
|
||||
sink.writeln('''
|
||||
clide $clideVersion — Flutter desktop IDE for Claude Code.
|
||||
|
||||
Usage:
|
||||
clide --daemon Run the long-running daemon process.
|
||||
clide <subcommand> Run a one-shot subcommand against the daemon.
|
||||
|
||||
Built-in subcommands:
|
||||
ping Round-trip a ping to the daemon.
|
||||
version Print the clide version.
|
||||
help Print this help.
|
||||
|
||||
Any other subcommand is forwarded to the daemon; registered handlers
|
||||
(e.g. `clide git status` once `builtin.git` lands) resolve there.
|
||||
Matches ADR 0006's exit-code contract:
|
||||
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _runDaemon(List<String> args) async {
|
||||
final socketPath = defaultSocketPath();
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final server = DaemonServer(
|
||||
socketPath: socketPath,
|
||||
dispatch: dispatcher.dispatch,
|
||||
);
|
||||
|
||||
final stopping = Completer<void>();
|
||||
void shutdown(ProcessSignal sig) {
|
||||
if (!stopping.isCompleted) {
|
||||
stderr.writeln('clide daemon: received ${sig.toString()}, shutting down');
|
||||
stopping.complete();
|
||||
}
|
||||
}
|
||||
|
||||
ProcessSignal.sigint.watch().listen(shutdown);
|
||||
ProcessSignal.sigterm.watch().listen(shutdown);
|
||||
|
||||
await server.start();
|
||||
await stopping.future;
|
||||
await server.stop();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
Future<void> _runCli(
|
||||
String cmd,
|
||||
List<String> args, {
|
||||
required bool exitOnOk,
|
||||
}) async {
|
||||
final socketPath = defaultSocketPath();
|
||||
Socket socket;
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
} catch (e) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon not reachable at $socketPath',
|
||||
hint: 'run `clide --daemon` in another terminal.',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
|
||||
final request = IpcRequest(
|
||||
id: '1',
|
||||
cmd: cmd,
|
||||
args: {'argv': args},
|
||||
);
|
||||
socket.writeln(request.encode());
|
||||
|
||||
final line = await socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.first;
|
||||
await socket.close();
|
||||
|
||||
try {
|
||||
final msg = IpcMessage.decode(line);
|
||||
if (msg is! IpcResponse) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'unexpected message from daemon',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
if (msg.ok) {
|
||||
stdout.writeln(jsonEncode(msg.data));
|
||||
if (exitOnOk) exit(IpcExitCode.ok);
|
||||
} else {
|
||||
final err = msg.error!;
|
||||
_emitError(
|
||||
code: err.code,
|
||||
kind: err.kind,
|
||||
message: err.message,
|
||||
hint: err.hint,
|
||||
);
|
||||
exit(err.code);
|
||||
}
|
||||
} on FormatException catch (e) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'bad response from daemon: $e',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
}
|
||||
|
||||
void _emitError({
|
||||
required int code,
|
||||
required String kind,
|
||||
required String message,
|
||||
String? hint,
|
||||
}) {
|
||||
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
|
||||
stderr.writeln(jsonEncode(err.toJson()));
|
||||
}
|
||||
Reference in New Issue
Block a user