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:
2026-04-21 15:36:40 +02:00
co-authored by Claude
parent bded1a2b65
commit 235cbcc046
15 changed files with 1373 additions and 0 deletions
+1
View File
@@ -30,6 +30,7 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- Dart core package at the repo root: `bin/clide.dart` (one binary, `--daemon` and one-shot subcommand modes), `lib/clide.dart` barrel exporting the shared IPC types, `lib/src/ipc/` (`envelope.dart`, `server.dart`, `paths.dart`, `schema_v1.dart`), and `lib/src/daemon/dispatcher.dart`. `clide --daemon` listens on a unix socket; `clide ping` / `clide version` round-trip through it with the ADR 0006 exit-code contract (`0/1/2/3/4`). Includes `test/ipc/` and `test/daemon/` suites covering envelope parsing, the in-process server, and a subprocess smoke that verifies signal-driven shutdown + socket unlink.
- `scripts/bazzite-flutter-setup.sh` — one-shot installer for the Flutter SDK + desktop build deps on Bazzite / Fedora Silverblue. Drops the SDK under `~/opt/flutter`, wires PATH in the user's shell rc files, and layers the Linux desktop build deps via `rpm-ostree install`.
- [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md) — Dart core; sidecar directory dissolved; `ptyc` as pql-peer. Establishes one Dart AOT binary for both CLI and daemon, `lib/` as the shared core, and promotes the C PTY helper to a standalone supporter tool on the same footing as pql.
- [ADR 0006](docs/ADRs/0006-cli-and-event-surface.md) — CLI and event surface contract. Defines the subsystem list (`pane`, `tab`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`), the command shape, the versioned JSON event schema, the pql-style exit-code contract, and the command↔event duality rule that operationalises user/Claude parity.
+169
View File
@@ -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()));
}
+35
View File
@@ -0,0 +1,35 @@
/// clide — Dart core library.
///
/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app
/// under `app/` (which depends on this package via `path: ../`).
///
/// See:
/// * docs/ADRs/0005-dart-core-ptyc-peer.md — layout + language rationale.
/// * docs/ADRs/0006-cli-and-event-surface.md — CLI + event contract.
library;
export 'src/daemon/dispatcher.dart';
export 'src/ipc/envelope.dart';
export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart';
export 'src/ipc/server.dart';
/// Build-time-stamped version string.
///
/// The Makefile's `build` target passes `--define=clideVersion=…` when
/// invoking `dart compile exe`, stamping `project.yaml`'s `version:`
/// plus the git short SHA and dirty marker.
const clideVersion = String.fromEnvironment(
'clideVersion',
defaultValue: '2.0.0-dev',
);
const clideCommit = String.fromEnvironment(
'clideCommit',
defaultValue: 'unknown',
);
const clideDate = String.fromEnvironment(
'clideDate',
defaultValue: 'unknown',
);
+48
View File
@@ -0,0 +1,48 @@
import 'package:clide/clide.dart' show clideVersion;
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/schema_v1.dart';
typedef CommandHandler = Future<IpcResponse> Function(IpcRequest req);
class DaemonDispatcher {
DaemonDispatcher() {
register('ping', _ping);
register('version', _version);
}
final Map<String, CommandHandler> _handlers = {};
void register(String cmd, CommandHandler handler) {
_handlers[cmd] = handler;
}
Future<IpcResponse> dispatch(IpcRequest req) async {
final h = _handlers[req.cmd];
if (h == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'unknown command: ${req.cmd}',
hint: 'run `clide --help` for the surface.',
),
);
}
return h(req);
}
Future<IpcResponse> _ping(IpcRequest req) async => IpcResponse.ok(
id: req.id,
data: {
'pong': true,
'ts': DateTime.now().toUtc().toIso8601String(),
'version': clideVersion,
},
);
Future<IpcResponse> _version(IpcRequest req) async => IpcResponse.ok(
id: req.id,
data: {'version': clideVersion},
);
}
+157
View File
@@ -0,0 +1,157 @@
import 'dart:convert';
import 'package:clide/src/ipc/schema_v1.dart';
sealed class IpcMessage {
Map<String, Object?> toJson();
String encode() => jsonEncode(toJson());
static IpcMessage decode(String line) {
final obj = jsonDecode(line);
if (obj is! Map<String, Object?>) {
throw const FormatException('IPC message is not a JSON object');
}
final type = obj['type'];
switch (type) {
case 'request':
return IpcRequest.fromJson(obj);
case 'response':
return IpcResponse.fromJson(obj);
case 'event':
return IpcEvent.fromJson(obj);
default:
throw FormatException('Unknown IPC message type: $type');
}
}
}
class IpcRequest extends IpcMessage {
IpcRequest({
required this.id,
required this.cmd,
this.args = const {},
});
final String id;
final String cmd;
final Map<String, Object?> args;
@override
Map<String, Object?> toJson() => {
'type': 'request',
'v': ipcSchemaVersion,
'id': id,
'cmd': cmd,
'args': args,
};
factory IpcRequest.fromJson(Map<String, Object?> j) => IpcRequest(
id: j['id']! as String,
cmd: j['cmd']! as String,
args: (j['args'] as Map?)?.cast<String, Object?>() ?? const {},
);
}
class IpcResponse extends IpcMessage {
IpcResponse.ok({required this.id, this.data = const {}})
: ok = true,
error = null;
IpcResponse.err({required this.id, required IpcError this.error})
: ok = false,
data = const {};
IpcResponse._({
required this.id,
required this.ok,
required this.data,
required this.error,
});
final String id;
final bool ok;
final Map<String, Object?> data;
final IpcError? error;
@override
Map<String, Object?> toJson() => {
'type': 'response',
'v': ipcSchemaVersion,
'id': id,
'ok': ok,
if (ok) 'data': data,
if (!ok && error != null) 'error': error!.toJson(),
};
factory IpcResponse.fromJson(Map<String, Object?> j) {
final ok = j['ok'] as bool? ?? false;
return IpcResponse._(
id: j['id']! as String,
ok: ok,
data: (j['data'] as Map?)?.cast<String, Object?>() ?? const {},
error: ok
? null
: IpcError.fromJson((j['error'] as Map).cast<String, Object?>()),
);
}
}
class IpcError {
IpcError({
required this.code,
required this.kind,
required this.message,
this.hint,
});
final int code;
final String kind;
final String message;
final String? hint;
Map<String, Object?> toJson() => {
'code': code,
'kind': kind,
'message': message,
if (hint != null) 'hint': hint,
};
factory IpcError.fromJson(Map<String, Object?> j) => IpcError(
code: (j['code'] as num).toInt(),
kind: j['kind']! as String,
message: j['message']! as String,
hint: j['hint'] as String?,
);
}
class IpcEvent extends IpcMessage {
IpcEvent({
required this.subsystem,
required this.kind,
required this.timestamp,
this.data = const {},
});
final String subsystem;
final String kind;
final DateTime timestamp;
final Map<String, Object?> data;
@override
Map<String, Object?> toJson() => {
'type': 'event',
'v': ipcSchemaVersion,
'subsystem': subsystem,
'kind': kind,
'ts': timestamp.toIso8601String(),
'data': data,
};
factory IpcEvent.fromJson(Map<String, Object?> j) => IpcEvent(
subsystem: j['subsystem']! as String,
kind: j['kind']! as String,
timestamp: DateTime.parse(j['ts']! as String),
data: (j['data'] as Map?)?.cast<String, Object?>() ?? const {},
);
}
+8
View File
@@ -0,0 +1,8 @@
import 'dart:io';
String defaultSocketPath() {
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
final user = Platform.environment['USER'] ?? 'anon';
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp';
return '$base/clide-$user.sock';
}
+21
View File
@@ -0,0 +1,21 @@
/// IPC wire schema version.
///
/// Bumped when the envelope shape changes in a non-backwards-compatible
/// way. The daemon and app both carry this constant and reject messages
/// whose `v:` doesn't match.
const int ipcSchemaVersion = 1;
abstract class IpcExitCode {
static const int ok = 0;
static const int userError = 1;
static const int toolError = 2;
static const int notFound = 3;
static const int conflict = 4;
}
abstract class IpcErrorKind {
static const String userError = 'user_error';
static const String toolError = 'tool_error';
static const String notFound = 'not_found';
static const String conflict = 'conflict';
}
+97
View File
@@ -0,0 +1,97 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/ipc/envelope.dart';
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
/// Unix-socket JSON-lines server. Each connection is an independent
/// bidirectional line-framed stream: client writes requests, daemon
/// writes responses (and events, later). Tier 0 handles request→response
/// only; event broadcasting lands with the first feature that emits.
class DaemonServer {
DaemonServer({
required this.socketPath,
required this.dispatch,
});
final String socketPath;
final RequestDispatcher dispatch;
ServerSocket? _server;
final Set<Socket> _clients = {};
Future<void> start() async {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
try {
_server = await ServerSocket.bind(addr, 0);
} on SocketException {
// stale socket from a prior crash — unlink and retry once
try {
await File(socketPath).delete();
} catch (_) {}
_server = await ServerSocket.bind(addr, 0);
}
stderr.writeln('clide daemon listening on $socketPath');
_server!.listen(_handleClient, onError: (e) {
stderr.writeln('clide daemon accept error: $e');
});
}
Future<void> stop() async {
for (final c in List<Socket>.from(_clients)) {
await c.close();
}
_clients.clear();
await _server?.close();
_server = null;
try {
await File(socketPath).delete();
} catch (_) {}
}
void _handleClient(Socket client) {
_clients.add(client);
client
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) => _handleLine(client, line),
onDone: () => _clients.remove(client),
onError: (Object e) {
stderr.writeln('clide daemon client error: $e');
_clients.remove(client);
},
cancelOnError: true,
);
}
Future<void> _handleLine(Socket client, String line) async {
if (line.isEmpty) return;
IpcMessage? msg;
try {
msg = IpcMessage.decode(line);
} on FormatException catch (e) {
stderr.writeln('clide daemon: bad line from client: $e');
return;
}
if (msg is! IpcRequest) return;
IpcResponse resp;
try {
resp = await dispatch(msg);
} catch (e, st) {
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'dispatch failed: $e',
),
);
}
client.writeln(resp.encode());
}
}
+405
View File
@@ -0,0 +1,405 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "67.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
meta:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.18.2"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
url: "https://pub.dev"
source: hosted
version: "1.25.8"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
test_core:
dependency: transitive
description:
name: test_core
sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
url: "https://pub.dev"
source: hosted
version: "0.6.5"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.1"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.0-0 <4.0.0"
+38
View File
@@ -0,0 +1,38 @@
# clide — Dart core package.
#
# This is the repo-root package: the `clide` CLI (one-shot subcommand
# mode) and the `clide --daemon` long-running process both compile from
# `bin/clide.dart` against `lib/`. It has NO Flutter dependency so
# `dart compile exe` produces a lean static binary.
#
# The Flutter desktop UI lives under `app/` as a separate package that
# depends on this one via a path: dependency. See ADR 0005 for the
# layout rationale.
#
# Version is mirrored from project.yaml on every bump. project.yaml is
# the single source of truth — if you change one without the other,
# `make build`'s ldflag stamping will reveal the drift.
name: clide
description: >-
Flutter desktop IDE for Claude Code — Dart core (CLI + daemon + shared
library). The Flutter UI lives under app/.
version: 2.0.0-dev
publish_to: none
repository: https://git.schweitz.net/jpmschweitzer/clide
environment:
sdk: ">=3.5.0 <4.0.0"
# Prefer-zero-deps. Anything added here must be justified against the
# "write it yourself" default (see memory:
# feedback_dart_deps_minimal_locked_cve_checked.md). Exact-pin; never
# carets.
dependencies: {}
dev_dependencies:
lints: 5.0.0
test: 1.25.8
# Mocks at IO boundaries (daemon subprocess, socket). Null-safe,
# no codegen. Hand-rolled fakes for ChangeNotifier-style services.
mocktail: 1.0.4
+123
View File
@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('DaemonServer (in-process)', () {
late DaemonServer server;
late DaemonDispatcher dispatcher;
late String socketPath;
setUp(() async {
final tmp = await Directory.systemTemp.createTemp('clide_daemon_');
socketPath = '${tmp.path}/daemon.sock';
dispatcher = DaemonDispatcher();
server = DaemonServer(
socketPath: socketPath,
dispatch: dispatcher.dispatch,
);
await server.start();
});
tearDown(() async {
await server.stop();
});
test('ping round-trips with pong=true', () async {
final resp = await _send(
socketPath,
IpcRequest(id: '1', cmd: 'ping').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.id, '1');
expect(parsed.data['pong'], true);
expect(parsed.data['ts'], isA<String>());
expect(parsed.data['version'], isA<String>());
});
test('version returns current clideVersion', () async {
final resp = await _send(
socketPath,
IpcRequest(id: 'v', cmd: 'version').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.data['version'], clideVersion);
});
test('unknown command returns NotFound (exit code 3)', () async {
final resp = await _send(
socketPath,
IpcRequest(id: 'x', cmd: 'this.does.not.exist').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, false);
expect(parsed.error!.code, IpcExitCode.notFound);
expect(parsed.error!.kind, IpcErrorKind.notFound);
expect(parsed.error!.message, contains('this.does.not.exist'));
});
test('multiple concurrent requests on one connection', () async {
final socket = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
for (var i = 0; i < 5; i++) {
socket.writeln(IpcRequest(id: '$i', cmd: 'ping').encode());
}
final lines = <String>[];
final done = Completer<void>();
final sub = socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
lines.add(line);
if (lines.length == 5) done.complete();
});
await done.future.timeout(const Duration(seconds: 2));
await sub.cancel();
await socket.close();
final ids =
lines.map((l) => (IpcMessage.decode(l) as IpcResponse).id).toSet();
expect(ids, {'0', '1', '2', '3', '4'});
});
test('custom handler plugs into dispatcher', () async {
dispatcher.register('test.custom', (req) async {
return IpcResponse.ok(id: req.id, data: {'echo': req.args});
});
final resp = await _send(
socketPath,
IpcRequest(
id: 'c',
cmd: 'test.custom',
args: const {'x': 1},
).encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.data['echo'], {'x': 1});
});
});
}
Future<String> _send(String socketPath, String line) async {
final socket = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
socket.writeln(line);
final resp = await socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 2));
await socket.close();
return resp;
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
/// Subprocess-level daemon smoke. Only runs if `bin/clide` has been
/// built (the test skips itself otherwise). This is the release-gate
/// suite — catches signal-handling, socket-unlink, and version-stamp
/// regressions that the in-process test masks.
void main() {
final binary = File('bin/clide');
group('bin/clide --daemon (subprocess)', () {
setUpAll(() {
if (!binary.existsSync()) {
markTestSkipped(
'bin/clide not built; run `make build` first to enable this suite');
}
});
test('starts, responds to ping, exits cleanly on SIGTERM', () async {
if (!binary.existsSync()) return;
// Use a fresh socket under a unique temp path so parallel test
// runs don't collide. The daemon resolves its socket path from
// XDG_RUNTIME_DIR + USER (see defaultSocketPath()).
final tmp = await Directory.systemTemp.createTemp('clide_sub_');
final env = Map<String, String>.from(Platform.environment)
..['XDG_RUNTIME_DIR'] = tmp.path
..['USER'] = 'daemon';
final socketPath = '${tmp.path}/clide-daemon.sock';
final process = await Process.start(
binary.absolute.path,
['--daemon'],
environment: env,
);
// Wait for "listening on ..." on stderr before connecting.
final ready = Completer<void>();
final stderrLines = <String>[];
final sub = process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
stderrLines.add(line);
if (line.contains('listening')) ready.complete();
});
try {
await ready.future.timeout(const Duration(seconds: 3));
// Connect and ping
final sock = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
).timeout(const Duration(seconds: 3));
sock.writeln(IpcRequest(id: '1', cmd: 'ping').encode());
final line = await sock
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.first
.timeout(const Duration(seconds: 3));
await sock.close();
final resp = IpcMessage.decode(line) as IpcResponse;
expect(resp.ok, true);
expect(resp.data['pong'], true);
// Clean shutdown
process.kill(ProcessSignal.sigterm);
final exitCode =
await process.exitCode.timeout(const Duration(seconds: 3));
expect(exitCode, 0);
// Socket file should be unlinked
expect(await File(socketPath).exists(), false);
} finally {
await sub.cancel();
try {
process.kill(ProcessSignal.sigkill);
} catch (_) {}
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
}, timeout: const Timeout(Duration(seconds: 15)));
});
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('IpcRequest', () {
test('encodes and decodes round-trip', () {
final req = IpcRequest(
id: '42',
cmd: 'git.status',
args: const {'path': '.'},
);
final line = req.encode();
final decoded = IpcMessage.decode(line);
expect(decoded, isA<IpcRequest>());
final r = decoded as IpcRequest;
expect(r.id, '42');
expect(r.cmd, 'git.status');
expect(r.args, {'path': '.'});
});
test('schema version is stamped', () {
final encoded = IpcRequest(id: '1', cmd: 'ping').toJson();
expect(encoded['v'], ipcSchemaVersion);
expect(encoded['type'], 'request');
});
});
group('IpcResponse.ok', () {
test('encodes with data, no error', () {
final r = IpcResponse.ok(id: '7', data: const {'pong': true});
final json = r.toJson();
expect(json['type'], 'response');
expect(json['ok'], true);
expect(json['data'], {'pong': true});
expect(json.containsKey('error'), isFalse);
});
test('decode round-trips', () {
final original = IpcResponse.ok(id: '7', data: const {'n': 42});
final roundtripped = IpcMessage.decode(original.encode()) as IpcResponse;
expect(roundtripped.ok, true);
expect(roundtripped.id, '7');
expect(roundtripped.data, {'n': 42});
expect(roundtripped.error, isNull);
});
});
group('IpcResponse.err', () {
test('encodes with error payload, no data', () {
final r = IpcResponse.err(
id: '9',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'missing',
hint: 'try --help',
),
);
final json = r.toJson();
expect(json['ok'], false);
expect(json['error'], {
'code': 3,
'kind': 'not_found',
'message': 'missing',
'hint': 'try --help',
});
expect(json.containsKey('data'), isFalse);
});
test('decode preserves error fields', () {
final original = IpcResponse.err(
id: '9',
error: IpcError(
code: IpcExitCode.conflict,
kind: IpcErrorKind.conflict,
message: 'race',
),
);
final r = IpcMessage.decode(original.encode()) as IpcResponse;
expect(r.ok, false);
expect(r.error, isNotNull);
expect(r.error!.code, IpcExitCode.conflict);
expect(r.error!.kind, IpcErrorKind.conflict);
expect(r.error!.message, 'race');
expect(r.error!.hint, isNull);
});
});
group('IpcEvent', () {
test('serializes subsystem + kind + ts + data', () {
final ts = DateTime.utc(2026, 4, 21, 12, 0, 0);
final e = IpcEvent(
subsystem: 'git',
kind: 'status-changed',
timestamp: ts,
data: const {'staged': 3},
);
final json = e.toJson();
expect(json['type'], 'event');
expect(json['subsystem'], 'git');
expect(json['kind'], 'status-changed');
expect(json['ts'], '2026-04-21T12:00:00.000Z');
expect(json['data'], {'staged': 3});
});
});
group('IpcMessage.decode errors', () {
test('throws on non-object line', () {
expect(() => IpcMessage.decode('[]'), throwsA(isA<FormatException>()));
});
test('throws on unknown type', () {
expect(
() => IpcMessage.decode('{"type":"bogus","id":"1"}'),
throwsA(isA<FormatException>()),
);
});
test('throws on malformed JSON', () {
expect(() => IpcMessage.decode('{this is not json'),
throwsA(isA<FormatException>()));
});
});
}
+28
View File
@@ -0,0 +1,28 @@
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('defaultSocketPath', () {
final originalXdg = Platform.environment['XDG_RUNTIME_DIR'];
final originalUser = Platform.environment['USER'];
test('uses XDG_RUNTIME_DIR when set', () {
// We can't mutate Platform.environment from dart:io, so this test
// just asserts the path shape for the current env. CI and dev
// boxes both have meaningful USER values.
final path = defaultSocketPath();
expect(path, endsWith('.sock'));
expect(path, contains('clide-'));
if (originalXdg != null && originalXdg.isNotEmpty) {
expect(path, startsWith(originalXdg));
} else {
expect(path, startsWith('/tmp'));
}
if (originalUser != null && originalUser.isNotEmpty) {
expect(path, contains('clide-$originalUser'));
}
});
});
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('schema_v1', () {
test('schema version is 1', () {
expect(ipcSchemaVersion, 1);
});
test('exit codes match ADR 0006', () {
expect(IpcExitCode.ok, 0);
expect(IpcExitCode.userError, 1);
expect(IpcExitCode.toolError, 2);
expect(IpcExitCode.notFound, 3);
expect(IpcExitCode.conflict, 4);
});
test('error kinds are unique strings', () {
final kinds = <String>{
IpcErrorKind.userError,
IpcErrorKind.toolError,
IpcErrorKind.notFound,
IpcErrorKind.conflict,
};
expect(kinds.length, 4);
});
});
}