diff --git a/CLAUDE.md b/CLAUDE.md index f6d8982..4247365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,11 @@ This file provides guidance to AI agents working with code in this repository. ## Project Overview -SealGate's command-line interface. Business logic is written **once** as a typed -async `Command` in the `engine` crate and exposed through the `sealg` binary (and, -later, an MCP transport). The `engine` core has no transport dependency; -transports live in `crates/cli` behind cargo features. +SealGate's command-line interface. `sealg` is a thin MCP client to the SealGate +gateway: `list` and `call` forward `tools/list` / `tools/call` to the per-user +gateway endpoint, where all policy and enforcement live. The `engine` core holds +the gateway client/config plus local `doctor` diagnostics and has no transport +dependency. **Before any other work in this repo, enable prek:** `bun add -g prek && prek install`. Hooks are defined in `prek.toml`. ## Common Commands @@ -13,17 +14,15 @@ transports live in `crates/cli` behind cargo features. ```bash cargo test --workspace # Run Rust tests cargo clippy --workspace --all-targets -- -D warnings -sealg call ping --json # Invoke a command headlessly -make new name=fetch_url # Scaffold a new engine command +sealg list # List the user's tools from the gateway +sealg call --args '{...}' # Call a gateway tool via tools/call ``` ## Architecture -- **crates/engine/** - typed async `Command` registry with `inventory` - self-registration; per-request `Ctx`; capability traits. No transport deps. +- **crates/engine/** - `GatewayConfig` (env-resolved coordinates) + `GatewayClient` + (hand-rolled MCP-over-HTTP client), plus `doctor` env facts. No transport deps. - **crates/cli/** - the `sealg` binary; the `cli` surface is a cargo feature. -- **crates/config/** - crate `app-config`; `AppConfig` (secrets) vs sanitized - `FrontendConfig`. The sanitizer is a security boundary. > **Making backend changes?** Use the `update-backend` skill for architecture details, command patterns, trait implementations, config access, and `sealg` testing workflows. @@ -33,14 +32,16 @@ Enforced by Biome (TS) and `cargo fmt` + Clippy (Rust). See `biome.json`. ## Configuration Pattern -Configuration is handled in Rust. Source of truth: -`crates/config/global_config.yaml` (`.env` / `APP__`-prefixed env overrides; -`APP_CONFIG_PATH` for a deployed binary). - -```rust -let config = app_config::get_config(); -println!("Model: {}", config.default_llm.default_model); -``` +`sealg` is a thin, stateless client: all configuration is resolved from the +**environment** (no config files), so the binary drops into any sandbox. The +gateway coordinates are read once at startup by `GatewayConfig::from_env` +(`crates/engine/src/gateway/config.rs`): `SEALGATE_URL` (gateway origin; +defaults to localhost), `SEALGATE_API_KEY` (optional β€” embedded in the +`/mcp/{key}/` path, else auth is injected upstream), `SEALGATE_SECRET_KEY`, +`SEALGATE_CONVERSATION_ID` (falling back to Centaur's `CENTAUR_THREAD_KEY`), and +a MITM CA bundle from `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS`. +The `--gateway-url` flag on `list`/`call` overrides `SEALGATE_URL` per +invocation (`from_env_with_url_override`). ## Commit Message Convention diff --git a/Cargo.lock b/Cargo.lock index d370792..8375148 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -67,46 +61,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "app-config" -version = "0.1.0" -dependencies = [ - "config", - "serde", - "serde_json", - "serial_test", -] - -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - -[[package]] -name = "assetgen" -version = "0.1.0" -dependencies = [ - "anyhow", - "app-config", - "base64", - "clap", - "image", - "reqwest", - "rustls", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -124,12 +78,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - [[package]] name = "base64" version = "0.22.1" @@ -147,18 +95,6 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] [[package]] name = "bumpalo" @@ -166,18 +102,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytemuck" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -262,78 +186,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "comfy-table" -version = "7.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "config" -version = "0.15.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" -dependencies = [ - "async-trait", - "convert_case", - "json5", - "pathdiff", - "ron", - "rust-ini", - "serde-untagged", - "serde_core", - "serde_json", - "toml", - "winnow", - "yaml-rust2", -] - -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -360,85 +212,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.10.0", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "shell-words", - "tempfile", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -450,36 +223,12 @@ dependencies = [ "syn", ] -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -514,17 +263,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -541,43 +279,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" -[[package]] -name = "flate2" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -602,17 +315,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-sink" version = "0.3.31" @@ -638,16 +340,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -690,36 +382,12 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -942,21 +610,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -964,7 +617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -1036,17 +689,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1071,21 +713,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -1113,16 +740,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.1.1" @@ -1134,16 +751,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1153,15 +760,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -1180,94 +778,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pest_meta" -version = "2.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1280,19 +796,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -1311,15 +814,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - [[package]] name = "quote" version = "1.0.44" @@ -1335,15 +829,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "regex-automata" version = "0.4.13" @@ -1414,30 +899,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ron" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" -dependencies = [ - "bitflags 2.10.0", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1545,15 +1006,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.28" @@ -1587,38 +1039,19 @@ dependencies = [ "syn", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sealg" version = "0.1.0" dependencies = [ - "anyhow", "clap", - "comfy-table", - "dialoguer", "engine", "rustls", "serde", "serde_json", - "serde_yaml", - "tempfile", "tokio", - "toml_edit", "tracing", "tracing-subscriber", "uuid", - "walkdir", ] [[package]] @@ -1654,18 +1087,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -1711,15 +1132,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1733,43 +1145,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "serial_test" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" -dependencies = [ - "futures-executor", - "futures-util", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1779,12 +1154,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -1801,12 +1170,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - [[package]] name = "slab" version = "0.4.11" @@ -1961,15 +1324,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -2030,61 +1384,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.9.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" -dependencies = [ - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -2210,42 +1509,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2299,12 +1568,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" @@ -2417,22 +1680,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -2442,12 +1689,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" @@ -2705,15 +1946,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2726,17 +1958,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "yaml-rust2" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - [[package]] name = "yoke" version = "0.8.1" @@ -2825,18 +2046,3 @@ name = "zmij" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index 528105a..021dd80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/engine", "crates/config", "crates/cli", "crates/assetgen"] +members = ["crates/engine", "crates/cli"] resolver = "2" diff --git a/HUMANS_SHOULD_TEST.md b/HUMANS_SHOULD_TEST.md index ca48db7..bf4e083 100644 --- a/HUMANS_SHOULD_TEST.md +++ b/HUMANS_SHOULD_TEST.md @@ -13,10 +13,8 @@ The release workflow builds `sealg` per platform; the produced binaries can't be fully exercised in CI. - [ ] **Downloaded binary runs** - On each target OS (macOS, Windows, Linux), - download the release archive, extract `sealg`, and run `sealg --help`, - `sealg call ping --json`, and `sealg doctor --json`. Also run `sealg mcp` - and confirm the documented stub behaviour: a stderr "not implemented" notice - and exit code 69. + download the release archive, extract `sealg`, and run `sealg --help` and + `sealg doctor --json` (both work offline, no gateway needed). - [ ] **`serve` binds and shuts down** - Run `sealg serve`, hit `GET /healthz`, then send SIGINT/SIGTERM (Ctrl-C) and confirm it shuts down gracefully without a panic or hung socket. diff --git a/Makefile b/Makefile index 570d9be..a757e81 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ build-release: ## Build the whole workspace (release) ######################################################## ### Initialization -.PHONY: setup init new banner logo +.PHONY: setup setup: ## Set up dev environment from scratch (installs deps, copies .env, checks tooling) @echo "$(BLUE)πŸ”§ Setting up dev environment...$(RESET)" @@ -70,34 +70,6 @@ setup: ## Set up dev environment from scratch (installs deps, copies .env, check fi @echo "$(GREEN)βœ… Setup complete. Run 'cargo run -p sealg -- --help' to get started.$(RESET)" -init: ## Onboard the template into a real project (sealg init). Bare = wizard; PROFILE=/CONFIG=/DRY_RUN=1/ARGS= for headless. - @cargo run -q -p sealg -- init \ - $(if $(PROFILE),--profile $(PROFILE),) \ - $(if $(CONFIG),--config $(CONFIG),) \ - $(if $(DRY_RUN),--dry-run,) \ - $(ARGS) - -new: ## Scaffold a new engine command (usage: make new name=fetch_url [description="..."]) - @if [ -z "$(name)" ]; then \ - echo "$(RED)Error: 'name' is required$(RESET)"; \ - echo "Usage: make new name= [description=\"...\"]"; \ - exit 1; \ - fi - @cargo run -q -p sealg -- new $(name) $(if $(description),--description "$(description)",) - -### Asset Generation -.PHONY: banner logo - -banner: ## Generate project banner image (requires APP__GEMINI_API_KEY) - @echo "$(YELLOW)πŸ”Generating banner...$(RESET)" - @cargo run -p assetgen --bin asset-gen -- banner - @echo "$(GREEN)βœ…Banner generated at media/banner.png$(RESET)" - -logo: ## Generate logo, icons, and favicon (requires APP__GEMINI_API_KEY) - @echo "$(YELLOW)πŸ”Generating logo and favicon...$(RESET)" - @cargo run -p assetgen --bin asset-gen -- logo - @echo "$(GREEN)βœ…Logo assets saved to docs/public/$(RESET)" - ######################################################## @@ -219,7 +191,7 @@ bump-version: ## Bump version across all manifests (usage: make bump-version VER echo "Usage: make bump-version VERSION=x.y.z"; \ exit 1; \ fi - @for f in crates/cli/Cargo.toml crates/engine/Cargo.toml crates/config/Cargo.toml crates/assetgen/Cargo.toml; do \ + @for f in crates/cli/Cargo.toml crates/engine/Cargo.toml; do \ perl -i.bak -0pe 's/^version = "[^"]*"/version = "$(VERSION)"/m' $$f && rm $$f.bak; \ done @jq --arg v "$(VERSION)" '.version = $$v' package.json > /tmp/_package.json && mv /tmp/_package.json package.json diff --git a/README.md b/README.md index 7242c23..5a7c98c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Key Features β€’ Architecture β€’ Quick Start β€’ - Configuration β€’ Agent Skills β€’ Credits

