cli: velox — add / ls / pause / resume / rm, with --json (build step 8, pulled forward)

The scriptable client, built now rather than last: it is how the daemon
gets exercised before the GUI is pointed at it (AGENT-DAEMON.md).

- src/client — synchronous blocking RPC over the Unix socket: resolve
  $XDG_RUNTIME_DIR/velox/velox.sock, connect, session.hello, one framed
  request/reply per call. Distinguishes transport failure (exit 3) from
  a daemon-returned error (exit 1).
- src/main — subcommands add/ls/pause/resume/rm; --json prints the raw
  JSON-RPC result or error; --dir/--out/--segments on add;
  --delete-file on rm. Usage errors exit 2.

ls works end to end against veloxd today (empty table). add and the
bulk verbs reach the daemon and surface its "not implemented" (-32603)
cleanly until the store lands — the plumbing is done, the commands
light up as handlers do.

Test velox.client: the real Client against an in-process UdsServer —
no-daemon path, session.hello, download.list, and a not-implemented
method surfacing as an RPC error rather than a transport error.
ASan+UBSan clean; full tree (21 tests, incl. conformance) green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-10 15:27:20 +04:00
co-authored by Claude Sonnet 5
parent e60d6669d8
commit 0c7ce1437c
7 changed files with 478 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# cli/ produces the `velox` binary — the scriptable RPC client. Owned by lane DAEMON.
# Built early (AGENT-DAEMON.md build step 8, pulled forward): it is how the daemon is
# tested before the GUI exists.
#
# Links velox::proto for the wire types and error codes. It speaks the same NDJSON Unix
# socket veloxd listens on; no daemon code is linked.
if(NOT TARGET nlohmann_json::nlohmann_json)
find_package(nlohmann_json 3.11 REQUIRED)
endif()
add_executable(velox
src/main.cpp
src/client.cpp
)
target_include_directories(velox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(velox PRIVATE cxx_std_23)
target_compile_options(velox PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(velox PRIVATE velox::proto nlohmann_json::nlohmann_json)
if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
add_subdirectory(tests)
endif()
View File
+130
View File
@@ -0,0 +1,130 @@
#include "client.hpp"
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include "velox_proto.hpp"
namespace velox::cli {
namespace {
std::string read_error_message(int e) { return std::strerror(e); }
} // namespace
std::string default_socket_path() {
std::string base;
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') {
base = xdg;
} else {
base = "/run/user/" + std::to_string(::geteuid());
}
if (!base.empty() && base.back() == '/') base.pop_back();
return base + "/velox/velox.sock";
}
Client::~Client() {
if (fd_ >= 0) ::close(fd_);
}
std::optional<CallError> Client::connect() {
socket_path_ = default_socket_path();
if (socket_path_.size() + 1 > sizeof(sockaddr_un::sun_path)) {
return CallError{CallError::kConnect, "socket path too long: " + socket_path_, {}};
}
fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd_ < 0) return CallError{CallError::kConnect, read_error_message(errno), {}};
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::memcpy(addr.sun_path, socket_path_.c_str(), socket_path_.size());
if (::connect(fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
const int e = errno;
::close(fd_);
fd_ = -1;
return CallError{CallError::kConnect,
"cannot reach veloxd at " + socket_path_ + ": " + read_error_message(e),
{}};
}
nlohmann::json hello = {
{"clientType", "cli"},
{"clientName", std::string("velox ") + std::string(velox::proto::kProtocolVersion)},
{"protocolVersion", std::string(velox::proto::kProtocolVersion)},
};
auto r = call("session.hello", hello);
if (!r) return r.error();
hello_result_ = *r;
return std::nullopt;
}
std::expected<nlohmann::json, CallError> Client::call(const std::string& method,
const nlohmann::json& params) {
const nlohmann::json request = {
{"jsonrpc", "2.0"},
{"id", next_id_++},
{"method", method},
{"params", params},
};
return round_trip(request);
}
std::expected<nlohmann::json, CallError> Client::round_trip(const nlohmann::json& request) {
if (fd_ < 0) return std::unexpected(CallError{CallError::kConnect, "not connected", {}});
std::string out = request.dump();
out.push_back('\n');
std::size_t off = 0;
while (off < out.size()) {
const ssize_t n = ::write(fd_, out.data() + off, out.size() - off);
if (n > 0) {
off += static_cast<std::size_t>(n);
continue;
}
if (n < 0 && errno == EINTR) continue;
return std::unexpected(CallError{CallError::kConnect,
"write to daemon failed: " + read_error_message(errno), {}});
}
// Read until a newline completes a frame.
for (;;) {
if (const auto nl = inbuf_.find('\n'); nl != std::string::npos) {
const std::string line = inbuf_.substr(0, nl);
inbuf_.erase(0, nl + 1);
nlohmann::json reply = nlohmann::json::parse(line, nullptr, false);
if (reply.is_discarded()) {
return std::unexpected(
CallError{CallError::kProtocol, "daemon sent a malformed reply", {}});
}
if (reply.contains("error")) {
const auto& e = reply.at("error");
return std::unexpected(CallError{e.value("code", 0), e.value("message", ""),
e.contains("data") ? e.at("data") : nlohmann::json()});
}
return reply.contains("result") ? reply.at("result") : nlohmann::json(nullptr);
}
char chunk[8192];
const ssize_t n = ::read(fd_, chunk, sizeof(chunk));
if (n > 0) {
inbuf_.append(chunk, static_cast<std::size_t>(n));
continue;
}
if (n == 0) {
return std::unexpected(CallError{CallError::kConnect,
"daemon closed the connection", {}});
}
if (errno == EINTR) continue;
return std::unexpected(CallError{CallError::kConnect,
"read from daemon failed: " + read_error_message(errno), {}});
}
}
} // namespace velox::cli
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// A synchronous, blocking RPC client for the Unix socket. The CLI does one request at a
// time and waits for the reply, so none of the daemon's async machinery is needed here —
// just connect, session.hello, call, read one NDJSON frame back.
#include <expected>
#include <optional>
#include <string>
#include <nlohmann/json.hpp>
namespace velox::cli {
struct CallError {
int code; // JSON-RPC error code, or a negative transport code below
std::string message;
nlohmann::json data; // may be null
static constexpr int kConnect = -1000; // could not reach the daemon
static constexpr int kProtocol = -1001; // malformed reply / framing error
};
class Client {
public:
~Client();
// Resolve the socket path ($XDG_RUNTIME_DIR/velox/velox.sock), connect, and complete
// session.hello with clientType "cli". On failure returns the error and leaves the
// client unusable.
std::optional<CallError> connect();
// Send one request and return its result, or the error. `params` is passed through
// verbatim as the JSON-RPC params.
std::expected<nlohmann::json, CallError> call(const std::string& method,
const nlohmann::json& params);
const std::string& socket_path() const noexcept { return socket_path_; }
const nlohmann::json& hello_result() const noexcept { return hello_result_; }
private:
std::expected<nlohmann::json, CallError> round_trip(const nlohmann::json& request);
int fd_ = -1;
int next_id_ = 1;
std::string socket_path_;
std::string inbuf_;
nlohmann::json hello_result_;
};
// $XDG_RUNTIME_DIR/velox/velox.sock, or /run/user/<uid>/velox/velox.sock when the env var
// is unset. Mirrors daemon/src/rpc/runtime_dir.cpp; kept in sync by being trivial.
std::string default_socket_path();
} // namespace velox::cli
+179
View File
@@ -0,0 +1,179 @@
// velox — the command-line client for veloxd.
//
// velox add <url> [--dir D] [--out NAME] [--segments N] [--json]
// velox ls [--json]
// velox pause <id>... velox resume <id>...
// velox rm <id>... [--delete-file]
//
// Exit codes: 0 ok, 1 daemon returned an error, 2 usage error, 3 cannot reach the daemon.
#include <cstdio>
#include <cstdlib>
#include <string>
#include <string_view>
#include <vector>
#include <nlohmann/json.hpp>
#include "client.hpp"
namespace {
using nlohmann::json;
using velox::cli::CallError;
using velox::cli::Client;
constexpr int kOk = 0;
constexpr int kRpcError = 1;
constexpr int kUsage = 2;
constexpr int kNoDaemon = 3;
struct Args {
std::vector<std::string> positional;
bool json = false;
bool delete_file = false;
std::string dir;
std::string out;
long segments = 0;
};
[[noreturn]] void usage(int code) {
std::fprintf(code == kOk ? stdout : stderr,
"usage: velox <command> [options]\n\n"
" add <url> [--dir DIR] [--out NAME] [--segments N]\n"
" ls\n"
" pause <id>...\n"
" resume <id>...\n"
" rm <id>... [--delete-file]\n\n"
" --json print the raw JSON-RPC result\n");
std::exit(code);
}
Args parse_args(int argc, char** argv) {
Args a;
for (int i = 2; i < argc; ++i) {
const std::string_view arg = argv[i];
if (arg == "--json") {
a.json = true;
} else if (arg == "--delete-file") {
a.delete_file = true;
} else if (arg == "--dir" && i + 1 < argc) {
a.dir = argv[++i];
} else if (arg == "--out" && i + 1 < argc) {
a.out = argv[++i];
} else if (arg == "--segments" && i + 1 < argc) {
a.segments = std::strtol(argv[++i], nullptr, 10);
} else if (arg == "-h" || arg == "--help") {
usage(kOk);
} else if (!arg.empty() && arg.front() == '-') {
std::fprintf(stderr, "velox: unknown option %s\n", argv[i]);
usage(kUsage);
} else {
a.positional.emplace_back(arg);
}
}
return a;
}
int report_error(const CallError& e, bool as_json) {
if (as_json) {
json j = {{"error", {{"code", e.code}, {"message", e.message}}}};
if (!e.data.is_null()) j["error"]["data"] = e.data;
std::printf("%s\n", j.dump(2).c_str());
} else {
std::fprintf(stderr, "velox: %s\n", e.message.c_str());
}
return e.code == CallError::kConnect ? kNoDaemon : kRpcError;
}
void print_task_table(const json& items) {
std::printf("%-38s %-10s %-9s %s\n", "ID", "STATE", "PROGRESS", "NAME");
for (const auto& t : items) {
const std::string id = t.value("taskId", "");
const std::string state = t.value("state", "");
const std::string name = t.value("filename", "");
const long long total = t.value("sizeBytes", 0LL);
const long long done = t.value("downloadedBytes", 0LL);
char pct[12] = "-";
if (total > 0) std::snprintf(pct, sizeof(pct), "%lld%%", done * 100 / total);
std::printf("%-38s %-10s %-9s %s\n", id.c_str(), state.c_str(), pct, name.c_str());
}
}
int cmd_ls(Client& c, const Args& a) {
auto r = c.call("download.list", json::object());
if (!r) return report_error(r.error(), a.json);
if (a.json) {
std::printf("%s\n", r->dump(2).c_str());
return kOk;
}
const auto& items = r->contains("items") ? r->at("items") : json::array();
if (items.empty()) {
std::printf("no downloads\n");
return kOk;
}
print_task_table(items);
return kOk;
}
int cmd_add(Client& c, const Args& a) {
if (a.positional.empty()) {
std::fprintf(stderr, "velox add: a URL is required\n");
return kUsage;
}
json params = {{"url", a.positional.front()}};
if (!a.dir.empty()) params["saveDir"] = a.dir;
if (!a.out.empty()) params["filename"] = a.out;
if (a.segments > 0) params["segments"] = a.segments;
auto r = c.call("download.add", params);
if (!r) return report_error(r.error(), a.json);
if (a.json) {
std::printf("%s\n", r->dump(2).c_str());
} else {
std::printf("added %s\n", r->value("taskId", "?").c_str());
}
return kOk;
}
int cmd_bulk(Client& c, const Args& a, const char* method) {
if (a.positional.empty()) {
std::fprintf(stderr, "velox: at least one task id is required\n");
return kUsage;
}
json params = {{"taskIds", a.positional}};
if (std::string_view(method) == "download.remove" && a.delete_file) params["deleteFile"] = true;
auto r = c.call(method, params);
if (!r) return report_error(r.error(), a.json);
if (a.json) {
std::printf("%s\n", r->dump(2).c_str());
} else {
std::printf("ok\n");
}
return kOk;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) usage(kUsage);
const std::string command = argv[1];
if (command == "-h" || command == "--help") usage(kOk);
const Args args = parse_args(argc, argv);
Client client;
if (const auto err = client.connect()) {
return report_error(*err, args.json);
}
if (command == "ls") return cmd_ls(client, args);
if (command == "add") return cmd_add(client, args);
if (command == "pause") return cmd_bulk(client, args, "download.pause");
if (command == "resume") return cmd_bulk(client, args, "download.resume");
if (command == "rm") return cmd_bulk(client, args, "download.remove");
std::fprintf(stderr, "velox: unknown command '%s'\n", command.c_str());
usage(kUsage);
}
+13
View File
@@ -0,0 +1,13 @@
# CLI integration test: a real in-process UdsServer on a temp socket, the real Client
# against it. Links veloxd_rpc for the server half.
add_executable(velox_client_test client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/client.cpp)
target_include_directories(velox_client_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../src
${CMAKE_CURRENT_SOURCE_DIR}/../../daemon/tests
)
target_compile_features(velox_client_test PRIVATE cxx_std_23)
target_compile_options(velox_client_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(velox_client_test PRIVATE veloxd_rpc velox::proto nlohmann_json::nlohmann_json)
add_test(NAME velox.client COMMAND velox_client_test)
set_tests_properties(velox.client PROPERTIES TIMEOUT 30)
+78
View File
@@ -0,0 +1,78 @@
// The CLI's Client against a real in-process daemon RPC server.
#include <sys/stat.h>
#include <unistd.h>
#include <cstdlib>
#include <string>
#include <thread>
#include "check.hpp"
#include "client.hpp"
#include "rpc/dispatcher.hpp"
#include "rpc/event_loop.hpp"
#include "rpc/uds_server.hpp"
namespace rpc = velox::daemon::rpc;
using velox::cli::CallError;
using velox::cli::Client;
void run() {
// connect() with no daemon -> a kConnect error, not a crash.
{
::setenv("XDG_RUNTIME_DIR", "/tmp/velox-cli-test-nonexistent-xyz", 1);
Client c;
auto err = c.connect();
CHECK(err.has_value());
if (err) CHECK_EQ(err->code, CallError::kConnect);
}
// Point XDG_RUNTIME_DIR at a fresh temp dir; the client derives
// <XDG_RUNTIME_DIR>/velox/velox.sock and the server binds exactly that.
char tmpl[] = "/tmp/velox-cli-test-XXXXXX";
const char* xdg = ::mkdtemp(tmpl);
CHECK(xdg != nullptr);
if (xdg == nullptr) return;
::setenv("XDG_RUNTIME_DIR", xdg, 1);
const std::string velox_dir = std::string(xdg) + "/velox";
::mkdir(velox_dir.c_str(), 0700);
const std::string server_sock = velox_dir + "/velox.sock";
rpc::EventLoop loop;
rpc::VeloxDispatcher dispatcher;
rpc::UdsServer server(loop, dispatcher, server_sock);
const auto ec = server.start();
CHECK(!ec);
if (ec) return;
std::thread th([&loop] { loop.run(); });
{
Client c;
auto err = c.connect();
CHECK(!err.has_value());
if (err) {
loop.stop();
th.join();
return;
}
CHECK_EQ(c.hello_result().value("transport", ""), std::string("uds"));
auto ls = c.call("download.list", nlohmann::json::object());
CHECK(ls.has_value());
if (ls) {
CHECK_EQ(ls->value("total", -1), 0);
CHECK(ls->at("items").is_array());
}
// A not-yet-implemented method surfaces the daemon's error, not a transport error.
auto add = c.call("download.add", {{"url", "https://example.com/x"}});
CHECK(!add.has_value());
if (!add) CHECK_EQ(add.error().code, -32603);
}
loop.stop();
th.join();
::unlink(server_sock.c_str());
}
TEST_MAIN()