add tier-2 CLI shortcuts and clide tail --events

`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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 10:21:03 +02:00
co-authored by Claude
parent d32e8fdc17
commit 50d0143afc
4 changed files with 374 additions and 47 deletions
+11
View File
@@ -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 <path>`,
`clide active`, `clide insert <text | ->`, `clide replace-selection
<text | ->`, `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,
+184 -47
View File
@@ -1,12 +1,12 @@
// clide — CLI + daemon entry point.
//
// One binary, two modes (per ADR 0005):
// 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 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<void> main(List<String> 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 <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 '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 <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).
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<void> _runCli(
String cmd,
List<String> 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<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();
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<void> _runCli(
);
exit(IpcExitCode.toolError);
}
}
final request = IpcRequest(
id: '1',
cmd: cmd,
args: {'argv': args},
);
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());
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<List<int>>()
.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<void> _runCli(
}
}
/// `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,
@@ -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);
}
+13
View File
@@ -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-<user>.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-<user>.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';
+166
View File
@@ -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<void>();
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<Map<String, Object?>> run(List<String> 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<String, Object?>;
}
test('clide open <path> 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 = <Map<String, Object?>>[];
final sub = tail.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
if (line.isEmpty) return;
received.add(jsonDecode(line) as Map<String, Object?>);
});
// Give the subscriber a beat to connect.
await Future<void>.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<void>.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');
}
});
}