diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..828443a --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,32 @@ +name: rust-ci + +on: + push: + paths: + - "rust/**" + - ".github/workflows/rust-ci.yml" + pull_request: + paths: + - "rust/**" + - ".github/workflows/rust-ci.yml" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + - name: Format + working-directory: rust + run: cargo fmt --check + - name: Lint + working-directory: rust + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Test + working-directory: rust + run: cargo test --all-targets + - name: Package + working-directory: rust + run: cargo package --locked diff --git a/.gitignore b/.gitignore index 8921fee..18282fa 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ pnpm-lock.yaml yarn.lock npm-debug.log* +# Rust +target/ + # Editors .vscode/ .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index edf7976..c04904b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ `cccc-sdk` tracks the `cccc` daemon version. Each release targets a specific CCCC line and exposes the IPC surface available on that line. +## Rust crate [0.0.1] — 2026-08-03 + +### Added + +- Initial `cccc-sdk` Rust crate with Unix Socket/TCP endpoint discovery, + Daemon IPC v1 NDJSON transport, structured protocol errors, response limits, + and configurable timeouts. +- Generic non-streaming operation calls plus focused helpers for compatibility, + groups, chat, inbox, and context workflows. +- Unit tests, a live compatibility example, crate documentation, and Rust CI. + ## [0.4.33] — Unreleased ### Added diff --git a/README.ja.md b/README.ja.md index 99f93ed..687c259 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ CCCC SDK は CCCC プラットフォーム向けの **クライアント SDK** - CCCC 本体リポジトリ: https://github.com/ChesterRa/cccc - `cccc`(本体)は daemon/web/CLI を提供し、`CCCC_HOME` の実行状態を管理します。 -- `cccc-sdk`(このリポジトリ)は Python/TypeScript から **Daemon IPC v1** を呼ぶクライアントです。 +- `cccc-sdk`(このリポジトリ)は Python、TypeScript、Rust から **Daemon IPC v1** を呼ぶクライアントです。 - SDK 単体では動作せず、実行中の CCCC daemon が必要です。 SDK と CCCC Web が同じ `CCCC_HOME` を参照していれば、書き込みは即時に共有されます @@ -22,6 +22,7 @@ SDK と CCCC Web が同じ `CCCC_HOME` を参照していれば、書き込み - `python/` — Python パッケージ(PyPI 名: `cccc-sdk`、import: `cccc_sdk`) - `ts/` — TypeScript パッケージ(`cccc-sdk`) +- `rust/` — Rust crate(`cccc-sdk`、crate 名 `cccc_sdk`) - `spec/` — SDK 開発用の契約ドキュメントミラー 主な用途: @@ -33,6 +34,7 @@ SDK と CCCC Web が同じ `CCCC_HOME` を参照していれば、書き込み 言語別の詳細: - Python SDK: `python/README.md` - TypeScript SDK: `ts/README.md` +- Rust SDK: `rust/README.md` --- @@ -64,7 +66,6 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_ops=["groups", "send", "reply", "tracked_send", "context_sync"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") @@ -84,12 +85,23 @@ python python/examples/stream.py --group g_xxx python python/examples/auto_ack_attention.py --group g_xxx --actor user ``` +## クイックスタート(Rust) + +```toml +[dependencies] +cccc-sdk = "0.0.1" +``` + +Rust クライアントは `CCCC_HOME` の Unix Socket/TCP daemon を自動検出し、 +汎用 `call` と group、chat、inbox、context の主要メソッドを提供します。 +詳細は `rust/README.md` を参照してください。 + --- ## バージョニングと互換性 SDK リリースは daemon のバージョン文字列ではなく contract に追従します: -- Python と TypeScript のパッケージバージョンは現在の SDK リリースラインに追従し、RC 番号は SDK 側で管理します。 +- Python と TypeScript は現在の SDK リリースラインに追従し、Rust crate は `0.0.1` から開始します。 - 実行時互換性は `assert_compatible(...)` で必要な capability/op を指定して確認します。 互換性は “契約/能力” で保証し、バージョン文字列の厳密一致には依存しません: diff --git a/README.md b/README.md index 47bfb1e..5b99698 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CCCC SDK provides **client SDKs** for building applications on top of the CCCC p - CCCC core repository: https://github.com/ChesterRa/cccc - `cccc` (core) ships the daemon/web/CLI and owns runtime state in `CCCC_HOME`. -- `cccc-sdk` (this repo) provides Python/TypeScript clients for **Daemon IPC v1**. +- `cccc-sdk` (this repo) provides Python, TypeScript, and Rust clients for **Daemon IPC v1**. - The SDK is not a standalone framework. It always talks to a running CCCC daemon. If SDK clients and CCCC Web use the same `CCCC_HOME`, all writes are shared immediately @@ -22,6 +22,7 @@ If SDK clients and CCCC Web use the same `CCCC_HOME`, all writes are shared imme - `python/` — Python package (`cccc-sdk`, import name `cccc_sdk`) - `ts/` — TypeScript package (`cccc-sdk`) +- `rust/` — Rust crate (`cccc-sdk`, crate name `cccc_sdk`) - `spec/CCCC_*.md` and `spec/CCCS_V1.md` — mirrored CCCC contract docs - `spec/SDK_*.md` — SDK-owned surface notes that are not yet core standards @@ -34,6 +35,7 @@ Typical use cases: For language-specific details: - Python SDK: `python/README.md` - TypeScript SDK: `ts/README.md` +- Rust SDK: `rust/README.md` --- @@ -65,7 +67,6 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_ops=["groups", "send", "reply", "tracked_send", "context_sync"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") @@ -85,12 +86,35 @@ python python/examples/stream.py --group g_xxx python python/examples/auto_ack_attention.py --group g_xxx --actor user ``` +## Quick start (Rust) + +```toml +[dependencies] +cccc-sdk = "0.0.1" +``` + +```rust +use cccc_sdk::{CCCCClient, CompatibilityRequirements}; + +fn main() -> Result<(), Box> { + let client = CCCCClient::discover()?; + client.assert_compatible(&CompatibilityRequirements { + minimum_ipc_version: 1, + operations: vec!["groups", "send", "reply", "context_get"], + ..Default::default() + })?; + println!("{:#?}", client.groups()?); + Ok(()) +} +``` + --- ## Versioning and compatibility SDK releases follow daemon contracts, not strict daemon version strings: -- Python and TypeScript package versions track the current SDK release line, while RC sequencing remains SDK-owned. +- Python and TypeScript package versions track the current SDK release line; the + Rust crate starts at `0.0.1` while its public API settles. - Use `assert_compatible(...)` with required capabilities/ops for runtime gating. Compatibility is enforced by **contracts**, not by strict version string matching: diff --git a/README.zh-CN.md b/README.zh-CN.md index 3535fc1..975079e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,7 +3,7 @@ [English](README.md) | **中文** | [日本語](README.ja.md) > 状态:**面向 CCCC Daemon IPC v1 的契约优先 SDK**。`main` 上的源码包面向 -> 当前源码包面向 CCCC 0.4.33;发布仍是独立的 release 步骤。具体范围见 `CHANGELOG.md` +> CCCC 0.4.33;发布仍是独立的 release 步骤。具体范围见 `CHANGELOG.md` > 与 `spec/ADAPTATION_PLAN.md`。 CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 @@ -12,7 +12,7 @@ CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 - CCCC 本体仓库:https://github.com/ChesterRa/cccc - `cccc`(本体)负责 daemon/web/CLI,以及 `CCCC_HOME` 下的运行时状态。 -- `cccc-sdk`(本仓库)提供 Python/TypeScript 客户端,调用 **Daemon IPC v1**。 +- `cccc-sdk`(本仓库)提供 Python、TypeScript 和 Rust 客户端,调用 **Daemon IPC v1**。 - SDK 不是独立框架,必须连接到已运行的 CCCC daemon。 只要 SDK 与 CCCC Web 指向同一个 `CCCC_HOME`,写入会立即互通 @@ -22,6 +22,7 @@ CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 - `python/` — Python 包(PyPI 名称 `cccc-sdk`,import 名称 `cccc_sdk`) - `ts/` — TypeScript 包(`cccc-sdk`) +- `rust/` — Rust crate(`cccc-sdk`,crate 名称 `cccc_sdk`) - `spec/` — SDK 开发使用的合约文档镜像 典型场景: @@ -33,6 +34,7 @@ CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 语言细分文档: - Python SDK:`python/README.md` - TypeScript SDK:`ts/README.md` +- Rust SDK:`rust/README.md` --- @@ -64,7 +66,6 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_ops=["groups", "send", "reply", "tracked_send", "context_sync"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") @@ -84,12 +85,22 @@ python python/examples/stream.py --group g_xxx python python/examples/auto_ack_attention.py --group g_xxx --actor user ``` +## 快速开始(Rust) + +```toml +[dependencies] +cccc-sdk = "0.0.1" +``` + +Rust 客户端会自动发现 `CCCC_HOME` 下的 Unix Socket/TCP daemon,并提供通用 +`call` 以及常用的 group、chat、inbox、context 方法。完整示例见 `rust/README.md`。 + --- ## 版本策略与兼容性 SDK 发布跟随 daemon 合约,而不是硬匹配 daemon 版本号: -- Python 和 TypeScript 包版本跟随当前 SDK 发布线;RC 序号由 SDK 自身维护。 +- Python 和 TypeScript 包版本跟随当前 SDK 发布线;Rust crate 从 `0.0.1` 起步。 - 运行时兼容请用 `assert_compatible(...)` 指定所需 capability/op。 我们保证兼容性的手段是“契约/能力”,而不是字符串版本号硬匹配: diff --git a/RELEASING.md b/RELEASING.md index 6a09657..7d827a0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,13 +1,15 @@ # Releasing `cccc-sdk` -This repo is a monorepo with two deliverables: +This repo is a monorepo with three deliverables: - Python package: `python/` (PyPI name: `cccc-sdk`) - TypeScript package: `ts/` (npm name: `cccc-sdk`) +- Rust crate: `rust/` (crates.io name: `cccc-sdk`) ## Versioning policy - SDK version tracks the supported CCCC line: currently `0.4.33`. - RC sequence is SDK-owned (`0.4.33rcN` for Python, `0.4.33-rc.N` for npm). +- The Rust crate begins at `0.0.1` while its public API settles. - Compatibility is enforced by contracts/capabilities/op-probing, not by matching RC numbers. ## 0) Sync specs (recommended) @@ -96,7 +98,29 @@ cd ts npm publish --access public ``` -## 3) Post-release sanity +## 3) Rust release (crates.io) + +### Local checks + +```bash +cd rust +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +cargo package --locked +``` + +### Publish + +```bash +cd rust +cargo publish --locked --registry crates-io +``` + +Published crate versions are immutable. Confirm the package file list and +metadata before running `cargo publish`. + +## 4) Post-release sanity - Run Python compat check against a running daemon: @@ -105,3 +129,5 @@ python python/examples/compat_check.py ``` - Verify npm package installs and can `import { CCCCClient } from 'cccc-sdk'`. +- Verify `cargo info cccc-sdk --registry crates-io` reports the expected Rust + crate version and repository. diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..73bf675 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,128 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cccc-sdk" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..3fbcbd6 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cccc-sdk" +version = "0.0.1" +edition = "2021" +rust-version = "1.74" +description = "Official Rust client SDK for CCCC daemon IPC v1" +license = "Apache-2.0" +repository = "https://github.com/ChesterRa/cccc-sdk" +homepage = "https://github.com/ChesterRa/cccc" +documentation = "https://docs.rs/cccc-sdk" +readme = "README.md" +keywords = ["cccc", "sdk", "ipc", "agent", "automation"] +categories = ["api-bindings", "development-tools"] +exclude = ["tests/**"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..18a27c5 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,58 @@ +# CCCC Rust SDK + +Official blocking Rust client for CCCC Daemon IPC v1. + +## Install + +```toml +[dependencies] +cccc-sdk = "0.0.1" +``` + +## Quick start + +```rust +use cccc_sdk::{CCCCClient, CompatibilityRequirements}; + +fn main() -> Result<(), Box> { + let client = CCCCClient::discover()?; + let requirements = CompatibilityRequirements { + minimum_ipc_version: 1, + operations: vec!["groups", "send", "reply", "context_get"], + ..Default::default() + }; + let daemon = client.assert_compatible(&requirements)?; + println!("connected to CCCC {}", daemon.version); + + let groups = client.groups()?; + println!("{groups:#?}"); + Ok(()) +} +``` + +The client discovers `${CCCC_HOME}/daemon/ccccd.addr.json`, supports Unix +sockets and TCP, and falls back to `${CCCC_HOME}/daemon/ccccd.sock` on Unix. + +Common helpers include `ping`, `groups`, `group_show`, `send`, `reply`, +`inbox_list`, `context_get`, and `context_sync`. Use `call` for every other +non-streaming CCCC 0.4.33 operation: + +```rust +use cccc_sdk::CCCCClient; +use serde_json::{json, Map}; + +let client = CCCCClient::discover()?; +let args: Map = [("group_id".into(), json!("g_xxx"))] + .into_iter() + .collect(); +let preamble = client.call("group_preamble_get", args)?; +# Ok::<(), Box>(()) +``` + +`assert_compatible` probes requested operation names and rejects an advertised +capability whose actual operation returns `unknown_op`. + +Streaming upgrade operations such as `events_stream` and `term_attach` are not +exposed as iterators in 0.0.1. Their handshake can still be checked through +operation probing; a reusable duplex-stream API will be added only with stable +ownership and backpressure semantics. diff --git a/rust/examples/compat_check.rs b/rust/examples/compat_check.rs new file mode 100644 index 0000000..7c964ff --- /dev/null +++ b/rust/examples/compat_check.rs @@ -0,0 +1,28 @@ +use cccc_sdk::{CCCCClient, CompatibilityRequirements}; + +fn main() -> Result<(), Box> { + let client = CCCCClient::discover()?; + let requirements = CompatibilityRequirements { + minimum_ipc_version: 1, + operations: vec![ + "groups", + "group_show", + "send", + "reply", + "inbox_list", + "context_get", + "context_sync", + "group_preamble_get", + "terminal_history", + ], + ..Default::default() + }; + let daemon = client.assert_compatible(&requirements)?; + println!( + "CCCC {} ({}) is compatible with IPC v{}", + daemon.version, + daemon.implementation.as_deref().unwrap_or("unknown"), + daemon.ipc_v + ); + Ok(()) +} diff --git a/rust/src/client.rs b/rust/src/client.rs new file mode 100644 index 0000000..1cb7fa8 --- /dev/null +++ b/rust/src/client.rs @@ -0,0 +1,350 @@ +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde_json::{json, Map, Value}; + +use crate::{ + discover_endpoint, DaemonEndpoint, DaemonRequest, DaemonResponse, Error, PingResult, Result, +}; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_REQUEST_BYTES: usize = 2_000_000; +const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +/// Capabilities and operations required by [`CCCCClient::assert_compatible`]. +#[derive(Clone, Debug, Default)] +pub struct CompatibilityRequirements<'a> { + pub minimum_ipc_version: u32, + pub capabilities: BTreeMap<&'a str, bool>, + pub operations: Vec<&'a str>, +} + +/// Blocking CCCC Daemon IPC v1 client. +#[derive(Clone, Debug)] +pub struct CCCCClient { + endpoint: DaemonEndpoint, + timeout: Duration, +} + +impl CCCCClient { + /// Discover the currently running daemon. + pub fn discover() -> Result { + Self::discover_in(None) + } + + /// Discover a daemon under an explicit `CCCC_HOME`. + pub fn discover_in(cccc_home: Option<&Path>) -> Result { + Ok(Self::new(discover_endpoint(cccc_home)?)) + } + + pub fn new(endpoint: DaemonEndpoint) -> Self { + Self { + endpoint, + timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn endpoint(&self) -> &DaemonEndpoint { + &self.endpoint + } + + /// Call any non-streaming daemon operation and return its result object. + pub fn call(&self, op: &str, args: Map) -> Result> { + let response = self.call_raw(op, args)?; + if response.v != 1 { + return Err(Error::UnsupportedIpcVersion(response.v)); + } + if response.ok { + return Ok(response.result); + } + Err(Error::Daemon(response.error.unwrap_or_else(|| { + crate::DaemonError { + code: "error".into(), + message: "daemon returned ok=false without an error".into(), + details: json!({}), + } + }))) + } + + /// Call an operation and retain the full response envelope. + pub fn call_raw(&self, op: &str, args: Map) -> Result { + if op.trim().is_empty() { + return Err(Error::Incompatible("operation name cannot be empty".into())); + } + let request = DaemonRequest { + v: 1, + op, + args: &args, + }; + let mut encoded = serde_json::to_vec(&request)?; + encoded.push(b'\n'); + if encoded.len() > MAX_REQUEST_BYTES { + return Err(Error::RequestTooLarge(MAX_REQUEST_BYTES)); + } + + match &self.endpoint { + DaemonEndpoint::Tcp { host, port } => { + let address = (host.as_str(), *port) + .to_socket_addrs()? + .next() + .ok_or_else(|| { + Error::InvalidEndpoint(format!("cannot resolve {host}:{port}")) + })?; + let mut stream = TcpStream::connect_timeout(&address, self.timeout)?; + stream.set_read_timeout(Some(self.timeout))?; + stream.set_write_timeout(Some(self.timeout))?; + exchange(&mut stream, &encoded) + } + DaemonEndpoint::Unix(path) => self.call_unix(path, &encoded), + } + } + + #[cfg(unix)] + fn call_unix(&self, path: &Path, encoded: &[u8]) -> Result { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(path)?; + stream.set_read_timeout(Some(self.timeout))?; + stream.set_write_timeout(Some(self.timeout))?; + exchange(&mut stream, encoded) + } + + #[cfg(not(unix))] + fn call_unix(&self, _path: &Path, _encoded: &[u8]) -> Result { + Err(Error::UnixSocketUnsupported) + } + + pub fn ping(&self) -> Result { + self.call_typed("ping", Map::new()) + } + + pub fn groups(&self) -> Result> { + self.call("groups", Map::new()) + } + + pub fn group_show(&self, group_id: &str) -> Result> { + self.call("group_show", object([("group_id", json!(group_id))])) + } + + pub fn send(&self, group_id: &str, text: &str, by: &str) -> Result> { + self.call( + "send", + object([ + ("group_id", json!(group_id)), + ("text", json!(text)), + ("by", json!(by)), + ]), + ) + } + + pub fn reply( + &self, + group_id: &str, + reply_to: &str, + text: &str, + by: &str, + ) -> Result> { + self.call( + "reply", + object([ + ("group_id", json!(group_id)), + ("reply_to", json!(reply_to)), + ("text", json!(text)), + ("by", json!(by)), + ]), + ) + } + + pub fn inbox_list( + &self, + group_id: &str, + actor_id: &str, + limit: Option, + ) -> Result> { + let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]); + if let Some(limit) = limit { + args.insert("limit".into(), json!(limit)); + } + self.call("inbox_list", args) + } + + pub fn context_get(&self, group_id: &str) -> Result> { + self.call("context_get", object([("group_id", json!(group_id))])) + } + + pub fn context_sync( + &self, + group_id: &str, + by: &str, + operations: Vec, + ) -> Result> { + self.call( + "context_sync", + object([ + ("group_id", json!(group_id)), + ("by", json!(by)), + ("ops", Value::Array(operations)), + ]), + ) + } + + /// Validate protocol, advertised capabilities, and actual op recognition. + pub fn assert_compatible( + &self, + requirements: &CompatibilityRequirements<'_>, + ) -> Result { + let ping = self.ping()?; + let minimum = requirements.minimum_ipc_version.max(1); + if ping.ipc_v < minimum { + return Err(Error::Incompatible(format!( + "daemon ipc_v={} is below required ipc_v={minimum}", + ping.ipc_v + ))); + } + for (name, required) in &requirements.capabilities { + if *required && ping.capabilities.get(*name).and_then(Value::as_bool) != Some(true) { + return Err(Error::Incompatible(format!( + "daemon capability {name}=true is required" + ))); + } + } + for operation in &requirements.operations { + if operation_probe_is_unsafe(operation) { + continue; + } + match self.call(operation, Map::new()) { + Err(Error::Daemon(error)) if error.code == "unknown_op" => { + return Err(Error::Incompatible(format!( + "daemon does not support operation {operation}" + ))); + } + Err(Error::Daemon(_)) | Ok(_) => {} + Err(error) => return Err(error), + } + } + Ok(ping) + } + + fn call_typed(&self, op: &str, args: Map) -> Result { + let result = self.call(op, args)?; + Ok(serde_json::from_value(Value::Object(result))?) + } +} + +fn operation_probe_is_unsafe(operation: &str) -> bool { + matches!( + operation, + "ping" + | "shutdown" + | "term_attach" + | "presentation_browser_attach" + | "presentation_browser_vnc_attach" + | "web_model_browser_attach" + | "web_model_browser_vnc_attach" + | "space_provider_auth_browser_attach" + | "space_provider_auth_browser_vnc_attach" + | "runtime_hermes_prepare" + | "runtime_hermes_mcp_test" + ) +} + +fn object(entries: [(&str, Value); N]) -> Map { + entries + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect() +} + +fn exchange(stream: &mut S, request: &[u8]) -> Result { + stream.write_all(request)?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut response = Vec::new(); + let read = reader + .by_ref() + .take((MAX_RESPONSE_BYTES + 1) as u64) + .read_until(b'\n', &mut response)?; + if read == 0 { + return Err(Error::EmptyResponse); + } + if response.len() > MAX_RESPONSE_BYTES { + return Err(Error::ResponseTooLarge(MAX_RESPONSE_BYTES)); + } + Ok(serde_json::from_slice(&response)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::thread; + + fn server_once(response: &'static str) -> (DaemonEndpoint, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("local address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept client"); + let mut request = String::new(); + BufReader::new(&mut stream) + .read_line(&mut request) + .expect("read request"); + stream + .write_all(response.as_bytes()) + .expect("write response"); + request + }); + ( + DaemonEndpoint::Tcp { + host: "127.0.0.1".into(), + port: address.port(), + }, + handle, + ) + } + + #[test] + fn sends_a_valid_ping_envelope() { + let (endpoint, server) = server_once( + "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"0.4.33\",\"ipc_v\":1,\"capabilities\":{}}}\n", + ); + let ping = CCCCClient::new(endpoint).ping().expect("ping"); + assert_eq!(ping.version, "0.4.33"); + assert_eq!(ping.ipc_v, 1); + + let request: Value = + serde_json::from_str(&server.join().expect("server thread")).expect("request JSON"); + assert_eq!(request, json!({"v": 1, "op": "ping", "args": {}})); + } + + #[test] + fn preserves_structured_daemon_errors() { + let (endpoint, server) = server_once( + "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n", + ); + let error = CCCCClient::new(endpoint) + .call("future_op", Map::new()) + .expect_err("daemon error"); + assert!(matches!( + error, + Error::Daemon(crate::DaemonError { ref code, .. }) if code == "unknown_op" + )); + server.join().expect("server thread"); + } + + #[test] + fn never_probes_destructive_or_streaming_operations() { + assert!(operation_probe_is_unsafe("shutdown")); + assert!(operation_probe_is_unsafe("term_attach")); + assert!(!operation_probe_is_unsafe("group_show")); + } +} diff --git a/rust/src/endpoint.rs b/rust/src/endpoint.rs new file mode 100644 index 0000000..961ae2d --- /dev/null +++ b/rust/src/endpoint.rs @@ -0,0 +1,139 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::{Error, Result}; + +/// Transport endpoint advertised by the CCCC daemon. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DaemonEndpoint { + Unix(PathBuf), + Tcp { host: String, port: u16 }, +} + +#[derive(Debug, Deserialize)] +struct EndpointDescriptor { + v: u32, + transport: String, + #[serde(default)] + path: String, + #[serde(default)] + host: String, + #[serde(default)] + port: u16, +} + +/// Discover a daemon endpoint under `cccc_home`, `CCCC_HOME`, or `~/.cccc`. +pub fn discover_endpoint(cccc_home: Option<&Path>) -> Result { + let home = match cccc_home { + Some(path) => path.to_path_buf(), + None => default_cccc_home()?, + }; + let descriptor_path = home.join("daemon").join("ccccd.addr.json"); + + if let Ok(contents) = fs::read_to_string(&descriptor_path) { + let descriptor: EndpointDescriptor = serde_json::from_str(&contents) + .map_err(|error| Error::InvalidEndpoint(error.to_string()))?; + return endpoint_from_descriptor(descriptor); + } + + #[cfg(unix)] + { + let socket = home.join("daemon").join("ccccd.sock"); + if socket.exists() { + return Ok(DaemonEndpoint::Unix(socket)); + } + } + + Err(Error::EndpointNotFound( + descriptor_path.display().to_string(), + )) +} + +fn default_cccc_home() -> Result { + if let Some(path) = env::var_os("CCCC_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + let user_home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .ok_or_else(|| Error::EndpointNotFound("HOME/USERPROFILE is not set".into()))?; + Ok(PathBuf::from(user_home).join(".cccc")) +} + +fn endpoint_from_descriptor(descriptor: EndpointDescriptor) -> Result { + if descriptor.v != 1 { + return Err(Error::InvalidEndpoint(format!( + "descriptor version must be 1, got {}", + descriptor.v + ))); + } + match descriptor.transport.as_str() { + "unix" if !descriptor.path.is_empty() => { + Ok(DaemonEndpoint::Unix(PathBuf::from(descriptor.path))) + } + "tcp" if descriptor.port > 0 => Ok(DaemonEndpoint::Tcp { + host: normalize_tcp_host(&descriptor.host), + port: descriptor.port, + }), + transport => Err(Error::InvalidEndpoint(format!( + "invalid transport or address: {transport}" + ))), + } +} + +fn normalize_tcp_host(host: &str) -> String { + match host.trim() { + "" | "0.0.0.0" | "::" | "[::]" | "localhost" => "127.0.0.1".into(), + value => value.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_home() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + env::temp_dir().join(format!("cccc-sdk-endpoint-{}-{nonce}", std::process::id())) + } + + #[test] + fn discovers_and_normalizes_tcp_descriptor() { + let home = temp_home(); + let daemon = home.join("daemon"); + fs::create_dir_all(&daemon).expect("create daemon dir"); + fs::write( + daemon.join("ccccd.addr.json"), + r#"{"v":1,"transport":"tcp","host":"0.0.0.0","port":43123}"#, + ) + .expect("write descriptor"); + + let endpoint = discover_endpoint(Some(&home)).expect("discover endpoint"); + assert_eq!( + endpoint, + DaemonEndpoint::Tcp { + host: "127.0.0.1".into(), + port: 43123, + } + ); + fs::remove_dir_all(home).expect("cleanup"); + } + + #[test] + fn rejects_unknown_descriptor_versions() { + let result = endpoint_from_descriptor(EndpointDescriptor { + v: 2, + transport: "tcp".into(), + path: String::new(), + host: "127.0.0.1".into(), + port: 43123, + }); + assert!(matches!(result, Err(Error::InvalidEndpoint(_)))); + } +} diff --git a/rust/src/error.rs b/rust/src/error.rs new file mode 100644 index 0000000..54c1371 --- /dev/null +++ b/rust/src/error.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +/// Structured application error returned by the CCCC daemon. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DaemonError { + pub code: String, + pub message: String, + #[serde(default)] + pub details: Value, +} + +impl fmt::Display for DaemonError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for DaemonError {} + +/// Errors produced while discovering, connecting to, or calling CCCC. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("CCCC daemon endpoint was not found: {0}")] + EndpointNotFound(String), + + #[error("invalid CCCC daemon endpoint descriptor: {0}")] + InvalidEndpoint(String), + + #[error("Unix sockets are unavailable on this platform")] + UnixSocketUnsupported, + + #[error("CCCC daemon I/O failed: {0}")] + Io(#[from] std::io::Error), + + #[error("CCCC daemon returned invalid JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("CCCC daemon response exceeded {0} bytes")] + ResponseTooLarge(usize), + + #[error("CCCC daemon request exceeded {0} bytes")] + RequestTooLarge(usize), + + #[error("CCCC daemon closed the connection without a response")] + EmptyResponse, + + #[error("CCCC daemon response used unsupported IPC version {0}")] + UnsupportedIpcVersion(u32), + + #[error("CCCC daemon error {0}")] + Daemon(#[from] DaemonError), + + #[error("incompatible CCCC daemon: {0}")] + Incompatible(String), +} + +pub type Result = std::result::Result; diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..986cc4c --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,15 @@ +//! Official Rust client for CCCC daemon IPC v1. +//! +//! The client discovers the daemon from `CCCC_HOME`, sends one NDJSON request +//! per connection, and exposes both a generic JSON API and focused helpers for +//! common workflows. + +mod client; +mod endpoint; +mod error; +mod protocol; + +pub use client::{CCCCClient, CompatibilityRequirements}; +pub use endpoint::{discover_endpoint, DaemonEndpoint}; +pub use error::{DaemonError, Error, Result}; +pub use protocol::{DaemonRequest, DaemonResponse, PingResult}; diff --git a/rust/src/protocol.rs b/rust/src/protocol.rs new file mode 100644 index 0000000..dd3fc17 --- /dev/null +++ b/rust/src/protocol.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::DaemonError; + +/// Daemon IPC v1 request envelope. +#[derive(Clone, Debug, Serialize)] +pub struct DaemonRequest<'a> { + pub v: u32, + pub op: &'a str, + pub args: &'a Map, +} + +/// Daemon IPC v1 response envelope. +#[derive(Clone, Debug, Deserialize)] +pub struct DaemonResponse { + pub v: u32, + pub ok: bool, + #[serde(default)] + pub result: Map, + #[serde(default)] + pub error: Option, +} + +/// Stable fields returned by `ping`; unknown fields remain available through +/// [`CCCCClient::call`](crate::CCCCClient::call). +#[derive(Clone, Debug, Deserialize)] +pub struct PingResult { + #[serde(default)] + pub version: String, + #[serde(default)] + pub pid: u64, + #[serde(default)] + pub ts: String, + #[serde(default)] + pub ipc_v: u32, + #[serde(default)] + pub capabilities: Map, + #[serde(default)] + pub implementation: Option, + #[serde(default)] + pub compatibility: Option, +}