T-126: C clide shell client + _argv unwrap in the dispatcher

Third slice of T-99. After this `clide status` actually does
something when typed in a shell.

* native/clide-cli/clide.c — ~250 LOC C. Walks CWD up to .git,
  hashes the workspace root with FNV-1a 64-bit (byte-for-byte
  identical to the Dart side, pinned via reference vectors in
  paths_test.dart), opens the per-workspace socket, and ships argv
  across the wire as `{cmd:"_argv", args:{argv:[...]}}`.
* lib/src/cli/argv_dispatch.dart — registers the `_argv` sentinel
  command on the dispatcher. The handler runs the T-125 parser on
  the embedded argv and either re-dispatches the unwrapped request
  through the same dispatcher or returns the pre-built error
  response. Keeps the parser in Dart so the C side stays dumb.
* lib/src/ipc/paths.dart — fnv1a64Hex hoisted to a public helper +
  fixed to format as unsigned (Dart `int` is signed int64; the high
  bit lit a leading minus that broke the cross-language compare).
  Reference-vector tests added against the FNV reference.
* `make clide-cli` builds it via the host `cc`; output lands at
  native/<platform>/clide and is gitignored. Test
  test/cli/clide_cli_e2e_test.dart compiles + exercises the full
  round-trip; skips cleanly when no cc is on PATH.
* CONTRIBUTING.md gets a "C clide shell client" section.

