Files
clide/lib/src/cli/argv_to_request.dart
T
jpmschweitzerandClaude Opus 4.8 d6dccd841b add cursor-based pull events: clide events --since <cursor> (T-223)
The one-shot request/response complement to the never-returning
`tail --events` stream — what an agent poll loop wants. The IPC server now
keeps a single global, arrival-ordered event log keyed by a monotonic
cursor (alongside the per-subsystem replay ring), bounded by eventLogDepth
with drop-oldest back-pressure (D-85: producer never blocks).

`clide events [--since <cursor>] [--filter X]` returns events after the
cursor, a high-water `cursor` to poll from next, and `gap: true` (+
oldestCursor) when the requested cursor predates the retained window so a
caller detects loss instead of silently missing dropped events. Repeated
polls neither drop nor duplicate. No on-disk persistence.

`events` is handled in the IPC server like `tail` (not the dispatcher);
added to the argv umbrella set. bindingWhen/CommandContribution untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:24:34 +02:00

216 lines
7.2 KiB
Dart

/// Translate the argv tail of a `clide …` shell invocation into an
/// [IpcRequest] the server can dispatch. Pure Dart, no I/O.
///
/// Second slice of T-99 (D-56 path a). The C client (T-126) is a
/// dumb pipe: it sends `{argv:[…]}` over the socket, the server
/// runs this function, then dispatches. Keeping the CLI grammar in
/// Dart means the translator can be unit-tested and shared with
/// `make t T=…` workflows that don't shell out to the C client.
///
/// Grammar (per D-6):
///
/// ```
/// clide SUBSYSTEM VERB [positional...] [--flag value] [-- passthrough...]
/// clide UMBRELLA # status, tail, version, ping
/// ```
///
/// The umbrella entries have no subsystem.verb split; the first arg
/// IS the command id. D-6 lists `tail` and `status` explicitly; the
/// dispatcher also recognises `ping` and `version` (registered by
/// default in [DaemonDispatcher]).
library;
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/schema_v1.dart';
/// Umbrella commands — single-token names with no subsystem.verb
/// split. Match the IDs the dispatcher exposes directly. `tail` and
/// `events` are handled by the IPC server itself (streaming / cursor-pull
/// event reads, T-129 / T-223) rather than the dispatcher.
const Set<String> _umbrellaCommands = {'status', 'tail', 'events', 'version', 'ping', 'capabilities'};
/// Sealed result of translating argv. Caller (the IPC server, or the
/// C client wrapper in T-126) handles either branch.
sealed class ArgvParseResult {
const ArgvParseResult();
}
/// Successful parse — the [request] is ready to dispatch.
class ArgvParsed extends ArgvParseResult {
const ArgvParsed(this.request);
final IpcRequest request;
}
/// argv was malformed at the syntactic layer (no subsystem, broken
/// flag, etc.). The [response] is ready to write back to the client
/// — exit code in `response.error.code` follows pql's sysexit
/// convention (64 = EX_USAGE for shape errors).
class ArgvError extends ArgvParseResult {
const ArgvError(this.response);
final IpcResponse response;
}
/// Translate [argv] (everything after the program name) into an
/// [IpcRequest] or an [ArgvError]. [requestId] is the wire id the
/// server should echo on the response — caller assigns it (typically
/// a counter or random short string).
ArgvParseResult parseArgv(List<String> argv, {required String requestId}) {
if (argv.isEmpty) {
return ArgvError(_err(requestId, 'usage: clide <subsystem> <verb> [args...]'));
}
final first = argv[0];
// Umbrella commands: single-token name, no verb required.
if (_umbrellaCommands.contains(first)) {
final tail = argv.sublist(1);
final parsed = _parseTail(tail);
if (parsed is _TailError) {
return ArgvError(_err(requestId, parsed.message));
}
return ArgvParsed(IpcRequest(
id: requestId,
cmd: first,
args: (parsed as _TailOk).toArgs(),
));
}
// Subsystem.verb form: need at least two tokens.
if (argv.length < 2) {
return ArgvError(_err(requestId, 'usage: clide $first <verb> [args...]'));
}
final subsystem = first;
final verb = argv[1];
if (!_isValidIdentifier(subsystem)) {
return ArgvError(_err(requestId, 'invalid subsystem: $subsystem'));
}
if (!_isValidIdentifier(verb)) {
return ArgvError(_err(requestId, 'invalid verb: $verb'));
}
final tail = argv.sublist(2);
final parsed = _parseTail(tail);
if (parsed is _TailError) {
return ArgvError(_err(requestId, parsed.message));
}
return ArgvParsed(IpcRequest(
id: requestId,
cmd: '$subsystem.$verb',
args: (parsed as _TailOk).toArgs(),
));
}
// -- internals --------------------------------------------------------------
sealed class _TailParseResult {
const _TailParseResult();
}
class _TailOk extends _TailParseResult {
const _TailOk({required this.positional, required this.flags, required this.passthrough});
final List<String> positional;
final Map<String, Object?> flags;
final List<String> passthrough;
Map<String, Object?> toArgs() => {
if (positional.isNotEmpty) 'positional': positional,
if (flags.isNotEmpty) 'flags': flags,
if (passthrough.isNotEmpty) 'passthrough': passthrough,
};
}
class _TailError extends _TailParseResult {
const _TailError(this.message);
final String message;
}
/// Walk [tail] splitting it into positionals, flags, and (everything
/// after a lone `--`) passthrough.
///
/// Flag forms:
/// --key=value → flags[key] = value
/// --key value → flags[key] = value (value can't start with `--`)
/// --key → flags[key] = true (boolean; next token is `--…` or end)
///
/// Anything not matching `--` is a positional. A bare `--` token
/// terminates option parsing — everything after lands in passthrough.
_TailParseResult _parseTail(List<String> tail) {
final positional = <String>[];
final flags = <String, Object?>{};
final passthrough = <String>[];
var i = 0;
var inPassthrough = false;
while (i < tail.length) {
final t = tail[i];
if (inPassthrough) {
passthrough.add(t);
i++;
continue;
}
if (t == '--') {
inPassthrough = true;
i++;
continue;
}
if (t.startsWith('--')) {
final body = t.substring(2);
if (body.isEmpty) {
return const _TailError('empty flag: "--" with no name; use a bare "--" to terminate options');
}
final eq = body.indexOf('=');
if (eq >= 0) {
final key = body.substring(0, eq);
final value = body.substring(eq + 1);
if (!_isValidFlagName(key)) {
return _TailError('invalid flag name: $key');
}
flags[key] = value;
i++;
continue;
}
// --key with no `=` — peek at next token.
if (!_isValidFlagName(body)) {
return _TailError('invalid flag name: $body');
}
final next = i + 1 < tail.length ? tail[i + 1] : null;
if (next == null || next == '--' || next.startsWith('--')) {
// Boolean flag — no value follows.
flags[body] = true;
i++;
} else {
flags[body] = next;
i += 2;
}
continue;
}
positional.add(t);
i++;
}
return _TailOk(positional: positional, flags: flags, passthrough: passthrough);
}
/// Subsystems + verbs use the same shape — letters, digits, dot,
/// hyphen, underscore. Reject anything else so a typo doesn't reach
/// the dispatcher as a wire-shaped command id.
bool _isValidIdentifier(String s) {
if (s.isEmpty) return false;
for (final code in s.codeUnits) {
final isLetter = (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
final isDigit = code >= 0x30 && code <= 0x39;
final isOther = code == 0x2e /* . */ || code == 0x2d /* - */ || code == 0x5f /* _ */;
if (!isLetter && !isDigit && !isOther) return false;
}
return true;
}
/// Flag names are the same alphabet plus `.` is rare but allowed.
/// Tighter than identifier here would just create false rejections.
bool _isValidFlagName(String s) => _isValidIdentifier(s);
IpcResponse _err(String id, String message) => IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: message,
),
);