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