T-128 (delete legacy IPC) unblocked.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:04:50 +02:00
co-authored by Claude
parent 1147bfac0e
commit 42955b6417
12 changed files with 638 additions and 23 deletions
+7
View File
@@ -46,6 +46,13 @@ tools/ui/.serve.pid
# Regenerated on every make build/run/test target.
/lib/src/build_info.g.dart
# -- Built C `clide` shell client (T-126). Source under ----------
# native/clide-cli/ stays in git; the per-platform compiled binary
# is built by `make clide-cli`.
/native/linux-x64/clide
/native/macos-arm64/clide
/native/macos-x64/clide
# -- Test, coverage, profile output ------------------------------------
*.test
*.out
@@ -1856,3 +1856,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-124', 'status', 'in_progress', 'done', NULL, '2026-05-18 12:51:08', '2026-05-18 12:51:08', '2026-05-18 12:51:08', NULL, 'b928ce09ae0b7dbb77bf93f8c0593657', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 15:49:20', '2026-05-18 15:49:20', '2026-05-18 15:49:20', NULL, 'ac0838fac9e3c97f1728256910776bed', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-125', 'status', 'in_progress', 'done', NULL, '2026-05-18 15:51:09', '2026-05-18 15:51:09', '2026-05-18 15:51:09', NULL, '938624de487aa2f02e034424cae40c77', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 15:54:20', '2026-05-18 15:54:20', '2026-05-18 15:54:20', NULL, '3266e7ea943867f49ff20a90b7f12ae6', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'status', 'in_progress', 'done', NULL, '2026-05-18 16:04:29', '2026-05-18 16:04:29', '2026-05-18 16:04:29', NULL, 'b6c783f599585e55ecc36f69ccbcefb1', 1) ON CONFLICT(hash) DO NOTHING;
+12
View File
@@ -2122,3 +2122,15 @@ Acceptance:
4. Sysexit-code parity with pql (0/1/2/3/4 + 64-78 reserved).
Source: T-99 sketch.', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:52', '2026-05-18 15:51:09', NULL, '99f60de735779ddc37e7701ef4dd3304', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-126', 'task', 'T-99', 'thin C `clide` client binary in native/clide-cli/', 'Third slice of T-99(a). The user-facing entry point.
~150 LOC of C in native/clide-cli/. Resolves the socket path (env override + default), connects, sends a single JSON-line request shaped as {"argv": [...]} (parsing happens in Dart per T-125), reads the JSON-line response, writes stdout/stderr per the pql contract, exits with the response''s exit code.
Acceptance:
1. native/clide-cli/clide compiles on Linux + macOS via the existing native build harness (same shape as dugite / libtree-sitter.so).
2. Binary lands in the install bundle; runs from the user''s PATH after `make build-linux` / `make build-macos`.
3. `clide status` against a running app returns the status JSON and exits 0.
4. `clide nonsense` returns the right exit code (sysexit 64 = usage error per D-6).
5. Documented in assets/licenses.yaml + a one-paragraph note in CONTRIBUTING.md.
Source: T-99 sketch. Depends on T-124 (server) + T-125 (argv translator).', 'done', 'high', NULL, NULL, NULL, '2026-05-18 11:58:57', '2026-05-18 16:04:29', NULL, 'e4be31e3d80dbaadd64e3a4a2fa029e0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -30,6 +30,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
the umbrella commands (`status`, `tail`, `version`, `ping`) per D-6
into the wire envelope. Pure Dart; lets the C client (T-126) stay a
dumb pipe (T-99 / T-125).
- C `clide` shell client at `native/clide-cli/clide.c`. Walks CWD up
to the git root, hashes to the per-workspace socket (D-70), ships
argv. `make clide-cli` builds it; on PATH, `clide status` works
from any clide-workspace directory once the app is up (T-99,
T-126).
- Startup project picker — clide now opens to the welcome screen by
default instead of auto-opening the last project. A per-row
"always open this project on launch" checkbox in welcome's RECENT
+26
View File
@@ -56,6 +56,32 @@ commit. Pre-push includes:
[D-66](governance/decisions/testing.md#d-66))
- `CHANGELOG.md` `[Unreleased]` bullets ≤ 60 words each
## The C `clide` shell client
`clide` (the binary) is a ~250 LOC C program in
[`native/clide-cli/clide.c`](native/clide-cli/clide.c) that talks to
the running Flutter app's IPC socket so Claude (and you) can drive
clide from any shell. It walks CWD up to the workspace's `.git`,
computes the same FNV-1a 64-bit hash the Dart side uses (per D-70),
opens the per-workspace socket, and sends argv across the wire under
a sentinel `_argv` cmd. The argv parser lives in Dart
([`lib/src/cli/argv_to_request.dart`](lib/src/cli/argv_to_request.dart)),
so the C side stays a dumb pipe.
Build it with `make clide-cli` — output lands at
`native/<platform>/clide` (gitignored). Drop that on your PATH (or
symlink) and `clide status` works from any directory inside a clide
workspace once the app is running. Standard POSIX + libc only;
pure C99; no third-party deps.
The cross-language hash agreement is load-bearing — if the Dart
server and C client disagree on the socket path, every shell
invocation fails to connect. The test suite covers it:
[`test/ipc/paths_test.dart`](test/ipc/paths_test.dart) pins
FNV-1a vectors against the reference, and
[`test/cli/clide_cli_e2e_test.dart`](test/cli/clide_cli_e2e_test.dart)
compiles the C client and exercises the full round-trip.
## Decisions, questions, rejected (DQR)
clide tracks architectural commitments as durable records under
+21
View File
@@ -268,6 +268,27 @@ dugite-fetch: ## Download and extract the dugite-native git distribution.
dugite-clean: ## Remove the dugite-native directory.
rm -rf $(DUGITE_DIR)
# -- clide-cli ----------------------------------------------------------
# The C `clide` shell client that talks to the in-process IPC server
# (T-99 / T-126). One source file, no third-party deps; the build
# target picks up whatever `cc` is on PATH.
CLIDE_CLI_SRC := native/clide-cli/clide.c
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
CC ?= cc
.PHONY: clide-cli
clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
@mkdir -p $(dir $(CLIDE_CLI_BIN))
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
@echo "==> built $(CLIDE_CLI_BIN)"
.PHONY: clide-cli-clean
clide-cli-clean: ## Remove the compiled C `clide` client.
rm -f $(CLIDE_CLI_BIN)
# -- security -------------------------------------------------------------
.PHONY: security
+2
View File
@@ -38,6 +38,7 @@ 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/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart';
@@ -115,6 +116,7 @@ Future<void> main() async {
registerGitCommands(dispatcher, gitClient, eventSink);
final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql);
registerArgvUnwrap(dispatcher);
return dispatcher;
}
+50
View File
@@ -0,0 +1,50 @@
/// Register the `_argv` sentinel command on a [DaemonDispatcher].
///
/// The C client (T-126) doesn't know the dispatcher's command surface
/// — it ships raw argv across the wire under cmd `_argv`. This handler
/// runs [parseArgv] on the embedded argv and either dispatches the
/// resulting [IpcRequest] or returns the pre-built error response.
///
/// Why a sentinel cmd rather than a top-level parse step in the IPC
/// server: keeps the server transport-agnostic — every consumer that
/// already has a typed [IpcRequest] goes the direct path; only the
/// CLI's raw-argv envelope hits this unwrap shim.
library;
import 'package:clide/src/cli/argv_to_request.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/schema_v1.dart';
/// Sentinel command id the C `clide` client sends. Anything else
/// goes through the normal dispatcher path unchanged.
const String argvSentinelCmd = '_argv';
/// Wire the `_argv` sentinel handler onto [dispatcher]. The handler:
/// 1. Extracts `args.argv` as a List&lt;String&gt;.
/// 2. Calls [parseArgv].
/// 3. If parsed → re-dispatches the inner request through the
/// *same* dispatcher (so per-handler logic runs once).
/// 4. If error → returns the pre-built [IpcResponse] verbatim,
/// patched with the outer request id so the client correlates.
void registerArgvUnwrap(DaemonDispatcher dispatcher) {
dispatcher.register(argvSentinelCmd, (outer) async {
final raw = outer.args['argv'];
if (raw is! List) {
return IpcResponse.err(
id: outer.id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: '_argv requires args.argv to be a JSON array',
),
);
}
final argv = raw.cast<String>();
final result = parseArgv(argv, requestId: outer.id);
return switch (result) {
ArgvParsed(:final request) => dispatcher.dispatch(request),
ArgvError(:final response) => response,
};
});
}
+28 -23
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
/// Resolve the per-workspace Unix-domain socket path served by the
@@ -27,28 +28,32 @@ String socketDirectory() {
return '$base/clide';
}
/// FNV-1a 64-bit, lower-case hex, fixed 16 chars. Matches the shape
/// used by `lib/builtin/claude/src/session_naming.dart#_hash`. Not a
/// cryptographic hash — D-70 explains why one isn't needed here.
String _hash(String s) {
// 0xcbf29ce484222325 as two 32-bit halves to dodge JS-precision
// issues if this file ever runs under the web target.
var hiHi = 0xcbf2, hiLo = 0x9ce4;
var loHi = 0x8422, loLo = 0x2325;
const primeHiHi = 0x0000, primeHiLo = 0x0100;
const primeLoHi = 0x0000, primeLoLo = 0x01b3;
for (var i = 0; i < s.length; i++) {
loLo ^= s.codeUnitAt(i) & 0xffff;
// 64-bit multiply, hand-rolled across four 16-bit limbs.
final r0 = loLo * primeLoLo;
final r1 = (loLo * primeLoHi) + (loHi * primeLoLo) + (r0 >> 16);
final r2 = (loLo * primeHiLo) + (loHi * primeLoHi) + (hiLo * primeLoLo) + (r1 >> 16);
final r3 = (loLo * primeHiHi) + (loHi * primeHiLo) + (hiLo * primeLoHi) + (hiHi * primeLoLo) + (r2 >> 16);
loLo = r0 & 0xffff;
loHi = r1 & 0xffff;
hiLo = r2 & 0xffff;
hiHi = r3 & 0xffff;
/// FNV-1a 64-bit hash of [s] as a 16-char lower-case hex string.
/// The C client (T-126) reproduces the same algorithm byte-for-byte
/// so server + client always agree on socket path. Not cryptographic
/// — D-70 explains why one isn't needed here. The algorithm:
///
/// h = 0xcbf29ce484222325 // FNV offset basis
/// for each byte b in utf-8(s):
/// h = (h xor b) * 0x100000001b3 mod 2^64 // FNV prime, 64-bit wrap
///
/// Reference: <http://isthe.com/chongo/tech/comp/fnv/> — FNV-1a 64-bit.
String fnv1a64Hex(String s) {
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
var h = 0xcbf29ce484222325;
const prime = 0x100000001b3;
final bytes = utf8.encode(s);
for (final b in bytes) {
h ^= b;
h = h * prime; // wraps mod 2^64 on the VM (signed int64)
}
String hex4(int v) => v.toRadixString(16).padLeft(4, '0');
return '${hex4(hiHi)}${hex4(hiLo)}${hex4(loHi)}${hex4(loLo)}';
// Dart's `int` is signed 64-bit on the VM; once the high bit lights
// up, `toRadixString` would emit a leading minus. Split into two
// unsigned 32-bit halves (>>> is logical shift) and concatenate.
final hi = (h >>> 32) & 0xffffffff;
final lo = h & 0xffffffff;
return '${hi.toRadixString(16).padLeft(8, '0')}${lo.toRadixString(16).padLeft(8, '0')}';
}
String _hash(String s) => fnv1a64Hex(s);
+323
View File
@@ -0,0 +1,323 @@
/*
* clide — thin C client for the in-process IPC server hosted by the
* Flutter app (T-99 / T-126). Third slice of D-56 path (a).
*
* What it does:
* 1. Walks CWD up to a directory containing `.git` (the workspace
* root, same definition the Flutter app uses on boot).
* 2. Hashes that path with FNV-1a 64-bit and resolves the per-
* workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock).
* 3. Connects, sends `{"v":1,"type":"request","id":"<pid>",
* "cmd":"_argv","args":{"argv":[...]}}` (the server runs
* parseArgv on it per T-125), reads the JSON-line response,
* writes payload to stdout, error message (if any) to stderr,
* exits with the response's exit code.
*
* Design notes:
* - No third-party deps. Standard POSIX + a minimal JSON writer
* (string-escape only — we never PARSE JSON, just emit argv into
* it; the response is read whole then printed as-is to stdout).
* - The argv→IpcRequest translator lives in Dart (T-125). We just
* ship argv across the wire under a sentinel cmd `_argv`; the
* server unpacks it.
* - Workspace-root discovery: we look for `.git` (dir OR file —
* submodules use a file). If we don't find one walking upward,
* exit with EX_USAGE.
*
* Build: `make clide-cli` (see Makefile). Pure C99, builds with
* any gcc / clang / cc.
*/
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
#ifdef __APPLE__
#include <TargetConditionals.h>
#endif
#define EX_USAGE 64
#define EX_SOFTWARE 70
#define EX_OSERR 71
#define EX_UNAVAILABLE 69
static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL;
static const uint64_t FNV_PRIME = 0x100000001b3ULL;
static void fnv1a64_hex(const char *s, char out[17]) {
uint64_t h = FNV_OFFSET;
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
h ^= *p;
h *= FNV_PRIME;
}
/* 16 lowercase hex chars + NUL. */
snprintf(out, 17, "%016" PRIx64, h);
}
/* Walk `start` upward looking for an entry named `.git`. Writes the
* containing directory into `out` (PATH_MAX). Returns 0 on success,
* -1 if no .git was found before /. */
static int find_workspace_root(const char *start, char *out, size_t out_size) {
char cwd[4096];
if (start) {
strncpy(cwd, start, sizeof(cwd) - 1);
cwd[sizeof(cwd) - 1] = '\0';
} else if (!getcwd(cwd, sizeof(cwd))) {
return -1;
}
while (1) {
size_t len = strlen(cwd);
if (len + 6 >= sizeof(cwd)) return -1;
char probe[4108];
snprintf(probe, sizeof(probe), "%s/.git", cwd);
struct stat st;
if (lstat(probe, &st) == 0) {
strncpy(out, cwd, out_size - 1);
out[out_size - 1] = '\0';
return 0;
}
/* Climb one. /foo/bar -> /foo, / -> stop. */
if (cwd[0] == '/' && cwd[1] == '\0') return -1;
char *slash = strrchr(cwd, '/');
if (!slash) return -1;
if (slash == cwd) cwd[1] = '\0';
else *slash = '\0';
}
}
/* Compose `$XDG_RUNTIME_DIR/clide/<hash>.sock` on Linux,
* `$HOME/Library/Caches/clide/<hash>.sock` on macOS. */
static int socket_path_for(const char *workspace_root, char *out, size_t out_size) {
char hash[17];
fnv1a64_hex(workspace_root, hash);
#ifdef __APPLE__
const char *home = getenv("HOME");
if (!home || !*home) home = "/tmp";
return snprintf(out, out_size, "%s/Library/Caches/clide/%s.sock", home, hash);
#else
const char *xdg = getenv("XDG_RUNTIME_DIR");
if (!xdg || !*xdg) xdg = "/tmp";
return snprintf(out, out_size, "%s/clide/%s.sock", xdg, hash);
#endif
}
/* Open a UNIX-domain stream socket connected to `path`. Returns fd
* on success, -1 on failure (errno set). */
static int connect_unix(const char *path) {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return -1;
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
if (strlen(path) >= sizeof(addr.sun_path)) {
close(fd);
errno = ENAMETOOLONG;
return -1;
}
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
int saved = errno;
close(fd);
errno = saved;
return -1;
}
return fd;
}
/* Emit `s` as a JSON string literal (quotes + standard escapes) to
* `out`. Caller ensures `out` is large enough — we cap at 8 * input
* length + 2 (worst case is every byte → \uXXXX). */
static void json_escape(const char *s, char *out, size_t out_size) {
size_t j = 0;
out[j++] = '"';
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
if (j + 8 >= out_size) break;
switch (*p) {
case '"': out[j++] = '\\'; out[j++] = '"'; break;
case '\\': out[j++] = '\\'; out[j++] = '\\'; break;
case '\b': out[j++] = '\\'; out[j++] = 'b'; break;
case '\f': out[j++] = '\\'; out[j++] = 'f'; break;
case '\n': out[j++] = '\\'; out[j++] = 'n'; break;
case '\r': out[j++] = '\\'; out[j++] = 'r'; break;
case '\t': out[j++] = '\\'; out[j++] = 't'; break;
default:
if (*p < 0x20) {
j += snprintf(out + j, out_size - j, "\\u%04x", *p);
} else {
out[j++] = (char)*p;
}
}
}
if (j + 1 < out_size) out[j++] = '"';
out[j] = '\0';
}
/* Build the request envelope and write it to `out`. Returns 0 on
* success, -1 if any input was too large. */
static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out_size) {
/* Compute argv array size: each arg gets its own escaped JSON. */
int n = snprintf(out, out_size,
"{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[",
(long long)pid);
if (n < 0 || (size_t)n >= out_size) return -1;
for (int i = 0; i < argc; i++) {
char esc[4096];
json_escape(argv[i], esc, sizeof(esc));
n += snprintf(out + n, out_size - n, "%s%s", i ? "," : "", esc);
if (n < 0 || (size_t)n >= out_size) return -1;
}
n += snprintf(out + n, out_size - n, "]}}\n");
return (n < 0 || (size_t)n >= out_size) ? -1 : 0;
}
/* Read one line (terminated by \n) from fd into out. Returns 0 on
* success, -1 on EOF / error. The trailing \n is stripped. */
static int read_line(int fd, char *out, size_t out_size) {
size_t i = 0;
while (i + 1 < out_size) {
char c;
ssize_t r = read(fd, &c, 1);
if (r <= 0) {
if (r < 0 && errno == EINTR) continue;
return -1;
}
if (c == '\n') {
out[i] = '\0';
return 0;
}
out[i++] = c;
}
out[i] = '\0';
/* Line too long; treat as overflow but keep what we have. */
return -1;
}
/* Minimal JSON peek: locate the bytes between `"key":` and the next
* sibling separator (`,` or `}`). Returns a pointer into `buf` and
* writes the length to *out_len. Returns NULL if the key isn't
* found. This is a deliberately tiny scanner — we never need to
* fully parse the response, just pick out `ok`, `code`, `message`,
* `data`. The response is well-formed by construction (the server
* builds it via Dart's JSON encoder). */
static const char *json_value(const char *buf, const char *key, size_t *out_len) {
/* Search for `"key": ` (allow optional whitespace). */
char needle[128];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(buf, needle);
if (!p) return NULL;
p += strlen(needle);
while (*p == ' ' || *p == '\t') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t') p++;
const char *start = p;
int depth = 0;
int in_str = 0;
while (*p) {
if (in_str) {
if (*p == '\\' && p[1]) { p += 2; continue; }
if (*p == '"') in_str = 0;
} else {
if (*p == '"') in_str = 1;
else if (*p == '{' || *p == '[') depth++;
else if (*p == '}' || *p == ']') {
if (depth == 0) break;
depth--;
} else if (*p == ',' && depth == 0) break;
}
p++;
}
*out_len = (size_t)(p - start);
return start;
}
int main(int argc, char **argv) {
/* argv[0] is the program name; everything after is what the user
* typed after `clide`. */
if (argc < 2) {
fprintf(stderr, "usage: clide <subsystem> <verb> [args...]\n"
" clide status | tail | version | ping\n");
return EX_USAGE;
}
char ws_root[4096];
if (find_workspace_root(NULL, ws_root, sizeof(ws_root)) != 0) {
fprintf(stderr, "clide: not inside a git repository — no workspace to talk to\n");
return EX_USAGE;
}
char sock_path[4096];
if (socket_path_for(ws_root, sock_path, sizeof(sock_path)) >= (int)sizeof(sock_path)) {
fprintf(stderr, "clide: socket path overflow\n");
return EX_SOFTWARE;
}
int fd = connect_unix(sock_path);
if (fd < 0) {
fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, strerror(errno));
return EX_UNAVAILABLE;
}
/* Build + send request. Worst-case envelope sizing: argv totals
* plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */
char req[65536];
if (build_request(argc - 1, argv + 1, getpid(), req, sizeof(req)) != 0) {
fprintf(stderr, "clide: request payload too large\n");
close(fd);
return EX_USAGE;
}
if (write(fd, req, strlen(req)) != (ssize_t)strlen(req)) {
fprintf(stderr, "clide: write failed: %s\n", strerror(errno));
close(fd);
return EX_OSERR;
}
/* Read the response — one JSON line. */
char resp[65536];
if (read_line(fd, resp, sizeof(resp)) != 0) {
fprintf(stderr, "clide: response read failed: %s\n",
errno ? strerror(errno) : "short read");
close(fd);
return EX_OSERR;
}
close(fd);
/* Pull out `ok`, `data`/`error` from the response. */
size_t ok_len = 0, data_len = 0, code_len = 0, msg_len = 0;
const char *ok_v = json_value(resp, "ok", &ok_len);
int ok = (ok_v && ok_len >= 4 && strncmp(ok_v, "true", 4) == 0);
if (ok) {
const char *data = json_value(resp, "data", &data_len);
if (data) {
fwrite(data, 1, data_len, stdout);
fputc('\n', stdout);
} else {
fputs("{}\n", stdout);
}
return 0;
}
const char *code_v = json_value(resp, "code", &code_len);
const char *msg_v = json_value(resp, "message", &msg_len);
int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE;
if (msg_v) {
/* Trim the surrounding quotes from the JSON string literal. */
if (msg_len >= 2 && msg_v[0] == '"' && msg_v[msg_len - 1] == '"') {
fwrite(msg_v + 1, 1, msg_len - 2, stderr);
} else {
fwrite(msg_v, 1, msg_len, stderr);
}
fputc('\n', stderr);
}
return exit_code;
}
+138
View File
@@ -0,0 +1,138 @@
/// End-to-end test for the C `clide` shell client (T-126).
///
/// Compiles native/clide-cli/clide.c via the host `cc` (skip if not
/// available), starts an IpcServer with a controlled workspace root,
/// and exercises the client as a child process. Verifies the
/// cross-language FNV-1a hash agreement: if the C binary and the
/// Dart server compute the same socket path for the same workspace,
/// the round-trip works; if not, the connect fails.
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/server.dart';
import 'package:test/test.dart';
void main() {
// Build the binary once for the whole suite.
late final String binaryPath;
late final bool hasCC;
late final Directory workspaceRoot;
late final IpcServer server;
late final DaemonDispatcher dispatcher;
setUpAll(() async {
final repoRoot = Directory.current.path;
final ccProbe = await Process.run('sh', ['-c', 'command -v cc']);
hasCC = ccProbe.exitCode == 0;
if (!hasCC) return;
final src = '$repoRoot/native/clide-cli/clide.c';
final out = '${Directory.systemTemp.createTempSync('clide-cli-test-').path}/clide';
final build = await Process.run('cc', [
'-std=c99',
'-O2',
'-Wall',
src,
'-o',
out,
]);
expect(build.exitCode, 0, reason: 'cc failed: ${build.stderr}');
binaryPath = out;
// Synthetic git workspace — the C client walks up looking for
// `.git`, hashes whatever it lands on, and connects to the
// matching socket. Match it by handing the same root to the
// server.
workspaceRoot = Directory.systemTemp.createTempSync('clide-ws-');
Directory('${workspaceRoot.path}/.git').createSync();
dispatcher = DaemonDispatcher();
registerArgvUnwrap(dispatcher);
server = IpcServer(
dispatcher: dispatcher,
workspaceRoot: workspaceRoot.path,
log: Logger(minLevel: LogLevel.error, sinks: const []),
);
await server.start();
});
tearDownAll(() async {
if (!hasCC) return;
try {
await server.stop();
} catch (_) {}
if (workspaceRoot.existsSync()) {
workspaceRoot.deleteSync(recursive: true);
}
});
Future<ProcessResult> runCli(List<String> argv) {
return Process.run(binaryPath, argv, workingDirectory: workspaceRoot.path);
}
group('clide-cli (T-126)', () {
test('no args → EX_USAGE (64) with a usage banner on stderr', () async {
if (!hasCC) {
markTestSkipped('cc not available');
return;
}
final r = await runCli(const []);
expect(r.exitCode, 64);
expect(r.stderr.toString(), contains('usage'));
});
test('outside a git repo → EX_USAGE', () async {
if (!hasCC) {
markTestSkipped('cc not available');
return;
}
final outside = Directory.systemTemp.createTempSync('clide-no-git-');
addTearDown(() => outside.deleteSync(recursive: true));
final r = await Process.run(binaryPath, ['status'], workingDirectory: outside.path);
expect(r.exitCode, 64);
expect(r.stderr.toString(), contains('git repository'));
});
test('ping returns ok JSON on stdout, exit 0', () async {
if (!hasCC) {
markTestSkipped('cc not available');
return;
}
final r = await runCli(['ping']);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
final data = jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
expect(data['pong'], isTrue);
});
test('subsystem.verb routing through the dispatcher', () async {
if (!hasCC) {
markTestSkipped('cc not available');
return;
}
// Stub handler that echoes the request's args back so we can
// verify the wire shape end-to-end.
dispatcher.register('probe.echo', (req) async => IpcResponse.ok(id: req.id, data: req.args));
final r = await runCli(['probe', 'echo', 'first', '--flag=val', '--bool', '--', 'pass1']);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
final data = jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
expect(data['positional'], ['first']);
expect((data['flags'] as Map)['flag'], 'val');
expect((data['flags'] as Map)['bool'], isTrue);
expect(data['passthrough'], ['pass1']);
});
test('unknown verb → notFound exit code', () async {
if (!hasCC) {
markTestSkipped('cc not available');
return;
}
final r = await runCli(['nosuchsub', 'nosuchverb']);
expect(r.exitCode, isNot(0));
expect(r.stderr.toString(), isNotEmpty);
});
});
}
+24
View File
@@ -44,4 +44,28 @@ void main() {
expect(socketDirectory(), '$home/Library/Caches/clide');
});
});
group('fnv1a64Hex (T-126 cross-check)', () {
// Reference values from <http://isthe.com/chongo/tech/comp/fnv/>.
// The C client in native/clide-cli/clide.c MUST produce the same
// 16-char hex strings for the same inputs, or server + client see
// different socket paths and the integration falls apart silently.
test('empty string → offset basis', () {
expect(fnv1a64Hex(''), 'cbf29ce484222325');
});
test('"a" → reference value', () {
expect(fnv1a64Hex('a'), 'af63dc4c8601ec8c');
});
test('"foo" → reference value', () {
expect(fnv1a64Hex('foo'), 'dcb27518fed9d577');
});
test('"/home/me/projects/clide" → 16 hex chars, deterministic', () {
final h = fnv1a64Hex('/home/me/projects/clide');
expect(h, matches(RegExp(r'^[0-9a-f]{16}$')));
expect(fnv1a64Hex('/home/me/projects/clide'), h);
});
});
}