@@ -27,15 +26,15 @@ ## Key Features -Business logic is written **once** as a typed async `Command` in the `engine` -crate and exposed through the `sealg` binary (with an MCP transport planned). +`sealg` is a thin MCP client to the SealGate gateway: `list` and `call` forward +`tools/list` / `tools/call` to the per-user gateway endpoint, where all policy +and enforcement live. | Feature | Tech Stack | |---------|:----------:| -| **Core** | `engine` crate - typed async `Command` registry (no transport deps) | -| **CLI** | `sealg` binary - `call` / `doctor` / `probe` / `run-scenario` | -| **Contract** | `schemars` JSON Schema shared across the CLI and future MCP | -| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) | +| **Core** | `engine` crate - env-resolved gateway config + hand-rolled MCP client (no transport deps) | +| **CLI** | `sealg` binary - `list` / `call` / `doctor` | +| **Transport** | MCP Streamable HTTP to the gateway's `/mcp/{api_key}/` endpoint | | **Logging** | `tracing` + redaction layer | | **Packaging** | `cargo-dist` (binaries + installers) | | **Package Manager** | Bun | @@ -44,34 +43,31 @@ crate and exposed through the `sealg` binary (with an MCP transport planned). ## Architecture ``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ TRANSPORT (crates/cli - one binary `sealg`) β”‚ - β”‚ β”‚ - β”‚ sealg call --args '{...}' one-shot JSON I/O β”‚ - β”‚ sealg doctor | probe | run-scenario β”‚ - β”‚ sealg mcp (stub - planned) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ same registry + typed contract - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ crates/engine - the service core (no transport deps) β”‚ - β”‚ Command trait: Input: JsonSchema + Deserialize β”‚ - β”‚ Output: JsonSchema + Serialize β”‚ - β”‚ CommandRegistry (inventory self-registration) β”‚ - β”‚ Ctx (per-request): fs / network capabilities β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ crates/config (app-config) - AppConfig / FrontendConfig β”‚ - β”‚ YAML + APP__ env overrides + sanitizer β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ sealg list tools/list β”‚ + β”‚ sealg call --args '{...}' tools/call β”‚ + β”‚ sealg doctor local env facts β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ engine::gateway (MCP over HTTP) + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ crates/engine - the service core (no transport deps) β”‚ + β”‚ GatewayConfig - coordinates resolved from the env β”‚ + β”‚ GatewayClient - initialize / tools/list / tools/call β”‚ + β”‚ doctor / types - env facts + stable result contract β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTPS + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SealGate gateway - per-user MCP endpoint; owns ALL β”‚ + β”‚ policy, trifecta, and PII enforcement β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -- `crates/engine/` - all real logic; a typed, async `Command` registry with - self-registration (`inventory`). No transport dependency. -- `crates/cli/` - the `sealg` binary. The `cli` surface is a cargo feature. -- `crates/config/` - `AppConfig` (with secrets) vs the sanitized - `FrontendConfig`. The sanitizer is a security boundary. -- `crates/assetgen/` - `asset-gen` binary for `make banner` / `make logo`. +- `crates/engine/` - the gateway client + config, `doctor` env facts, and the + shared result contract. No transport dependency. +- `crates/cli/` - the `sealg` binary. The `cli` surface (`doctor`) is a cargo + feature. ## Quick Start @@ -80,24 +76,14 @@ crate and exposed through the `sealg` binary (with an MCP transport planned). cargo build --workspace cargo test --workspace -# 2. Call a command headlessly -cargo run -p sealg -- call ping --json -cargo run -p sealg -- call read_file --args '{"path": "/etc/hostname"}' --json -``` - -Scaffold a new command with `make new name=fetch_url` (or `sealg new -fetch_url`) - it self-registers, so it's immediately callable over the CLI. - -## Configuration +# 2. Point at a gateway and drive it (coordinates come from the environment) +export SEALGATE_URL=http://localhost:3000 +cargo run -p sealg -- list +cargo run -p sealg -- call some_tool --args '{"query": "hello"}' -Configuration is handled in Rust. - -- `app_config::get_config()` (full) / `app_config::get_frontend_config()` (sanitized). - -### Environment Variables -Prefix variables with `APP__` to override YAML settings (e.g., -`APP__MODEL_NAME=gpt-4`). Point a deployed binary at its config file with -`APP_CONFIG_PATH`. +# ...or override the gateway per-invocation +cargo run -p sealg -- list --gateway-url https://dashboard.sealgate.ai +``` ## Agent Skills diff --git a/crates/assetgen/Cargo.toml b/crates/assetgen/Cargo.toml deleted file mode 100644 index 4da7575..0000000 --- a/crates/assetgen/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "assetgen" -version = "0.1.0" -edition = "2021" -description = "Gemini-backed logo/banner asset generator (dev tooling)" -license = "MIT" -publish = false - -[[bin]] -name = "asset-gen" -path = "src/main.rs" - -[dependencies] -app-config = { path = "../config" } -anyhow = "1.0" -base64 = "0.22" -clap = { version = "4", features = ["derive"] } -image = { version = "0.25", default-features = false, features = ["png", "ico", "jpeg"] } -reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "http2", "charset", "system-proxy"] } -rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "fs"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/assetgen/src/main.rs b/crates/assetgen/src/main.rs deleted file mode 100644 index bb6a9cf..0000000 --- a/crates/assetgen/src/main.rs +++ /dev/null @@ -1,627 +0,0 @@ -use std::fs::File; -use std::io::Cursor; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, Context, Result}; -use app_config as config; -use base64::{engine::general_purpose, Engine as _}; -use clap::{Parser, Subcommand}; -use image::codecs::ico::IcoEncoder; -use image::codecs::png::PngEncoder; -use image::imageops::{invert, resize, FilterType}; -use image::ImageEncoder; -use image::{ColorType, DynamicImage, GenericImage, ImageBuffer, Rgba, RgbaImage}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tracing::{error, info, warn}; - -/// Minimal standalone logging for this dev tool (the engine/server crates own -/// their own logging setup). -fn init_logging() { - let _ = tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_writer(std::io::stderr) - .try_init(); -} - -const IMAGE_MODEL: &str = "gemini-3-pro-image-preview"; -const IMAGE_PROMPT_STYLE: &str = "Create a minimalist, modern horizontal wordmark logo (4:1 aspect) with an icon on the left and clear text on the right. Use dark tones, clean typography, and avoid photorealism. The background should be bright lime green (#00FF00) to act as a greenscreen, but keep the logo colors distinct and readable."; -const ICON_EXTRACTION_PROMPT: &str = "Remove ALL TEXT from this image. Keep ONLY the icon/symbol from the left side, center it in a square 1:1 aspect ratio, and preserve the BRIGHT LIME GREEN (#00FF00) background exactly as it appears. Do not tweak the icon colors, just remove the text and center the symbol."; -const BANNER_STYLE_PROMPT: &str = "Style the image in a Japanese minimalist sumi-e ink wash style with monochrome tones, fluid brushstrokes, and thoughtful negative space. Use a wide 16:9 composition, keep the view horizontal, and make the banner the dominant focal point with legible text centered at the top."; - -#[derive(Parser)] -#[command(author, version, about = "Legacy asset generator replacement", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Generate the project logo, icons, and favicon - Logo { - /// Project name used in prompts - #[arg(long)] - project_name: Option, - /// Optional creative suggestion for the wordmark - #[arg(long)] - suggestion: Option, - /// Where to write assets (defaults to docs/public) - #[arg(long)] - output_dir: Option, - }, - /// Generate the hero banner image - Banner { - /// Title/text that belongs on the banner - #[arg(long)] - title: Option, - /// Optional guiding suggestion for the description - #[arg(long)] - suggestion: Option, - /// Output directory for the banner (defaults to media/) - #[arg(long)] - output_dir: Option, - /// Path to an icon/logo image to incorporate into the banner. - /// If omitted, falls back to docs/public/icon-light.png when it exists. - #[arg(long)] - icon: Option, - }, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Install ring as the rustls crypto provider (reqwest needs this with rustls-no-provider) - let _ = rustls::crypto::ring::default_provider().install_default(); - - init_logging(); - let cli = Cli::parse(); - let client = GeminiClient::new()?; - - match cli.command { - Command::Logo { - project_name, - suggestion, - output_dir, - } => run_logo(project_name, suggestion, output_dir, client).await, - Command::Banner { - title, - suggestion, - output_dir, - icon, - } => run_banner(title, suggestion, output_dir, icon, client).await, - } -} - -async fn run_logo( - project_name: Option, - suggestion: Option, - output_dir: Option, - client: GeminiClient, -) -> Result<()> { - let workspace = workspace_root()?; - let project_name = match project_name { - Some(name) => name, - None => read_project_name(&workspace) - .await - .unwrap_or_else(|_| "SealGate".into()), - }; - let target = output_dir.unwrap_or_else(|| workspace.join("docs").join("public")); - tokio::fs::create_dir_all(&target) - .await - .context("Failed to create output directory")?; - - info!("Generating wordmark for {}...", project_name); - let description = client - .generate_text_description(&project_name, suggestion.as_deref()) - .await - .context("Failed to describe the wordmark")?; - - let prompt = format!( - "{description}. Create a HORIZONTAL 4:1 wordmark logo (3200x800) that includes the text '{project_name}'. {IMAGE_PROMPT_STYLE} Use DARK colors to match a light mode header, keep the icon on the left, and ensure the lime-green background exists only to support chroma-keying.", - ); - - let mut light_image = client - .generate_image(IMAGE_MODEL, &prompt) - .await - .context("Failed to generate light mode wordmark")? - .to_rgba8(); - let icon_reference = light_image.clone(); - info!("Extracting icon from wordmark..."); - let icon_prompt = format!( - "{ICON_EXTRACTION_PROMPT} Remove the text '{project_name}' and keep only the icon." - ); - let mut icon_light = client - .generate_image_from_reference(IMAGE_MODEL, &icon_prompt, &icon_reference) - .await - .context("Failed to extract icon")? - .to_rgba8(); - - remove_greenscreen(&mut light_image, 60); - save_png(&light_image, &target.join("logo-light.png"))?; - info!( - "Saved light wordmark at {}", - target.join("logo-light.png").display() - ); - remove_greenscreen(&mut icon_light, 60); - - let mut dark_wordmark = light_image.clone(); - invert(&mut dark_wordmark); - save_png(&dark_wordmark, &target.join("logo-dark.png"))?; - info!( - "Saved dark wordmark at {}", - target.join("logo-dark.png").display() - ); - - let mut icon_dark = icon_light.clone(); - invert(&mut icon_dark); - - let icon_light_square = ensure_square(&icon_light)?; - let icon_dark_square = ensure_square(&icon_dark)?; - - let icon_light_512 = resize(&icon_light_square, 512, 512, FilterType::Lanczos3); - let icon_dark_512 = resize(&icon_dark_square, 512, 512, FilterType::Lanczos3); - let favicon_32 = resize(&icon_light_square, 32, 32, FilterType::Lanczos3); - - save_png(&icon_light_512, &target.join("icon-light.png"))?; - save_png(&icon_dark_512, &target.join("icon-dark.png"))?; - save_ico(&favicon_32, &target.join("favicon.ico"))?; - - // Also emit a 1024x1024 source icon (useful for docs / social cards). - let icon_1024 = resize(&icon_light_square, 1024, 1024, FilterType::Lanczos3); - save_png(&icon_1024, &target.join("icon-1024.png"))?; - - info!("Logo assets saved to {}", target.display()); - Ok(()) -} - -async fn run_banner( - title: Option, - suggestion: Option, - output_dir: Option, - icon: Option, - client: GeminiClient, -) -> Result<()> { - let workspace = workspace_root()?; - let title = match title { - Some(t) => t, - None => read_project_name(&workspace) - .await - .unwrap_or_else(|_| "SealGate".into()), - }; - let target = output_dir.unwrap_or_else(|| workspace.join("media")); - tokio::fs::create_dir_all(&target) - .await - .context("Failed to create banner output directory")?; - - // Try to load an icon image: explicit --icon flag, or fall back to docs/public/icon-light.png - let explicit_icon = icon.is_some(); - let icon_path = icon.or_else(|| { - let default = workspace.join("docs").join("public").join("icon-light.png"); - default.exists().then_some(default) - }); - let icon_image = match &icon_path { - Some(p) => { - info!("Using icon from {}", p.display()); - match image::open(p) { - Ok(img) => Some(img.to_rgba8()), - Err(e) if !explicit_icon => { - // Auto-detected path failed - degrade gracefully - warn!( - "Failed to load auto-detected icon at {}: {e}, continuing without reference", - p.display() - ); - None - } - Err(e) => { - return Err(anyhow::Error::from(e)) - .with_context(|| format!("Failed to load icon at {}", p.display())); - } - } - } - None => { - info!("No icon found, generating banner without logo reference"); - None - } - }; - - let banner_description = client - .generate_banner_description(&title, suggestion.as_deref()) - .await - .context("Failed to describe banner")?; - - let banner = if let Some(ref icon_img) = icon_image { - let full_prompt = format!( - "{banner_description}. Create a WIDE 16:9 horizontal image where the banner takes up 80% of the screen and the text '{title}' is centered at the top with excellent contrast. {BANNER_STYLE_PROMPT} IMPORTANT: Use the provided icon/logo as the main visual element in the banner - do NOT use a default placeholder icon. Incorporate this exact icon prominently in the composition.", - ); - client - .generate_image_from_reference(IMAGE_MODEL, &full_prompt, icon_img) - .await - .context("Failed to generate banner with icon reference")? - } else { - let full_prompt = format!( - "{banner_description}. Create a WIDE 16:9 horizontal image where the banner takes up 80% of the screen and the text '{title}' is centered at the top with excellent contrast. {BANNER_STYLE_PROMPT}", - ); - client - .generate_image(IMAGE_MODEL, &full_prompt) - .await - .context("Failed to generate banner")? - }; - - let banner_path = target.join("banner.png"); - banner - .save(&banner_path) - .context("Failed to write banner image")?; - - info!("Banner saved to {}", banner_path.display()); - Ok(()) -} - -fn save_png(image: &RgbaImage, path: &Path) -> Result<()> { - image - .save(path) - .with_context(|| format!("Failed to save PNG at {}", path.display())) -} - -fn save_ico(image: &RgbaImage, path: &Path) -> Result<()> { - let file = File::create(path) - .with_context(|| format!("Failed to open ICO file at {}", path.display()))?; - let encoder = IcoEncoder::new(file); - encoder - .write_image( - image.as_raw(), - image.width(), - image.height(), - ColorType::Rgba8.into(), - ) - .with_context(|| format!("Failed to write ICO at {}", path.display())) -} - -fn remove_greenscreen(image: &mut RgbaImage, tolerance: i32) { - for pixel in image.pixels_mut() { - let [r, mut g, b, mut a] = pixel.0; - let tolerance_f = tolerance as f32; - let r_f = r as f32; - let g_f = g as f32; - let b_f = b as f32; - - let green_high = g_f > 180.0; - let green_dominant = g_f > r_f + tolerance_f + 20.0 && g_f > b_f + tolerance_f + 20.0; - if green_high && green_dominant { - a = 0; - } - - let visible = a > 128; - let has_green_tint = g_f > r_f + 20.0 && g_f > b_f + 20.0; - if visible && has_green_tint { - let avg_rb = (r_f + b_f) / 2.0; - let new_g = (g_f * 0.6).min(avg_rb); - g = new_g.clamp(0.0, 255.0) as u8; - } - - pixel.0 = [r, g, b, a]; - } -} - -fn ensure_square(image: &RgbaImage) -> Result { - let size = image.width().max(image.height()); - let mut square = ImageBuffer::from_pixel(size, size, Rgba([255, 255, 255, 0])); - let offset_x = (size - image.width()) / 2; - let offset_y = (size - image.height()) / 2; - square - .copy_from(image, offset_x, offset_y) - .with_context(|| "Failed to center image in square canvas")?; - Ok(square) -} - -fn workspace_root() -> Result { - // This crate lives at `/crates/assetgen`, so the workspace root - // is two directories up from the manifest dir. - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - Path::new(manifest_dir) - .ancestors() - .nth(2) - .map(Path::to_path_buf) - .ok_or_else(|| anyhow!("Unable to determine workspace root")) -} - -async fn read_project_name(workspace: &Path) -> Result { - let package_json = workspace.join("package.json"); - let data = tokio::fs::read_to_string(&package_json) - .await - .with_context(|| format!("Failed to read {}", package_json.display()))?; - let json: Value = serde_json::from_str(&data).context("Invalid package.json")?; - json.get("name") - .and_then(|value| value.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("package.json does not declare a name")) -} - -struct GeminiClient { - http: Client, - api_key: String, - text_model: String, -} - -impl GeminiClient { - fn new() -> Result { - let cfg = config::get_config(); - let api_key = cfg - .gemini_api_key() - .ok_or_else(|| anyhow!("Missing APP__GEMINI_API_KEY"))? - .to_string(); - // Strip provider prefix (e.g. "gemini/gemini-3-flash-preview" -> "gemini-3-flash-preview") - let text_model = cfg - .model_name - .rsplit_once('/') - .map(|(_, name): (&str, &str)| name.to_string()) - .unwrap_or_else(|| cfg.model_name.clone()); - Ok(Self { - http: Client::new(), - api_key, - text_model, - }) - } - - async fn generate_text_description( - &self, - title: &str, - suggestion: Option<&str>, - ) -> Result { - let prompt = format!( - "Create a concise, creative description of a modern horizontal wordmark for '{title}'. {}", - suggestion.unwrap_or(""), - ); - self.generate_text(&self.text_model, &prompt).await - } - - async fn generate_banner_description( - &self, - title: &str, - suggestion: Option<&str>, - ) -> Result { - let prompt = format!( - "Describe a Japanese-style banner featuring the text '{title}'. {}", - suggestion.unwrap_or(""), - ); - self.generate_text(&self.text_model, &prompt).await - } - - async fn generate_text(&self, model: &str, prompt: &str) -> Result { - let request = GenerateContentRequest::new_text(prompt); - let response = self.send_request(model, &request).await?; - extract_text(&response).ok_or_else(|| anyhow!("No text returned from Gemini")) - } - - async fn generate_image(&self, model: &str, prompt: &str) -> Result { - let request = GenerateContentRequest::new_image(prompt); - let response = self.send_request(model, &request).await?; - extract_first_image(&response).ok_or_else(|| anyhow!("No image returned from Gemini")) - } - - async fn generate_image_from_reference( - &self, - model: &str, - prompt: &str, - reference: &RgbaImage, - ) -> Result { - let inline = inline_image_from_rgba(reference)?; - let request = GenerateContentRequest::new_image_with_ref(prompt, inline); - let response = self.send_request(model, &request).await?; - extract_first_image(&response) - .ok_or_else(|| anyhow!("No inline image returned from Gemini")) - } - - async fn send_request( - &self, - model: &str, - payload: &GenerateContentRequest, - ) -> Result { - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" - ); - let response = self - .http - .post(&url) - .header("x-goog-api-key", &self.api_key) - .json(payload) - .send() - .await - .context("Failed to reach Gemini API")?; - - let status = response.status(); - if !status.is_success() { - let body: String = response.text().await.unwrap_or_default(); - error!("Gemini returned {}: {}", status, body); - return Err(anyhow!("Gemini request failed")); - } - - response - .json::() - .await - .context("Failed to decode Gemini response") - } -} - -fn inline_image_from_rgba(image: &RgbaImage) -> Result { - let mut buffer = Vec::new(); - PngEncoder::new(Cursor::new(&mut buffer)) - .write_image( - image.as_raw(), - image.width(), - image.height(), - ColorType::Rgba8.into(), - ) - .context("Failed to encode reference image")?; - Ok(InlineImage { - mime_type: "image/png".into(), - data: general_purpose::STANDARD.encode(&buffer), - }) -} - -fn extract_text(response: &GenerateContentResponse) -> Option { - response - .candidates - .iter() - .flat_map(|candidate| candidate.content.parts.iter()) - .filter_map(|part| part.text.clone()) - .next() -} - -fn extract_first_image(response: &GenerateContentResponse) -> Option { - for candidate in &response.candidates { - for part in &candidate.content.parts { - if let Some(data) = &part.inline_data { - if data.mime_type.starts_with("image/") { - if let Ok(bytes) = general_purpose::STANDARD.decode(&data.data) { - if let Ok(img) = image::load_from_memory(&bytes) { - return Some(img); - } - } - } - } - } - } - None -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GenerateContentRequest { - contents: Vec, - generation_config: GenerationConfig, -} - -impl GenerateContentRequest { - fn new_text(prompt: &str) -> Self { - Self { - contents: vec![RequestContent { - parts: vec![RequestPart::Text { - text: prompt.into(), - }], - }], - generation_config: GenerationConfig { - response_modalities: vec!["TEXT".into()], - }, - } - } - - fn new_image(prompt: &str) -> Self { - Self { - contents: vec![RequestContent { - parts: vec![RequestPart::Text { - text: prompt.into(), - }], - }], - generation_config: GenerationConfig { - response_modalities: vec!["IMAGE".into(), "TEXT".into()], - }, - } - } - - fn new_image_with_ref(prompt: &str, inline: InlineImage) -> Self { - Self { - contents: vec![RequestContent { - parts: vec![ - RequestPart::Text { - text: prompt.into(), - }, - RequestPart::InlineData { - inline_data: inline, - }, - ], - }], - generation_config: GenerationConfig { - response_modalities: vec!["IMAGE".into(), "TEXT".into()], - }, - } - } -} - -#[derive(Serialize)] -struct RequestContent { - parts: Vec, -} - -#[derive(Serialize)] -#[serde(untagged)] -enum RequestPart { - Text { - text: String, - }, - InlineData { - #[serde(rename = "inlineData")] - inline_data: InlineImage, - }, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GenerationConfig { - response_modalities: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InlineImage { - mime_type: String, - data: String, -} - -#[derive(Deserialize)] -struct GenerateContentResponse { - candidates: Vec, -} - -#[derive(Deserialize)] -struct Candidate { - content: Content, -} - -#[derive(Deserialize)] -struct Content { - parts: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ContentPart { - text: Option, - inline_data: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct InlineData { - mime_type: String, - data: String, -} - -// No additional test coverage needed: this is a disposable asset generation script, -// not core application logic. It is run manually/ad-hoc and its outputs are visually -// verified. The minimal smoke tests below guard against obvious regressions in the -// pure image-processing helpers. -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - - #[test] - fn remove_greenscreen_hides_green_pixel() { - let mut image = ImageBuffer::from_pixel(1, 1, Rgba([0, 255, 0, 255])); - remove_greenscreen(&mut image, 60); - assert_eq!(image.get_pixel(0, 0)[3], 0); - } - - #[test] - fn ensure_square_adds_padding() -> Result<()> { - let image = ImageBuffer::from_pixel(10, 20, Rgba([1, 2, 3, 4])); - let square = ensure_square(&image)?; - assert_eq!(square.width(), square.height()); - assert!(square.width() >= image.height()); - Ok(()) - } -} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5b697b4..94347fc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,16 +20,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "tls12 clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -serde_yaml = "0.9" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "net", "io-util", "signal"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1", features = ["v4"] } -dialoguer = "0.12.0" -walkdir = "2" -toml_edit = "0.22" -comfy-table = "7" -anyhow = "1" - -[dev-dependencies] -tempfile = "3.27.0" diff --git a/crates/cli/README.md b/crates/cli/README.md index b33acc3..1d76e0a 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -1,8 +1,8 @@ # sealg – SealGate CLI -The `sealg` binary drives the shared `engine` command registry over the CLI -(`call` / `probe` / `doctor` / `run-scenario`). `new` scaffolds a command, and -`mcp` is a stub for the future MCP transport. +`sealg` is a thin MCP client to the SealGate gateway. `list` and `call` forward +`tools/list` / `tools/call` to the per-user gateway endpoint, where the gateway +applies all policy and enforcement. `doctor` reports local environment facts. ## Build @@ -11,120 +11,59 @@ cargo build -p sealg # Binary at target/debug/sealg (or target/release/sealg with --release) ``` -## Commands - -### doctor +## Gateway configuration -Collect environment facts (OS, kernel, headless detection, proxy vars). - -```bash -# Human-readable -sealg doctor +The gateway coordinates come from the environment so the binary stays stateless: -# JSON output -sealg doctor --json +- `SEALGATE_URL` – gateway origin (default `http://localhost:3000`). Overridable + per-invocation with `--gateway-url`. +- `SEALGATE_API_KEY` – API key; embedded in the `/mcp/{key}/` path when set, + otherwise auth is expected from an upstream injecting proxy. +- `SEALGATE_SECRET_KEY` – zero-knowledge secret key, sent as the + `sealgate_secret_key` header. +- `SEALGATE_CONVERSATION_ID` (or `CENTAUR_THREAD_KEY`) – stable conversation id. -# Write result to file -sealg doctor --json --out /tmp/env.json -``` +## Commands -### call +### list -Invoke a backend command by name with JSON arguments. +List the user's tools from the live gateway (`tools/list`). ```bash -# Ping (prove wiring works) -sealg call ping --json - -# Read a file -sealg call read_file --args '{"path": "/etc/hostname"}' --json - -# Write a file -sealg call write_file --args '{"path": "/tmp/test.txt", "content": "hello"}' --json - -# With artifacts directory -sealg call ping --json --artifacts /tmp/artifacts +sealg list +sealg list --json +sealg list --gateway-url https://dashboard.sealgate.ai ``` -### probe - -Targeted capability checks. - -```bash -# Filesystem probe (create/read/write/delete in temp dir) -sealg probe filesystem --json - -# Network probe (DNS resolve + HTTPS GET) -sealg probe network --json -``` +### call -### run-scenario - -Execute a scripted scenario from a YAML file. - -```yaml -# scenario.yaml -name: basic smoke test -steps: - - call: "ping" - args: {} - expect_status: "pass" - - call: "write_file" - args: - path: "/tmp/scenario_test.txt" - content: "written by scenario" - expect_status: "pass" - - call: "read_file" - args: - path: "/tmp/scenario_test.txt" - expect_status: "pass" - - probe: "filesystem" -``` +Call a tool on the live gateway (`tools/call`). The MCP result is pretty-printed; +a result with `isError: true` exits with code `6`. ```bash -sealg run-scenario scenario.yaml --json -sealg run-scenario scenario.yaml --artifacts /tmp/artifacts +sealg call some_tool +sealg call some_tool --args '{"query": "hello"}' +sealg call some_tool --args '{...}' --gateway-url https://dashboard.sealgate.ai ``` -### mcp - -Stub for the future MCP transport - prints a notice and exits (`EX_UNAVAILABLE`). - -## Output Contract - -The CLI wraps command output in a diagnostic envelope with this stable JSON -schema: - -```json -{ - "run_id": "uuid", - "command": "call|probe|doctor|run-scenario", - "target": "", - "status": "pass|fail|skip|error", - "error": { "code": "ERROR_CODE", "message": "..." }, - "timing_ms": { "total": 1234, "steps": { "init": 10, "work": 1200 } }, - "artifacts": [], - "env_summary": { "os": "linux|macos", "arch": "x86_64|aarch64", "headless": true }, - "data": {} -} -``` +### doctor -Error codes: `INVALID_INPUT`, `UNSUPPORTED`, `UNIMPLEMENTED`, `DEPENDENCY_MISSING`, -`PERMISSION_DENIED`, `NETWORK_ERROR`, `IO_ERROR`, `TIMEOUT`, `EXTERNAL_INTERFERENCE`, -`INTERNAL_ERROR`. +Collect local environment facts (OS, kernel, headless detection, proxy vars). -## Artifacts +```bash +# Human-readable +sealg doctor -When `--artifacts ` is provided, the CLI writes: +# JSON output +sealg doctor --json -``` -// - result.json # Full result object - events.jsonl # JSON Lines log of events +# Write result to file +sealg doctor --json --out /tmp/env.json ``` ## Exit Codes -- `0` -- pass or skip -- `1` -- fail -- `2` -- error +- `0` -- success +- `1` -- client/transport failure (bad args, connection error) +- `2` -- clap usage error +- `6` -- the gateway tool call returned an MCP error (`isError: true`) diff --git a/crates/cli/examples/smoke_test.yaml b/crates/cli/examples/smoke_test.yaml deleted file mode 100644 index 39862d9..0000000 --- a/crates/cli/examples/smoke_test.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: smoke test -steps: - - call: "ping" - args: {} - expect_status: "pass" - timeout_ms: 5000 - - - call: "write_file" - args: - path: "/tmp/sealg_smoke_test.txt" - content: "written by sealg scenario runner" - expect_status: "pass" - timeout_ms: 5000 - - - call: "read_file" - args: - path: "/tmp/sealg_smoke_test.txt" - expect_status: "pass" - - - probe: "filesystem" diff --git a/crates/cli/src/diagnostics.rs b/crates/cli/src/diagnostics.rs index 95e4686..114e295 100644 --- a/crates/cli/src/diagnostics.rs +++ b/crates/cli/src/diagnostics.rs @@ -1,16 +1,13 @@ -//! CLI diagnostic subcommand implementations (`doctor`, `call`, `probe`, -//! `run-scenario`) and their human/JSON output + artifact helpers. +//! CLI `doctor` subcommand and its human/JSON output helpers. //! //! Split out of `main.rs` so the entrypoint stays focused on clap wiring. The //! whole module is gated behind the `cli` feature, so a `server-only` prune //! drops it wholesale. -use engine::types::*; -use engine::{AppContext, CommandRegistry, CommandResult, Ctx}; +use engine::types::{CommandResult, Status}; use std::path::PathBuf; -use std::time::{Duration, Instant}; -// Subcommand implementations (CLI diagnostics) +// Subcommand implementation pub(crate) async fn cmd_doctor(json: bool, out: Option) { let result = engine::doctor::run_doctor(); @@ -20,267 +17,6 @@ pub(crate) async fn cmd_doctor(json: bool, out: Option) { output_result(&result, json); } -/// Parse a human timeout string (`"30s"`, `"5000ms"`, `"2m"`, or a bare number -/// of seconds) into a [`Duration`]. The `ms` suffix is checked before `s` so -/// `"500ms"` isn't misread as seconds. -fn parse_timeout(s: &str) -> Result { - let s = s.trim(); - let invalid = || format!("invalid timeout '{s}' (use e.g. '30s', '500ms', '2m')"); - // `try_from_secs_f64` (not `from_secs_f64`) so a negative, non-finite, or - // overflowing value yields an `Err` rather than panicking the process. - if let Some(ms) = s.strip_suffix("ms") { - ms.trim() - .parse::() - .map(Duration::from_millis) - .map_err(|_| invalid()) - } else if let Some(sec) = s.strip_suffix('s') { - let v: f64 = sec.trim().parse().map_err(|_| invalid())?; - Duration::try_from_secs_f64(v).map_err(|_| invalid()) - } else if let Some(min) = s.strip_suffix('m') { - let v: f64 = min.trim().parse().map_err(|_| invalid())?; - Duration::try_from_secs_f64(v * 60.0).map_err(|_| invalid()) - } else { - s.parse::() - .map(Duration::from_secs) - .map_err(|_| invalid()) - } -} - -pub(crate) async fn cmd_call( - cmd: &str, - args_str: &str, - json: bool, - timeout: Option, - artifacts: Option, - ctx: &AppContext, - registry: &CommandRegistry, -) { - let args: serde_json::Value = match serde_json::from_str(args_str) { - Ok(v) => v, - Err(e) => { - let r = result_err( - "call", - cmd, - &new_run_id(), - 0, - ErrorCode::InvalidInput, - format!("invalid JSON args: {}", e), - ); - output_result(&r, json); - return; - } - }; - - let timeout_dur = match timeout { - Some(ref s) => match parse_timeout(s) { - Ok(d) => Some(d), - Err(msg) => { - let r = result_err("call", cmd, &new_run_id(), 0, ErrorCode::InvalidInput, msg); - output_result(&r, json); - return; - } - }, - None => None, - }; - - let mut cx = Ctx::new(ctx); - let started = Instant::now(); - if let Some(dur) = timeout_dur { - cx = cx.with_deadline(started + dur); - } - let run_id = cx.request_id.clone(); - - let result = match timeout_dur { - Some(dur) => match tokio::time::timeout(dur, registry.execute(cmd, args, &cx)).await { - Ok(r) => r, - Err(_) => result_err( - "call", - cmd, - &run_id, - started.elapsed().as_millis() as u64, - ErrorCode::Timeout, - format!( - "command '{cmd}' timed out after {}", - timeout.unwrap_or_default() - ), - ), - }, - None => registry.execute(cmd, args, &cx).await, - }; - - if let Some(ref dir) = artifacts { - write_artifacts(dir, &result); - } - output_result(&result, json); -} - -pub(crate) async fn cmd_probe( - target: &str, - json: bool, - artifacts: Option, - ctx: &AppContext, -) { - let result = engine::probes::run_probe(target, ctx).await; - if let Some(ref dir) = artifacts { - write_artifacts(dir, &result); - } - output_result(&result, json); -} - -pub(crate) async fn cmd_run_scenario( - file: &PathBuf, - json: bool, - interactive: bool, - artifacts: Option, - ctx: &AppContext, - registry: &CommandRegistry, -) { - let yaml = match std::fs::read_to_string(file) { - Ok(s) => s, - Err(e) => { - let r = result_err( - "run-scenario", - &file.display().to_string(), - &new_run_id(), - 0, - ErrorCode::IoError, - format!("cannot read scenario file: {}", e), - ); - output_result(&r, json); - return; - } - }; - - let scenario = match engine::scenario::load_scenario(&yaml) { - Ok(s) => s, - Err(e) => { - let r = result_err( - "run-scenario", - &file.display().to_string(), - &new_run_id(), - 0, - ErrorCode::InvalidInput, - e, - ); - output_result(&r, json); - return; - } - }; - - let scenario_result = if interactive { - if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { - eprintln!("error: --interactive requires a TTY (stdin is not a terminal)"); - std::process::exit(1); - } - engine::scenario::run_scenario_interactive( - &scenario, - ctx, - registry, - |idx, total, label, can_go_back| { - use engine::scenario::StepChoice; - - // block_in_place tells Tokio this closure will block on TTY I/O, - // so it can move async tasks off this worker thread. - tokio::task::block_in_place(|| { - eprintln!("\n--- Step {}/{}: {} ---", idx + 1, total, label); - - let mut choices = vec!["Run", "Skip"]; - if can_go_back { - choices.push("\u{2190} Go back"); - } - - let selection = match dialoguer::Select::new() - .with_prompt("Run this step?") - .items(&choices) - .default(0) - .interact_opt() - { - Ok(Some(s)) => s, - Ok(None) => return None, - Err(e) => { - eprintln!("error: interactive prompt failed: {e}"); - return None; - } - }; - - Some(match choices[selection] { - "Run" => StepChoice::Run, - "Skip" => StepChoice::Skip, - _ => StepChoice::GoBack, - }) - }) - }, - |idx, total, label| { - use engine::scenario::FailureChoice; - - tokio::task::block_in_place(|| { - eprintln!("\n--- Step {}/{}: {} FAILED ---", idx + 1, total, label); - - let choices = ["Continue to next step", "Abort scenario"]; - let selection = match dialoguer::Select::new() - .with_prompt("Step failed. What would you like to do?") - .items(choices) - .default(0) - .interact_opt() - { - Ok(Some(s)) => s, - Ok(None) => return None, - Err(e) => { - eprintln!("error: interactive prompt failed: {e}"); - return None; - } - }; - - Some(match choices[selection] { - "Continue to next step" => FailureChoice::Continue, - _ => FailureChoice::Abort, - }) - }) - }, - ) - .await - } else { - engine::scenario::run_scenario(&scenario, ctx, registry).await - }; - - if json { - let j = serde_json::to_string_pretty(&scenario_result).unwrap_or_default(); - println!("{}", j); - } else { - println!( - "Scenario: {}", - scenario_result.name.as_deref().unwrap_or("") - ); - println!("Overall: {:?}", scenario_result.overall_status); - for (i, sr) in scenario_result.step_results.iter().enumerate() { - println!( - " Step {}: {} -> {:?} ({}ms)", - i, sr.target, sr.status, sr.timing_ms.total - ); - } - } - - if let Some(ref dir) = artifacts { - let run_id = new_run_id(); - let art_dir = dir.join(&run_id); - let _ = std::fs::create_dir_all(&art_dir); - let result_path = art_dir.join("result.json"); - let j = serde_json::to_string_pretty(&scenario_result).unwrap_or_default(); - let _ = std::fs::write(&result_path, j); - - // Write per-step results as events.jsonl - let events_path = art_dir.join("events.jsonl"); - let mut lines = String::new(); - for sr in &scenario_result.step_results { - if let Ok(line) = serde_json::to_string(sr) { - lines.push_str(&line); - lines.push('\n'); - } - } - let _ = std::fs::write(&events_path, lines); - } -} - // Output helpers fn output_result(result: &CommandResult, json: bool) { @@ -349,66 +85,3 @@ fn write_result_file(path: &std::path::Path, result: &CommandResult) { ); } } - -fn write_artifacts(dir: &std::path::Path, result: &CommandResult) { - let art_dir = dir.join(&result.run_id); - if let Err(e) = std::fs::create_dir_all(&art_dir) { - eprintln!( - "warning: failed to create artifacts dir {}: {}", - art_dir.display(), - e - ); - return; - } - - // result.json - let result_path = art_dir.join("result.json"); - let j = serde_json::to_string_pretty(result).unwrap_or_default(); - let _ = std::fs::write(&result_path, &j); - - // events.jsonl (single event for non-scenario) - let events_path = art_dir.join("events.jsonl"); - if let Ok(line) = serde_json::to_string(result) { - let _ = std::fs::write(&events_path, format!("{}\n", line)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_timeout_units() { - assert_eq!(parse_timeout("30s"), Ok(Duration::from_secs(30))); - assert_eq!(parse_timeout("2m"), Ok(Duration::from_secs(120))); - assert_eq!(parse_timeout("500ms"), Ok(Duration::from_millis(500))); - assert_eq!(parse_timeout("1.5s"), Ok(Duration::from_millis(1500))); - // bare number = seconds; surrounding whitespace tolerated - assert_eq!(parse_timeout(" 10 "), Ok(Duration::from_secs(10))); - } - - #[test] - fn parse_timeout_ms_takes_precedence_over_s() { - // "500ms" ends in 's' too - must be read as milliseconds, not seconds. - assert_eq!(parse_timeout("5000ms"), Ok(Duration::from_millis(5000))); - } - - #[test] - fn parse_timeout_rejects_garbage() { - assert!(parse_timeout("bogus").is_err()); - assert!(parse_timeout("").is_err()); - assert!(parse_timeout("s").is_err()); - assert!(parse_timeout("12x").is_err()); - } - - #[test] - fn parse_timeout_rejects_negative_and_nonfinite() { - // These parse as valid f64 but must NOT reach Duration::from_secs_f64, - // which panics on negative / non-finite / overflowing input. - assert!(parse_timeout("-5s").is_err()); - assert!(parse_timeout("-1m").is_err()); - assert!(parse_timeout("NaNs").is_err()); - assert!(parse_timeout("infs").is_err()); - assert!(parse_timeout("1e400s").is_err()); // parses to f64::INFINITY - } -} diff --git a/crates/cli/src/gateway_cmd.rs b/crates/cli/src/gateway_cmd.rs index e5cfedb..6cedff7 100644 --- a/crates/cli/src/gateway_cmd.rs +++ b/crates/cli/src/gateway_cmd.rs @@ -18,16 +18,16 @@ const TIMEOUT: Duration = Duration::from_secs(30); /// caller can tell a failed tool call apart from a CLI or connection failure. const EXIT_TOOL_ERROR: i32 = 6; -/// `sealg list [--json]` β€” list the user's gateway tools. -pub async fn cmd_list(json_out: bool) { - if let Err(e) = run_list(json_out).await { +/// `sealg list [--json] [--gateway-url ]` β€” list the user's gateway tools. +pub async fn cmd_list(json_out: bool, gateway_url: Option) { + if let Err(e) = run_list(json_out, gateway_url).await { eprintln!("error: {e}"); std::process::exit(1); } } -async fn run_list(json_out: bool) -> Result<(), GatewayError> { - let cfg = GatewayConfig::from_env(); +async fn run_list(json_out: bool, gateway_url: Option) -> Result<(), GatewayError> { + let cfg = GatewayConfig::from_env_with_url_override(gateway_url); let client = GatewayClient::connect(cfg, TIMEOUT).await?; let tools = client.tools_list().await?; @@ -48,9 +48,10 @@ async fn run_list(json_out: bool) -> Result<(), GatewayError> { Ok(()) } -/// `sealg gw-call [--args '']` β€” call one gateway tool. -pub async fn cmd_call(tool: &str, args: &str) { - match run_call(tool, args).await { +/// `sealg call [--args ''] [--gateway-url ]` β€” call one +/// gateway tool via MCP `tools/call`. +pub async fn cmd_call(tool: &str, args: &str, gateway_url: Option) { + match run_call(tool, args, gateway_url).await { Ok(exit_code) => std::process::exit(exit_code), Err(e) => { eprintln!("error: {e}"); @@ -62,11 +63,15 @@ pub async fn cmd_call(tool: &str, args: &str) { /// Returns the process exit code: `0` on a normal result, non-zero when the /// gateway returns an MCP tool error (`isError: true`) β€” the result is still /// printed so the caller sees the error content. -async fn run_call(tool: &str, args: &str) -> Result { +async fn run_call( + tool: &str, + args: &str, + gateway_url: Option, +) -> Result { let arguments: Value = serde_json::from_str(args) .map_err(|e| GatewayError::Config(format!("invalid --args JSON: {e}")))?; - let cfg = GatewayConfig::from_env(); + let cfg = GatewayConfig::from_env_with_url_override(gateway_url); let client = GatewayClient::connect(cfg, TIMEOUT).await?; let result = client.tools_call(tool, arguments).await?; diff --git a/crates/cli/src/init/config.rs b/crates/cli/src/init/config.rs deleted file mode 100644 index 0283514..0000000 --- a/crates/cli/src/init/config.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Source of truth for `sealg init` onboarding. -//! -//! Every selectable choice, its default, the implications between choices -//! ([`Config::expand`]), the rename sentinels ([`Config::rename_rules`]), and -//! the exact prune operations each choice triggers ([`Config::prune_ops`]) are -//! defined here. The wizard, plan renderer, and executors all read this module; -//! nothing decides policy on its own. - -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; - -// --------------------------------------------------------------------------- -// Sentinels - the template's own names, replaced during rename. -// --------------------------------------------------------------------------- - -/// Lowercase/kebab project sentinel (package.json name, crate references). -pub const SENTINEL_PROJECT_KEBAB: &str = "sealgate"; -/// Title-case project sentinel (README headings, directory prose). -pub const SENTINEL_PROJECT_TITLE: &str = "SealGate"; -/// The CLI binary sentinel. -pub const SENTINEL_CLI: &str = "sealg"; -/// The GitHub owner sentinel (auto-detected from `git remote` when possible). -pub const SENTINEL_ORG: &str = "Edison-Watch"; - -// --------------------------------------------------------------------------- -// Profiles & surfaces -// --------------------------------------------------------------------------- - -/// High-level project shape. A profile is a preset over the finer-grained -/// [`Surface`] set plus the optional extras. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Profile { - /// CLI diagnostics only; no HTTP server, no frontend. - CliOnly, - /// HTTP API only; no interactive CLI diagnostics. - ServerOnly, - /// Both the CLI and the HTTP API (the template default). - CliServer, -} - -impl Profile { - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().replace('_', "-").as_str() { - "cli-only" | "cli" => Some(Self::CliOnly), - "server-only" | "server" => Some(Self::ServerOnly), - "cli-server" | "cli+server" | "both" => Some(Self::CliServer), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::CliOnly => "cli-only", - Self::ServerOnly => "server-only", - Self::CliServer => "cli+server", - } - } - - pub const ALL: [Profile; 3] = [Self::CliOnly, Self::ServerOnly, Self::CliServer]; -} - -/// A service surface. Maps 1:1 onto a cargo feature of the `sealg` crate, so a -/// pruned surface compiles out cleanly rather than being deleted from source. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Surface { - /// CLI diagnostic subcommands - cargo feature `cli`. - Cli, - /// axum HTTP API (`sealg serve`) - cargo feature `http-api`. - HttpApi, -} - -impl Surface { - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().replace('-', "_").as_str() { - "cli" => Some(Self::Cli), - "http_api" | "http" | "api" | "server" => Some(Self::HttpApi), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Cli => "cli", - Self::HttpApi => "http_api", - } - } - - /// The cargo feature (in `crates/cli/Cargo.toml`'s `default` list) this - /// surface is gated behind. - pub fn cargo_feature(self) -> &'static str { - match self { - Self::Cli => "cli", - Self::HttpApi => "http-api", - } - } -} - -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -/// A fully-resolved onboarding configuration. Build one from a [`Profile`] -/// (`from_profile`) or the wizard, apply per-axis overrides, then call -/// [`Config::expand`] before planning. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Config { - pub project_name: String, - pub cli_name: String, - pub org: String, - pub description: String, - pub profile: Profile, - pub surfaces: BTreeSet, - pub frontend: bool, - pub docs: bool, - pub docker: bool, -} - -impl Config { - /// The preset for a profile, with template-default names and extras. - pub fn from_profile(profile: Profile) -> Self { - let mut surfaces = BTreeSet::new(); - match profile { - Profile::CliOnly => { - surfaces.insert(Surface::Cli); - } - Profile::ServerOnly => { - surfaces.insert(Surface::HttpApi); - } - Profile::CliServer => { - surfaces.insert(Surface::Cli); - surfaces.insert(Surface::HttpApi); - } - } - let has_api = surfaces.contains(&Surface::HttpApi); - Self { - project_name: SENTINEL_PROJECT_TITLE.to_string(), - cli_name: SENTINEL_CLI.to_string(), - org: SENTINEL_ORG.to_string(), - description: "A Rust CLI + HTTP API application".to_string(), - profile, - surfaces, - frontend: has_api, - docs: true, - docker: has_api, - } - } - - /// Normalise the configuration so implied choices are consistent. Idempotent. - /// - /// - a frontend needs the HTTP API to talk to, so it implies `http_api`; - /// - dropping `http_api` drops the frontend and the Dockerfile (both target - /// the server), leaving a coherent CLI-only shape. - pub fn expand(&mut self) { - if self.frontend { - self.surfaces.insert(Surface::HttpApi); - } - if !self.surfaces.contains(&Surface::HttpApi) { - self.frontend = false; - self.docker = false; - } - // A profile with nothing selected is meaningless; fall back to CLI. - if self.surfaces.is_empty() { - self.surfaces.insert(Surface::Cli); - } - } - - pub fn has_surface(&self, s: Surface) -> bool { - self.surfaces.contains(&s) - } - - /// Reset the API-dependent extras to match the current surface set. Call - /// this after *narrowing* `surfaces` explicitly so a stale preset - /// `frontend`/`docker` doesn't linger (and get re-added by `expand`). The - /// user can still turn them back on afterward, which re-adds the API. - pub fn reconcile_extras_to_surfaces(&mut self) { - if !self.has_surface(Surface::HttpApi) { - self.frontend = false; - self.docker = false; - } - } - - // -- rename ------------------------------------------------------------ - - /// Sentinel β†’ replacement pairs applied across the source tree. Longer - /// sentinels come first so a title-case match is never clobbered by the - /// kebab-case rule. - pub fn rename_rules(&self) -> Vec<(String, String)> { - let mut rules = Vec::new(); - if self.project_name != SENTINEL_PROJECT_TITLE { - rules.push(( - SENTINEL_PROJECT_TITLE.to_string(), - self.project_name.clone(), - )); - // Only rewrite the kebab sentinel when the name yields a valid - // (non-empty) kebab; otherwise a name like "!!!" would blank out - // `sealgate` in the manifests and break the generated project. - let kebab = kebab_case(&self.project_name); - if !kebab.is_empty() { - rules.push((SENTINEL_PROJECT_KEBAB.to_string(), kebab)); - } - } - if self.cli_name != SENTINEL_CLI { - rules.push((SENTINEL_CLI.to_string(), self.cli_name.clone())); - } - if self.org != SENTINEL_ORG { - rules.push((SENTINEL_ORG.to_string(), self.org.clone())); - } - rules - } - - // -- prune ------------------------------------------------------------- - - /// The concrete filesystem/manifest mutations implied by this config, - /// resolved against `root`. Order is stable so plans are reproducible. - pub fn prune_ops(&self, root: &Path) -> Vec { - let cli_manifest = root.join("crates/cli/Cargo.toml"); - let mut ops = Vec::new(); - - // A surface that is off has its cargo feature dropped from `default`. - for surface in [Surface::Cli, Surface::HttpApi] { - if !self.has_surface(surface) { - ops.push(PruneOp::DropCargoDefaultFeature { - manifest: cli_manifest.clone(), - feature: surface.cargo_feature().to_string(), - }); - if surface == Surface::HttpApi { - ops.push(PruneOp::DeletePath( - root.join("crates/cli/src/serve_http.rs"), - )); - } - } - } - - if !self.frontend { - // The frontend's sources live in `frontend/`, but it also contributes - // deps/scripts to the root package.json and owns the root TS + knip - // configs. Remove all of them so the pruned project has no dangling - // references (a bare `bun run knip` / `tsc` would otherwise fail). - ops.push(PruneOp::DeletePath(root.join("frontend"))); - ops.push(PruneOp::DeletePath(root.join("tsconfig.json"))); - ops.push(PruneOp::DeletePath(root.join("tsconfig.node.json"))); - ops.push(PruneOp::StripFrontendPackageJson { - manifest: root.join("package.json"), - }); - ops.push(PruneOp::DropKnipFrontendWorkspace { - manifest: root.join("knip.json"), - }); - } - - if !self.docs { - ops.push(PruneOp::DeletePath(root.join("docs"))); - ops.push(PruneOp::DropPackageJsonWorkspace { - manifest: root.join("package.json"), - name: "docs".to_string(), - }); - } - - if !self.docker { - ops.push(PruneOp::DeletePath(root.join("Dockerfile"))); - ops.push(PruneOp::DeletePath(root.join(".dockerignore"))); - } - - ops - } -} - -// --------------------------------------------------------------------------- -// Prune operations -// --------------------------------------------------------------------------- - -/// A single mutating step produced by [`Config::prune_ops`]. Rendered in the -/// dry-run plan and executed by `prune.rs`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PruneOp { - /// Recursively delete a file or directory (existence-guarded). - DeletePath(PathBuf), - /// Drop a feature from `[features].default` in a Cargo manifest. - DropCargoDefaultFeature { manifest: PathBuf, feature: String }, - /// Remove a workspace entry from package.json `workspaces`. - DropPackageJsonWorkspace { manifest: PathBuf, name: String }, - /// Strip frontend deps/scripts from package.json. - StripFrontendPackageJson { manifest: PathBuf }, - /// Remove the root (`"."`) frontend workspace from knip.json. - DropKnipFrontendWorkspace { manifest: PathBuf }, -} - -impl PruneOp { - /// One-line human summary for the dry-run plan table. - pub fn describe(&self) -> String { - match self { - PruneOp::DeletePath(p) => format!("delete {}", p.display()), - PruneOp::DropCargoDefaultFeature { manifest, feature } => { - format!("{}: drop default feature `{}`", manifest.display(), feature) - } - PruneOp::DropPackageJsonWorkspace { manifest, name } => { - format!("{}: drop workspace `{}`", manifest.display(), name) - } - PruneOp::StripFrontendPackageJson { manifest } => { - format!("{}: strip frontend deps + scripts", manifest.display()) - } - PruneOp::DropKnipFrontendWorkspace { manifest } => { - format!("{}: drop frontend workspace", manifest.display()) - } - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Convert an arbitrary project name into a kebab-case package identifier. -pub fn kebab_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut prev_dash = false; - for ch in name.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - prev_dash = false; - } else if !prev_dash && !out.is_empty() { - out.push('-'); - prev_dash = true; - } - } - out.trim_matches('-').to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn kebab_case_normalises() { - assert_eq!(kebab_case("My Cool App"), "my-cool-app"); - assert_eq!(kebab_case("SealGate"), "sealgate"); - assert_eq!(kebab_case("weird__name!!"), "weird-name"); - } - - #[test] - fn frontend_implies_http_api() { - let mut c = Config::from_profile(Profile::CliOnly); - c.frontend = true; - c.expand(); - assert!(c.has_surface(Surface::HttpApi)); - } - - #[test] - fn dropping_api_drops_frontend_and_docker() { - let mut c = Config::from_profile(Profile::CliServer); - c.surfaces.remove(&Surface::HttpApi); - // Frontend requires the API, so narrowing away the API drops it too. - c.reconcile_extras_to_surfaces(); - c.expand(); - assert!(!c.frontend); - assert!(!c.docker); - assert!(!c.has_surface(Surface::HttpApi)); - } - - #[test] - fn expand_is_idempotent() { - let mut c = Config::from_profile(Profile::ServerOnly); - c.expand(); - let once = c.clone(); - c.expand(); - assert_eq!(once, c); - } - - #[test] - fn cli_only_prunes_http_api_feature_and_serve() { - let mut c = Config::from_profile(Profile::CliOnly); - c.expand(); - let ops = c.prune_ops(Path::new(".")); - assert!(ops.iter().any(|o| matches!( - o, - PruneOp::DropCargoDefaultFeature { feature, .. } if feature == "http-api" - ))); - assert!(ops - .iter() - .any(|o| matches!(o, PruneOp::DeletePath(p) if p.ends_with("serve_http.rs")))); - } - - #[test] - fn no_frontend_prunes_dangling_ts_and_knip_configs() { - let mut c = Config::from_profile(Profile::ServerOnly); - c.frontend = false; - c.expand(); - let ops = c.prune_ops(Path::new(".")); - for rel in ["frontend", "tsconfig.json", "tsconfig.node.json"] { - assert!( - ops.iter() - .any(|o| matches!(o, PruneOp::DeletePath(p) if p.ends_with(rel))), - "expected DeletePath for {rel}" - ); - } - assert!(ops - .iter() - .any(|o| matches!(o, PruneOp::DropKnipFrontendWorkspace { .. }))); - assert!(ops - .iter() - .any(|o| matches!(o, PruneOp::StripFrontendPackageJson { .. }))); - } - - #[test] - fn full_config_prunes_nothing() { - let mut c = Config::from_profile(Profile::CliServer); - c.docs = true; - c.docker = true; - c.frontend = true; - c.expand(); - assert!(c.prune_ops(Path::new(".")).is_empty()); - } - - #[test] - fn rename_rules_skip_unchanged_names() { - let c = Config::from_profile(Profile::CliServer); - assert!(c.rename_rules().is_empty()); - } - - #[test] - fn rename_rules_emit_title_and_kebab() { - let mut c = Config::from_profile(Profile::CliServer); - c.project_name = "Acme App".to_string(); - let rules = c.rename_rules(); - assert!(rules - .iter() - .any(|(f, t)| f == "SealGate" && t == "Acme App")); - assert!(rules - .iter() - .any(|(f, t)| f == "sealgate" && t == "acme-app")); - } -} diff --git a/crates/cli/src/init/env.rs b/crates/cli/src/init/env.rs deleted file mode 100644 index 45bd2e2..0000000 --- a/crates/cli/src/init/env.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! `.env` bootstrap. Copies `.env.example` β†’ `.env` on first init so the new -//! project has a place to fill in `APP__*` secrets. Existence-guarded: an -//! existing `.env` is never overwritten (idempotent). - -use anyhow::Result; -use std::path::Path; - -/// Ensure a `.env` exists under `root`, seeded from `.env.example`. Returns -/// whether a `.env` was (or would be, in `dry_run`) created. -pub fn ensure_env(root: &Path, dry_run: bool) -> Result { - let example = root.join(".env.example"); - let target = root.join(".env"); - - if target.exists() || !example.exists() { - return Ok(false); - } - if !dry_run { - std::fs::copy(&example, &target)?; - // `.env` holds `APP__*` secrets - restrict it to the owner so it isn't - // world-readable (the copy inherits `.env.example`'s broad perms). If the - // chmod fails, delete the file so secrets aren't left world-readable. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)) - { - let _ = std::fs::remove_file(&target); - return Err(e.into()); - } - } - } - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seeds_env_from_example() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join(".env.example"), "APP__DEV_ENV=dev").unwrap(); - - assert!(ensure_env(root, false).unwrap()); - assert_eq!( - std::fs::read_to_string(root.join(".env")).unwrap(), - "APP__DEV_ENV=dev" - ); - } - - #[cfg(unix)] - #[test] - fn seeded_env_is_owner_only() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join(".env.example"), "APP__OPENAI_API_KEY=secret").unwrap(); - - assert!(ensure_env(root, false).unwrap()); - let mode = std::fs::metadata(root.join(".env")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600, "seeded .env must be owner-only"); - } - - #[test] - fn does_not_clobber_existing_env() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join(".env.example"), "APP__DEV_ENV=dev").unwrap(); - std::fs::write(root.join(".env"), "APP__DEV_ENV=prod").unwrap(); - - assert!(!ensure_env(root, false).unwrap()); - assert_eq!( - std::fs::read_to_string(root.join(".env")).unwrap(), - "APP__DEV_ENV=prod" - ); - } - - #[test] - fn dry_run_creates_nothing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join(".env.example"), "X=1").unwrap(); - assert!(ensure_env(root, true).unwrap()); - assert!(!root.join(".env").exists()); - } -} diff --git a/crates/cli/src/init/mod.rs b/crates/cli/src/init/mod.rs deleted file mode 100644 index b38408f..0000000 --- a/crates/cli/src/init/mod.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! `sealg init` - one-time project onboarding. -//! -//! Turns this template into a real project: renames the template sentinels, -//! prunes the surfaces you don't want, and seeds `.env`. Everything routes -//! through [`config`] (the source of truth); this module only wires the flags, -//! the wizard, the dry-run plan, and the executors together. -//! -//! Flow: build a [`Config`] (from `--config`, `--profile`, or the wizard) β†’ -//! apply per-axis overrides β†’ `expand()` β†’ print the plan β†’ (dry-run stops -//! here) β†’ confirm β†’ rename + prune + env β†’ print verification hints. - -pub mod config; -mod env; -mod plan; -mod prune; -mod rename; -mod wizard; - -use anyhow::{bail, Context, Result}; -use config::{Config, Profile, Surface}; -use std::collections::BTreeSet; -use std::io::IsTerminal; -use std::path::PathBuf; - -/// The `sealg init` subcommand flags (clap). Booleans use paired -/// `--flag`/`--no-flag` so an unspecified axis stays `None` and keeps the -/// profile default. -#[derive(Debug, clap::Args)] -pub struct InitArgs { - /// Project profile: cli-only | server-only | cli+server. - #[arg(long)] - pub profile: Option, - /// Path to a YAML config file (overrides the profile). - #[arg(long)] - pub config: Option, - /// Comma-separated surfaces to keep: cli,http_api. - #[arg(long)] - pub surfaces: Option, - #[arg(long)] - pub frontend: bool, - #[arg(long)] - pub no_frontend: bool, - #[arg(long)] - pub docs: bool, - #[arg(long)] - pub no_docs: bool, - #[arg(long)] - pub docker: bool, - #[arg(long)] - pub no_docker: bool, - /// Override the project name (default: prompted / template sentinel). - #[arg(long)] - pub name: Option, - /// Override the CLI binary name. - #[arg(long)] - pub cli_name: Option, - /// Override the GitHub owner/org (default: auto-detected from git remote). - #[arg(long)] - pub org: Option, - /// Override the one-line description. - #[arg(long)] - pub description: Option, - /// Print the plan without changing any files. - #[arg(long)] - pub dry_run: bool, - /// Skip the confirmation prompt before applying. - #[arg(long)] - pub yes: bool, - /// Repo root (defaults to the current directory). - #[arg(long)] - pub root: Option, -} - -impl From for InitOptions { - fn from(a: InitArgs) -> Self { - let tri = |yes: bool, no: bool| { - if no { - Some(false) - } else if yes { - Some(true) - } else { - None - } - }; - InitOptions { - profile: a.profile, - config_path: a.config, - surfaces: a.surfaces, - frontend: tri(a.frontend, a.no_frontend), - docs: tri(a.docs, a.no_docs), - docker: tri(a.docker, a.no_docker), - name: a.name, - cli_name: a.cli_name, - org: a.org, - description: a.description, - dry_run: a.dry_run, - yes: a.yes, - root: a.root, - } - } -} - -/// Resolved options after mapping the clap flags. -#[derive(Debug, Default, Clone)] -pub struct InitOptions { - pub profile: Option, - pub config_path: Option, - pub surfaces: Option, - pub frontend: Option, - pub docs: Option, - pub docker: Option, - pub name: Option, - pub cli_name: Option, - pub org: Option, - pub description: Option, - pub dry_run: bool, - pub yes: bool, - pub root: Option, -} - -/// A `--config ` file. Every field is optional and overrides the profile. -#[derive(Debug, Default, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct FileConfig { - profile: Option, - surfaces: Option>, - frontend: Option, - docs: Option, - docker: Option, - name: Option, - cli_name: Option, - org: Option, - description: Option, -} - -/// Entry point for the `init` subcommand. -pub fn run(args: InitArgs) -> Result<()> { - execute(args.into()) -} - -fn execute(opts: InitOptions) -> Result<()> { - let root = opts.root.clone().unwrap_or_else(|| PathBuf::from(".")); - let mut config = build_config(&opts)?; - apply_overrides(&mut config, &opts)?; - autodetect_org(&mut config, &root); - config.expand(); - - println!("{}", plan::render(&config, &root)); - - if opts.dry_run { - println!("Dry run - no files were changed. Re-run without --dry-run to apply."); - return Ok(()); - } - - if !opts.yes && !wizard::confirm_apply()? { - println!("Aborted - no files were changed."); - return Ok(()); - } - - let renamed = rename::apply(&config, &root, false)?; - let pruned = prune::apply(&config, &root, false)?; - let env_seeded = env::ensure_env(&root, false)?; - - print_summary(renamed, &pruned, env_seeded); - print_verify_hints(&config); - Ok(()) -} - -/// Build the base config from `--config`, `--profile`, or the wizard. -fn build_config(opts: &InitOptions) -> Result { - if let Some(path) = &opts.config_path { - let text = std::fs::read_to_string(path) - .with_context(|| format!("reading config file {}", path.display()))?; - let file: FileConfig = - serde_yaml::from_str(&text).with_context(|| "parsing --config YAML")?; - return config_from_file(file); - } - - if let Some(profile_str) = &opts.profile { - let profile = Profile::parse(profile_str) - .with_context(|| format!("unknown profile `{profile_str}`"))?; - return Ok(Config::from_profile(profile)); - } - - // No headless input: fall back to the interactive wizard. - if !std::io::stdin().is_terminal() { - bail!("no --profile or --config given and stdin is not a TTY; pass --profile for non-interactive init"); - } - let defaults = Config::from_profile(Profile::CliServer); - wizard::run(&defaults) -} - -fn config_from_file(file: FileConfig) -> Result { - let profile = match &file.profile { - Some(p) => Profile::parse(p).with_context(|| format!("unknown profile `{p}`"))?, - None => Profile::CliServer, - }; - let mut config = Config::from_profile(profile); - if let Some(surfaces) = &file.surfaces { - config.surfaces = parse_surfaces(surfaces)?; - config.reconcile_extras_to_surfaces(); - } - if let Some(v) = file.frontend { - config.frontend = v; - } - if let Some(v) = file.docs { - config.docs = v; - } - if let Some(v) = file.docker { - config.docker = v; - } - if let Some(v) = file.name { - config.project_name = v; - } - if let Some(v) = file.cli_name { - config.cli_name = v; - } - if let Some(v) = file.org { - config.org = v; - } - if let Some(v) = file.description { - config.description = v; - } - Ok(config) -} - -/// Apply per-axis CLI flag overrides on top of the base config. -fn apply_overrides(config: &mut Config, opts: &InitOptions) -> Result<()> { - if let Some(surfaces) = &opts.surfaces { - let list: Vec = surfaces.split(',').map(|s| s.to_string()).collect(); - config.surfaces = parse_surfaces(&list)?; - config.reconcile_extras_to_surfaces(); - } - if let Some(v) = opts.frontend { - config.frontend = v; - } - if let Some(v) = opts.docs { - config.docs = v; - } - if let Some(v) = opts.docker { - config.docker = v; - } - if let Some(v) = &opts.name { - config.project_name = v.clone(); - } - if let Some(v) = &opts.cli_name { - config.cli_name = v.clone(); - } - if let Some(v) = &opts.org { - config.org = v.clone(); - } - if let Some(v) = &opts.description { - config.description = v.clone(); - } - Ok(()) -} - -fn parse_surfaces(list: &[String]) -> Result> { - let mut set = BTreeSet::new(); - for s in list { - let s = s.trim(); - if s.is_empty() { - continue; - } - let surface = - Surface::parse(s).with_context(|| format!("unknown surface `{s}` (cli | http_api)"))?; - set.insert(surface); - } - if set.is_empty() { - bail!("--surfaces resolved to an empty set"); - } - Ok(set) -} - -/// If the org is still the template sentinel, try to read the GitHub owner from -/// `git remote get-url origin`. Read-only; failures are ignored. -fn autodetect_org(config: &mut Config, root: &std::path::Path) { - if config.org != config::SENTINEL_ORG { - return; // user set it explicitly - } - let Ok(output) = std::process::Command::new("git") - .arg("-C") - .arg(root) - .args(["remote", "get-url", "origin"]) - .output() - else { - return; - }; - if !output.status.success() { - return; - } - let url = String::from_utf8_lossy(&output.stdout); - if let Some(owner) = parse_owner(url.trim()) { - config.org = owner; - } -} - -/// Extract the owner segment from a GitHub remote URL (ssh or https). -fn parse_owner(url: &str) -> Option { - let stripped = url.trim_end_matches(".git"); - // git@host:owner/repo or https://host/owner/repo or proxy paths. - let tail = stripped - .rsplit_once(':') - .map(|(_, t)| t) - .unwrap_or(stripped); - let mut segs: Vec<&str> = tail.split('/').filter(|s| !s.is_empty()).collect(); - if segs.len() < 2 { - return None; - } - let _repo = segs.pop(); - segs.pop().map(|owner| owner.to_string()) -} - -fn print_summary(renamed: usize, pruned: &[String], env_seeded: bool) { - println!("\nApplied:"); - println!(" renamed sentinels in {renamed} file(s)"); - if pruned.is_empty() { - println!(" pruned nothing"); - } else { - println!(" pruned {} item(s):", pruned.len()); - for d in pruned { - println!(" - {d}"); - } - } - println!( - " .env {}", - if env_seeded { - "seeded from .env.example" - } else { - "left as-is" - } - ); -} - -fn print_verify_hints(config: &Config) { - println!("\nNext steps:"); - println!(" cargo build --workspace"); - println!(" cargo test --workspace"); - if config.has_surface(Surface::Cli) { - println!( - " {} --help && {} call ping", - config.cli_name, config.cli_name - ); - } - if config.has_surface(Surface::HttpApi) { - println!( - " {} serve # then GET /healthz and /api/v1/commands", - config.cli_name - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_owner_handles_ssh_https_and_proxy() { - assert_eq!( - parse_owner("git@github.com:Acme/repo.git").as_deref(), - Some("Acme") - ); - assert_eq!( - parse_owner("https://github.com/Acme/repo").as_deref(), - Some("Acme") - ); - assert_eq!( - parse_owner("http://local_proxy@127.0.0.1:41729/git/Acme/Repo").as_deref(), - Some("Acme") - ); - assert_eq!(parse_owner("garbage"), None); - } - - #[test] - fn config_file_overrides_profile() { - let file = FileConfig { - profile: Some("cli+server".into()), - frontend: Some(false), - ..Default::default() - }; - let mut c = config_from_file(file).unwrap(); - c.expand(); - assert!(!c.frontend); - assert!(c.has_surface(Surface::HttpApi)); - } - - #[test] - fn surfaces_override_replaces_set() { - let mut c = Config::from_profile(Profile::CliServer); - let opts = InitOptions { - surfaces: Some("cli".into()), - ..Default::default() - }; - apply_overrides(&mut c, &opts).unwrap(); - c.expand(); - assert!(c.has_surface(Surface::Cli)); - assert!(!c.has_surface(Surface::HttpApi)); - } - - #[test] - fn empty_surfaces_is_error() { - assert!(parse_surfaces(&[]).is_err()); - } -} diff --git a/crates/cli/src/init/plan.rs b/crates/cli/src/init/plan.rs deleted file mode 100644 index 3896bdc..0000000 --- a/crates/cli/src/init/plan.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Dry-run plan rendering. Prints the resolved [`Config`] and every mutation it -//! implies as a table, *before* anything is written. `sealg init` prints this -//! for `--dry-run` and again (for confirmation) before a real apply. - -use super::config::{Config, Surface}; -use comfy_table::{presets::UTF8_FULL, Cell, Color, Table}; -use std::path::Path; - -/// Render the full plan (settings + rename + prune) as a printable string. -pub fn render(config: &Config, root: &Path) -> String { - let mut out = String::new(); - out.push_str(&render_settings(config)); - out.push('\n'); - out.push_str(&render_rename(config)); - out.push('\n'); - out.push_str(&render_prune(config, root)); - out -} - -fn render_settings(config: &Config) -> String { - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_header(vec![Cell::new("Setting"), Cell::new("Value")]); - - table.add_row(vec!["profile", config.profile.as_str()]); - table.add_row(vec!["project name", &config.project_name]); - table.add_row(vec!["cli name", &config.cli_name]); - table.add_row(vec!["org", &config.org]); - - let surfaces: Vec<&str> = config.surfaces.iter().map(|s| s.as_str()).collect(); - table.add_row(vec!["surfaces", &surfaces.join(", ")]); - table.add_row(vec![ - Cell::new("cli diagnostics"), - yes_no(config.has_surface(Surface::Cli)), - ]); - table.add_row(vec![ - Cell::new("http api"), - yes_no(config.has_surface(Surface::HttpApi)), - ]); - table.add_row(vec![Cell::new("frontend"), yes_no(config.frontend)]); - table.add_row(vec![Cell::new("docs site"), yes_no(config.docs)]); - table.add_row(vec![Cell::new("dockerfile"), yes_no(config.docker)]); - - format!("Resolved configuration:\n{table}\n") -} - -fn render_rename(config: &Config) -> String { - let rules = config.rename_rules(); - if rules.is_empty() { - return "Rename: nothing to rename (names unchanged from the template).\n".to_string(); - } - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_header(vec![Cell::new("Replace"), Cell::new("With")]); - for (from, to) in rules { - table.add_row(vec![from, to]); - } - format!("Rename (applied across the source tree):\n{table}\n") -} - -fn render_prune(config: &Config, root: &Path) -> String { - let ops = config.prune_ops(root); - if ops.is_empty() { - return "Prune: nothing to remove (every surface kept).\n".to_string(); - } - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_header(vec![Cell::new("#"), Cell::new("Prune action")]); - for (i, op) in ops.iter().enumerate() { - table.add_row(vec![ - Cell::new(i + 1), - Cell::new(op.describe()).fg(Color::Yellow), - ]); - } - format!("Prune (removes the surfaces you turned off):\n{table}\n") -} - -fn yes_no(v: bool) -> Cell { - if v { - Cell::new("yes").fg(Color::Green) - } else { - Cell::new("no").fg(Color::DarkGrey) - } -} diff --git a/crates/cli/src/init/prune.rs b/crates/cli/src/init/prune.rs deleted file mode 100644 index 4002b12..0000000 --- a/crates/cli/src/init/prune.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Executes the [`PruneOp`]s a [`Config`] implies: deleting pruned surfaces and -//! rewriting the manifests that reference them. Cargo edits use `toml_edit` -//! (format-preserving); package.json edits use `serde_json` (key order -//! preserved via the `preserve_order` feature). Every op is existence-guarded -//! and idempotent, so re-running after a partial pass is safe. - -use super::config::{Config, PruneOp}; -use anyhow::{Context, Result}; -use std::path::Path; - -/// Frontend dependency keys removed by [`PruneOp::StripFrontendPackageJson`]. -/// These are the only deps the frontend contributes to the root manifest; -/// stripping them (plus deleting `frontend/` and the TS/knip configs) leaves a -/// clean, lint-green project with no dangling references. -const FRONTEND_DEPS: &[&str] = &[ - "react", - "react-dom", - "@vitejs/plugin-react", - "@types/react", - "@types/react-dom", - "vite", - "typescript", -]; - -/// Frontend npm scripts removed alongside the deps. -const FRONTEND_SCRIPTS: &[&str] = &["dev", "build", "preview"]; - -/// Apply every prune op for `config` under `root`. When `dry_run`, nothing is -/// written. Returns the human-readable descriptions of ops that had an effect. -pub fn apply(config: &Config, root: &Path, dry_run: bool) -> Result> { - let mut done = Vec::new(); - for op in config.prune_ops(root) { - let effective = if dry_run { - true - } else { - execute(&op).with_context(|| op.describe())? - }; - if effective { - done.push(op.describe()); - } - } - Ok(done) -} - -/// Run one op. Returns whether it changed anything (false = already absent). -fn execute(op: &PruneOp) -> Result { - match op { - PruneOp::DeletePath(path) => { - if !path.exists() { - return Ok(false); - } - if path.is_dir() { - std::fs::remove_dir_all(path)?; - } else { - std::fs::remove_file(path)?; - } - Ok(true) - } - PruneOp::DropCargoDefaultFeature { manifest, feature } => { - drop_cargo_default_feature(manifest, feature) - } - PruneOp::DropPackageJsonWorkspace { manifest, name } => { - drop_package_json_workspace(manifest, name) - } - PruneOp::StripFrontendPackageJson { manifest } => strip_frontend_package_json(manifest), - PruneOp::DropKnipFrontendWorkspace { manifest } => drop_knip_frontend_workspace(manifest), - } -} - -/// Remove the root (`"."`) frontend workspace from `knip.json` so a -/// frontend-pruned project's `bun run knip` doesn't point at deleted files. -fn drop_knip_frontend_workspace(manifest: &Path) -> Result { - if !manifest.exists() { - return Ok(false); - } - let text = std::fs::read_to_string(manifest)?; - let mut json: serde_json::Value = serde_json::from_str(&text)?; - let removed = json - .get_mut("workspaces") - .and_then(|w| w.as_object_mut()) - .map(|ws| ws.remove(".").is_some()) - .unwrap_or(false); - if removed { - write_json(manifest, &json)?; - } - Ok(removed) -} - -fn drop_cargo_default_feature(manifest: &Path, feature: &str) -> Result { - if !manifest.exists() { - return Ok(false); - } - let text = std::fs::read_to_string(manifest)?; - let mut doc = text.parse::()?; - let Some(arr) = doc - .get_mut("features") - .and_then(|f| f.get_mut("default")) - .and_then(|d| d.as_array_mut()) - else { - return Ok(false); - }; - let before = arr.len(); - arr.retain(|v| v.as_str() != Some(feature)); - if arr.len() == before { - return Ok(false); - } - std::fs::write(manifest, doc.to_string())?; - Ok(true) -} - -fn drop_package_json_workspace(manifest: &Path, name: &str) -> Result { - if !manifest.exists() { - return Ok(false); - } - let text = std::fs::read_to_string(manifest)?; - let mut json: serde_json::Value = serde_json::from_str(&text)?; - let Some(arr) = json.get_mut("workspaces").and_then(|w| w.as_array_mut()) else { - return Ok(false); - }; - let before = arr.len(); - arr.retain(|v| v.as_str() != Some(name)); - if arr.len() == before { - return Ok(false); - } - write_json(manifest, &json)?; - Ok(true) -} - -fn strip_frontend_package_json(manifest: &Path) -> Result { - if !manifest.exists() { - return Ok(false); - } - let text = std::fs::read_to_string(manifest)?; - let mut json: serde_json::Value = serde_json::from_str(&text)?; - let mut changed = false; - - for section in ["dependencies", "devDependencies"] { - if let Some(obj) = json.get_mut(section).and_then(|s| s.as_object_mut()) { - for dep in FRONTEND_DEPS { - changed |= obj.remove(*dep).is_some(); - } - } - } - if let Some(scripts) = json.get_mut("scripts").and_then(|s| s.as_object_mut()) { - for script in FRONTEND_SCRIPTS { - changed |= scripts.remove(*script).is_some(); - } - } - - if changed { - write_json(manifest, &json)?; - } - Ok(changed) -} - -fn write_json(path: &Path, json: &serde_json::Value) -> Result<()> { - let mut text = serde_json::to_string_pretty(json)?; - text.push('\n'); - std::fs::write(path, text)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::init::config::Profile; - - #[test] - fn drops_http_api_feature_from_cargo() { - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - std::fs::write( - &manifest, - "[features]\ndefault = [\"cli\", \"http-api\"]\ncli = []\n", - ) - .unwrap(); - - assert!(drop_cargo_default_feature(&manifest, "http-api").unwrap()); - let out = std::fs::read_to_string(&manifest).unwrap(); - assert!(out.contains("default = [\"cli\"]")); - assert!(!out.contains("http-api\"]")); - // Idempotent second run. - assert!(!drop_cargo_default_feature(&manifest, "http-api").unwrap()); - } - - #[test] - fn strips_frontend_deps_but_keeps_others() { - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("package.json"); - std::fs::write( - &manifest, - r#"{"scripts":{"dev":"vite","knip":"knip"},"dependencies":{"react":"19","zod":"3"}}"#, - ) - .unwrap(); - - assert!(strip_frontend_package_json(&manifest).unwrap()); - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&manifest).unwrap()).unwrap(); - assert!(json["dependencies"].get("react").is_none()); - assert!(json["dependencies"].get("zod").is_some()); - assert!(json["scripts"].get("dev").is_none()); - assert!(json["scripts"].get("knip").is_some()); - } - - #[test] - fn drops_knip_frontend_workspace_keeps_docs() { - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("knip.json"); - std::fs::write( - &manifest, - r#"{"workspaces":{".":{"entry":["frontend/src/main.tsx"]},"docs":{"entry":["app/page.tsx"]}}}"#, - ) - .unwrap(); - - assert!(drop_knip_frontend_workspace(&manifest).unwrap()); - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&manifest).unwrap()).unwrap(); - assert!(json["workspaces"].get(".").is_none()); - assert!(json["workspaces"].get("docs").is_some()); - // Idempotent second run. - assert!(!drop_knip_frontend_workspace(&manifest).unwrap()); - } - - #[test] - fn delete_path_is_existence_guarded() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("nope"); - assert!(!execute(&PruneOp::DeletePath(missing)).unwrap()); - } - - #[test] - fn dry_run_reports_without_writing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir_all(root.join("crates/cli/src")).unwrap(); - std::fs::write(root.join("crates/cli/src/serve_http.rs"), "// serve").unwrap(); - std::fs::write( - root.join("crates/cli/Cargo.toml"), - "[features]\ndefault = [\"cli\", \"http-api\"]\n", - ) - .unwrap(); - - let mut c = Config::from_profile(Profile::CliOnly); - c.expand(); - let done = apply(&c, root, true).unwrap(); - assert!(!done.is_empty()); - // Nothing actually removed. - assert!(root.join("crates/cli/src/serve_http.rs").exists()); - } -} diff --git a/crates/cli/src/init/rename.rs b/crates/cli/src/init/rename.rs deleted file mode 100644 index c6ce06f..0000000 --- a/crates/cli/src/init/rename.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Bulk sentinel replacement across the source tree. -//! -//! Walks the repo (skipping VCS/build/dependency dirs), and for every file with -//! an allow-listed extension replaces each `(from, to)` rename rule in place. -//! `str::replace` makes this naturally idempotent: a second run finds no -//! remaining sentinels and no-ops. - -use super::config::Config; -use anyhow::{Context, Result}; -use std::path::Path; -use walkdir::WalkDir; - -/// Directories never descended into. -const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".next", "dist", ".turbo"]; - -/// File extensions eligible for in-place rename. -const ALLOWED_EXTS: &[&str] = &[ - "rs", - "toml", - "ts", - "tsx", - "js", - "jsx", - "json", - "md", - "mdx", - "yaml", - "yml", - "html", - "css", - "txt", - "sh", - "dockerfile", -]; - -/// Files (by exact name) eligible even without an allow-listed extension. -const ALLOWED_NAMES: &[&str] = &["Dockerfile", "Makefile", ".env.example"]; - -/// Apply all rename rules under `root`. When `dry_run`, counts affected files -/// without writing. Returns the number of files that would change / did change. -pub fn apply(config: &Config, root: &Path, dry_run: bool) -> Result { - let rules = config.rename_rules(); - if rules.is_empty() { - return Ok(0); - } - - let mut changed = 0usize; - for entry in WalkDir::new(root) - .into_iter() - .filter_entry(|e| !is_skipped(e.path())) - { - let entry = entry?; - if !entry.file_type().is_file() || !is_eligible(entry.path()) { - continue; - } - - // Skip binary / non-UTF-8 files, but surface real read errors (e.g. - // permission denied) rather than silently leaving sentinels in place. - let original = match std::fs::read_to_string(entry.path()) { - Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue, - Err(e) => return Err(e).with_context(|| format!("reading {}", entry.path().display())), - }; - - let mut updated = original.clone(); - for (from, to) in &rules { - if updated.contains(from.as_str()) { - updated = updated.replace(from.as_str(), to); - } - } - - if updated != original { - changed += 1; - if !dry_run { - std::fs::write(entry.path(), updated) - .with_context(|| format!("writing {}", entry.path().display()))?; - } - } - } - Ok(changed) -} - -fn is_skipped(path: &Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| SKIP_DIRS.contains(&n)) - .unwrap_or(false) -} - -fn is_eligible(path: &Path) -> bool { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if ALLOWED_NAMES.contains(&name) { - return true; - } - } - path.extension() - .and_then(|e| e.to_str()) - .map(|e| ALLOWED_EXTS.contains(&e.to_ascii_lowercase().as_str())) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::init::config::Profile; - - fn cfg(name: &str) -> Config { - let mut c = Config::from_profile(Profile::CliServer); - c.project_name = name.to_string(); - c - } - - #[test] - fn renames_sentinels_in_place() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join("README.md"), "# SealGate\nname: sealgate").unwrap(); - - let n = apply(&cfg("Acme"), root, false).unwrap(); - assert_eq!(n, 1); - let content = std::fs::read_to_string(root.join("README.md")).unwrap(); - assert_eq!(content, "# Acme\nname: acme"); - } - - #[test] - fn dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join("a.rs"), "// SealGate").unwrap(); - - let n = apply(&cfg("Acme"), root, true).unwrap(); - assert_eq!(n, 1); - assert_eq!( - std::fs::read_to_string(root.join("a.rs")).unwrap(), - "// SealGate" - ); - } - - #[test] - fn idempotent_second_run_noops() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join("a.toml"), "name = \"sealgate\"").unwrap(); - - assert_eq!(apply(&cfg("Acme"), root, false).unwrap(), 1); - assert_eq!(apply(&cfg("Acme"), root, false).unwrap(), 0); - } - - #[test] - fn skips_target_and_binary() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir(root.join("target")).unwrap(); - std::fs::write(root.join("target/x.rs"), "SealGate").unwrap(); - std::fs::write(root.join("photo.png"), [0u8, 159, 146, 150]).unwrap(); - - assert_eq!(apply(&cfg("Acme"), root, false).unwrap(), 0); - } -} diff --git a/crates/cli/src/init/wizard.rs b/crates/cli/src/init/wizard.rs deleted file mode 100644 index 88599bc..0000000 --- a/crates/cli/src/init/wizard.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Interactive onboarding flow (dialoguer). Bare `sealg init` (no headless -//! flags) runs this to build a [`Config`] by prompting for branding, the -//! project profile, and the optional extras. Requires a TTY; headless callers -//! pass `--profile`/`--config` instead and never reach here. - -use super::config::{Config, Profile, Surface}; -use anyhow::Result; -use dialoguer::{Confirm, Input, Select}; - -/// Run the wizard, returning an un-expanded [`Config`] (the caller expands and -/// plans). `defaults` seeds the prompts (e.g. org auto-detected from git). -pub fn run(defaults: &Config) -> Result { - println!("sealg init - interactive setup\n"); - - let project_name: String = Input::new() - .with_prompt("Project name") - .default(defaults.project_name.clone()) - .interact_text()?; - - let cli_name: String = Input::new() - .with_prompt("CLI binary name") - .default(defaults.cli_name.clone()) - .interact_text()?; - - let org: String = Input::new() - .with_prompt("GitHub owner / org") - .default(defaults.org.clone()) - .interact_text()?; - - let description: String = Input::new() - .with_prompt("One-line description") - .default(defaults.description.clone()) - .interact_text()?; - - let profile_idx = Select::new() - .with_prompt("Project shape") - .items( - Profile::ALL - .iter() - .map(|p| profile_label(*p)) - .collect::>(), - ) - .default(profile_default_idx(defaults.profile)) - .interact()?; - let profile = Profile::ALL[profile_idx]; - - let mut config = Config::from_profile(profile); - config.project_name = project_name; - config.cli_name = cli_name; - config.org = org; - config.description = description; - - // Optional extras, only meaningful when the HTTP API is present. - if config.has_surface(Surface::HttpApi) { - config.frontend = Confirm::new() - .with_prompt("Include the optional React/Vite frontend?") - .default(config.frontend) - .interact()?; - config.docker = Confirm::new() - .with_prompt("Include a Dockerfile for the server?") - .default(config.docker) - .interact()?; - } - config.docs = Confirm::new() - .with_prompt("Keep the docs site (docs/)?") - .default(config.docs) - .interact()?; - - Ok(config) -} - -/// Confirm a mutating apply after the plan is shown. Returns the user's choice. -pub fn confirm_apply() -> Result { - Ok(Confirm::new() - .with_prompt("Apply this plan? This mutates files in place") - .default(false) - .interact()?) -} - -fn profile_label(p: Profile) -> String { - match p { - Profile::CliOnly => "cli-only - CLI diagnostics, no server".to_string(), - Profile::ServerOnly => "server-only - HTTP API, no CLI diagnostics".to_string(), - Profile::CliServer => "cli+server - both (template default)".to_string(), - } -} - -fn profile_default_idx(p: Profile) -> usize { - Profile::ALL.iter().position(|x| *x == p).unwrap_or(2) -} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6ca42c0..7db6d85 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,20 +1,15 @@ //! `sealg` – the SealGate command-line interface. //! -//! Runs the shared `engine` command registry over the CLI diagnostics -//! (`call`, `probe`, `doctor`, `run-scenario`). `new` scaffolds a fresh engine -//! command, and `mcp` is a stub for the future MCP transport. +//! A thin MCP client to the SealGate gateway. `list` and `call` forward +//! `tools/list` / `tools/call` to the per-user gateway endpoint (all policy and +//! enforcement live in the gateway). `doctor` reports local environment facts. #[cfg(feature = "cli")] mod diagnostics; mod gateway_cmd; -mod init; -mod mcp; -mod scaffold; use clap::{Parser, Subcommand}; -#[cfg(feature = "cli")] -use engine::{AppContext, CommandRegistry}; #[cfg(feature = "cli")] use std::path::PathBuf; @@ -33,31 +28,6 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Onboard this template into a real project (rename, prune, .env). - Init(init::InitArgs), - - /// Scaffold a new engine command from the template. - New(scaffold::NewArgs), - - /// (stub) Serve the registry over MCP - designed-for, not yet implemented. - Mcp, - - /// List the user's tools from the live SealGate gateway. - List { - /// Output as a JSON array of {name, description}. - #[arg(long)] - json: bool, - }, - - /// Call a tool on the live SealGate gateway. - GwCall { - /// Tool name as advertised by `sealg list`. - tool: String, - /// JSON arguments object to pass to the tool. - #[arg(long, default_value = "{}")] - args: String, - }, - /// Collect environment facts and emit an env summary. #[cfg(feature = "cli")] Doctor { @@ -69,52 +39,26 @@ enum Commands { out: Option, }, - /// Invoke a backend command by name with JSON args. - #[cfg(feature = "cli")] - Call { - /// Command name (e.g. "ping", "read_file", "write_file"). - cmd: String, - /// JSON args to pass to the command. - #[arg(long, default_value = "{}")] - args: String, - /// Output as JSON. - #[arg(long)] - json: bool, - /// Abort the command after this long (e.g. "30s", "5000ms", "2m"). - #[arg(long)] - timeout: Option, - /// Directory for artifacts output. - #[arg(long)] - artifacts: Option, - }, - - /// Targeted capability check: filesystem or network. - #[cfg(feature = "cli")] - Probe { - /// Probe target: filesystem | network - target: String, - /// Output as JSON. + /// List the user's tools from the live SealGate gateway. + List { + /// Output as a JSON array of {name, description}. #[arg(long)] json: bool, - /// Directory for artifacts output. + /// Override the gateway base URL (default: $SEALGATE_URL or localhost). #[arg(long)] - artifacts: Option, + gateway_url: Option, }, - /// Run a scripted scenario from a YAML file. - #[cfg(feature = "cli")] - RunScenario { - /// Path to the scenario YAML file. - file: PathBuf, - /// Directory for artifacts output. - #[arg(long)] - artifacts: Option, - /// Output as JSON. - #[arg(long)] - json: bool, - /// Run interactively with go-back navigation. + /// Call a tool on the live SealGate gateway. + Call { + /// Tool name as advertised by `sealg list`. + tool: String, + /// JSON arguments object to pass to the tool. + #[arg(long, default_value = "{}")] + args: String, + /// Override the gateway base URL (default: $SEALGATE_URL or localhost). #[arg(long)] - interactive: bool, + gateway_url: Option, }, } @@ -134,55 +78,13 @@ async fn main() { let cli = Cli::parse(); match cli.command { - Commands::Init(args) => { - if let Err(e) = init::run(args) { - eprintln!("error: {e:#}"); - std::process::exit(1); - } - } - Commands::New(args) => { - if let Err(e) = scaffold::run(args) { - eprintln!("error: {e:#}"); - std::process::exit(1); - } - } - Commands::Mcp => mcp::run(), - Commands::List { json } => gateway_cmd::cmd_list(json).await, - Commands::GwCall { tool, args } => gateway_cmd::cmd_call(&tool, &args).await, #[cfg(feature = "cli")] Commands::Doctor { json, out } => diagnostics::cmd_doctor(json, out).await, - #[cfg(feature = "cli")] + Commands::List { json, gateway_url } => gateway_cmd::cmd_list(json, gateway_url).await, Commands::Call { - cmd, + tool, args, - json, - timeout, - artifacts, - } => { - let ctx = AppContext::default(); - let registry = CommandRegistry::new(); - diagnostics::cmd_call(&cmd, &args, json, timeout, artifacts, &ctx, ®istry).await - } - #[cfg(feature = "cli")] - Commands::Probe { - target, - json, - artifacts, - } => { - let ctx = AppContext::default(); - diagnostics::cmd_probe(&target, json, artifacts, &ctx).await - } - #[cfg(feature = "cli")] - Commands::RunScenario { - file, - artifacts, - json, - interactive, - } => { - let ctx = AppContext::default(); - let registry = CommandRegistry::new(); - diagnostics::cmd_run_scenario(&file, json, interactive, artifacts, &ctx, ®istry) - .await - } + gateway_url, + } => gateway_cmd::cmd_call(&tool, &args, gateway_url).await, } } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs deleted file mode 100644 index 8911497..0000000 --- a/crates/cli/src/mcp.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `sealg mcp` - placeholder for the future MCP transport. -//! -//! MCP is **designed-for but not built** this iteration (see the PRD Β§8 and -//! `docs/mcp.md`). It needs nothing new from `engine`: the typed command -//! registry already exposes `registry.schemas()` / `registry.schema(name)`, -//! which is exactly what an adapter maps `tools/list` β†’ schemas and -//! `tools/call` β†’ `registry.call` onto. This stub keeps the subcommand surface -//! stable until the adapter lands, and is intentionally ungated (like `init` -//! and `new`) so it survives surface pruning. - -/// Print the "not implemented" notice and exit with `EX_UNAVAILABLE` (69). -pub fn run() -> ! { - eprintln!( - "sealg mcp is not implemented yet.\n\ - \n\ - MCP is a planned transport that will expose the same engine command\n\ - registry as MCP tools:\n\ - tools/list ← registry schemas (registry.schemas())\n\ - tools/call β†’ registry dispatch (registry.call)\n\ - mounted in-process alongside `sealg serve`, mirroring the HTTP API.\n\ - See docs/mcp.md for the adapter design.\n\ - \n\ - For now use `sealg serve` (HTTP API) or `sealg call ` (CLI)." - ); - std::process::exit(69); -} diff --git a/crates/cli/src/scaffold.rs b/crates/cli/src/scaffold.rs deleted file mode 100644 index e176a3d..0000000 --- a/crates/cli/src/scaffold.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! `sealg new ` - command scaffolding. -//! -//! Generates a new engine [`Command`] by substituting into -//! `templates/command.rs.tpl` and drops it in `crates/engine/src/commands/`. -//! Because commands self-register via `inventory`, the only wiring needed is the -//! `mod ;` line in `commands/mod.rs`, which this inserts alphabetically - -//! no hand-editing a registration list. -//! -//! Rust has no runtime module discovery, so the `mod` line is unavoidable; the -//! generator maintains it for you, preserving the "drop a file, it's wired" UX. - -use anyhow::{bail, Context, Result}; -use std::path::PathBuf; - -/// The command template, embedded at build time so `sealg new` works from any -/// working directory. -const TEMPLATE: &str = include_str!("../../../templates/command.rs.tpl"); - -/// The `sealg new` subcommand flags (clap). -#[derive(Debug, clap::Args)] -pub struct NewArgs { - /// Command name in snake_case (e.g. `fetch_url`). - pub name: String, - /// One-line command description. - #[arg(long)] - pub description: Option, - /// Repo root (defaults to the current directory). - #[arg(long)] - pub root: Option, - /// Overwrite an existing command file. - #[arg(long)] - pub force: bool, -} - -impl From for NewOptions { - fn from(a: NewArgs) -> Self { - NewOptions { - name: a.name, - description: a.description, - root: a.root, - force: a.force, - } - } -} - -/// Resolved options after mapping the clap flags. -#[derive(Debug, Default, Clone)] -pub struct NewOptions { - pub name: String, - pub description: Option, - pub root: Option, - pub force: bool, -} - -/// Entry point for the `new` subcommand. -pub fn run(args: NewArgs) -> Result<()> { - execute(args.into()) -} - -fn execute(opts: NewOptions) -> Result<()> { - let name = normalize_name(&opts.name)?; - let struct_name = pascal_case(&name); - let description = opts - .description - .clone() - .unwrap_or_else(|| format!("TODO: describe the {name} command.")); - let root = opts.root.clone().unwrap_or_else(|| PathBuf::from(".")); - - let commands_dir = root.join("crates/engine/src/commands"); - if !commands_dir.is_dir() { - bail!( - "commands directory not found at {} - run this from the repo root", - commands_dir.display() - ); - } - - let dest = commands_dir.join(format!("{name}.rs")); - if dest.exists() && !opts.force { - bail!( - "{} already exists (use --force to overwrite)", - dest.display() - ); - } - - let rendered = render(&name, &struct_name, &description); - std::fs::write(&dest, rendered).with_context(|| format!("writing {}", dest.display()))?; - - let mod_path = commands_dir.join("mod.rs"); - let mod_src = std::fs::read_to_string(&mod_path) - .with_context(|| format!("reading {}", mod_path.display()))?; - let (updated, inserted) = insert_mod_decl(&mod_src, &name); - if inserted { - std::fs::write(&mod_path, updated)?; - } - - println!("Created {}", dest.display()); - if inserted { - println!("Registered `mod {name};` in {}", mod_path.display()); - } else { - println!("`mod {name};` already present in {}", mod_path.display()); - } - println!("\nNext:"); - println!(" edit {}", dest.display()); - println!(" cargo test --workspace"); - println!(" sealg call {name} --args '{{\"message\":\"hi\"}}'"); - Ok(()) -} - -fn render(name: &str, struct_name: &str, description: &str) -> String { - TEMPLATE - .replace("{{STRUCT}}", struct_name) - .replace("{{NAME}}", name) - .replace("{{DESCRIPTION}}", description) -} - -/// Validate/normalise a command name to a snake_case Rust identifier. -fn normalize_name(raw: &str) -> Result { - let name = raw.trim(); - if name.is_empty() { - bail!("command name cannot be empty"); - } - let valid = name - .chars() - .enumerate() - .all(|(i, c)| c == '_' || c.is_ascii_lowercase() || (i > 0 && c.is_ascii_digit())); - if !valid || !name.starts_with(|c: char| c.is_ascii_lowercase()) { - bail!("command name `{name}` must be snake_case (lowercase, digits, underscores; start with a letter)"); - } - Ok(name.to_string()) -} - -fn pascal_case(snake: &str) -> String { - snake - .split('_') - .filter(|s| !s.is_empty()) - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(), - None => String::new(), - } - }) - .collect() -} - -/// Insert `mod ;` alphabetically among the existing simple `mod X;` -/// declarations. Returns the new source and whether a line was added -/// (idempotent: an existing declaration is left untouched). -fn insert_mod_decl(src: &str, name: &str) -> (String, bool) { - let lines: Vec<&str> = src.lines().collect(); - let is_mod_decl = |l: &str| { - let t = l.trim(); - t.starts_with("mod ") && t.ends_with(';') && !t.contains('{') - }; - - let indices: Vec = lines - .iter() - .enumerate() - .filter(|(_, l)| is_mod_decl(l)) - .map(|(i, _)| i) - .collect(); - - let new_line = format!("mod {name};"); - if indices.is_empty() { - return (src.to_string(), false); // no block to extend - } - if lines.iter().any(|l| l.trim() == new_line) { - return (src.to_string(), false); // already declared - } - - let first = indices[0]; - let last = *indices.last().unwrap(); - let mut mods: Vec = indices - .iter() - .map(|&i| lines[i].trim().to_string()) - .collect(); - mods.push(new_line); - mods.sort(); - mods.dedup(); - - let mut out: Vec = Vec::with_capacity(lines.len() + 1); - out.extend(lines[..first].iter().map(|s| s.to_string())); - out.extend(mods); - out.extend(lines[last + 1..].iter().map(|s| s.to_string())); - - let mut joined = out.join("\n"); - if src.ends_with('\n') { - joined.push('\n'); - } - (joined, true) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pascal_case_from_snake() { - assert_eq!(pascal_case("http_request"), "HttpRequest"); - assert_eq!(pascal_case("ping"), "Ping"); - assert_eq!(pascal_case("my_new_thing"), "MyNewThing"); - } - - #[test] - fn rejects_bad_names() { - assert!(normalize_name("HttpRequest").is_err()); - assert!(normalize_name("2fast").is_err()); - assert!(normalize_name("has-dash").is_err()); - assert!(normalize_name("").is_err()); - assert!(normalize_name("good_name2").is_ok()); - } - - #[test] - fn inserts_alphabetically() { - let src = "use x;\n\nmod alpha;\nmod zeta;\n\nfn main() {}\n"; - let (out, inserted) = insert_mod_decl(src, "middle"); - assert!(inserted); - assert!(out.contains("mod alpha;\nmod middle;\nmod zeta;")); - assert!(out.contains("fn main() {}")); - } - - #[test] - fn insert_is_idempotent() { - let src = "mod alpha;\nmod zeta;\n"; - let (out, inserted) = insert_mod_decl(src, "alpha"); - assert!(!inserted); - assert_eq!(out, src); - } - - #[test] - fn render_substitutes_all_placeholders() { - let out = render("my_cmd", "MyCmd", "does a thing"); - assert!(out.contains("pub struct MyCmd;")); - assert!(out.contains("\"my_cmd\"")); - assert!(out.contains("does a thing")); - assert!(!out.contains("{{")); - } -} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml deleted file mode 100644 index d59e58e..0000000 --- a/crates/config/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "app-config" -version = "0.1.0" -edition = "2021" -description = "Application configuration (AppConfig/FrontendConfig) + loader – shared across transports" -license = "MIT" - -[lib] -name = "app_config" -path = "src/lib.rs" - -[dependencies] -serde = { version = "1", features = ["derive"] } -config = { version = "0.15", features = ["yaml"] } - -[dev-dependencies] -serial_test = "3" -serde_json = "1" diff --git a/crates/config/global_config.yaml b/crates/config/global_config.yaml deleted file mode 100644 index e3601fe..0000000 --- a/crates/config/global_config.yaml +++ /dev/null @@ -1,86 +0,0 @@ -model_name: gemini/gemini-3-flash-preview -dot_global_config_health_check: true -dev_env: dev - -example_parent: - example_child: "example_value" - -######################################################## -# HTTP server (sealg serve) -######################################################## -server: - host: "127.0.0.1" - port: 8080 - # HTTP request timeout in seconds (omit β†’ 30s default). - request_timeout_secs: 30 - # Allowed CORS origins. Empty = permissive (dev). In production, list the - # exact frontend origins, e.g. ["https://app.example.com"]. - cors_allow_origins: [] - -######################################################## -# LLMs -######################################################## -default_llm: - default_model: gemini/gemini-3-flash-preview - fallback_model: gemini/gemini-2.5-flash-preview - default_temperature: 0.5 - default_max_tokens: 100000 - -llm_config: - cache_enabled: false - retry: - max_attempts: 3 - min_wait_seconds: 1 - max_wait_seconds: 5 - -######################################################## -# Debugging -######################################################## -features: - # Add feature flags here. Can be overridden by env vars: FEATURES__NEW_UI=true - new_ui: false - beta_features: false - enable_llm_fallback: true - -logging: - verbose: false - format: - show_time: false - show_session_id: true - location: - enabled: true - show_file: true - show_function: true - show_line: true - # Configure which log levels show location information - show_for_info: false - show_for_debug: true - show_for_warning: true - show_for_error: true - levels: - debug: false # Suppress all debug logs - info: true # Show info logs - warning: true # Show warning logs - error: true # Show error logs - critical: true # Show critical logs - redaction: - enabled: true - use_default_pii: true - patterns: - - name: "ANTHROPIC_API_KEY" - regex: "sk-ant-[a-zA-Z0-9-]{20,}" - placeholder: "[REDACTED_API_KEY]" - - name: "OPENAI_API_KEY" - regex: "sk-[a-zA-Z0-9]{20,}" - placeholder: "[REDACTED_API_KEY]" - - name: "STRIPE_API_KEY" - regex: "[spr]k_(live|test)_[a-zA-Z0-9]{20,}" - placeholder: "[REDACTED_API_KEY]" - - name: "BEARER_TOKEN" - regex: "Bearer\\s+[a-zA-Z0-9._\\-]{20,}" - placeholder: "[REDACTED_BEARER_TOKEN]" - - name: "GENERIC_KEY" - regex: "(?i:(?:api[_-]?key|project[_-]?key|secret[_-]?key)[=:\\s]+['\"]?[a-zA-Z0-9_\\-]{16,}['\"]?)" - placeholder: "[REDACTED_KEY]" - - diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs deleted file mode 100644 index 2ac16a9..0000000 --- a/crates/config/src/lib.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Application configuration, shared across every transport (CLI, HTTP API, -//! and future MCP). -//! -//! [`AppConfig`] is the full config including secret credentials; -//! [`FrontendConfig`] is the sanitized projection safe to expose over HTTP. The -//! sanitizer is a **security boundary** - no secret field may ever cross it -//! (enforced by `#[serde(skip_serializing)]` and covered by tests here). Config -//! loads from `global_config.yaml` (next to this crate, or `APP_CONFIG_PATH`), -//! layered with optional `production_config.yaml` / `.global_config.yaml`, then -//! overridden by `APP__`-prefixed env vars. - -use config::{Config, ConfigError, Environment, File}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::RwLock; - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct AppConfig { - pub model_name: String, - pub dot_global_config_health_check: bool, - #[serde(default = "default_dev_env")] - pub dev_env: String, - - pub example_parent: ExampleParent, - pub default_llm: DefaultLlm, - pub llm_config: LlmConfig, - pub logging: LoggingConfig, - #[serde(default)] - pub server: ServerConfig, - #[serde(default)] - pub features: HashMap, - - // Secret credentials - never serialized (`skip_serializing` = the security - // boundary; see the sanitization test). Read via the accessors below. - #[serde(skip_serializing)] - pub openai_api_key: Option, - #[serde(skip_serializing)] - pub anthropic_api_key: Option, - #[serde(skip_serializing)] - pub groq_api_key: Option, - #[serde(skip_serializing)] - pub perplexity_api_key: Option, - #[serde(skip_serializing)] - pub gemini_api_key: Option, -} - -impl AppConfig { - pub fn openai_api_key(&self) -> Option<&str> { - self.openai_api_key.as_deref() - } - pub fn anthropic_api_key(&self) -> Option<&str> { - self.anthropic_api_key.as_deref() - } - pub fn groq_api_key(&self) -> Option<&str> { - self.groq_api_key.as_deref() - } - pub fn perplexity_api_key(&self) -> Option<&str> { - self.perplexity_api_key.as_deref() - } - pub fn gemini_api_key(&self) -> Option<&str> { - self.gemini_api_key.as_deref() - } -} - -/// A sanitized version of the configuration intended for exposure to the frontend. -/// This strictly excludes sensitive information like API keys. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct FrontendConfig { - pub model_name: String, - pub dot_global_config_health_check: bool, - pub dev_env: String, - pub example_parent: ExampleParent, - pub default_llm: DefaultLlm, - pub llm_config: LlmConfig, - pub features: HashMap, -} - -impl From<&AppConfig> for FrontendConfig { - fn from(config: &AppConfig) -> Self { - Self { - model_name: config.model_name.clone(), - dot_global_config_health_check: config.dot_global_config_health_check, - dev_env: config.dev_env.clone(), - example_parent: config.example_parent.clone(), - default_llm: config.default_llm.clone(), - llm_config: config.llm_config.clone(), - features: config.features.clone(), - } - } -} - -fn default_dev_env() -> String { - "dev".to_string() -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct ExampleParent { - pub example_child: String, -} - -/// HTTP server settings for `sealg serve` (override via `APP__SERVER__*`). -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct ServerConfig { - pub host: String, - pub port: u16, - /// HTTP request timeout in seconds (`None` β†’ 30s). - #[serde(default)] - pub request_timeout_secs: Option, - /// Allowed CORS origins; empty = permissive dev default, else locks the API. - #[serde(default)] - pub cors_allow_origins: Vec, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - host: "127.0.0.1".to_string(), - port: 8080, - request_timeout_secs: None, - cors_allow_origins: Vec::new(), - } - } -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct DefaultLlm { - pub default_model: String, - pub fallback_model: Option, - pub default_temperature: f32, - pub default_max_tokens: i32, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LlmConfig { - pub cache_enabled: bool, - pub retry: RetryConfig, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct RetryConfig { - pub max_attempts: i32, - pub min_wait_seconds: i32, - pub max_wait_seconds: i32, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LoggingConfig { - pub verbose: bool, - pub format: LoggingFormatConfig, - pub levels: LoggingLevelsConfig, - #[serde(default)] - pub redaction: RedactionConfig, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LoggingFormatConfig { - pub show_time: bool, - pub show_session_id: bool, - pub location: LoggingLocationConfig, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LoggingLocationConfig { - pub enabled: bool, - pub show_file: bool, - pub show_function: bool, - pub show_line: bool, - pub show_for_info: bool, - pub show_for_debug: bool, - pub show_for_warning: bool, - pub show_for_error: bool, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct LoggingLevelsConfig { - pub debug: bool, - pub info: bool, - pub warning: bool, - pub error: bool, - pub critical: bool, -} - -#[derive(Debug, Deserialize, Serialize, Default, Clone)] -pub struct RedactionConfig { - #[serde(default = "true_default")] - pub enabled: bool, - #[serde(default = "true_default")] - pub use_default_pii: bool, - #[serde(default)] - pub patterns: Vec, -} - -fn true_default() -> bool { - true -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct RedactionPattern { - pub name: String, - pub regex: String, - pub placeholder: String, -} - -#[derive(Clone, Copy)] -struct ConfigStorage { - app: &'static AppConfig, - frontend: &'static FrontendConfig, -} - -static CONFIG_STORAGE: RwLock> = RwLock::new(None); - -fn try_get_config_storage() -> Result { - if let Some(storage) = *CONFIG_STORAGE.read().unwrap() { - return Ok(storage); - } - - let mut write = CONFIG_STORAGE.write().unwrap(); - if let Some(storage) = *write { - return Ok(storage); - } - - let app_config = load_config()?; - let frontend_config = FrontendConfig::from(&app_config); - - let app_cfg = Box::leak(Box::new(app_config)); - let frontend_cfg = Box::leak(Box::new(frontend_config)); - - let storage = ConfigStorage { - app: app_cfg, - frontend: frontend_cfg, - }; - *write = Some(storage); - Ok(storage) -} - -fn get_config_storage() -> ConfigStorage { - try_get_config_storage().unwrap_or_else(|e| { - panic!( - "Failed to load configuration: {e}\n\ - Ensure `global_config.yaml` exists next to the `app-config` crate, \ - or set `APP_CONFIG_PATH` to point at your config file." - ) - }) -} - -/// Fallible config accessor for callers that want to handle a missing/malformed -/// config file gracefully (e.g. print a friendly error and exit) instead of -/// panicking like [`get_config`]. Initialization is shared: the first successful -/// call caches the config for both accessors. -pub fn try_get_config() -> Result<&'static AppConfig, ConfigError> { - Ok(try_get_config_storage()?.app) -} - -pub fn get_config() -> &'static AppConfig { - get_config_storage().app -} - -pub fn get_frontend_config() -> &'static FrontendConfig { - get_config_storage().frontend -} - -#[cfg(test)] -pub fn reset_config() { - let mut write = CONFIG_STORAGE.write().unwrap(); - *write = None; -} - -/// Directory config files resolve against: this crate's dir -/// (`CARGO_MANIFEST_DIR`) for `cargo run`/`test`; a deployed binary should set -/// `APP_CONFIG_PATH` instead (sibling overrides resolve next to that file). -fn config_base_dir() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -fn load_config() -> Result { - // `APP_CONFIG_PATH` (full path to the primary YAML) overrides the default - // location; production/local overrides are resolved next to it. - let config_path = std::env::var("APP_CONFIG_PATH") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| config_base_dir().join("global_config.yaml")); - - let config_dir = config_path - .parent() - .map(std::path::Path::to_path_buf) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - - let prod_config_path = config_dir.join("production_config.yaml"); - let local_config_path = config_dir.join(".global_config.yaml"); - - let builder = Config::builder() - // Load default config (mandatory) - .add_source(File::from(config_path).required(true)) - // Load production config if in prod - .add_source(File::from(prod_config_path).required(false)) - // Load local override - .add_source(File::from(local_config_path).required(false)) - // Load environment variables - // Map nested env vars like APP__LOGGING__VERBOSE=true - .add_source(Environment::with_prefix("APP").separator("__")); - - builder.build()?.try_deserialize() -} - -#[cfg(test)] -mod tests; diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs deleted file mode 100644 index e88f643..0000000 --- a/crates/config/src/tests.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Unit tests for config loading, env-override precedence, type coercion, -//! and the `FrontendConfig` sanitization security boundary. Split out of -//! `lib.rs` to keep that file under the file-length cap; `use super::*` still -//! reaches the crate-private loader/reset helpers. - -use super::*; -use serial_test::serial; -use std::env; -struct EnvGuard(&'static str); -impl EnvGuard { - fn new(key: &'static str, val: &str) -> Self { - reset_config(); - env::set_var(key, val); - Self(key) - } -} -impl Drop for EnvGuard { - fn drop(&mut self) { - env::remove_var(self.0); - reset_config(); - } -} - -#[test] -#[serial] -fn test_load_config() { - // Ensure the config loads without error - let config = load_config(); - assert!(config.is_ok(), "Failed to load config: {:?}", config.err()); - - let config = config.unwrap(); - // Verify some default values from global_config.yaml - assert_eq!( - config.default_llm.default_model, - "gemini/gemini-3-flash-preview" - ); - assert_eq!(config.llm_config.retry.max_attempts, 3); -} - -#[test] -#[serial] -fn test_env_var_override_precedence() { - // YAML value is "gemini/gemini-3-flash-preview" - let _guard = EnvGuard::new("APP__MODEL_NAME", "override-model"); - - let config = load_config().expect("Should load config"); - assert_eq!(config.model_name, "override-model"); -} - -#[test] -#[serial] -fn test_type_coercion_boolean() { - { - let _guard = EnvGuard::new("APP__LLM_CONFIG__CACHE_ENABLED", "true"); - let config = load_config().expect("Should load config"); - assert!(config.llm_config.cache_enabled); - } - - { - let _guard = EnvGuard::new("APP__LLM_CONFIG__CACHE_ENABLED", "false"); - let config = load_config().expect("Should load config"); - assert!(!config.llm_config.cache_enabled); - } - - // Test boolean coercion from '1' and '0' (porting from Python tests) - { - let _guard = EnvGuard::new("APP__LOGGING__FORMAT__LOCATION__ENABLED", "1"); - let config = load_config().expect("Should load config"); - assert!(config.logging.format.location.enabled); - } - - { - let _guard = EnvGuard::new("APP__LOGGING__FORMAT__LOCATION__ENABLED", "0"); - let config = load_config().expect("Should load config"); - assert!(!config.logging.format.location.enabled); - } -} - -#[test] -#[serial] -fn test_type_coercion_numeric() { - let _guard1 = EnvGuard::new("APP__DEFAULT_LLM__DEFAULT_TEMPERATURE", "0.95"); - let _guard2 = EnvGuard::new("APP__LLM_CONFIG__RETRY__MAX_ATTEMPTS", "10"); - - let config = load_config().expect("Should load config"); - assert_eq!(config.default_llm.default_temperature, 0.95); - assert_eq!(config.llm_config.retry.max_attempts, 10); - - // Test float-to-int coercion if applicable (usually handled by serde) - // Here we test string-to-numeric coercion specifically. - let _guard3 = EnvGuard::new("APP__LLM_CONFIG__RETRY__MAX_ATTEMPTS", "5"); - let config = load_config().expect("Should load config"); - assert_eq!(config.llm_config.retry.max_attempts, 5); -} - -#[test] -#[serial] -fn test_dev_env_override() { - // Field name is lowercase `dev_env` - let _guard = EnvGuard::new("APP__DEV_ENV", "production"); - let config = load_config().expect("Should load config"); - assert_eq!(config.dev_env, "production"); -} - -#[test] -#[serial] -fn test_api_key_loading() { - // Test that API keys are loaded from environment variables (APP__ prefix) - let _guard1 = EnvGuard::new("APP__OPENAI_API_KEY", "test-openai-key"); - let _guard2 = EnvGuard::new("APP__ANTHROPIC_API_KEY", "test-anthropic-key"); - - let config = load_config().expect("Should load config"); - assert_eq!(config.openai_api_key(), Some("test-openai-key")); - assert_eq!(config.anthropic_api_key(), Some("test-anthropic-key")); -} - -#[test] -#[serial] -fn test_frontend_config_sanitization() { - let config = AppConfig { - model_name: "gpt-4".to_string(), - dot_global_config_health_check: true, - dev_env: "dev".to_string(), - example_parent: ExampleParent { - example_child: "val".to_string(), - }, - default_llm: DefaultLlm { - default_model: "gpt-4".to_string(), - fallback_model: None, - default_temperature: 0.7, - default_max_tokens: 100, - }, - llm_config: LlmConfig { - cache_enabled: true, - retry: RetryConfig { - max_attempts: 1, - min_wait_seconds: 1, - max_wait_seconds: 1, - }, - }, - logging: LoggingConfig { - verbose: true, - format: LoggingFormatConfig { - show_time: true, - show_session_id: true, - location: LoggingLocationConfig { - enabled: true, - show_file: true, - show_function: true, - show_line: true, - show_for_info: true, - show_for_debug: true, - show_for_warning: true, - show_for_error: true, - }, - }, - levels: LoggingLevelsConfig { - debug: true, - info: true, - warning: true, - error: true, - critical: true, - }, - redaction: RedactionConfig::default(), - }, - server: ServerConfig::default(), - features: HashMap::new(), - openai_api_key: Some("secret-key".to_string()), - anthropic_api_key: None, - groq_api_key: None, - perplexity_api_key: None, - gemini_api_key: None, - }; - - // The sanitized projection must not leak the secret value or its key. - let frontend_config = FrontendConfig::from(&config); - let json = serde_json::to_string(&frontend_config).unwrap(); - - assert!(!json.contains("secret-key")); - assert!(!json.contains("openai_api_key")); - - // Security boundary: even serializing the *full* AppConfig (e.g. by - // accident in a log line or debug endpoint) must never emit a secret. - let mut config = config; - config.openai_api_key = Some("secret-openai".into()); - config.anthropic_api_key = Some("secret-anthropic".into()); - config.groq_api_key = Some("secret-groq".into()); - config.perplexity_api_key = Some("secret-perplexity".into()); - config.gemini_api_key = Some("secret-gemini".into()); - - let full_json = serde_json::to_string(&config).unwrap(); - for leaked in [ - "secret-openai", - "secret-anthropic", - "secret-groq", - "secret-perplexity", - "secret-gemini", - "api_key", - ] { - assert!( - !full_json.contains(leaked), - "AppConfig serialization leaked `{leaked}`: {full_json}" - ); - } -} - -#[test] -#[serial] -fn test_logging_verbose_default_is_false() { - let config = load_config().expect("Should load config"); - assert!( - !config.logging.verbose, - "Logging verbose should be false by default" - ); -} diff --git a/crates/engine/README.md b/crates/engine/README.md index 5310445..9832cdf 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -1,79 +1,38 @@ # engine – Shared Backend Logic -The transport-agnostic service core of SealGate - all real -backend logic. Driven by `sealg` (`crates/cli`) over the CLI and the HTTP API, -and (later) MCP; the same registry serves every transport. +The transport-agnostic service core of SealGate. `sealg` (`crates/cli`) is a thin +MCP client to the SealGate gateway; this crate holds the pieces it needs behind a +clean, transport-free API (no CLI, axum, or HTTP types). ## Design Principles -- **No transport dependency** – the engine never imports CLI, axum, or HTTP - types, so it can run in any Rust context (CLI, HTTP API, tests, etc.). -- **Trait-based OS access** – filesystem and network operations are behind - traits (`FilesystemOps`, `NetworkOps`). Callers inject the implementation they - need (real platform vs. headless stubs). -- **Structured results** – every operation returns a `CommandResult` with a - stable JSON schema including `run_id`, `status`, `error`, `timing_ms`, and - `env_summary`. -- **No panics on missing capabilities** – headless environments get `SKIP` or - `UNSUPPORTED` error codes instead of crashes. +- **No transport dependency** – the engine never imports CLI or HTTP framework + types, so it can run in any Rust context (CLI, tests, etc.). +- **Thin gateway client** – `sealg` speaks only the small slice of MCP it needs + (`initialize`, `tools/list`, `tools/call`) directly to the gateway's per-user + `/mcp/{api_key}/` endpoint. All policy and enforcement live in the gateway. +- **Structured results** – `doctor` returns a `CommandResult` with a stable JSON + schema including `run_id`, `status`, `error`, `timing_ms`, and `env_summary`. ## Modules | Module | Purpose | |--------|---------| -| `types` | Output contract: `CommandResult`, `Status`, `ErrorCode`, `EnvSummary`, scenario types | -| `traits` | OS capability traits: `FilesystemOps`, `NetworkOps` | -| `platform` | Real implementations: `StdFilesystem`, `ReqwestNetwork` | -| `context` | `AppContext` – holds trait objects and config; constructors for platform/headless | -| `commands` | `CommandRegistry` with built-in commands: `ping`, `read_file`, `write_file`, … | -| `probes` | Capability probes: `filesystem`, `network` | +| `gateway` | The SealGate gateway transport: `GatewayConfig` (env-resolved coordinates) and `GatewayClient` (hand-rolled MCP-over-HTTP client) | | `doctor` | Environment diagnostics (OS, kernel, headless detection, proxy vars) | -| `scenario` | YAML scenario parser and async runner | +| `types` | Output contract: `CommandResult`, `Status`, `ErrorCode`, `EnvSummary` | ## Usage ```rust -use engine::{AppContext, CommandRegistry, Ctx}; +use engine::{GatewayClient, GatewayConfig}; +use std::time::Duration; -// Shared capabilities built once; a lightweight Ctx is built per invocation. -let caps = AppContext::default(); -let registry = CommandRegistry::new(); -let cx = Ctx::new(&caps); +// Coordinates come from the environment ($SEALGATE_URL, $SEALGATE_API_KEY, …). +let cfg = GatewayConfig::from_env(); +let client = GatewayClient::connect(cfg, Duration::from_secs(30)).await?; -// `execute` returns the diagnostic CommandResult envelope (used by the CLI); -// `call` returns the bare typed Output (used by the HTTP API). -let result = registry.execute("ping", serde_json::json!({}), &cx).await; -assert_eq!(result.status, engine::Status::Pass); - -// Run a probe -let probe_result = engine::probes::run_probe("filesystem", &caps).await; -``` - -## Adding Commands - -Commands implement the typed, async `Command` trait and **self-register at link -time** via `register_command!` - there is no hand-maintained registration list. -The fastest path is `sealg new ` (or `make new name=`); see the -`update-backend` skill for the full pattern. - -```rust -register_command!(MyCommand); // at the bottom of commands/my_command.rs -``` - -## OS Traits - -Implement custom capability providers by implementing the traits: - -```rust -use engine::traits::{FilesystemOps, CapResult, CapError, DirEntry}; -use std::path::{Path, PathBuf}; - -struct ReadOnlyFs; - -impl FilesystemOps for ReadOnlyFs { - fn write_file(&self, _path: &Path, _data: &[u8]) -> CapResult<()> { - Err(CapError::PermissionDenied("read-only filesystem".into())) - } - // ...implement the remaining FilesystemOps methods... -} +// Discover and call the user's policy-filtered gateway tools. +let tools = client.tools_list().await?; +let result = client.tools_call("some_tool", serde_json::json!({})).await?; ``` diff --git a/crates/engine/src/commands/http_request.rs b/crates/engine/src/commands/http_request.rs deleted file mode 100644 index 61b6059..0000000 --- a/crates/engine/src/commands/http_request.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! `http_request` – perform an outbound HTTP GET. -//! -//! The canonical async example: it awaits real network I/O through the -//! [`NetworkOps`](crate::traits::NetworkOps) capability, exercising the typed -//! contract, the per-request [`Ctx`], and the capability-error mapping. - -use crate::commands::{Command, CommandError, Expose}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// Default per-request timeout when the caller does not specify one. -const DEFAULT_TIMEOUT_MS: u64 = 10_000; - -#[derive(Default)] -pub struct HttpRequest; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct HttpRequestInput { - /// Absolute URL to fetch. Must use the `https://` scheme. - pub url: String, - /// HTTP method. Only `GET` is supported by the network capability today; - /// anything else returns an `Unsupported` error. - #[serde(default)] - pub method: Option, - /// Request timeout in milliseconds (default 10000). - #[serde(default)] - pub timeout_ms: Option, -} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct HttpRequestOutput { - pub status: u16, - /// First few KiB of the response body. - pub body_snippet: String, -} - -#[async_trait] -impl Command for HttpRequest { - type Input = HttpRequestInput; - type Output = HttpRequestOutput; - - fn name(&self) -> &'static str { - "http_request" - } - - fn description(&self) -> &'static str { - "Perform an outbound HTTP GET and return the status and a body snippet." - } - - /// Fetches a caller-supplied URL with no scheme/host allowlist - CLI-only so - /// it is not reachable as an unauthenticated SSRF primitive over the HTTP API. - fn expose(&self) -> Expose { - Expose::cli_only() - } - - async fn run( - &self, - input: HttpRequestInput, - cx: &Ctx<'_>, - ) -> Result { - let method = input.method.as_deref().unwrap_or("GET"); - if !method.eq_ignore_ascii_case("GET") { - return Err(CommandError::Unsupported(format!( - "method {method} is not supported (only GET)" - ))); - } - - // The capability is `https_get`; enforce the scheme rather than silently - // issuing a cleartext request for an `http://` URL. Schemes are - // case-insensitive (RFC 3986 Β§3.1). - let scheme_ok = input - .url - .split_once("://") - .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https")); - if !scheme_ok { - return Err(CommandError::InvalidInput( - "url must use the https:// scheme".to_string(), - )); - } - - // A `0` timeout would fire instantly; treat it (and absent) as the default. - let timeout_ms = input - .timeout_ms - .filter(|&t| t > 0) - .unwrap_or(DEFAULT_TIMEOUT_MS); - let (status, body_snippet) = cx.network().https_get(&input.url, timeout_ms).await?; - Ok(HttpRequestOutput { - status, - body_snippet, - }) - } -} - -register_command!(HttpRequest); diff --git a/crates/engine/src/commands/list_dir.rs b/crates/engine/src/commands/list_dir.rs deleted file mode 100644 index 51fb0cd..0000000 --- a/crates/engine/src/commands/list_dir.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! `list_dir` – list entries in a directory. - -use crate::commands::{Command, CommandError, Expose}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct ListDir; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ListDirInput { - /// Directory path to list. - pub path: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct DirEntry { - pub name: String, - pub is_dir: bool, - pub size_bytes: u64, -} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct ListDirOutput { - pub entries: Vec, -} - -#[async_trait] -impl Command for ListDir { - type Input = ListDirInput; - type Output = ListDirOutput; - - fn name(&self) -> &'static str { - "list_dir" - } - - fn description(&self) -> &'static str { - "List the entries of a directory." - } - - /// Lists a caller-supplied path with no sandbox - CLI-only so it is not - /// reachable as an unauthenticated directory-enumeration over the HTTP API. - fn expose(&self) -> Expose { - Expose::cli_only() - } - - async fn run(&self, input: ListDirInput, cx: &Ctx<'_>) -> Result { - let path = std::path::Path::new(&input.path); - let entries = cx - .fs() - .list_dir(path)? - .into_iter() - .map(|e| DirEntry { - name: e.name, - is_dir: e.is_dir, - size_bytes: e.size_bytes, - }) - .collect(); - Ok(ListDirOutput { entries }) - } -} - -register_command!(ListDir); diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs deleted file mode 100644 index 3e3bc03..0000000 --- a/crates/engine/src/commands/mod.rs +++ /dev/null @@ -1,487 +0,0 @@ -//! The typed command contract and registry. -//! -//! A [`Command`] is the unit of backend logic. It declares a typed `Input` -//! (deserialized from the request body / CLI args) and a typed `Output` -//! (serialized back to the caller). Both derive [`schemars::JsonSchema`], so -//! every transport gets a JSON Schema for free - the HTTP `GET /commands` -//! introspection endpoint and the future MCP `tools/list` both read it. -//! -//! Commands **self-register** at link time via [`inventory`]: dropping a new -//! command file that ends in `register_command!(MyCommand);` makes it appear in -//! [`CommandRegistry::new`] with no hand-edited registration list. -//! -//! ```ignore -//! #[derive(Default)] -//! pub struct Ping; -//! -//! #[derive(Deserialize, JsonSchema)] -//! pub struct PingInput {} -//! -//! #[derive(Serialize, JsonSchema)] -//! pub struct PingOutput { pub pong: bool } -//! -//! #[async_trait] -//! impl Command for Ping { -//! type Input = PingInput; -//! type Output = PingOutput; -//! fn name(&self) -> &'static str { "ping" } -//! async fn run(&self, _in: PingInput, _cx: &Ctx<'_>) -> Result { -//! Ok(PingOutput { pong: true }) -//! } -//! } -//! register_command!(Ping); -//! ``` - -use crate::context::Ctx; -use crate::types::*; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{de::DeserializeOwned, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; -use std::time::Instant; - -mod http_request; -mod list_dir; -mod ping; -mod read_file; -mod system_info; -mod write_file; - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error("invalid input: {0}")] - InvalidInput(String), - #[error("io: {0}")] - Io(#[from] std::io::Error), - #[error("permission denied: {0}")] - PermissionDenied(String), - #[error("network error: {0}")] - NetworkError(String), - #[error("timeout")] - Timeout, - #[error("unsupported: {0}")] - Unsupported(String), - #[error("unimplemented: {0}")] - Unimplemented(String), - #[error("{0}")] - Other(String), -} - -impl CommandError { - pub fn error_code(&self) -> ErrorCode { - match self { - CommandError::InvalidInput(_) => ErrorCode::InvalidInput, - CommandError::Io(_) => ErrorCode::IoError, - CommandError::PermissionDenied(_) => ErrorCode::PermissionDenied, - CommandError::NetworkError(_) => ErrorCode::NetworkError, - CommandError::Timeout => ErrorCode::Timeout, - CommandError::Unsupported(_) => ErrorCode::Unsupported, - CommandError::Unimplemented(_) => ErrorCode::Unimplemented, - CommandError::Other(_) => ErrorCode::InternalError, - } - } -} - -/// Map a capability-layer error onto a command error, preserving the code. -impl From for CommandError { - fn from(e: crate::traits::CapError) -> Self { - use crate::traits::CapError; - match e { - CapError::Unsupported(m) => CommandError::Unsupported(m), - CapError::DependencyMissing(m) => CommandError::Unsupported(m), - CapError::PermissionDenied(m) => CommandError::PermissionDenied(m), - CapError::Io(io) => CommandError::Io(io), - CapError::Network(m) => CommandError::NetworkError(m), - CapError::Timeout => CommandError::Timeout, - CapError::Other(m) => CommandError::Other(m), - } - } -} - -// --------------------------------------------------------------------------- -// Per-transport visibility -// --------------------------------------------------------------------------- - -/// Which transports may invoke a command. Mirrors MCP-Template's per-service -/// exclusion set: a CLI-only utility can stay off the HTTP/MCP surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct Expose { - pub cli: bool, - pub api: bool, - pub mcp: bool, -} - -impl Expose { - pub const fn all() -> Self { - Self { - cli: true, - api: true, - mcp: true, - } - } - pub const fn cli_only() -> Self { - Self { - cli: true, - api: false, - mcp: false, - } - } - /// Everywhere except MCP (the reference's common case for utility commands). - pub const fn no_mcp() -> Self { - Self { - cli: true, - api: true, - mcp: false, - } - } -} - -impl Default for Expose { - fn default() -> Self { - Self::all() - } -} - -// --------------------------------------------------------------------------- -// The typed Command trait -// --------------------------------------------------------------------------- - -/// A unit of backend logic with a typed input/output contract. -/// -/// The trait deliberately does **not** bound `Input` on `clap::Args` - the -/// engine stays free of CLI concerns. Individual input structs may add a -/// `#[derive(clap::Args)]` when a hand-written/generated CLI subcommand wants -/// to reuse them (see the CLI scaffolding phase). -#[async_trait] -pub trait Command: Send + Sync + 'static { - type Input: DeserializeOwned + JsonSchema + Send; - type Output: Serialize + JsonSchema + Send; - - fn name(&self) -> &'static str; - - fn description(&self) -> &'static str { - "" - } - - /// Per-transport visibility. Defaults to everywhere. - fn expose(&self) -> Expose { - Expose::all() - } - - async fn run(&self, input: Self::Input, cx: &Ctx<'_>) -> Result; -} - -// --------------------------------------------------------------------------- -// Type erasure – object-safe inner trait doing Value<->typed conversion -// --------------------------------------------------------------------------- - -/// Object-safe form of [`Command`]. Callers never implement this directly; it -/// is blanket-implemented for every `Command` and stored in the registry. -#[async_trait] -pub trait ErasedCommand: Send + Sync { - fn name(&self) -> &'static str; - fn description(&self) -> &'static str; - fn expose(&self) -> Expose; - fn input_schema(&self) -> Value; - fn output_schema(&self) -> Value; - async fn run_json(&self, input: Value, cx: &Ctx<'_>) -> Result; -} - -#[async_trait] -impl ErasedCommand for C { - fn name(&self) -> &'static str { - Command::name(self) - } - fn description(&self) -> &'static str { - Command::description(self) - } - fn expose(&self) -> Expose { - Command::expose(self) - } - fn input_schema(&self) -> Value { - serde_json::to_value(schemars::schema_for!(C::Input)).unwrap_or(Value::Null) - } - fn output_schema(&self) -> Value { - serde_json::to_value(schemars::schema_for!(C::Output)).unwrap_or(Value::Null) - } - async fn run_json(&self, input: Value, cx: &Ctx<'_>) -> Result { - // An absent body is treated as an empty object so commands with no - // required fields (e.g. `ping`) can be called with `{}` or nothing. - let input = if input.is_null() { - Value::Object(Default::default()) - } else { - input - }; - let typed: C::Input = - serde_json::from_value(input).map_err(|e| CommandError::InvalidInput(e.to_string()))?; - let out = Command::run(self, typed, cx).await?; - serde_json::to_value(out).map_err(|e| CommandError::Other(e.to_string())) - } -} - -// --------------------------------------------------------------------------- -// Link-time self-registration (inventory) -// --------------------------------------------------------------------------- - -/// One collected registration entry. Each `register_command!` submits one. -pub struct CommandRegistration { - pub make: fn() -> Box, -} - -inventory::collect!(CommandRegistration); - -/// Register a `Command` type so it self-installs into `CommandRegistry::new`. -/// The type must implement `Default`. -#[macro_export] -macro_rules! register_command { - ($ty:ty) => { - inventory::submit! { - $crate::commands::CommandRegistration { - make: || ::std::boxed::Box::new(<$ty as ::std::default::Default>::default()), - } - } - }; -} - -// --------------------------------------------------------------------------- -// Schema introspection -// --------------------------------------------------------------------------- - -/// A command's public contract, consumed by `GET /commands` and future MCP. -#[derive(Debug, Clone, Serialize)] -pub struct CommandSchema { - pub name: String, - pub description: String, - pub expose: Expose, - pub input_schema: Value, - pub output_schema: Value, -} - -// --------------------------------------------------------------------------- -// Registry -// --------------------------------------------------------------------------- - -pub struct CommandRegistry { - commands: BTreeMap<&'static str, Box>, -} - -impl CommandRegistry { - /// Build the registry from every `register_command!`-registered command. - pub fn new() -> Self { - let mut commands: BTreeMap<&'static str, Box> = BTreeMap::new(); - for reg in inventory::iter:: { - let cmd = (reg.make)(); - let name = cmd.name(); - if commands.insert(name, cmd).is_some() { - // Two commands claimed the same name - a scaffolding mistake. - panic!("duplicate command registered: {name}"); - } - } - Self { commands } - } - - /// All registered command names, sorted. - pub fn list(&self) -> Vec<&str> { - self.commands.keys().copied().collect() - } - - fn get(&self, name: &str) -> Option<&dyn ErasedCommand> { - self.commands.get(name).map(|b| b.as_ref()) - } - - /// The schema for a single command, if present. - pub fn schema(&self, name: &str) -> Option { - self.get(name).map(|c| CommandSchema { - name: c.name().to_string(), - description: c.description().to_string(), - expose: c.expose(), - input_schema: c.input_schema(), - output_schema: c.output_schema(), - }) - } - - /// Schemas for every command, sorted by name. - pub fn schemas(&self) -> Vec { - self.list() - .into_iter() - .filter_map(|n| self.schema(n)) - .collect() - } - - /// Bare-output path (HTTP API / MCP): deserialize `args`, run, serialize the - /// typed `Output`. Returns the raw output value or a typed error the - /// transport maps onto an HTTP status. - pub async fn call(&self, name: &str, args: Value, cx: &Ctx<'_>) -> Result { - match self.get(name) { - Some(cmd) => cmd.run_json(args, cx).await, - None => Err(CommandError::InvalidInput(format!( - "unknown command: {name}" - ))), - } - } - - /// Envelope path (CLI / scenario runner): run a command and wrap the result - /// in the diagnostic [`CommandResult`] contract (run_id, status, timing). - pub async fn execute(&self, name: &str, args: Value, cx: &Ctx<'_>) -> CommandResult { - let start = Instant::now(); - let run_id = cx.request_id.clone(); - - match self.call(name, args, cx).await { - Ok(data) => { - let mut r = result_ok("call", name, &run_id, start.elapsed().as_millis() as u64); - r.data = Some(data); - r - } - Err(e) => result_err( - "call", - name, - &run_id, - start.elapsed().as_millis() as u64, - e.error_code(), - e.to_string(), - ), - } - } -} - -impl Default for CommandRegistry { - fn default() -> Self { - Self::new() - } -} - -// =========================================================================== -// Tests -// =========================================================================== - -#[cfg(test)] -mod tests { - use super::*; - use crate::context::{AppContext, Ctx}; - - async fn run(name: &str, args: Value) -> CommandResult { - let ctx = AppContext::default(); - let cx = Ctx::new(&ctx); - CommandRegistry::new().execute(name, args, &cx).await - } - - #[tokio::test] - async fn test_ping_command() { - let result = run("ping", serde_json::json!({})).await; - assert_eq!(result.status, Status::Pass); - assert_eq!(result.data.unwrap()["pong"], true); - } - - #[tokio::test] - async fn test_ping_accepts_null_args() { - // A null/absent body should be treated as `{}` for no-field inputs. - let result = run("ping", Value::Null).await; - assert_eq!(result.status, Status::Pass); - } - - #[tokio::test] - async fn test_unknown_command() { - let result = run("nonexistent", serde_json::json!({})).await; - assert_eq!(result.status, Status::Error); - assert_eq!(result.error.unwrap().code, ErrorCode::InvalidInput); - } - - #[tokio::test] - async fn test_invalid_input_maps_to_error() { - // read_file requires a 'path' string; omitting it is InvalidInput. - let result = run("read_file", serde_json::json!({})).await; - assert_eq!(result.status, Status::Error); - assert_eq!(result.error.unwrap().code, ErrorCode::InvalidInput); - } - - #[tokio::test] - async fn test_read_write_file() { - let ctx = AppContext::default(); - let tmp = std::env::temp_dir().join("engine_test_rw.txt"); - let path_str = tmp.to_str().unwrap(); - - let cx = Ctx::new(&ctx); - let reg = CommandRegistry::new(); - let w = reg - .execute( - "write_file", - serde_json::json!({ "path": path_str, "content": "hello engine" }), - &cx, - ) - .await; - assert_eq!(w.status, Status::Pass); - - let cx = Ctx::new(&ctx); - let r = reg - .execute("read_file", serde_json::json!({ "path": path_str }), &cx) - .await; - assert_eq!(r.status, Status::Pass); - assert_eq!(r.data.unwrap()["content"], "hello engine"); - - let _ = std::fs::remove_file(&tmp); - } - - #[test] - fn test_list_commands() { - let reg = CommandRegistry::new(); - let names = reg.list(); - for expected in [ - "ping", - "read_file", - "write_file", - "system_info", - "list_dir", - "http_request", - ] { - assert!(names.contains(&expected), "missing command: {expected}"); - } - } - - #[test] - fn test_schema_introspection() { - let reg = CommandRegistry::new(); - let schema = reg.schema("read_file").expect("read_file schema"); - assert_eq!(schema.name, "read_file"); - // read_file reads a caller-supplied path with no sandbox, so it is - // CLI-only - reachable via the CLI, never over the unauthenticated API. - assert!(schema.expose.cli); - assert!(!schema.expose.api); - // The input schema should describe the required `path` field. - let s = serde_json::to_string(&schema.input_schema).unwrap(); - assert!(s.contains("path"), "input schema missing path: {s}"); - } - - #[tokio::test] - async fn test_system_info_command() { - let result = run("system_info", serde_json::json!({})).await; - assert_eq!(result.status, Status::Pass); - let data = result.data.unwrap(); - assert!(data["os"].is_string()); - assert!(data["arch"].is_string()); - assert!(data["hostname"].is_string()); - } - - #[tokio::test] - async fn test_list_dir_command() { - let tmp = std::env::temp_dir(); - let path_str = tmp.to_str().unwrap(); - let result = run("list_dir", serde_json::json!({ "path": path_str })).await; - assert_eq!(result.status, Status::Pass); - assert!(result.data.unwrap()["entries"].is_array()); - } - - #[tokio::test] - async fn test_list_dir_not_a_directory() { - let result = run( - "list_dir", - serde_json::json!({ "path": "/nonexistent_dir_12345" }), - ) - .await; - assert_eq!(result.status, Status::Error); - } -} diff --git a/crates/engine/src/commands/ping.rs b/crates/engine/src/commands/ping.rs deleted file mode 100644 index 83b884f..0000000 --- a/crates/engine/src/commands/ping.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! `ping` – liveness check. Proves the wiring works end to end. - -use crate::commands::{Command, CommandError}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct Ping; - -#[derive(Debug, Default, Deserialize, JsonSchema)] -pub struct PingInput {} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct PingOutput { - pub pong: bool, -} - -#[async_trait] -impl Command for Ping { - type Input = PingInput; - type Output = PingOutput; - - fn name(&self) -> &'static str { - "ping" - } - - fn description(&self) -> &'static str { - "Liveness check; returns { pong: true }." - } - - async fn run(&self, _input: PingInput, _cx: &Ctx<'_>) -> Result { - Ok(PingOutput { pong: true }) - } -} - -register_command!(Ping); diff --git a/crates/engine/src/commands/read_file.rs b/crates/engine/src/commands/read_file.rs deleted file mode 100644 index e69db4b..0000000 --- a/crates/engine/src/commands/read_file.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! `read_file` – read a file and return its contents as a UTF-8 string. - -use crate::commands::{Command, CommandError, Expose}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct ReadFile; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ReadFileInput { - /// Absolute path to the file to read. - pub path: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct ReadFileOutput { - pub content: String, - pub size_bytes: usize, -} - -#[async_trait] -impl Command for ReadFile { - type Input = ReadFileInput; - type Output = ReadFileOutput; - - fn name(&self) -> &'static str { - "read_file" - } - - fn description(&self) -> &'static str { - "Read a file and return its UTF-8 contents." - } - - /// Reads a caller-supplied path with no sandbox - CLI-only so it is not - /// reachable as an unauthenticated arbitrary-file-read over the HTTP API. - fn expose(&self) -> Expose { - Expose::cli_only() - } - - async fn run( - &self, - input: ReadFileInput, - cx: &Ctx<'_>, - ) -> Result { - let path = std::path::Path::new(&input.path); - let data = cx.fs().read_file(path)?; - let size_bytes = data.len(); - // The output contract is UTF-8; surface a clear error on binary input - // rather than silently returning lossily-replaced content. - let content = String::from_utf8(data) - .map_err(|_| CommandError::InvalidInput("file is not valid UTF-8".to_string()))?; - Ok(ReadFileOutput { - content, - size_bytes, - }) - } -} - -register_command!(ReadFile); diff --git a/crates/engine/src/commands/system_info.rs b/crates/engine/src/commands/system_info.rs deleted file mode 100644 index 7885104..0000000 --- a/crates/engine/src/commands/system_info.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! `system_info` – return OS, architecture, hostname, and headless status. - -use crate::commands::{Command, CommandError}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct SystemInfo; - -#[derive(Debug, Default, Deserialize, JsonSchema)] -pub struct SystemInfoInput {} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct SystemInfoOutput { - pub os: String, - pub arch: String, - pub hostname: String, - pub headless: bool, -} - -#[async_trait] -impl Command for SystemInfo { - type Input = SystemInfoInput; - type Output = SystemInfoOutput; - - fn name(&self) -> &'static str { - "system_info" - } - - fn description(&self) -> &'static str { - "Report OS, architecture, hostname, and headless status." - } - - async fn run( - &self, - _input: SystemInfoInput, - _cx: &Ctx<'_>, - ) -> Result { - let hostname = hostname::get() - .map(|h| h.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".to_string()); - - Ok(SystemInfoOutput { - os: std::env::consts::OS.to_string(), - arch: std::env::consts::ARCH.to_string(), - hostname, - headless: crate::types::detect_headless(), - }) - } -} - -register_command!(SystemInfo); diff --git a/crates/engine/src/commands/write_file.rs b/crates/engine/src/commands/write_file.rs deleted file mode 100644 index d190faf..0000000 --- a/crates/engine/src/commands/write_file.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! `write_file` – write string content to a file. - -use crate::commands::{Command, CommandError, Expose}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct WriteFile; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct WriteFileInput { - /// Absolute path to write to (parent directories are created). - pub path: String, - /// UTF-8 content to write. - pub content: String, -} - -#[derive(Debug, Serialize, JsonSchema)] -pub struct WriteFileOutput { - pub bytes_written: usize, -} - -#[async_trait] -impl Command for WriteFile { - type Input = WriteFileInput; - type Output = WriteFileOutput; - - fn name(&self) -> &'static str { - "write_file" - } - - fn description(&self) -> &'static str { - "Write UTF-8 content to a file, creating parent directories." - } - - /// Writes to a caller-supplied path with no sandbox - CLI-only so it is not - /// reachable as an unauthenticated arbitrary-file-write over the HTTP API. - fn expose(&self) -> Expose { - Expose::cli_only() - } - - async fn run( - &self, - input: WriteFileInput, - cx: &Ctx<'_>, - ) -> Result { - let path = std::path::Path::new(&input.path); - // This command creates parent dirs and writes arbitrary content, so - // reject relative/traversal paths rather than writing somewhere unintended. - if !path.is_absolute() { - return Err(CommandError::InvalidInput(format!( - "path must be absolute: {}", - input.path - ))); - } - let data = input.content.as_bytes(); - cx.fs().write_file(path, data)?; - Ok(WriteFileOutput { - bytes_written: data.len(), - }) - } -} - -register_command!(WriteFile); diff --git a/crates/engine/src/context.rs b/crates/engine/src/context.rs deleted file mode 100644 index c1e1029..0000000 --- a/crates/engine/src/context.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Application context – holds capability trait objects and config. -//! -//! Two layers, mirroring the transport design: -//! - [`AppContext`] holds the shared OS capabilities (fs/net). It is -//! built once at process/transport start and shared (e.g. behind an `Arc` in -//! the HTTP server's state). -//! - [`Ctx`] is constructed **per request/invocation**, borrowing the shared -//! capabilities and carrying request-scoped data (`request_id`, `deadline`). -//! Commands receive `&Ctx`. This is the seam where auth/identity would later -//! attach - no identity field exists yet, by design. - -use crate::platform::{ReqwestNetwork, StdFilesystem}; -use crate::traits::*; -use crate::types::new_run_id; -use std::time::Instant; - -/// Central context passed to all engine operations. -/// -/// Holds trait-object capabilities. [`AppContext::default`] wires the real -/// platform implementations; [`AppContext::new`] is the injection seam - pass -/// stub capabilities to it to run a command against fakes (e.g. an offline test -/// filesystem/network). -pub struct AppContext { - fs: Box, - network: Box, - /// Target URL the `network` probe hits. Defaults to - /// [`DEFAULT_NETWORK_PROBE_HOST`], overridable via the - /// `APP__NETWORK_PROBE_HOST` env var or by setting this field directly - /// (e.g. to an internal health endpoint you control). - pub network_probe_host: String, -} - -/// Default target for the `network` probe when no override is set. -pub const DEFAULT_NETWORK_PROBE_HOST: &str = "https://httpbin.org/get"; - -impl Default for AppContext { - /// Wire the real platform capabilities. Use [`AppContext::new`] to inject - /// stub implementations instead. - /// - /// This is the production path, so it also honors the - /// `APP__NETWORK_PROBE_HOST` env override for the `network` probe target. - /// [`AppContext::new`] deliberately does **not** read env, so stub-injecting - /// tests stay deterministic regardless of ambient environment. - fn default() -> Self { - let mut ctx = Self::new(Box::new(StdFilesystem), Box::new(ReqwestNetwork)); - if let Some(host) = std::env::var("APP__NETWORK_PROBE_HOST") - .ok() - .filter(|v| !v.trim().is_empty()) - { - ctx.network_probe_host = host; - } - ctx - } -} - -impl AppContext { - /// Build a context over caller-supplied capabilities. This is the seam for - /// injecting stubs (tests, offline runs); [`AppContext::default`] wires the - /// real platform ones. Pure: reads no environment, so tests are - /// deterministic. `network_probe_host` starts at - /// [`DEFAULT_NETWORK_PROBE_HOST`] and can be overridden by setting the field - /// directly. - pub fn new(fs: Box, network: Box) -> Self { - Self { - fs, - network, - network_probe_host: DEFAULT_NETWORK_PROBE_HOST.to_string(), - } - } - - pub fn fs(&self) -> &dyn FilesystemOps { - self.fs.as_ref() - } - - pub fn network(&self) -> &dyn NetworkOps { - self.network.as_ref() - } -} - -/// Per-request context passed to every [`Command`](crate::commands::Command). -/// -/// Borrows the shared [`AppContext`] capabilities and adds request-scoped -/// state. Built fresh for each invocation so each request gets its own -/// `request_id` (and, when set, `deadline`). -pub struct Ctx<'a> { - /// Unique id for this invocation (surfaced as `run_id` in the CLI envelope - /// and the `x-run-id` HTTP header). - pub request_id: String, - /// Optional wall-clock deadline for this invocation. Enforcement lives in - /// the transport (scenario runner / tower timeout); commands may consult it. - pub deadline: Option, - caps: &'a AppContext, -} - -impl<'a> Ctx<'a> { - /// Build a per-request context over shared capabilities with a fresh id. - pub fn new(caps: &'a AppContext) -> Self { - Self { - request_id: new_run_id(), - deadline: None, - caps, - } - } - - /// Build a context with a caller-supplied request id (e.g. an incoming - /// `x-run-id`/trace header). - pub fn with_request_id(caps: &'a AppContext, request_id: impl Into) -> Self { - Self { - request_id: request_id.into(), - deadline: None, - caps, - } - } - - /// Attach a deadline (builder-style). - pub fn with_deadline(mut self, deadline: Instant) -> Self { - self.deadline = Some(deadline); - self - } - - /// The shared capability bundle. - pub fn caps(&self) -> &AppContext { - self.caps - } - - pub fn fs(&self) -> &dyn FilesystemOps { - self.caps.fs() - } - - pub fn network(&self) -> &dyn NetworkOps { - self.caps.network() - } -} diff --git a/crates/engine/src/gateway/config.rs b/crates/engine/src/gateway/config.rs index a476754..82c4315 100644 --- a/crates/engine/src/gateway/config.rs +++ b/crates/engine/src/gateway/config.rs @@ -61,6 +61,22 @@ impl GatewayConfig { Self::from_getter(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty())) } + /// Resolve from the environment, then override the gateway origin with + /// `url` when it is `Some` and non-blank (the `--gateway-url` flag). A + /// trailing `/` is trimmed to match [`from_env`](Self::from_env); an + /// override that is blank β€” or degenerate, like `/` or `///`, which trims to + /// empty β€” is ignored so the env/default value stands. + pub fn from_env_with_url_override(url: Option) -> Self { + let mut cfg = Self::from_env(); + if let Some(u) = url { + let trimmed = u.trim().trim_end_matches('/'); + if !trimmed.is_empty() { + cfg.base_url = trimmed.to_string(); + } + } + cfg + } + /// Resolve from an arbitrary getter (the test seam). pub fn from_getter(get: impl Fn(&str) -> Option) -> Self { let base_url = get(env_keys::URL) @@ -156,6 +172,33 @@ mod tests { assert_eq!(c.ca_bundle.as_deref(), Some("/a.pem")); } + #[test] + fn url_override_trims_trailing_slash_and_ignores_blank() { + // A non-blank override replaces base_url and drops the trailing slash. + let cfg = + GatewayConfig::from_env_with_url_override(Some("https://gw.example.com/".to_string())); + assert_eq!(cfg.base_url, "https://gw.example.com"); + + // A blank / whitespace-only override is ignored, leaving the resolved + // base_url identical to plain `from_env` (deterministic regardless of + // ambient SEALGATE_URL). + let base = GatewayConfig::from_env().base_url; + let cfg = GatewayConfig::from_env_with_url_override(Some(" ".to_string())); + assert_eq!(cfg.base_url, base); + let cfg = GatewayConfig::from_env_with_url_override(None); + assert_eq!(cfg.base_url, base); + + // A degenerate override that trims to empty (all slashes) is ignored + // too, so base_url never becomes empty (which would break mcp_url()). + for degenerate in ["/", "///", " // "] { + let cfg = GatewayConfig::from_env_with_url_override(Some(degenerate.to_string())); + assert_eq!( + cfg.base_url, base, + "override {degenerate:?} should be ignored" + ); + } + } + #[test] fn blank_values_are_treated_as_unset_via_from_env_filter() { // from_getter itself does not filter, but from_env filters blanks; emulate. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 018c663..8775b95 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,22 +1,15 @@ //! Engine crate – the shared service core for SealGate. //! -//! This crate contains all real backend logic and OS integrations behind -//! traits. It has NO transport dependency (no CLI, axum, or HTTP types), so the -//! same commands run over the CLI, the HTTP API, and (later) MCP. +//! `sealg` is a thin MCP client to the SealGate gateway. This crate holds the +//! transport-agnostic pieces it needs: the gateway client/config +//! ([`gateway`]), the environment diagnostics behind `doctor`, and the shared +//! result contract in [`types`]. It carries no CLI or HTTP types. -pub mod commands; -pub mod context; pub mod doctor; mod env; pub mod gateway; -pub mod platform; -pub mod probes; -pub mod scenario; -pub mod traits; pub mod types; // Re-exports for convenience -pub use commands::{Command, CommandError, CommandRegistry, CommandSchema, ErasedCommand, Expose}; -pub use context::{AppContext, Ctx}; pub use gateway::{GatewayClient, GatewayConfig, GatewayError, ToolInfo}; pub use types::{CommandResult, ErrorCode, ErrorInfo, Status}; diff --git a/crates/engine/src/platform.rs b/crates/engine/src/platform.rs deleted file mode 100644 index e6bd306..0000000 --- a/crates/engine/src/platform.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Platform-specific implementations of OS capability traits. -//! -//! - [`StdFilesystem`]: real std::fs operations -//! - [`ReqwestNetwork`]: real HTTP via reqwest - -use crate::traits::*; -use std::path::{Path, PathBuf}; - -// =========================================================================== -// Filesystem – wraps std::fs -// =========================================================================== - -pub struct StdFilesystem; - -impl FilesystemOps for StdFilesystem { - fn read_file(&self, path: &Path) -> CapResult> { - std::fs::read(path).map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => CapError::Io(e), - std::io::ErrorKind::PermissionDenied => { - CapError::PermissionDenied(format!("cannot read {}: {}", path.display(), e)) - } - _ => CapError::Io(e), - }) - } - - fn write_file(&self, path: &Path, data: &[u8]) -> CapResult<()> { - if let Some(parent) = path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::write(path, data).map_err(|e| match e.kind() { - std::io::ErrorKind::PermissionDenied => { - CapError::PermissionDenied(format!("cannot write {}: {}", path.display(), e)) - } - _ => CapError::Io(e), - }) - } - - fn remove_file(&self, path: &Path) -> CapResult<()> { - std::fs::remove_file(path).map_err(CapError::Io) - } - - fn create_dir_all(&self, path: &Path) -> CapResult<()> { - std::fs::create_dir_all(path).map_err(CapError::Io) - } - - fn remove_dir_all(&self, path: &Path) -> CapResult<()> { - std::fs::remove_dir_all(path).map_err(CapError::Io) - } - - fn exists(&self, path: &Path) -> bool { - path.exists() - } - - fn temp_dir(&self) -> PathBuf { - std::env::temp_dir() - } - - fn list_dir(&self, path: &Path) -> CapResult> { - let read_dir = std::fs::read_dir(path)?; - let mut entries = Vec::new(); - for entry in read_dir { - let entry = entry?; - // Skip entries whose metadata is transiently unavailable - let Ok(meta) = entry.metadata() else { continue }; - entries.push(DirEntry { - name: entry.file_name().to_string_lossy().into_owned(), - is_dir: meta.is_dir(), - size_bytes: meta.len(), - }); - } - Ok(entries) - } -} - -// =========================================================================== -// Network – wraps reqwest -// =========================================================================== - -pub struct ReqwestNetwork; - -#[async_trait::async_trait] -impl NetworkOps for ReqwestNetwork { - async fn dns_resolve(&self, host: &str) -> CapResult> { - use tokio::net::lookup_host; - let addrs: Vec = lookup_host(format!("{}:443", host)) - .await - .map_err(|e| CapError::Network(format!("DNS resolution failed for {}: {}", host, e)))? - .map(|a| a.ip().to_string()) - .collect(); - if addrs.is_empty() { - return Err(CapError::Network(format!( - "DNS resolution returned no addresses for {}", - host - ))); - } - Ok(addrs) - } - - async fn https_get(&self, url: &str, timeout_ms: u64) -> CapResult<(u16, String)> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_millis(timeout_ms)) - .build() - .map_err(|e| CapError::Network(format!("failed to build HTTP client: {}", e)))?; - - let resp = client.get(url).send().await.map_err(|e| { - if e.is_timeout() { - CapError::Timeout - } else { - CapError::Network(format!("HTTPS GET {}: {}", url, e)) - } - })?; - - let status = resp.status().as_u16(); - // Read at most 4 KiB for the snippet - let body = resp - .text() - .await - .map_err(|e| CapError::Network(format!("reading body: {}", e)))?; - let snippet: String = body.chars().take(4096).collect(); - Ok((status, snippet)) - } -} diff --git a/crates/engine/src/probes.rs b/crates/engine/src/probes.rs deleted file mode 100644 index 436bbff..0000000 --- a/crates/engine/src/probes.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Targeted capability probes – filesystem, network. - -use crate::context::AppContext; -use crate::traits::CapError; -use crate::types::*; -use std::collections::HashMap; -use std::time::Instant; - -/// Run a probe by name and return a full CommandResult. -pub async fn run_probe(name: &str, ctx: &AppContext) -> CommandResult { - match name { - "filesystem" => probe_filesystem(ctx), - "network" => probe_network(ctx).await, - _ => { - let run_id = new_run_id(); - result_err( - "probe", - name, - &run_id, - 0, - ErrorCode::InvalidInput, - format!("unknown probe: {} (available: filesystem, network)", name), - ) - } - } -} - -// --------------------------------------------------------------------------- -// Filesystem probe -// --------------------------------------------------------------------------- - -fn probe_filesystem(ctx: &AppContext) -> CommandResult { - let run_id = new_run_id(); - let start = Instant::now(); - let mut steps = HashMap::new(); - - let tmp_dir = ctx - .fs() - .temp_dir() - .join(format!("engine_probe_{}", &run_id[..8])); - - // Step 1: create temp directory - let t0 = Instant::now(); - if let Err(e) = ctx.fs().create_dir_all(&tmp_dir) { - return probe_fs_err(&run_id, start, steps, "create_dir", e); - } - steps.insert("create_dir".into(), t0.elapsed().as_millis() as u64); - - // Step 2: write a test file - let test_file = tmp_dir.join("probe_test.txt"); - let payload = b"engine filesystem probe"; - let t1 = Instant::now(); - if let Err(e) = ctx.fs().write_file(&test_file, payload) { - let _ = ctx.fs().remove_dir_all(&tmp_dir); - return probe_fs_err(&run_id, start, steps, "write_file", e); - } - steps.insert("write_file".into(), t1.elapsed().as_millis() as u64); - - // Step 3: read it back and verify - let t2 = Instant::now(); - match ctx.fs().read_file(&test_file) { - Ok(data) => { - if data != payload { - let _ = ctx.fs().remove_dir_all(&tmp_dir); - return result_err( - "probe", - "filesystem", - &run_id, - start.elapsed().as_millis() as u64, - ErrorCode::ExternalInterference, - "read-back data does not match written data", - ); - } - } - Err(e) => { - let _ = ctx.fs().remove_dir_all(&tmp_dir); - return probe_fs_err(&run_id, start, steps, "read_file", e); - } - } - steps.insert("read_verify".into(), t2.elapsed().as_millis() as u64); - - // Step 4: cleanup - let t3 = Instant::now(); - let _ = ctx.fs().remove_dir_all(&tmp_dir); - steps.insert("cleanup".into(), t3.elapsed().as_millis() as u64); - - let mut r = result_ok( - "probe", - "filesystem", - &run_id, - start.elapsed().as_millis() as u64, - ); - r.timing_ms.steps = steps; - r.data = Some(serde_json::json!({ - "temp_dir_used": tmp_dir.display().to_string(), - })); - r -} - -fn probe_fs_err( - run_id: &str, - start: Instant, - steps: HashMap, - failed_step: &str, - err: CapError, -) -> CommandResult { - let code = match &err { - CapError::PermissionDenied(_) => ErrorCode::PermissionDenied, - CapError::Io(_) => ErrorCode::IoError, - _ => ErrorCode::InternalError, - }; - let mut r = result_err( - "probe", - "filesystem", - run_id, - start.elapsed().as_millis() as u64, - code, - format!("filesystem probe failed at {}: {}", failed_step, err), - ); - r.timing_ms.steps = steps; - r -} - -// --------------------------------------------------------------------------- -// Network probe -// --------------------------------------------------------------------------- - -async fn probe_network(ctx: &AppContext) -> CommandResult { - let run_id = new_run_id(); - let start = Instant::now(); - let mut steps = HashMap::new(); - - let host = &ctx.network_probe_host; - // Extract hostname for DNS (strip scheme + path) - let dns_host = host - .trim_start_matches("https://") - .trim_start_matches("http://") - .split('/') - .next() - .unwrap_or(host); - - // Step 1: DNS resolve - let t0 = Instant::now(); - let addrs = match ctx.network().dns_resolve(dns_host).await { - Ok(addrs) => addrs, - Err(e) => { - steps.insert("dns_resolve".into(), t0.elapsed().as_millis() as u64); - let msg = format!("DNS resolution failed: {}", e); - return probe_net_err(&run_id, start, steps, ErrorCode::NetworkError, msg); - } - }; - steps.insert("dns_resolve".into(), t0.elapsed().as_millis() as u64); - - // Step 2: HTTPS GET - let t1 = Instant::now(); - let status = match ctx.network().https_get(host, 10_000).await { - Ok((status, _snippet)) => status, - Err(e) => { - steps.insert("https_get".into(), t1.elapsed().as_millis() as u64); - let code = match &e { - CapError::Timeout => ErrorCode::Timeout, - _ => ErrorCode::NetworkError, - }; - let msg = format!("HTTPS GET failed: {}", e); - return probe_net_err(&run_id, start, steps, code, msg); - } - }; - steps.insert("https_get".into(), t1.elapsed().as_millis() as u64); - - let mut r = result_ok( - "probe", - "network", - &run_id, - start.elapsed().as_millis() as u64, - ); - r.timing_ms.steps = steps; - r.data = Some(serde_json::json!({ - "dns_addresses": addrs, - "http_status": status, - // Names only - values can embed credentials (e.g. an authenticated - // HTTP_PROXY URL) and this probe is reachable over the HTTP API. - "proxy_env_set": crate::env::proxy_env_names(), - "target_url": host, - })); - r -} - -fn probe_net_err( - run_id: &str, - start: Instant, - steps: HashMap, - code: ErrorCode, - message: String, -) -> CommandResult { - let mut r = result_err( - "probe", - "network", - run_id, - start.elapsed().as_millis() as u64, - code, - message, - ); - r.timing_ms.steps = steps; - r -} diff --git a/crates/engine/src/scenario.rs b/crates/engine/src/scenario.rs deleted file mode 100644 index 7a92e81..0000000 --- a/crates/engine/src/scenario.rs +++ /dev/null @@ -1,633 +0,0 @@ -//! Scenario runner – execute scripted flows from YAML files. - -use crate::commands::CommandRegistry; -use crate::context::{AppContext, Ctx}; -use crate::probes; -use crate::types::*; -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -/// Load a scenario from a YAML string. -pub fn load_scenario(yaml: &str) -> Result { - serde_yaml::from_str(yaml).map_err(|e| format!("failed to parse scenario YAML: {}", e)) -} - -/// User choice at each interactive step. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StepChoice { - Run, - Skip, - GoBack, -} - -/// User choice after a step failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FailureChoice { - Continue, - Abort, -} - -/// Outcome stored per step so re-running overwrites the previous result. -#[derive(Debug, Clone)] -pub(crate) struct StepOutcome { - pub(crate) status: StepStatus, - pub(crate) result: CommandResult, -} - -/// Disposition of a completed step. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StepStatus { - Completed, - Skipped, - Failed, -} - -/// Return the label (target name) for a scenario step. -fn step_label(step: &ScenarioStep) -> String { - match step { - ScenarioStep::Call { call, .. } => call.clone(), - ScenarioStep::Probe { probe } => format!("probe:{}", probe), - } -} - -/// Execute a single scenario step and return the result plus whether the -/// expectation was met. -async fn execute_step( - step: &ScenarioStep, - idx: usize, - ctx: &AppContext, - registry: &CommandRegistry, -) -> (CommandResult, bool) { - match step { - ScenarioStep::Call { - call, - args, - expect_status, - timeout_ms, - } => { - // Commands are async, so `tokio::time::timeout` now genuinely - // preempts a command that yields at an `.await` point once the - // deadline elapses. The per-step `Ctx` also carries the deadline so - // a command can consult it. - let deadline = Duration::from_millis(*timeout_ms); - let cx = Ctx::new(ctx).with_deadline(Instant::now() + deadline); - - let timeout_result = - tokio::time::timeout(deadline, registry.execute(call, args.clone(), &cx)).await; - - let r = match timeout_result { - Ok(result) => result, - Err(_elapsed) => result_err( - "call", - call, - &cx.request_id, - *timeout_ms, - ErrorCode::Timeout, - format!("step {} ('{}') timed out after {}ms", idx, call, timeout_ms), - ), - }; - - let actual_status = serde_json::to_value(r.status) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - let met = actual_status == *expect_status; - if !met { - tracing::warn!( - step = idx, - expected = %expect_status, - actual = %actual_status, - "scenario step status mismatch" - ); - } - (r, met) - } - ScenarioStep::Probe { probe } => { - let r = probes::run_probe(probe, ctx).await; - let met = r.status == Status::Pass || r.status == Status::Skip; - (r, met) - } - } -} - -/// Execute a scenario non-interactively (forward-only). -pub async fn run_scenario( - scenario: &Scenario, - ctx: &AppContext, - registry: &CommandRegistry, -) -> ScenarioResult { - let mut step_results = Vec::new(); - let mut overall = Status::Pass; - - for (i, step) in scenario.steps.iter().enumerate() { - let (result, expectation_met) = execute_step(step, i, ctx, registry).await; - if !expectation_met { - overall = Status::Fail; - } - step_results.push(result); - } - - ScenarioResult { - name: scenario.name.clone(), - overall_status: overall, - step_results, - } -} - -/// Execute a scenario interactively with go-back navigation. -/// -/// - `prompt_fn` is called at each step to ask the user whether to run, skip, -/// or go back. Returns `None` to abort the scenario. -/// - `failure_fn` is called when a step fails, asking the user whether to -/// continue or abort. Returns `None` to abort. -/// -/// This keeps the engine crate free of direct terminal I/O dependencies - -/// the CLI crate provides the real prompters. -pub async fn run_scenario_interactive( - scenario: &Scenario, - ctx: &AppContext, - registry: &CommandRegistry, - mut prompt_fn: F, - mut failure_fn: G, -) -> ScenarioResult -where - F: FnMut(usize, usize, &str, bool) -> Option, - G: FnMut(usize, usize, &str) -> Option, -{ - let total = scenario.steps.len(); - let mut results: HashMap = HashMap::new(); - - let mut idx = 0; - while idx < total { - let step = &scenario.steps[idx]; - let label = step_label(step); - let can_go_back = idx > 0; - - let choice = match prompt_fn(idx, total, &label, can_go_back) { - Some(c) => c, - None => break, // user aborted - }; - - match choice { - StepChoice::GoBack => { - // Use an explicit guard rather than saturating_sub so the - // intent - stay at step 0 when can_go_back is false - is - // immediately visible without reading clippy docs. - #[allow(clippy::implicit_saturating_sub)] - if idx > 0 { - // Invalidate all steps at or after the current position - // (includes idx itself, which may hold a stale result - // from a prior forward pass) so stale entries cannot - // masquerade as a completed run if the user later aborts. - // Note: results[idx-1] (the destination) may retain a - // stale entry - it will be overwritten when the user - // re-decides that step (Run/Skip both insert). - for stale in idx..total { - results.remove(&stale); - } - idx -= 1; - } - continue; - } - StepChoice::Skip => { - // This entry will be overwritten if the user later revisits - // this step via GoBack, or cleaned up by a GoBack from a - // subsequent step (which invalidates idx..total). - let run_id = new_run_id(); - results.insert( - idx, - StepOutcome { - status: StepStatus::Skipped, - result: { - let mut r = result_skip("scenario", &label, &run_id, 0, "user skipped"); - // Override the default Unsupported code - this is - // a deliberate user choice, not a platform limitation. - if let Some(ref mut err) = r.error { - err.code = ErrorCode::UserSkipped; - } - r - }, - }, - ); - idx += 1; - continue; - } - StepChoice::Run => {} - } - - let (result, expectation_met) = execute_step(step, idx, ctx, registry).await; - - if !expectation_met { - // Insert the failed outcome first so failure_fn sees a - // consistent results map if it ever inspects it. - results.insert( - idx, - StepOutcome { - status: StepStatus::Failed, - result, - }, - ); - let decision = failure_fn(idx, total, &label); - if decision != Some(FailureChoice::Continue) { - break; - } - idx += 1; - continue; - } - - results.insert( - idx, - StepOutcome { - status: StepStatus::Completed, - result, - }, - ); - idx += 1; - } - - // Derive overall status from results - let overall = if results.values().any(|o| o.status == StepStatus::Failed) { - Status::Fail - } else if results.len() < total - || (total > 0 && results.values().all(|o| o.status == StepStatus::Skipped)) - { - // User aborted before all steps were reached, or skipped every step - Status::Skip - } else { - Status::Pass - }; - - // Collect results in step order - let step_results: Vec = (0..total) - .filter_map(|i| results.remove(&i).map(|o| o.result)) - .collect(); - - ScenarioResult { - name: scenario.name.clone(), - overall_status: overall, - step_results, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_scenario() { - let yaml = r#" -name: basic test -steps: - - call: "ping" - args: {} - expect_status: "pass" - timeout_ms: 5000 - - probe: "filesystem" -"#; - let s = load_scenario(yaml).expect("should parse"); - assert_eq!(s.name, Some("basic test".into())); - assert_eq!(s.steps.len(), 2); - } - - #[tokio::test] - async fn test_run_scenario_ping() { - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - let result = run_scenario(&scenario, &ctx, ®).await; - assert_eq!(result.overall_status, Status::Pass); - assert_eq!(result.step_results.len(), 1); - } - - #[test] - fn test_parse_scenario_minimal() { - let yaml = r#" -steps: - - call: "read_file" - args: - path: "/tmp/nope" -"#; - let s = load_scenario(yaml).expect("should parse"); - assert_eq!(s.steps.len(), 1); - } - - #[tokio::test] - async fn test_interactive_go_back() { - // 3-step scenario: write_file (step 0), ping, ping - // Simulate: run step 0, go back at step 1, run step 0 again, run step 1, skip step 2 - // Use write_file for step 0 so we can verify it actually executed twice - // by checking the file exists (proves re-execution, not caching). - let tmp_dir = tempfile::tempdir().unwrap(); - let tmp = tmp_dir.path().join("step0.txt"); - let tmp_str = tmp.to_str().unwrap().to_string(); - - // Build the scenario struct directly instead of formatting a YAML - // string, to avoid backslash-escape issues with Windows paths. - let scenario = Scenario { - name: None, - steps: vec![ - ScenarioStep::Call { - call: "write_file".to_string(), - args: serde_json::json!({ "path": tmp_str, "content": "x" }), - expect_status: "pass".to_string(), - timeout_ms: 30_000, - }, - ScenarioStep::Call { - call: "ping".to_string(), - args: serde_json::json!({}), - expect_status: "pass".to_string(), - timeout_ms: 30_000, - }, - ScenarioStep::Call { - call: "ping".to_string(), - args: serde_json::json!({}), - expect_status: "pass".to_string(), - timeout_ms: 30_000, - }, - ], - }; - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let call_count = std::cell::Cell::new(0usize); - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |idx, _total, _label, _can_go_back| { - let n = call_count.get(); - call_count.set(n + 1); - match n { - 0 => { - assert_eq!(idx, 0); - Some(StepChoice::Run) - } - 1 => { - assert_eq!(idx, 1); - // Delete the file before going back, so re-execution - // of step 0 must recreate it. - std::fs::remove_file(&tmp).expect("file should exist after first run"); - Some(StepChoice::GoBack) - } - 2 => { - assert_eq!(idx, 0); - Some(StepChoice::Run) - } - 3 => { - assert_eq!(idx, 1); - Some(StepChoice::Run) - } - 4 => { - assert_eq!(idx, 2); - Some(StepChoice::Skip) - } - _ => panic!("unexpected call {}", n), - } - }, - |_idx, _total, _label| panic!("no failures expected"), - ) - .await; - - assert_eq!(result.overall_status, Status::Pass); - assert_eq!(result.step_results.len(), 3); - assert_eq!(result.step_results[0].status, Status::Pass); - assert_eq!(result.step_results[1].status, Status::Pass); - assert_eq!(result.step_results[2].status, Status::Skip); - - // Verify step 0 actually re-executed (file recreated after deletion) - assert!(tmp.exists(), "write_file should have been called twice"); - // tmp_dir auto-cleans on drop, even on panic - } - - #[tokio::test] - async fn test_interactive_skip_all() { - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "pass" - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |_idx, _total, _label, _can_go_back| Some(StepChoice::Skip), - |_idx, _total, _label| panic!("no failures expected"), - ) - .await; - - assert_eq!(result.overall_status, Status::Skip); - assert_eq!(result.step_results.len(), 2); - assert_eq!(result.step_results[0].status, Status::Skip); - assert_eq!(result.step_results[1].status, Status::Skip); - } - - #[tokio::test] - async fn test_interactive_abort() { - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "pass" - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |idx, _total, _label, _can_go_back| { - if idx == 0 { - Some(StepChoice::Run) - } else { - None - } - }, - |_idx, _total, _label| panic!("no failures expected"), - ) - .await; - - assert_eq!(result.overall_status, Status::Skip); - assert_eq!(result.step_results.len(), 1); - assert_eq!(result.step_results[0].status, Status::Pass); - } - - #[tokio::test] - async fn test_interactive_failure_continue() { - // Step 0 passes, step 1 fails (expect "fail" but ping returns "pass"), - // user continues, step 2 passes - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "pass" - - call: "ping" - args: {} - expect_status: "fail" - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |_idx, _total, _label, _can_go_back| Some(StepChoice::Run), - |idx, _total, _label| { - assert_eq!(idx, 1); // only step 1 should fail - Some(FailureChoice::Continue) - }, - ) - .await; - - assert_eq!(result.overall_status, Status::Fail); - assert_eq!(result.step_results.len(), 3); - assert_eq!(result.step_results[0].status, Status::Pass); - // step 1's CommandResult.status is Pass (ping succeeded) but its expectation - // was "fail" - the only externally visible evidence is overall_status above. - assert_eq!(result.step_results[1].status, Status::Pass); - // step 1 failed expectation but we continued to step 2 - assert_eq!(result.step_results[2].status, Status::Pass); - } - - #[tokio::test] - async fn test_interactive_failure_abort() { - // Step 0 fails (expect "fail" but ping returns "pass"), user aborts - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "fail" - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |_idx, _total, _label, _can_go_back| Some(StepChoice::Run), - |idx, _total, _label| { - assert_eq!(idx, 0); - Some(FailureChoice::Abort) - }, - ) - .await; - - assert_eq!(result.overall_status, Status::Fail); - // Only step 0 recorded, step 1 never reached - assert_eq!(result.step_results.len(), 1); - // The failed step's CommandResult.status is Pass (ping executed OK) - // even though its expectation ("fail") was not met. - assert_eq!(result.step_results[0].status, Status::Pass); - } - - #[tokio::test] - async fn test_interactive_go_back_invalidates_stale_results() { - // Run step 0, skip step 1, go back from step 2, go back from step 1, - // then abort. The go-back should invalidate the stale Skip for step 1, - // so the result only contains step 0 and overall is Skip (not Pass). - let yaml = r#" -steps: - - call: "ping" - args: {} - expect_status: "pass" - - call: "ping" - args: {} - expect_status: "pass" - - call: "ping" - args: {} - expect_status: "pass" -"#; - let scenario = load_scenario(yaml).unwrap(); - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - - let call_count = std::cell::Cell::new(0usize); - let result = run_scenario_interactive( - &scenario, - &ctx, - ®, - |idx, _total, _label, _can_go_back| { - let n = call_count.get(); - call_count.set(n + 1); - match n { - 0 => { - assert_eq!(idx, 0); - Some(StepChoice::Run) - } // run step 0 - 1 => { - assert_eq!(idx, 1); - Some(StepChoice::Skip) - } // skip step 1 - 2 => { - assert_eq!(idx, 2); - Some(StepChoice::GoBack) - } // go back to step 1 - 3 => { - assert_eq!(idx, 1); - Some(StepChoice::GoBack) - } // go back to step 0 - 4 => { - assert_eq!(idx, 0); - None - } // abort - _ => panic!("unexpected call {}", n), - } - }, - |_idx, _total, _label| panic!("no failures expected"), - ) - .await; - - // Go-back invalidated the stale Skip for step 1, so only step 0 remains. - assert_eq!(result.overall_status, Status::Skip); - assert_eq!(result.step_results.len(), 1); - assert_eq!(result.step_results[0].status, Status::Pass); - } - - #[tokio::test] - async fn test_generous_timeout_does_not_fire() { - // Verify the timeout_ms field is accepted without panicking and that - // a generous deadline (5 s) does NOT trigger a false timeout on ping. - let scenario = Scenario { - name: Some("timeout test".into()), - steps: vec![ScenarioStep::Call { - call: "ping".to_string(), - args: serde_json::json!({}), - expect_status: "pass".to_string(), - timeout_ms: 5_000, - }], - }; - let ctx = AppContext::default(); - let reg = CommandRegistry::new(); - let result = run_scenario(&scenario, &ctx, ®).await; - assert_eq!(result.overall_status, Status::Pass); - assert_eq!(result.step_results[0].status, Status::Pass); - } -} diff --git a/crates/engine/src/traits.rs b/crates/engine/src/traits.rs deleted file mode 100644 index 903cff0..0000000 --- a/crates/engine/src/traits.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::path::{Path, PathBuf}; - -/// Result type for trait operations that may be unsupported. -pub type CapResult = Result; - -#[derive(Debug, thiserror::Error)] -pub enum CapError { - #[error("unsupported: {0}")] - Unsupported(String), - - #[error("dependency missing: {0}")] - DependencyMissing(String), - - #[error("permission denied: {0}")] - PermissionDenied(String), - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("network error: {0}")] - Network(String), - - #[error("timeout")] - Timeout, - - #[error("{0}")] - Other(String), -} - -// --------------------------------------------------------------------------- -// Filesystem operations -// --------------------------------------------------------------------------- - -/// Metadata for a single directory entry. -pub struct DirEntry { - pub name: String, - pub is_dir: bool, - pub size_bytes: u64, -} - -pub trait FilesystemOps: Send + Sync { - fn read_file(&self, path: &Path) -> CapResult>; - fn write_file(&self, path: &Path, data: &[u8]) -> CapResult<()>; - fn remove_file(&self, path: &Path) -> CapResult<()>; - fn create_dir_all(&self, path: &Path) -> CapResult<()>; - fn remove_dir_all(&self, path: &Path) -> CapResult<()>; - fn exists(&self, path: &Path) -> bool; - fn temp_dir(&self) -> PathBuf; - fn list_dir(&self, path: &Path) -> CapResult>; -} - -// --------------------------------------------------------------------------- -// Network operations -// --------------------------------------------------------------------------- - -#[async_trait::async_trait] -pub trait NetworkOps: Send + Sync { - /// Resolve a hostname to at least one IP address. - async fn dns_resolve(&self, host: &str) -> CapResult>; - - /// Perform an HTTPS GET and return (status_code, body_snippet). - async fn https_get(&self, url: &str, timeout_ms: u64) -> CapResult<(u16, String)>; -} diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index 1741df1..2d69eec 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -110,65 +110,6 @@ pub struct DoctorReport { pub proxy_env_set: Vec, } -// --------------------------------------------------------------------------- -// Scenario types -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Scenario { - #[serde(default)] - pub name: Option, - pub steps: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ScenarioStep { - Call { - call: String, - #[serde(default)] - args: serde_json::Value, - #[serde(default = "default_expect_status")] - expect_status: String, - #[serde(default = "default_timeout_ms")] - timeout_ms: u64, - }, - Probe { - probe: String, - }, -} - -fn default_expect_status() -> String { - "pass".to_string() -} - -fn default_timeout_ms() -> u64 { - 30_000 -} - -// --------------------------------------------------------------------------- -// Scenario result -// --------------------------------------------------------------------------- - -/// Result of a scenario run. -/// -/// When using `run_scenario_interactive`, `step_results` may contain fewer -/// entries than the declared scenario steps if the user aborts mid-run or -/// a step fails and the user chooses not to continue. `overall_status` values: -/// - `Pass` – every step was decided (run or skip) and all executed steps -/// met expectations. Note: individual steps may have been skipped by the -/// user - `Pass` does not guarantee every step ran. -/// - `Skip` – user aborted before all steps were reached, OR every step was -/// explicitly skipped. -/// - `Fail` – at least one step failed its expectation (run may be partial if -/// user also aborted at the failure dialog). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScenarioResult { - pub name: Option, - pub overall_status: Status, - pub step_results: Vec, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/templates/command.rs.tpl b/templates/command.rs.tpl deleted file mode 100644 index c17055e..0000000 --- a/templates/command.rs.tpl +++ /dev/null @@ -1,56 +0,0 @@ -//! `{{NAME}}` – {{DESCRIPTION}} -//! -//! Generated by `sealg new {{NAME}}`. Edit the Input/Output structs and the -//! body of `run` to implement the command; it is already registered (via the -//! `register_command!` at the bottom) and callable over the CLI and HTTP API. - -use crate::commands::{Command, CommandError}; -use crate::context::Ctx; -use crate::register_command; -use async_trait::async_trait; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Default)] -pub struct {{STRUCT}}; - -/// Typed request body. Fields become CLI flags / JSON body keys and appear in -/// the auto-generated JSON Schema. -#[derive(Debug, Deserialize, JsonSchema)] -pub struct {{STRUCT}}Input { - /// TODO: describe this field. - pub message: String, -} - -/// Typed response. This is the bare body returned over HTTP. -#[derive(Debug, Serialize, JsonSchema)] -pub struct {{STRUCT}}Output { - pub echoed: String, -} - -#[async_trait] -impl Command for {{STRUCT}} { - type Input = {{STRUCT}}Input; - type Output = {{STRUCT}}Output; - - fn name(&self) -> &'static str { - "{{NAME}}" - } - - fn description(&self) -> &'static str { - "{{DESCRIPTION}}" - } - - async fn run( - &self, - input: {{STRUCT}}Input, - _cx: &Ctx<'_>, - ) -> Result<{{STRUCT}}Output, CommandError> { - // TODO: implement. `_cx` exposes fs() and network() capabilities. - Ok({{STRUCT}}Output { - echoed: input.message, - }) - } -} - -register_command!({{STRUCT}});