From 64410e80473c3bb5e2c5785448d5f6e856c72110 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 29 Jun 2026 23:56:33 +0200 Subject: [PATCH] feat(cli): --stdin piped JSON payloads for structured commands (T-315) The C client slurps stdin when it sees --stdin, strips the flag, and ships the payload alongside the argv; the Dart unwrap folds it into the request as a `stdin` arg (undeclared keys pass the schema untouched). icon.show and image.show now read that payload as the peer of --file (stdin wins). Bounded slurp + the envelope's existing size guard keep a huge payload from corrupting the wire. C client builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 5 +++ .pql/changelog/tickets/2026-06.sql | 34 ++++++++++++++++++ CHANGELOG.md | 3 ++ lib/src/cli/argv_dispatch.dart | 16 ++++++++- lib/src/daemon/icon_commands.dart | 18 ++++++---- lib/src/daemon/image_commands.dart | 22 +++++++----- native/clide-cli/clide.c | 44 ++++++++++++++++++++--- test/cli/argv_dispatch_test.dart | 18 ++++++++++ test/daemon/icon_commands_test.dart | 14 ++++++++ test/daemon/image_commands_test.dart | 7 ++++ 10 files changed, 160 insertions(+), 21 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 5429e544..9781b4de 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -9080,3 +9080,8 @@ COLOR (2026-06-28, folded into payload spec): add an OPTIONAL per-entry ''color' DONE (2026-06-29): Phosphor glyph card landed end-to-end + tests green. icon.show (Flutter-free, dart-tested): variadic stringList positionals (clide icon show gear folder — required a schema enhancement so a trailing stringList positional is variadic) OR --file JSON [{icon,label,description,color}]; resolves by name (injected kPhosphorGlyphs resolver) or 0xNNNN; color via parseSvgColor (hex/CSS); publishes on ''icon'' bus; honest userError on unknown glyph/bad color/malformed payload/no UI. Card: per entry a hero (52) + continuous sample strip 10..48, optional label/description, per-entry/card color; display-only D-78. Commits 9a2b4e9e (handler+schema) + 281fb2a8 (card+wiring).', NULL, '2026-06-29 11:11:17', '2026-06-29 11:11:17.477', '2026-06-29 11:11:17.477', NULL, 'dc19f7fc9798ca56d6ec7db1779744e4', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB234WP4Y6Q16A0HFW8BSXMG', 'status', 'in_progress', 'done', NULL, '2026-06-29 11:11:17', '2026-06-29 11:11:17.514', '2026-06-29 11:11:17.514', NULL, 'b051374bed839b26002105c4febe1574', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 11:11:28', '2026-06-29 11:11:28.762', '2026-06-29 11:11:28.762', NULL, '490b259653d17d1643ebb9e8a17f0574', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'description', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318). + +DONE (2026-06-29): SVG-substrate compare card landed (approach chosen by user) + tests green. compareTemplateHandler lowers an images array [{path,label,description}] to side-by-side cells, each with the T-318 data-label/description caption + data-lightbox; paths resolved to absolute up front (injected, honest DrawErr on a miss). _paintImage now aspect-fits (contain, centered) so differing shapes don''t distort. Draw card path loads hrefs -> ui.Image via loadDrawingImages (decoder injectable for tests); renderer + lightbox both paint through the resolver. Commits a651c9e0 (template+painter) + 1f6d31ac (image-loading+register).', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.687', '2026-06-29 13:02:44.687', NULL, '61cd7ded0058d2727055ab9805712180', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'status', 'in_progress', 'done', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.726', '2026-06-29 13:02:44.726', NULL, 'a03f0481fb84e46fa45077355a64a47b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 16:44:18', '2026-06-29 16:44:18.342', '2026-06-29 16:44:18.342', NULL, '0d5c93289262d7527ea7471e96f8d02b', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index ba1a69a5..8be6423b 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10959,3 +10959,37 @@ COLOR (2026-06-28, folded into payload spec): add an OPTIONAL per-entry ''color' DONE (2026-06-29): Phosphor glyph card landed end-to-end + tests green. icon.show (Flutter-free, dart-tested): variadic stringList positionals (clide icon show gear folder — required a schema enhancement so a trailing stringList positional is variadic) OR --file JSON [{icon,label,description,color}]; resolves by name (injected kPhosphorGlyphs resolver) or 0xNNNN; color via parseSvgColor (hex/CSS); publishes on ''icon'' bus; honest userError on unknown glyph/bad color/malformed payload/no UI. Card: per entry a hero (52) + continuous sample strip 10..48, optional label/description, per-entry/card color; display-only D-78. Commits 9a2b4e9e (handler+schema) + 281fb2a8 (card+wiring).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:20:54', '2026-06-29 11:11:17.514', NULL, '0952c384083c3bd521c8b693bdbf1bf7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: compare-images / before-after', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:45', '2026-06-29 11:11:28.761', NULL, 'd0854a069e7eb5a42e250a9b7ef134cb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: compare-images / before-after', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318). + +DONE (2026-06-29): SVG-substrate compare card landed (approach chosen by user) + tests green. compareTemplateHandler lowers an images array [{path,label,description}] to side-by-side cells, each with the T-318 data-label/description caption + data-lightbox; paths resolved to absolute up front (injected, honest DrawErr on a miss). _paintImage now aspect-fits (contain, centered) so differing shapes don''t distort. Draw card path loads hrefs -> ui.Image via loadDrawingImages (decoder injectable for tests); renderer + lightbox both paint through the resolver. Commits a651c9e0 (template+painter) + 1f6d31ac (image-loading+register).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:45', '2026-06-29 13:02:44.687', NULL, 'c2e33736d8dcbe31102fd5f12495c53e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: compare-images / before-after', 'Template for the unified drawing card (T-317, D-91): compare-images / before-after. The JSON carries two (or more) image paths, each with an optional label + description; the card renders them side by side for visual comparison, each with a lightbox-on-click (shared with the image template). Lowers onto the canvas primitives + the shared per-object label/description widget (T-318). Display-only (D-78). Depends on the core engine (T-318). + +DONE (2026-06-29): SVG-substrate compare card landed (approach chosen by user) + tests green. compareTemplateHandler lowers an images array [{path,label,description}] to side-by-side cells, each with the T-318 data-label/description caption + data-lightbox; paths resolved to absolute up front (injected, honest DrawErr on a miss). _paintImage now aspect-fits (contain, centered) so differing shapes don''t distort. Draw card path loads hrefs -> ui.Image via loadDrawingImages (decoder injectable for tests); renderer + lightbox both paint through the resolver. Commits a651c9e0 (template+painter) + 1f6d31ac (image-loading+register).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:45', '2026-06-29 13:02:44.726', NULL, '0c8fca4b7ab4f3d23364e389373a35b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`. + +## Why + +Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file ` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume. + +## Where the work lives + +clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart). + +## Scope / decisions + +- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema. +- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence. +- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY). +- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures. +- D-6 parity: document the stdin convention alongside the other CLI verbs. + +## Acceptance + +- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload. +- `clide icon show --stdin` (T-313) and `clide image show --stdin` (T-316) both consume it. +- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY. +- Malformed JSON returns a userError with a helpful message. + +Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. + +Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 16:44:18.342', NULL, 'c6331517a674a7e66a02bac032bc6cff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 2aa3b436..b50ad20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Piped `--stdin` payloads.** `cat icons.json | clide icon show --stdin` (and + `image show`) accept a JSON payload on stdin — the ergonomic peer of `--file` + for structured commands. (T-315) - **Before/after compare cards.** `clide draw --file compare.json` (template `compare`) renders two or more images side by side, each with its own label/description and tap-to-zoom; images aspect-fit so differing shapes diff --git a/lib/src/cli/argv_dispatch.dart b/lib/src/cli/argv_dispatch.dart index 48a346e4..f329af51 100644 --- a/lib/src/cli/argv_dispatch.dart +++ b/lib/src/cli/argv_dispatch.dart @@ -36,7 +36,21 @@ ArgvParseResult unwrapArgvRequest(IpcRequest outer) { ), ); } - return parseArgv(raw.cast(), requestId: outer.id); + final result = parseArgv(raw.cast(), requestId: outer.id); + // A piped `--stdin` payload (T-315): the C client slurps stdin and ships it + // alongside the argv. Fold it into the inner request as a `stdin` flag so it + // surfaces as a named arg (undeclared keys pass the schema untouched) — the + // handler reads it as the structured payload, the piped peer of `--file`. + final stdin = outer.args['stdin']; + if (result is ArgvParsed && stdin is String) { + final req = result.request; + final args = Map.from(req.args); + final flags = Map.from((args['flags'] as Map?) ?? const {}); + flags['stdin'] = stdin; + args['flags'] = flags; + return ArgvParsed(IpcRequest(id: req.id, cmd: req.cmd, args: args)); + } + return result; } /// Wire the `_argv` sentinel handler onto [dispatcher]. The handler diff --git a/lib/src/daemon/icon_commands.dart b/lib/src/daemon/icon_commands.dart index 7dd1a599..8e01f173 100644 --- a/lib/src/daemon/icon_commands.dart +++ b/lib/src/daemon/icon_commands.dart @@ -62,11 +62,14 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher } final entries = >[]; + // The entry payload comes from a piped --stdin (T-315) or a --file; --stdin + // wins. Either is a JSON array of {icon,label,description,color}. + final stdin = _str(req.args['stdin']); final file = _str(req.args['file']); - - if (file != null) { - final raw = readFile == null ? null : await readFile(file); - if (raw == null) { + String? payload = stdin; + if (payload == null && file != null) { + payload = readFile == null ? null : await readFile(file); + if (payload == null) { return IpcResponse.err( id: req.id, error: IpcError( @@ -77,11 +80,14 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher ), ); } + } + + if (payload != null) { Object? decoded; try { - decoded = jsonDecode(raw); + decoded = jsonDecode(payload); } on FormatException catch (e) { - return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + return _userErr(req.id, 'invalid JSON in the icon payload: ${e.message}'); } if (decoded is! List) return _userErr(req.id, 'icon metadata must be a JSON array of entries'); for (final item in decoded) { diff --git a/lib/src/daemon/image_commands.dart b/lib/src/daemon/image_commands.dart index a76f53f2..647511f5 100644 --- a/lib/src/daemon/image_commands.dart +++ b/lib/src/daemon/image_commands.dart @@ -68,13 +68,15 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher String? label, description; String? caption = req.args['caption'] as String?; - // --file : an annotation payload {path,label,description,caption} - // (T-316). Additive — the bare `image show [--caption]` form is - // unchanged; label/description are the new richer metadata. - final file = req.args['file'] as String?; - if (file != null && file.trim().isNotEmpty) { - final raw = readFile == null ? null : await readFile(file); - if (raw == null) { + // An annotation payload {path,label,description,caption} from a piped --stdin + // (T-315) or a --file (T-316); --stdin wins. Additive — the bare + // `image show [--caption]` form is unchanged. + final stdin = _str(req.args['stdin']); + final file = _str(req.args['file']); + String? payload = stdin; + if (payload == null && file != null) { + payload = readFile == null ? null : await readFile(file); + if (payload == null) { return IpcResponse.err( id: req.id, error: IpcError( @@ -85,11 +87,13 @@ Future _show(IpcRequest req, MessagePublisher? Function() publisher ), ); } + } + if (payload != null) { Object? decoded; try { - decoded = jsonDecode(raw); + decoded = jsonDecode(payload); } on FormatException catch (e) { - return _userErr(req.id, 'invalid JSON in $file: ${e.message}'); + return _userErr(req.id, 'invalid JSON in the image payload: ${e.message}'); } if (decoded is! Map) return _userErr(req.id, 'image metadata must be a JSON object'); path = _str(decoded['path']) ?? path; diff --git a/native/clide-cli/clide.c b/native/clide-cli/clide.c index ad6fd1b1..01f689b7 100644 --- a/native/clide-cli/clide.c +++ b/native/clide-cli/clide.c @@ -279,19 +279,41 @@ static void json_escape(const char *s, char *out, size_t out_size) { /* 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, long long pid, char *out, size_t out_size) { +/* Slurp stdin (a piped JSON payload, T-315) into buf, NUL-terminated. Bounded: + * reads at most size-1 bytes — a larger payload is truncated, and the envelope's + * own size guard then rejects it loudly rather than corrupting the wire. */ +static int slurp_stdin(char *buf, size_t size) { + size_t n = fread(buf, 1, size - 1, stdin); + buf[n] = '\0'; + return (int)n; +} + +static int build_request(int argc, char **argv, long long pid, char *out, size_t out_size, const char *stdin_data) { /* 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\":[", pid); if (n < 0 || (size_t)n >= out_size) return -1; + int first = 1; for (int i = 0; i < argc; i++) { + /* The --stdin flag is a slurp signal, not a value — strip it from argv; + * the payload rides alongside in the `stdin` field (T-315). */ + if (strcmp(argv[i], "--stdin") == 0) continue; char esc[4096]; json_escape(argv[i], esc, sizeof(esc)); - n += snprintf(out + n, out_size - n, "%s%s", i ? "," : "", esc); + n += snprintf(out + n, out_size - n, "%s%s", first ? "" : ",", esc); + if (n < 0 || (size_t)n >= out_size) return -1; + first = 0; + } + n += snprintf(out + n, out_size - n, "]"); + if (n < 0 || (size_t)n >= out_size) return -1; + if (stdin_data && *stdin_data) { + char esc[40000]; + json_escape(stdin_data, esc, sizeof(esc)); + n += snprintf(out + n, out_size - n, ",\"stdin\":%s", esc); if (n < 0 || (size_t)n >= out_size) return -1; } - n += snprintf(out + n, out_size - n, "]}}\n"); + n += snprintf(out + n, out_size - n, "}}\n"); return (n < 0 || (size_t)n >= out_size) ? -1 : 0; } @@ -386,7 +408,7 @@ static int list_instances(void) { sock_t fd = connect_unix(path); if (fd == NET_INVALID) continue; /* dead socket — skip */ char req[1024]; - if (build_request(1, qargv, (long long)clide_getpid(), req, sizeof(req)) == 0 && + if (build_request(1, qargv, (long long)clide_getpid(), req, sizeof(req), NULL) == 0 && net_write(fd, req, (int)strlen(req)) == (int)strlen(req)) { char resp[65536]; if (read_line(fd, resp, sizeof(resp)) == 0) { @@ -460,10 +482,22 @@ int main(int argc, char **argv) { return EX_UNAVAILABLE; } + /* A `--stdin` flag anywhere in argv means slurp a piped JSON payload and + * ship it alongside (T-315) — the structured peer of `--file`. */ + char stdin_buf[32768]; + const char *stdin_data = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--stdin") == 0) { + slurp_stdin(stdin_buf, sizeof(stdin_buf)); + stdin_data = stdin_buf; + break; + } + } + /* 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, (long long)clide_getpid(), req, sizeof(req)) != 0) { + if (build_request(argc - 1, argv + 1, (long long)clide_getpid(), req, sizeof(req), stdin_data) != 0) { fprintf(stderr, "clide: request payload too large\n"); net_close(fd); return EX_USAGE; diff --git a/test/cli/argv_dispatch_test.dart b/test/cli/argv_dispatch_test.dart index 28a9149f..5cf72c93 100644 --- a/test/cli/argv_dispatch_test.dart +++ b/test/cli/argv_dispatch_test.dart @@ -6,6 +6,7 @@ library; import 'package:clide/src/cli/argv_dispatch.dart'; +import 'package:clide/src/cli/argv_to_request.dart' show ArgvParsed; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/schema_v1.dart'; @@ -34,6 +35,23 @@ void main() { expect(res.data['pong'], isTrue); }); + test('a stdin payload folds into the inner request as a flag (T-315)', () { + final result = unwrapArgvRequest( + IpcRequest( + id: 'x', + cmd: argvSentinelCmd, + args: { + 'argv': ['icon', 'show'], + 'stdin': '[{"icon":"gear"}]', + }, + ), + ); + expect(result, isA()); + final req = (result as ArgvParsed).request; + expect(req.cmd, 'icon.show'); + expect((req.args['flags'] as Map)['stdin'], '[{"icon":"gear"}]'); + }); + test('args.argv missing → userError', () async { final res = await d.dispatch(IpcRequest(id: 'y', cmd: argvSentinelCmd, args: const {})); expect(res.ok, isFalse); diff --git a/test/daemon/icon_commands_test.dart b/test/daemon/icon_commands_test.dart index ae217242..02c962cd 100644 --- a/test/daemon/icon_commands_test.dart +++ b/test/daemon/icon_commands_test.dart @@ -63,6 +63,20 @@ void main() { }); }); + test('a --stdin payload is parsed like --file (T-315)', () async { + wire(); + final r = await show([], flags: {'stdin': '[{"icon":"gear","label":"Settings"}]'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect((published.single.data['entries'] as List).single, {'codepoint': 0xe2a4, 'name': 'gear', 'label': 'Settings'}); + }); + + test('--stdin wins over --file', () async { + wire(files: {'i.json': '[{"icon":"folder"}]'}); + final r = await show([], flags: {'stdin': '[{"icon":"gear"}]'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect((published.single.data['entries'] as List).single['name'], 'gear'); + }); + test('a card-level --color rides along', () async { wire(); final r = await show(['gear'], flags: {'color': 'red'}); diff --git a/test/daemon/image_commands_test.dart b/test/daemon/image_commands_test.dart index d74dbca9..9804a592 100644 --- a/test/daemon/image_commands_test.dart +++ b/test/daemon/image_commands_test.dart @@ -56,6 +56,13 @@ void main() { expect(published.single.data, {'path': '/abs/docs/shot.png', 'caption': 'before', 'label': 'HUD v3', 'description': 'cramped status row'}); }); + test('a --stdin payload carries metadata like --file (T-315)', () async { + wire(found: {'docs/shot.png'}); + final r = await show([], flags: {'stdin': '{"path":"docs/shot.png","label":"HUD"}'}); + expect(r.ok, isTrue, reason: r.error?.message); + expect(published.single.data, {'path': '/abs/docs/shot.png', 'label': 'HUD'}); + }); + test('a malformed --file payload is an honest userError, nothing published', () async { wire(files: {'meta.json': 'not json'}); final r = await show([], flags: {'file': 'meta.json'});