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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user