From 50d0143afcb2674d53d639963e59570403d7e295 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 10:21:03 +0200 Subject: [PATCH] add tier-2 CLI shortcuts and clide tail --events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bin/clide` gains the single-word shortcuts CLAUDE.md's tier 2 spells out: open, active, insert, replace-selection, save. Each maps the flat positional argv into the canonical editor.* IPC shape. Insert and replace-selection accept a lone `-` to read text from stdin so piping works (`pbpaste | clide replace-selection -`). `clide tail --events` is the subscribe mode. Same socket as the request side; the client just reads + filters events. --filter SUBSYSTEM or SUBSYSTEM:ID narrows the stream. Exits cleanly on SIGINT. defaultSocketPath() now respects CLIDE_SOCKET_PATH before XDG — the existing override callers always had this up their sleeve (via XDG_RUNTIME_DIR manipulation) but making it explicit unblocks parallel test runs where each test needs its own daemon socket. The new end-to-end CLI suite does exactly that: 5 tests spin up real daemon subprocesses and exercise the shortcut surface through the live IPC stack. 74 core tests pass; round-trip verified by hand (open README.md → insert → tail --events captures editor.opened / edited / selection-changed / saved). Co-Authored-By: Claude --- CHANGELOG.md | 11 ++ bin/clide.dart | 231 ++++++++++++++++++++++++++++------- lib/src/ipc/paths.dart | 13 ++ test/cli/shortcuts_test.dart | 166 +++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 47 deletions(-) create mode 100644 test/cli/shortcuts_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d60f7a..893fae85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- CLI shortcuts per CLAUDE.md's Tier-2 list: `clide open `, + `clide active`, `clide insert `, `clide replace-selection + `, `clide save`, `clide tail --events [--filter + SUBSYSTEM[:ID]]`. A lone `-` on insert / replace-selection reads + text from stdin (pipe-friendly). `tail` reads the event-broadcast + stream and prints JSON lines until SIGINT; `--filter` narrows by + subsystem or subsystem+id. 5 new end-to-end CLI tests spin up real + daemon subprocesses via a per-test `CLIDE_SOCKET_PATH` override (new + env knob on `defaultSocketPath`) so tests run in parallel without + colliding. + - Editor subsystem in the daemon (`lib/src/editor/`). `EditorBuffer` holds path + content + cursor/selection + dirty flag; `EditorRegistry` owns the open-buffer set, active-buffer tracking, diff --git a/bin/clide.dart b/bin/clide.dart index 3a9f83e0..d432da96 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -1,12 +1,12 @@ // clide — CLI + daemon entry point. // -// One binary, two modes (per ADR 0005): +// One binary, two modes (per D-005): // * `clide ` — one-shot; connects to the daemon socket, -// sends a request, prints the response, exits with the ADR-0006 +// sends a request, prints the response, exits with the D-006 // 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. +// requests. Subsystems (pane, files, editor, …) register handlers +// at boot. import 'dart:async'; import 'dart:convert'; @@ -33,22 +33,50 @@ Future main(List argv) async { 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 _runCli('version', const [], exitOnOk: true); + await _runCliArgs('version', const {}, exitOnOk: true); case '--help': case '-h': case 'help': _printHelp(stdout); exit(0); case 'ping': - await _runCli('ping', argv.sublist(1), exitOnOk: true); + await _runCliArgs('ping', const {}, exitOnOk: true); + case 'open': + if (rest.isEmpty) _die('usage: clide open '); + 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 '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. - await _runCli(argv.first, argv.sublist(1), exitOnOk: true); + // 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, + ); } } @@ -65,9 +93,24 @@ Built-in subcommands: version Print the clide version. help Print this help. +Editor (tier 2): + open Open a file in the editor (editor.open). + active Print the active buffer (editor.active). + insert 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). + +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 ADR 0006's exit-code contract: +Matches D-006's exit-code contract: 0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict '''); } @@ -121,19 +164,33 @@ class _ServerEventSink implements DaemonEventSink { void emit(IpcEvent event) => _server.broadcast(event); } -Future _runCli( - String cmd, - List args, { - required bool exitOnOk, -}) async { +// --------------------------------------------------------------------------- +// 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 _readTextArg(List rest) async { + if (rest.isEmpty) _die('usage: clide (or `-` to read stdin)'); + if (rest.length == 1 && rest.first == '-') { + final bytes = []; + await for (final chunk in stdin) { + bytes.addAll(chunk); + } + return utf8.decode(bytes); + } + return rest.join(' '); +} + +Future _connectSocket() async { final socketPath = defaultSocketPath(); - Socket socket; try { - socket = await Socket.connect( + return await Socket.connect( InternetAddress(socketPath, type: InternetAddressType.unix), 0, ); - } catch (e) { + } catch (_) { _emitError( code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, @@ -142,44 +199,53 @@ Future _runCli( ); exit(IpcExitCode.toolError); } +} - final request = IpcRequest( - id: '1', - cmd: cmd, - args: {'argv': args}, - ); +Future _runCliArgs( + String cmd, + Map args, { + required bool exitOnOk, +}) async { + final socket = await _connectSocket(); + final request = IpcRequest(id: '1', cmd: cmd, args: args); socket.writeln(request.encode()); - final line = await socket + // 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>() .transform(utf8.decoder) - .transform(const LineSplitter()) - .first; - await socket.close(); + .transform(const LineSplitter()); 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); + 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, @@ -190,6 +256,68 @@ Future _runCli( } } +/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events. +Future _runTail(List 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>() + .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, @@ -199,3 +327,12 @@ void _emitError({ 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); +} diff --git a/lib/src/ipc/paths.dart b/lib/src/ipc/paths.dart index 2c66d1a0..ee5cca50 100644 --- a/lib/src/ipc/paths.dart +++ b/lib/src/ipc/paths.dart @@ -1,6 +1,19 @@ import 'dart:io'; +/// Resolve the daemon unix-socket path. +/// +/// Precedence (highest first): +/// 1. `CLIDE_SOCKET_PATH` — explicit override. Used by tests that +/// run multiple daemons in parallel and by power users who want +/// their own layout. +/// 2. `$XDG_RUNTIME_DIR/clide-.sock` — Linux default; the +/// per-user tmpfs lives exactly for this kind of short-lived +/// socket and is auto-cleaned on logout. +/// 3. `/tmp/clide-.sock` — fallback for environments without +/// `XDG_RUNTIME_DIR`. String defaultSocketPath() { + final override = Platform.environment['CLIDE_SOCKET_PATH']; + if (override != null && override.isNotEmpty) return override; final xdg = Platform.environment['XDG_RUNTIME_DIR']; final user = Platform.environment['USER'] ?? 'anon'; final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp'; diff --git a/test/cli/shortcuts_test.dart b/test/cli/shortcuts_test.dart new file mode 100644 index 00000000..9ad02e08 --- /dev/null +++ b/test/cli/shortcuts_test.dart @@ -0,0 +1,166 @@ +/// End-to-end tests for the tier-2 CLI shortcuts. +/// +/// Each test launches `bin/clide --daemon` as a subprocess, exercises +/// a shortcut (`open`, `active`, `insert`, `save`, `tail`), and +/// asserts the JSON response shape. Requires a built `bin/clide` +/// binary — `ci/test_core.sh` runs `make build` first when needed, +/// or here we build on demand. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +const _socketEnv = 'CLIDE_SOCKET_PATH'; + +void main() { + late Directory sandbox; + late Process daemon; + late String socketPath; + late String clideBin; + + setUpAll(() async { + final candidate = File('bin/clide'); + if (!candidate.existsSync()) { + final built = await Process.run('make', const ['build']); + if (built.exitCode != 0) { + throw StateError('make build failed: ${built.stderr}'); + } + } + clideBin = candidate.absolute.path; + }); + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('clide-cli-t2-'); + await File('${sandbox.path}/doc.md').writeAsString('alpha beta'); + // Each test gets its own socket path so concurrent test runs don't + // collide. Passed through the daemon via env. + socketPath = '${sandbox.path}/daemon.sock'; + daemon = await Process.start( + clideBin, + const ['--daemon'], + workingDirectory: sandbox.path, + environment: { + ...Platform.environment, + _socketEnv: socketPath, + }, + ); + // Wait for the "listening" line on stderr so we know it's ready. + final ready = Completer(); + daemon.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + if (!ready.isCompleted && line.contains('listening')) { + ready.complete(); + } + }); + await ready.future.timeout(const Duration(seconds: 5)); + }); + + tearDown(() async { + daemon.kill(ProcessSignal.sigterm); + await daemon.exitCode.timeout(const Duration(seconds: 3), + onTimeout: () { + daemon.kill(ProcessSignal.sigkill); + return -1; + }); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + Future> run(List args) async { + final r = await Process.run( + clideBin, + args, + workingDirectory: sandbox.path, + environment: { + ...Platform.environment, + _socketEnv: socketPath, + }, + ); + expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); + return jsonDecode(r.stdout.toString().trim()) as Map; + } + + test('clide open returns buffer metadata', () async { + final r = await run(['open', 'doc.md']); + expect(r['id'], startsWith('b_')); + expect(r['path'], 'doc.md'); + }); + + test('clide active reflects the most recent open', () async { + await run(['open', 'doc.md']); + final r = await run(['active']); + final active = r['active']! as Map; + expect(active['path'], 'doc.md'); + }); + + test('clide insert + clide active round-trip', () async { + await run(['open', 'doc.md']); + await run(['insert', 'hello ']); + final r = await run(['active']); + final active = r['active']! as Map; + expect(active['dirty'], isTrue); + expect((active['length'] as num).toInt(), greaterThan('alpha beta'.length)); + }); + + test('clide save clears dirty + writes to disk', () async { + await run(['open', 'doc.md']); + await run(['insert', 'X ']); + await run(['save']); + final active = (await run(['active']))['active']! as Map; + expect(active['dirty'], isFalse); + final disk = await File('${sandbox.path}/doc.md').readAsString(); + expect(disk.startsWith('X '), isTrue); + }); + + test('clide tail --events streams editor.* events', () async { + // Start a tail subscriber. + final tail = await Process.start( + clideBin, + const ['tail', '--events', '--filter', 'editor'], + workingDirectory: sandbox.path, + environment: { + ...Platform.environment, + _socketEnv: socketPath, + }, + ); + + final received = >[]; + final sub = tail.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + if (line.isEmpty) return; + received.add(jsonDecode(line) as Map); + }); + + // Give the subscriber a beat to connect. + await Future.delayed(const Duration(milliseconds: 200)); + + await run(['open', 'doc.md']); + await run(['insert', 'T ']); + + // Wait up to 2s for events. + for (var i = 0; i < 20 && received.length < 3; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + tail.kill(ProcessSignal.sigint); + await tail.exitCode.timeout(const Duration(seconds: 2), + onTimeout: () { + tail.kill(ProcessSignal.sigkill); + return -1; + }); + await sub.cancel(); + + final kinds = received.map((e) => e['kind']).toList(); + expect(kinds, containsAll(['editor.opened', 'editor.edited'])); + // Confirm filter actually filtered — no pane events made it in. + for (final e in received) { + expect(e['subsystem'], 'editor'); + } + }); +}