diff --git a/.gitignore b/.gitignore index 698aa61d..ceec8405 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index f7cbd832..e5759068 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index eae8d9e5..8db426bf 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 604d07f4..c6d50e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd1f3c6f..704ef8bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//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 diff --git a/Makefile b/Makefile index e9527883..a230aa8b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/lib/main.dart b/lib/main.dart index 9aebb0b7..dd085a9b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { registerGitCommands(dispatcher, gitClient, eventSink); final pql = PqlClient(workDir: workRoot, toolchain: tc); registerPqlCommands(dispatcher, pql); + registerArgvUnwrap(dispatcher); return dispatcher; } diff --git a/lib/src/cli/argv_dispatch.dart b/lib/src/cli/argv_dispatch.dart new file mode 100644 index 00000000..e12500be --- /dev/null +++ b/lib/src/cli/argv_dispatch.dart @@ -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<String>. +/// 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(); + final result = parseArgv(argv, requestId: outer.id); + return switch (result) { + ArgvParsed(:final request) => dispatcher.dispatch(request), + ArgvError(:final response) => response, + }; + }); +} diff --git a/lib/src/ipc/paths.dart b/lib/src/ipc/paths.dart index 7e4ee05b..cca1e313 100644 --- a/lib/src/ipc/paths.dart +++ b/lib/src/ipc/paths.dart @@ -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: — 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); diff --git a/native/clide-cli/clide.c b/native/clide-cli/clide.c new file mode 100644 index 00000000..14225c7f --- /dev/null +++ b/native/clide-cli/clide.c @@ -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/ + * .sock; macOS: $HOME/Library/Caches/clide/.sock). + * 3. Connects, sends `{"v":1,"type":"request","id":"", + * "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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#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/.sock` on Linux, + * `$HOME/Library/Caches/clide/.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 [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; +} diff --git a/test/cli/clide_cli_e2e_test.dart b/test/cli/clide_cli_e2e_test.dart new file mode 100644 index 00000000..c6f69c36 --- /dev/null +++ b/test/cli/clide_cli_e2e_test.dart @@ -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 runCli(List 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; + 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; + 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); + }); + }); +} diff --git a/test/ipc/paths_test.dart b/test/ipc/paths_test.dart index 1d0c3e47..f605fb06 100644 --- a/test/ipc/paths_test.dart +++ b/test/ipc/paths_test.dart @@ -44,4 +44,28 @@ void main() { expect(socketDirectory(), '$home/Library/Caches/clide'); }); }); + + group('fnv1a64Hex (T-126 cross-check)', () { + // Reference values from . + // 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); + }); + }); }