Files
clide/bin/clide.dart
T
jpmschweitzerandClaude 93ab6ea3bd add pql subsystem — daemon-side client + IPC handlers
PqlClient wraps pql CLI per D-003 (shell-out, never duplicate).
IPC verbs: pql.files, pql.meta, pql.backlinks, pql.outlinks,
pql.tags, pql.schema, pql.query, pql.doctor, pql.decisions.*,
pql.tickets.*, pql.plan.status. 15 new core tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-22 14:02:47 +02:00

434 lines
14 KiB
Dart

// clide — CLI + daemon entry point.
//
// One binary, two modes (per D-005):
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
// sends a request, prints the response, exits with the D-006
// exit code.
// * `clide --daemon` — long-running; owns the socket, dispatches
// requests. Subsystems (pane, files, editor, …) register handlers
// at boot.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
// daemon-subsystem wiring that the Flutter app doesn't need and
// can't compile for web. See lib/clide.dart for the barrel split.
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/pql_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/panes/registry.dart';
import 'package:clide/src/pql/client.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;
}
// Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat
// positional argv into the structured IPC shape of the canonical
// editor.* / pane.* verb. Keeps Claude's tool-use pattern short.
final rest = argv.sublist(1);
switch (argv.first) {
case '--version':
case 'version':
await _runCliArgs('version', const {}, exitOnOk: true);
case '--help':
case '-h':
case 'help':
_printHelp(stdout);
exit(0);
case 'ping':
await _runCliArgs('ping', const {}, exitOnOk: true);
case 'open':
if (rest.isEmpty) _die('usage: clide open <path>');
await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true);
case 'active':
await _runCliArgs('editor.active', const {}, exitOnOk: true);
case 'insert':
final text = await _readTextArg(rest);
await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true);
case 'replace-selection':
final text = await _readTextArg(rest);
await _runCliArgs(
'editor.replace-selection',
{'text': text},
exitOnOk: true,
);
case 'save':
await _runCliArgs('editor.save', const {}, exitOnOk: true);
case 'git':
await _runGit(rest);
case 'tail':
await _runTail(rest);
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. Args forward as-is under
// {argv: [...]} so daemon-side can parse whatever shape it wants.
await _runCliArgs(
argv.first,
{'argv': rest},
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.
Editor (tier 2):
open <path> Open a file in the editor (editor.open).
active Print the active buffer (editor.active).
insert <text|-> Insert text at the cursor in the active buffer.
`-` reads text from stdin.
replace-selection <…> Replace the selected text in the active buffer.
`-` reads text from stdin.
save Save the active buffer (editor.save).
Git (tier 3):
git status Working tree status (staged/unstaged/conflicts).
git diff [--staged] [P] Diff for file(s) P, or all if omitted.
git stage <paths…> Stage files (git add).
git stage-all Stage everything (git add -A).
git unstage [paths…] Unstage files (git reset HEAD).
git discard <paths…> Discard unstaged changes in files.
git commit "<msg>" Commit staged changes.
git log [--count N] Recent commit log (default 20).
git stash [--message M] Stash working changes.
git stash-pop Pop the top stash entry.
git pull Pull from remote.
git push [remote] [br] Push to remote.
Event subscription:
tail --events [--filter SUBSYSTEM[:ID]]
Stream events as JSON lines. --filter keeps
only events from one subsystem, optionally
narrowed to a single id. Exits on SIGINT.
Any other subcommand is forwarded to the daemon; registered handlers
(e.g. `clide git status` once `builtin.git` lands) resolve there.
Matches D-006'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();
late final DaemonServer server;
server = DaemonServer(
socketPath: socketPath,
dispatch: dispatcher.dispatch,
);
final events = _ServerEventSink(server);
final registry = PaneRegistry(events: events);
registerPaneCommands(dispatcher, registry);
final files = FilesService.atCwd(events: events);
registerFilesCommands(dispatcher, files);
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
registerEditorCommands(dispatcher, editor);
registerGitCommands(dispatcher, files.root, events);
final pql = PqlClient(workDir: files.root);
registerPqlCommands(dispatcher, pql);
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 registry.shutdown();
await editor.shutdown();
await files.shutdown();
await server.stop();
exit(0);
}
/// Thin adapter: the server doesn't `implement DaemonEventSink` itself
/// (that would tie ipc/ to panes/); instead the daemon entrypoint wraps
/// it at the seam where both are known.
class _ServerEventSink implements DaemonEventSink {
_ServerEventSink(this._server);
final DaemonServer _server;
@override
void emit(IpcEvent event) => _server.broadcast(event);
}
// ---------------------------------------------------------------------------
// CLI helpers
// ---------------------------------------------------------------------------
/// Read the "text" argument for insert / replace-selection. A lone
/// `-` means "slurp stdin"; anything else is concatenated into the
/// text body (so `clide insert hello world` emits "hello world").
Future<String> _readTextArg(List<String> rest) async {
if (rest.isEmpty) _die('usage: clide <verb> <text> (or `-` to read stdin)');
if (rest.length == 1 && rest.first == '-') {
final bytes = <int>[];
await for (final chunk in stdin) {
bytes.addAll(chunk);
}
return utf8.decode(bytes);
}
return rest.join(' ');
}
Future<Socket> _connectSocket() async {
final socketPath = defaultSocketPath();
try {
return await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
} catch (_) {
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not reachable at $socketPath',
hint: 'run `clide --daemon` in another terminal.',
);
exit(IpcExitCode.toolError);
}
}
Future<void> _runCliArgs(
String cmd,
Map<String, Object?> args, {
required bool exitOnOk,
}) async {
final socket = await _connectSocket();
final request = IpcRequest(id: '1', cmd: cmd, args: args);
socket.writeln(request.encode());
// Responses come back on the same socket. Events may be interleaved
// (the daemon broadcasts), so we skip events until we see the
// response whose id matches our request.
final lines = socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter());
try {
await for (final line in lines) {
if (line.isEmpty) continue;
final msg = IpcMessage.decode(line);
if (msg is! IpcResponse) continue;
if (msg.id != request.id) continue;
await socket.close();
if (msg.ok) {
stdout.writeln(jsonEncode(msg.data));
if (exitOnOk) exit(IpcExitCode.ok);
return;
} else {
final err = msg.error!;
_emitError(
code: err.code,
kind: err.kind,
message: err.message,
hint: err.hint,
);
exit(err.code);
}
}
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon closed socket before responding',
);
exit(IpcExitCode.toolError);
} on FormatException catch (e) {
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'bad response from daemon: $e',
);
exit(IpcExitCode.toolError);
}
}
/// `clide git <verb> [args]` — maps to `git.*` IPC verbs.
Future<void> _runGit(List<String> args) async {
if (args.isEmpty) {
_die('usage: clide git <verb> [args…]');
}
switch (args.first) {
case 'status':
await _runCliArgs('git.status', const {}, exitOnOk: true);
case 'diff':
final staged = args.contains('--staged');
final paths = args.sublist(1).where((a) => !a.startsWith('--')).toList();
await _runCliArgs(
'git.diff',
{'staged': staged, if (paths.isNotEmpty) 'paths': paths},
exitOnOk: true,
);
case 'stage':
final paths = args.sublist(1);
if (paths.isEmpty) _die('usage: clide git stage <path…>');
await _runCliArgs('git.stage', {'paths': paths}, exitOnOk: true);
case 'stage-all':
await _runCliArgs('git.stage-all', const {}, exitOnOk: true);
case 'unstage':
final paths = args.sublist(1);
await _runCliArgs('git.unstage', {'paths': paths}, exitOnOk: true);
case 'discard':
final paths = args.sublist(1);
if (paths.isEmpty) _die('usage: clide git discard <path…>');
await _runCliArgs('git.discard', {'paths': paths}, exitOnOk: true);
case 'commit':
if (args.length < 2) _die('usage: clide git commit "<message>"');
final message = args.sublist(1).join(' ');
await _runCliArgs('git.commit', {'message': message}, exitOnOk: true);
case 'log':
var count = 20;
for (var i = 1; i < args.length; i++) {
if (args[i] == '--count' && i + 1 < args.length) {
count = int.tryParse(args[i + 1]) ?? 20;
}
}
await _runCliArgs('git.log', {'count': count}, exitOnOk: true);
case 'stash':
String? message;
for (var i = 1; i < args.length; i++) {
if (args[i] == '--message' && i + 1 < args.length) {
message = args[i + 1];
}
}
await _runCliArgs(
'git.stash',
{if (message != null) 'message': message},
exitOnOk: true,
);
case 'stash-pop':
await _runCliArgs('git.stash-pop', const {}, exitOnOk: true);
case 'pull':
await _runCliArgs('git.pull', const {}, exitOnOk: true);
case 'push':
final rest = args.sublist(1);
final setUpstream = rest.contains('-u');
final positional = rest.where((a) => a != '-u').toList();
await _runCliArgs('git.push', {
if (setUpstream) 'setUpstream': true,
if (positional.isNotEmpty) 'remote': positional[0],
if (positional.length > 1) 'branch': positional[1],
}, exitOnOk: true);
default:
_die('unknown git verb: ${args.first}');
}
}
/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events.
Future<void> _runTail(List<String> args) async {
// Parse flags: --events (required today; keeps us honest when more
// modes like --history land), --filter SUBSYSTEM[:ID].
var wantEvents = false;
String? filterSubsystem;
String? filterId;
for (var i = 0; i < args.length; i++) {
final a = args[i];
if (a == '--events') {
wantEvents = true;
} else if (a == '--filter') {
if (i + 1 >= args.length) _die('--filter requires an argument');
final spec = args[++i];
final colon = spec.indexOf(':');
if (colon < 0) {
filterSubsystem = spec;
} else {
filterSubsystem = spec.substring(0, colon);
filterId = spec.substring(colon + 1);
}
} else {
_die('unknown argument: $a');
}
}
if (!wantEvents) _die('clide tail: pass --events');
final socket = await _connectSocket();
// Shutdown on SIGINT / SIGTERM — close the socket so the stream
// drains and we exit cleanly.
void quit() {
unawaited(socket.close());
}
ProcessSignal.sigint.watch().listen((_) => quit());
ProcessSignal.sigterm.watch().listen((_) => quit());
final lines = socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter());
try {
await for (final line in lines) {
if (line.isEmpty) continue;
IpcMessage msg;
try {
msg = IpcMessage.decode(line);
} on FormatException {
continue;
}
if (msg is! IpcEvent) continue;
if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue;
if (filterId != null && msg.data['id'] != filterId) continue;
stdout.writeln(line);
}
} finally {
await socket.close();
}
exit(0);
}
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()));
}
Never _die(String msg) {
_emitError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: msg,
);
exit(IpcExitCode.userError);
}