From c7fa608536e6f9e9324bf63b6ded386e8116598a Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 15:48:22 +0000
Subject: [PATCH 01/10] refactor(cli): fold gateway into `call`, drop
local-command machinery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`sealg` is a thin MCP client to the gateway, so `call` now hits the gateway
`tools/call` (replacing the old local demo-command registry) and `gw-call` is
gone. Command surface is now: init, mcp (stub), doctor, list, call.
- `call [--args] [--gateway-url]` → gateway tools/call (pretty-prints
the MCP result, exits 6 on isError, 1 on transport error).
- `list [--gateway-url]` → gateway tools/list (keeps --json).
- `--gateway-url` overrides the gateway origin over `GatewayConfig::from_env()`
via new `from_env_with_url_override` (trims a trailing `/`, ignores blank).
- Remove the local-command machinery entirely: `engine::commands` (Command/
Expose/CommandRegistry + all demo commands), `scenario`, `probes`, and the
now-dead `context`/`traits`/`platform` (doctor needs none of them). `doctor`
keeps working on env facts; the `gateway` module and its tests are untouched.
- Drop the `new` scaffold command + template, and the `probe`/`run-scenario`
subcommands and their diagnostics handlers.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
crates/cli/examples/smoke_test.yaml | 20 -
crates/cli/src/diagnostics.rs | 333 +----------
crates/cli/src/gateway_cmd.rs | 25 +-
crates/cli/src/main.rs | 127 +----
crates/cli/src/scaffold.rs | 238 --------
crates/engine/src/commands/http_request.rs | 97 ----
crates/engine/src/commands/list_dir.rs | 66 ---
crates/engine/src/commands/mod.rs | 487 ----------------
crates/engine/src/commands/ping.rs | 39 --
crates/engine/src/commands/read_file.rs | 63 --
crates/engine/src/commands/system_info.rs | 55 --
crates/engine/src/commands/write_file.rs | 67 ---
crates/engine/src/context.rs | 133 -----
crates/engine/src/gateway/config.rs | 32 ++
crates/engine/src/lib.rs | 15 +-
crates/engine/src/platform.rs | 124 ----
crates/engine/src/probes.rs | 205 -------
crates/engine/src/scenario.rs | 633 ---------------------
crates/engine/src/traits.rs | 63 --
crates/engine/src/types.rs | 59 --
templates/command.rs.tpl | 56 --
21 files changed, 77 insertions(+), 2860 deletions(-)
delete mode 100644 crates/cli/examples/smoke_test.yaml
delete mode 100644 crates/cli/src/scaffold.rs
delete mode 100644 crates/engine/src/commands/http_request.rs
delete mode 100644 crates/engine/src/commands/list_dir.rs
delete mode 100644 crates/engine/src/commands/mod.rs
delete mode 100644 crates/engine/src/commands/ping.rs
delete mode 100644 crates/engine/src/commands/read_file.rs
delete mode 100644 crates/engine/src/commands/system_info.rs
delete mode 100644 crates/engine/src/commands/write_file.rs
delete mode 100644 crates/engine/src/context.rs
delete mode 100644 crates/engine/src/platform.rs
delete mode 100644 crates/engine/src/probes.rs
delete mode 100644 crates/engine/src/scenario.rs
delete mode 100644 crates/engine/src/traits.rs
delete mode 100644 templates/command.rs.tpl
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/main.rs b/crates/cli/src/main.rs
index 6ca42c0..dd69775 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -1,20 +1,19 @@
//! `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,
+//! `init` onboards the template, and `mcp` is a stub for the future MCP
+//! transport.
#[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;
@@ -36,28 +35,9 @@ 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 +49,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,
},
}
@@ -140,49 +94,14 @@ async fn main() {
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/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/engine/src/commands/http_request.rs b/crates/engine/src/commands/http_request.rs
deleted file mode 100644
index 61b6059..0000000
--- a/crates/engine/src/commands/http_request.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-//! `http_request` – perform an outbound HTTP GET.
-//!
-//! The canonical async example: it awaits real network I/O through the
-//! [`NetworkOps`](crate::traits::NetworkOps) capability, exercising the typed
-//! contract, the per-request [`Ctx`], and the capability-error mapping.
-
-use crate::commands::{Command, CommandError, Expose};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-/// Default per-request timeout when the caller does not specify one.
-const DEFAULT_TIMEOUT_MS: u64 = 10_000;
-
-#[derive(Default)]
-pub struct HttpRequest;
-
-#[derive(Debug, Deserialize, JsonSchema)]
-pub struct HttpRequestInput {
- /// Absolute URL to fetch. Must use the `https://` scheme.
- pub url: String,
- /// HTTP method. Only `GET` is supported by the network capability today;
- /// anything else returns an `Unsupported` error.
- #[serde(default)]
- pub method: Option,
- /// Request timeout in milliseconds (default 10000).
- #[serde(default)]
- pub timeout_ms: Option,
-}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct HttpRequestOutput {
- pub status: u16,
- /// First few KiB of the response body.
- pub body_snippet: String,
-}
-
-#[async_trait]
-impl Command for HttpRequest {
- type Input = HttpRequestInput;
- type Output = HttpRequestOutput;
-
- fn name(&self) -> &'static str {
- "http_request"
- }
-
- fn description(&self) -> &'static str {
- "Perform an outbound HTTP GET and return the status and a body snippet."
- }
-
- /// Fetches a caller-supplied URL with no scheme/host allowlist - CLI-only so
- /// it is not reachable as an unauthenticated SSRF primitive over the HTTP API.
- fn expose(&self) -> Expose {
- Expose::cli_only()
- }
-
- async fn run(
- &self,
- input: HttpRequestInput,
- cx: &Ctx<'_>,
- ) -> Result {
- let method = input.method.as_deref().unwrap_or("GET");
- if !method.eq_ignore_ascii_case("GET") {
- return Err(CommandError::Unsupported(format!(
- "method {method} is not supported (only GET)"
- )));
- }
-
- // The capability is `https_get`; enforce the scheme rather than silently
- // issuing a cleartext request for an `http://` URL. Schemes are
- // case-insensitive (RFC 3986 §3.1).
- let scheme_ok = input
- .url
- .split_once("://")
- .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
- if !scheme_ok {
- return Err(CommandError::InvalidInput(
- "url must use the https:// scheme".to_string(),
- ));
- }
-
- // A `0` timeout would fire instantly; treat it (and absent) as the default.
- let timeout_ms = input
- .timeout_ms
- .filter(|&t| t > 0)
- .unwrap_or(DEFAULT_TIMEOUT_MS);
- let (status, body_snippet) = cx.network().https_get(&input.url, timeout_ms).await?;
- Ok(HttpRequestOutput {
- status,
- body_snippet,
- })
- }
-}
-
-register_command!(HttpRequest);
diff --git a/crates/engine/src/commands/list_dir.rs b/crates/engine/src/commands/list_dir.rs
deleted file mode 100644
index 51fb0cd..0000000
--- a/crates/engine/src/commands/list_dir.rs
+++ /dev/null
@@ -1,66 +0,0 @@
-//! `list_dir` – list entries in a directory.
-
-use crate::commands::{Command, CommandError, Expose};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct ListDir;
-
-#[derive(Debug, Deserialize, JsonSchema)]
-pub struct ListDirInput {
- /// Directory path to list.
- pub path: String,
-}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct DirEntry {
- pub name: String,
- pub is_dir: bool,
- pub size_bytes: u64,
-}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct ListDirOutput {
- pub entries: Vec,
-}
-
-#[async_trait]
-impl Command for ListDir {
- type Input = ListDirInput;
- type Output = ListDirOutput;
-
- fn name(&self) -> &'static str {
- "list_dir"
- }
-
- fn description(&self) -> &'static str {
- "List the entries of a directory."
- }
-
- /// Lists a caller-supplied path with no sandbox - CLI-only so it is not
- /// reachable as an unauthenticated directory-enumeration over the HTTP API.
- fn expose(&self) -> Expose {
- Expose::cli_only()
- }
-
- async fn run(&self, input: ListDirInput, cx: &Ctx<'_>) -> Result {
- let path = std::path::Path::new(&input.path);
- let entries = cx
- .fs()
- .list_dir(path)?
- .into_iter()
- .map(|e| DirEntry {
- name: e.name,
- is_dir: e.is_dir,
- size_bytes: e.size_bytes,
- })
- .collect();
- Ok(ListDirOutput { entries })
- }
-}
-
-register_command!(ListDir);
diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs
deleted file mode 100644
index 3e3bc03..0000000
--- a/crates/engine/src/commands/mod.rs
+++ /dev/null
@@ -1,487 +0,0 @@
-//! The typed command contract and registry.
-//!
-//! A [`Command`] is the unit of backend logic. It declares a typed `Input`
-//! (deserialized from the request body / CLI args) and a typed `Output`
-//! (serialized back to the caller). Both derive [`schemars::JsonSchema`], so
-//! every transport gets a JSON Schema for free - the HTTP `GET /commands`
-//! introspection endpoint and the future MCP `tools/list` both read it.
-//!
-//! Commands **self-register** at link time via [`inventory`]: dropping a new
-//! command file that ends in `register_command!(MyCommand);` makes it appear in
-//! [`CommandRegistry::new`] with no hand-edited registration list.
-//!
-//! ```ignore
-//! #[derive(Default)]
-//! pub struct Ping;
-//!
-//! #[derive(Deserialize, JsonSchema)]
-//! pub struct PingInput {}
-//!
-//! #[derive(Serialize, JsonSchema)]
-//! pub struct PingOutput { pub pong: bool }
-//!
-//! #[async_trait]
-//! impl Command for Ping {
-//! type Input = PingInput;
-//! type Output = PingOutput;
-//! fn name(&self) -> &'static str { "ping" }
-//! async fn run(&self, _in: PingInput, _cx: &Ctx<'_>) -> Result {
-//! Ok(PingOutput { pong: true })
-//! }
-//! }
-//! register_command!(Ping);
-//! ```
-
-use crate::context::Ctx;
-use crate::types::*;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{de::DeserializeOwned, Serialize};
-use serde_json::Value;
-use std::collections::BTreeMap;
-use std::time::Instant;
-
-mod http_request;
-mod list_dir;
-mod ping;
-mod read_file;
-mod system_info;
-mod write_file;
-
-// ---------------------------------------------------------------------------
-// Errors
-// ---------------------------------------------------------------------------
-
-#[derive(Debug, thiserror::Error)]
-pub enum CommandError {
- #[error("invalid input: {0}")]
- InvalidInput(String),
- #[error("io: {0}")]
- Io(#[from] std::io::Error),
- #[error("permission denied: {0}")]
- PermissionDenied(String),
- #[error("network error: {0}")]
- NetworkError(String),
- #[error("timeout")]
- Timeout,
- #[error("unsupported: {0}")]
- Unsupported(String),
- #[error("unimplemented: {0}")]
- Unimplemented(String),
- #[error("{0}")]
- Other(String),
-}
-
-impl CommandError {
- pub fn error_code(&self) -> ErrorCode {
- match self {
- CommandError::InvalidInput(_) => ErrorCode::InvalidInput,
- CommandError::Io(_) => ErrorCode::IoError,
- CommandError::PermissionDenied(_) => ErrorCode::PermissionDenied,
- CommandError::NetworkError(_) => ErrorCode::NetworkError,
- CommandError::Timeout => ErrorCode::Timeout,
- CommandError::Unsupported(_) => ErrorCode::Unsupported,
- CommandError::Unimplemented(_) => ErrorCode::Unimplemented,
- CommandError::Other(_) => ErrorCode::InternalError,
- }
- }
-}
-
-/// Map a capability-layer error onto a command error, preserving the code.
-impl From for CommandError {
- fn from(e: crate::traits::CapError) -> Self {
- use crate::traits::CapError;
- match e {
- CapError::Unsupported(m) => CommandError::Unsupported(m),
- CapError::DependencyMissing(m) => CommandError::Unsupported(m),
- CapError::PermissionDenied(m) => CommandError::PermissionDenied(m),
- CapError::Io(io) => CommandError::Io(io),
- CapError::Network(m) => CommandError::NetworkError(m),
- CapError::Timeout => CommandError::Timeout,
- CapError::Other(m) => CommandError::Other(m),
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Per-transport visibility
-// ---------------------------------------------------------------------------
-
-/// Which transports may invoke a command. Mirrors MCP-Template's per-service
-/// exclusion set: a CLI-only utility can stay off the HTTP/MCP surface.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
-pub struct Expose {
- pub cli: bool,
- pub api: bool,
- pub mcp: bool,
-}
-
-impl Expose {
- pub const fn all() -> Self {
- Self {
- cli: true,
- api: true,
- mcp: true,
- }
- }
- pub const fn cli_only() -> Self {
- Self {
- cli: true,
- api: false,
- mcp: false,
- }
- }
- /// Everywhere except MCP (the reference's common case for utility commands).
- pub const fn no_mcp() -> Self {
- Self {
- cli: true,
- api: true,
- mcp: false,
- }
- }
-}
-
-impl Default for Expose {
- fn default() -> Self {
- Self::all()
- }
-}
-
-// ---------------------------------------------------------------------------
-// The typed Command trait
-// ---------------------------------------------------------------------------
-
-/// A unit of backend logic with a typed input/output contract.
-///
-/// The trait deliberately does **not** bound `Input` on `clap::Args` - the
-/// engine stays free of CLI concerns. Individual input structs may add a
-/// `#[derive(clap::Args)]` when a hand-written/generated CLI subcommand wants
-/// to reuse them (see the CLI scaffolding phase).
-#[async_trait]
-pub trait Command: Send + Sync + 'static {
- type Input: DeserializeOwned + JsonSchema + Send;
- type Output: Serialize + JsonSchema + Send;
-
- fn name(&self) -> &'static str;
-
- fn description(&self) -> &'static str {
- ""
- }
-
- /// Per-transport visibility. Defaults to everywhere.
- fn expose(&self) -> Expose {
- Expose::all()
- }
-
- async fn run(&self, input: Self::Input, cx: &Ctx<'_>) -> Result;
-}
-
-// ---------------------------------------------------------------------------
-// Type erasure – object-safe inner trait doing Value<->typed conversion
-// ---------------------------------------------------------------------------
-
-/// Object-safe form of [`Command`]. Callers never implement this directly; it
-/// is blanket-implemented for every `Command` and stored in the registry.
-#[async_trait]
-pub trait ErasedCommand: Send + Sync {
- fn name(&self) -> &'static str;
- fn description(&self) -> &'static str;
- fn expose(&self) -> Expose;
- fn input_schema(&self) -> Value;
- fn output_schema(&self) -> Value;
- async fn run_json(&self, input: Value, cx: &Ctx<'_>) -> Result;
-}
-
-#[async_trait]
-impl ErasedCommand for C {
- fn name(&self) -> &'static str {
- Command::name(self)
- }
- fn description(&self) -> &'static str {
- Command::description(self)
- }
- fn expose(&self) -> Expose {
- Command::expose(self)
- }
- fn input_schema(&self) -> Value {
- serde_json::to_value(schemars::schema_for!(C::Input)).unwrap_or(Value::Null)
- }
- fn output_schema(&self) -> Value {
- serde_json::to_value(schemars::schema_for!(C::Output)).unwrap_or(Value::Null)
- }
- async fn run_json(&self, input: Value, cx: &Ctx<'_>) -> Result {
- // An absent body is treated as an empty object so commands with no
- // required fields (e.g. `ping`) can be called with `{}` or nothing.
- let input = if input.is_null() {
- Value::Object(Default::default())
- } else {
- input
- };
- let typed: C::Input =
- serde_json::from_value(input).map_err(|e| CommandError::InvalidInput(e.to_string()))?;
- let out = Command::run(self, typed, cx).await?;
- serde_json::to_value(out).map_err(|e| CommandError::Other(e.to_string()))
- }
-}
-
-// ---------------------------------------------------------------------------
-// Link-time self-registration (inventory)
-// ---------------------------------------------------------------------------
-
-/// One collected registration entry. Each `register_command!` submits one.
-pub struct CommandRegistration {
- pub make: fn() -> Box,
-}
-
-inventory::collect!(CommandRegistration);
-
-/// Register a `Command` type so it self-installs into `CommandRegistry::new`.
-/// The type must implement `Default`.
-#[macro_export]
-macro_rules! register_command {
- ($ty:ty) => {
- inventory::submit! {
- $crate::commands::CommandRegistration {
- make: || ::std::boxed::Box::new(<$ty as ::std::default::Default>::default()),
- }
- }
- };
-}
-
-// ---------------------------------------------------------------------------
-// Schema introspection
-// ---------------------------------------------------------------------------
-
-/// A command's public contract, consumed by `GET /commands` and future MCP.
-#[derive(Debug, Clone, Serialize)]
-pub struct CommandSchema {
- pub name: String,
- pub description: String,
- pub expose: Expose,
- pub input_schema: Value,
- pub output_schema: Value,
-}
-
-// ---------------------------------------------------------------------------
-// Registry
-// ---------------------------------------------------------------------------
-
-pub struct CommandRegistry {
- commands: BTreeMap<&'static str, Box>,
-}
-
-impl CommandRegistry {
- /// Build the registry from every `register_command!`-registered command.
- pub fn new() -> Self {
- let mut commands: BTreeMap<&'static str, Box> = BTreeMap::new();
- for reg in inventory::iter:: {
- let cmd = (reg.make)();
- let name = cmd.name();
- if commands.insert(name, cmd).is_some() {
- // Two commands claimed the same name - a scaffolding mistake.
- panic!("duplicate command registered: {name}");
- }
- }
- Self { commands }
- }
-
- /// All registered command names, sorted.
- pub fn list(&self) -> Vec<&str> {
- self.commands.keys().copied().collect()
- }
-
- fn get(&self, name: &str) -> Option<&dyn ErasedCommand> {
- self.commands.get(name).map(|b| b.as_ref())
- }
-
- /// The schema for a single command, if present.
- pub fn schema(&self, name: &str) -> Option {
- self.get(name).map(|c| CommandSchema {
- name: c.name().to_string(),
- description: c.description().to_string(),
- expose: c.expose(),
- input_schema: c.input_schema(),
- output_schema: c.output_schema(),
- })
- }
-
- /// Schemas for every command, sorted by name.
- pub fn schemas(&self) -> Vec {
- self.list()
- .into_iter()
- .filter_map(|n| self.schema(n))
- .collect()
- }
-
- /// Bare-output path (HTTP API / MCP): deserialize `args`, run, serialize the
- /// typed `Output`. Returns the raw output value or a typed error the
- /// transport maps onto an HTTP status.
- pub async fn call(&self, name: &str, args: Value, cx: &Ctx<'_>) -> Result {
- match self.get(name) {
- Some(cmd) => cmd.run_json(args, cx).await,
- None => Err(CommandError::InvalidInput(format!(
- "unknown command: {name}"
- ))),
- }
- }
-
- /// Envelope path (CLI / scenario runner): run a command and wrap the result
- /// in the diagnostic [`CommandResult`] contract (run_id, status, timing).
- pub async fn execute(&self, name: &str, args: Value, cx: &Ctx<'_>) -> CommandResult {
- let start = Instant::now();
- let run_id = cx.request_id.clone();
-
- match self.call(name, args, cx).await {
- Ok(data) => {
- let mut r = result_ok("call", name, &run_id, start.elapsed().as_millis() as u64);
- r.data = Some(data);
- r
- }
- Err(e) => result_err(
- "call",
- name,
- &run_id,
- start.elapsed().as_millis() as u64,
- e.error_code(),
- e.to_string(),
- ),
- }
- }
-}
-
-impl Default for CommandRegistry {
- fn default() -> Self {
- Self::new()
- }
-}
-
-// ===========================================================================
-// Tests
-// ===========================================================================
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::context::{AppContext, Ctx};
-
- async fn run(name: &str, args: Value) -> CommandResult {
- let ctx = AppContext::default();
- let cx = Ctx::new(&ctx);
- CommandRegistry::new().execute(name, args, &cx).await
- }
-
- #[tokio::test]
- async fn test_ping_command() {
- let result = run("ping", serde_json::json!({})).await;
- assert_eq!(result.status, Status::Pass);
- assert_eq!(result.data.unwrap()["pong"], true);
- }
-
- #[tokio::test]
- async fn test_ping_accepts_null_args() {
- // A null/absent body should be treated as `{}` for no-field inputs.
- let result = run("ping", Value::Null).await;
- assert_eq!(result.status, Status::Pass);
- }
-
- #[tokio::test]
- async fn test_unknown_command() {
- let result = run("nonexistent", serde_json::json!({})).await;
- assert_eq!(result.status, Status::Error);
- assert_eq!(result.error.unwrap().code, ErrorCode::InvalidInput);
- }
-
- #[tokio::test]
- async fn test_invalid_input_maps_to_error() {
- // read_file requires a 'path' string; omitting it is InvalidInput.
- let result = run("read_file", serde_json::json!({})).await;
- assert_eq!(result.status, Status::Error);
- assert_eq!(result.error.unwrap().code, ErrorCode::InvalidInput);
- }
-
- #[tokio::test]
- async fn test_read_write_file() {
- let ctx = AppContext::default();
- let tmp = std::env::temp_dir().join("engine_test_rw.txt");
- let path_str = tmp.to_str().unwrap();
-
- let cx = Ctx::new(&ctx);
- let reg = CommandRegistry::new();
- let w = reg
- .execute(
- "write_file",
- serde_json::json!({ "path": path_str, "content": "hello engine" }),
- &cx,
- )
- .await;
- assert_eq!(w.status, Status::Pass);
-
- let cx = Ctx::new(&ctx);
- let r = reg
- .execute("read_file", serde_json::json!({ "path": path_str }), &cx)
- .await;
- assert_eq!(r.status, Status::Pass);
- assert_eq!(r.data.unwrap()["content"], "hello engine");
-
- let _ = std::fs::remove_file(&tmp);
- }
-
- #[test]
- fn test_list_commands() {
- let reg = CommandRegistry::new();
- let names = reg.list();
- for expected in [
- "ping",
- "read_file",
- "write_file",
- "system_info",
- "list_dir",
- "http_request",
- ] {
- assert!(names.contains(&expected), "missing command: {expected}");
- }
- }
-
- #[test]
- fn test_schema_introspection() {
- let reg = CommandRegistry::new();
- let schema = reg.schema("read_file").expect("read_file schema");
- assert_eq!(schema.name, "read_file");
- // read_file reads a caller-supplied path with no sandbox, so it is
- // CLI-only - reachable via the CLI, never over the unauthenticated API.
- assert!(schema.expose.cli);
- assert!(!schema.expose.api);
- // The input schema should describe the required `path` field.
- let s = serde_json::to_string(&schema.input_schema).unwrap();
- assert!(s.contains("path"), "input schema missing path: {s}");
- }
-
- #[tokio::test]
- async fn test_system_info_command() {
- let result = run("system_info", serde_json::json!({})).await;
- assert_eq!(result.status, Status::Pass);
- let data = result.data.unwrap();
- assert!(data["os"].is_string());
- assert!(data["arch"].is_string());
- assert!(data["hostname"].is_string());
- }
-
- #[tokio::test]
- async fn test_list_dir_command() {
- let tmp = std::env::temp_dir();
- let path_str = tmp.to_str().unwrap();
- let result = run("list_dir", serde_json::json!({ "path": path_str })).await;
- assert_eq!(result.status, Status::Pass);
- assert!(result.data.unwrap()["entries"].is_array());
- }
-
- #[tokio::test]
- async fn test_list_dir_not_a_directory() {
- let result = run(
- "list_dir",
- serde_json::json!({ "path": "/nonexistent_dir_12345" }),
- )
- .await;
- assert_eq!(result.status, Status::Error);
- }
-}
diff --git a/crates/engine/src/commands/ping.rs b/crates/engine/src/commands/ping.rs
deleted file mode 100644
index 83b884f..0000000
--- a/crates/engine/src/commands/ping.rs
+++ /dev/null
@@ -1,39 +0,0 @@
-//! `ping` – liveness check. Proves the wiring works end to end.
-
-use crate::commands::{Command, CommandError};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct Ping;
-
-#[derive(Debug, Default, Deserialize, JsonSchema)]
-pub struct PingInput {}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct PingOutput {
- pub pong: bool,
-}
-
-#[async_trait]
-impl Command for Ping {
- type Input = PingInput;
- type Output = PingOutput;
-
- fn name(&self) -> &'static str {
- "ping"
- }
-
- fn description(&self) -> &'static str {
- "Liveness check; returns { pong: true }."
- }
-
- async fn run(&self, _input: PingInput, _cx: &Ctx<'_>) -> Result {
- Ok(PingOutput { pong: true })
- }
-}
-
-register_command!(Ping);
diff --git a/crates/engine/src/commands/read_file.rs b/crates/engine/src/commands/read_file.rs
deleted file mode 100644
index e69db4b..0000000
--- a/crates/engine/src/commands/read_file.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-//! `read_file` – read a file and return its contents as a UTF-8 string.
-
-use crate::commands::{Command, CommandError, Expose};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct ReadFile;
-
-#[derive(Debug, Deserialize, JsonSchema)]
-pub struct ReadFileInput {
- /// Absolute path to the file to read.
- pub path: String,
-}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct ReadFileOutput {
- pub content: String,
- pub size_bytes: usize,
-}
-
-#[async_trait]
-impl Command for ReadFile {
- type Input = ReadFileInput;
- type Output = ReadFileOutput;
-
- fn name(&self) -> &'static str {
- "read_file"
- }
-
- fn description(&self) -> &'static str {
- "Read a file and return its UTF-8 contents."
- }
-
- /// Reads a caller-supplied path with no sandbox - CLI-only so it is not
- /// reachable as an unauthenticated arbitrary-file-read over the HTTP API.
- fn expose(&self) -> Expose {
- Expose::cli_only()
- }
-
- async fn run(
- &self,
- input: ReadFileInput,
- cx: &Ctx<'_>,
- ) -> Result {
- let path = std::path::Path::new(&input.path);
- let data = cx.fs().read_file(path)?;
- let size_bytes = data.len();
- // The output contract is UTF-8; surface a clear error on binary input
- // rather than silently returning lossily-replaced content.
- let content = String::from_utf8(data)
- .map_err(|_| CommandError::InvalidInput("file is not valid UTF-8".to_string()))?;
- Ok(ReadFileOutput {
- content,
- size_bytes,
- })
- }
-}
-
-register_command!(ReadFile);
diff --git a/crates/engine/src/commands/system_info.rs b/crates/engine/src/commands/system_info.rs
deleted file mode 100644
index 7885104..0000000
--- a/crates/engine/src/commands/system_info.rs
+++ /dev/null
@@ -1,55 +0,0 @@
-//! `system_info` – return OS, architecture, hostname, and headless status.
-
-use crate::commands::{Command, CommandError};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct SystemInfo;
-
-#[derive(Debug, Default, Deserialize, JsonSchema)]
-pub struct SystemInfoInput {}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct SystemInfoOutput {
- pub os: String,
- pub arch: String,
- pub hostname: String,
- pub headless: bool,
-}
-
-#[async_trait]
-impl Command for SystemInfo {
- type Input = SystemInfoInput;
- type Output = SystemInfoOutput;
-
- fn name(&self) -> &'static str {
- "system_info"
- }
-
- fn description(&self) -> &'static str {
- "Report OS, architecture, hostname, and headless status."
- }
-
- async fn run(
- &self,
- _input: SystemInfoInput,
- _cx: &Ctx<'_>,
- ) -> Result {
- let hostname = hostname::get()
- .map(|h| h.to_string_lossy().into_owned())
- .unwrap_or_else(|_| "unknown".to_string());
-
- Ok(SystemInfoOutput {
- os: std::env::consts::OS.to_string(),
- arch: std::env::consts::ARCH.to_string(),
- hostname,
- headless: crate::types::detect_headless(),
- })
- }
-}
-
-register_command!(SystemInfo);
diff --git a/crates/engine/src/commands/write_file.rs b/crates/engine/src/commands/write_file.rs
deleted file mode 100644
index d190faf..0000000
--- a/crates/engine/src/commands/write_file.rs
+++ /dev/null
@@ -1,67 +0,0 @@
-//! `write_file` – write string content to a file.
-
-use crate::commands::{Command, CommandError, Expose};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct WriteFile;
-
-#[derive(Debug, Deserialize, JsonSchema)]
-pub struct WriteFileInput {
- /// Absolute path to write to (parent directories are created).
- pub path: String,
- /// UTF-8 content to write.
- pub content: String,
-}
-
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct WriteFileOutput {
- pub bytes_written: usize,
-}
-
-#[async_trait]
-impl Command for WriteFile {
- type Input = WriteFileInput;
- type Output = WriteFileOutput;
-
- fn name(&self) -> &'static str {
- "write_file"
- }
-
- fn description(&self) -> &'static str {
- "Write UTF-8 content to a file, creating parent directories."
- }
-
- /// Writes to a caller-supplied path with no sandbox - CLI-only so it is not
- /// reachable as an unauthenticated arbitrary-file-write over the HTTP API.
- fn expose(&self) -> Expose {
- Expose::cli_only()
- }
-
- async fn run(
- &self,
- input: WriteFileInput,
- cx: &Ctx<'_>,
- ) -> Result {
- let path = std::path::Path::new(&input.path);
- // This command creates parent dirs and writes arbitrary content, so
- // reject relative/traversal paths rather than writing somewhere unintended.
- if !path.is_absolute() {
- return Err(CommandError::InvalidInput(format!(
- "path must be absolute: {}",
- input.path
- )));
- }
- let data = input.content.as_bytes();
- cx.fs().write_file(path, data)?;
- Ok(WriteFileOutput {
- bytes_written: data.len(),
- })
- }
-}
-
-register_command!(WriteFile);
diff --git a/crates/engine/src/context.rs b/crates/engine/src/context.rs
deleted file mode 100644
index c1e1029..0000000
--- a/crates/engine/src/context.rs
+++ /dev/null
@@ -1,133 +0,0 @@
-//! Application context – holds capability trait objects and config.
-//!
-//! Two layers, mirroring the transport design:
-//! - [`AppContext`] holds the shared OS capabilities (fs/net). It is
-//! built once at process/transport start and shared (e.g. behind an `Arc` in
-//! the HTTP server's state).
-//! - [`Ctx`] is constructed **per request/invocation**, borrowing the shared
-//! capabilities and carrying request-scoped data (`request_id`, `deadline`).
-//! Commands receive `&Ctx`. This is the seam where auth/identity would later
-//! attach - no identity field exists yet, by design.
-
-use crate::platform::{ReqwestNetwork, StdFilesystem};
-use crate::traits::*;
-use crate::types::new_run_id;
-use std::time::Instant;
-
-/// Central context passed to all engine operations.
-///
-/// Holds trait-object capabilities. [`AppContext::default`] wires the real
-/// platform implementations; [`AppContext::new`] is the injection seam - pass
-/// stub capabilities to it to run a command against fakes (e.g. an offline test
-/// filesystem/network).
-pub struct AppContext {
- fs: Box,
- network: Box,
- /// Target URL the `network` probe hits. Defaults to
- /// [`DEFAULT_NETWORK_PROBE_HOST`], overridable via the
- /// `APP__NETWORK_PROBE_HOST` env var or by setting this field directly
- /// (e.g. to an internal health endpoint you control).
- pub network_probe_host: String,
-}
-
-/// Default target for the `network` probe when no override is set.
-pub const DEFAULT_NETWORK_PROBE_HOST: &str = "https://httpbin.org/get";
-
-impl Default for AppContext {
- /// Wire the real platform capabilities. Use [`AppContext::new`] to inject
- /// stub implementations instead.
- ///
- /// This is the production path, so it also honors the
- /// `APP__NETWORK_PROBE_HOST` env override for the `network` probe target.
- /// [`AppContext::new`] deliberately does **not** read env, so stub-injecting
- /// tests stay deterministic regardless of ambient environment.
- fn default() -> Self {
- let mut ctx = Self::new(Box::new(StdFilesystem), Box::new(ReqwestNetwork));
- if let Some(host) = std::env::var("APP__NETWORK_PROBE_HOST")
- .ok()
- .filter(|v| !v.trim().is_empty())
- {
- ctx.network_probe_host = host;
- }
- ctx
- }
-}
-
-impl AppContext {
- /// Build a context over caller-supplied capabilities. This is the seam for
- /// injecting stubs (tests, offline runs); [`AppContext::default`] wires the
- /// real platform ones. Pure: reads no environment, so tests are
- /// deterministic. `network_probe_host` starts at
- /// [`DEFAULT_NETWORK_PROBE_HOST`] and can be overridden by setting the field
- /// directly.
- pub fn new(fs: Box, network: Box) -> Self {
- Self {
- fs,
- network,
- network_probe_host: DEFAULT_NETWORK_PROBE_HOST.to_string(),
- }
- }
-
- pub fn fs(&self) -> &dyn FilesystemOps {
- self.fs.as_ref()
- }
-
- pub fn network(&self) -> &dyn NetworkOps {
- self.network.as_ref()
- }
-}
-
-/// Per-request context passed to every [`Command`](crate::commands::Command).
-///
-/// Borrows the shared [`AppContext`] capabilities and adds request-scoped
-/// state. Built fresh for each invocation so each request gets its own
-/// `request_id` (and, when set, `deadline`).
-pub struct Ctx<'a> {
- /// Unique id for this invocation (surfaced as `run_id` in the CLI envelope
- /// and the `x-run-id` HTTP header).
- pub request_id: String,
- /// Optional wall-clock deadline for this invocation. Enforcement lives in
- /// the transport (scenario runner / tower timeout); commands may consult it.
- pub deadline: Option,
- caps: &'a AppContext,
-}
-
-impl<'a> Ctx<'a> {
- /// Build a per-request context over shared capabilities with a fresh id.
- pub fn new(caps: &'a AppContext) -> Self {
- Self {
- request_id: new_run_id(),
- deadline: None,
- caps,
- }
- }
-
- /// Build a context with a caller-supplied request id (e.g. an incoming
- /// `x-run-id`/trace header).
- pub fn with_request_id(caps: &'a AppContext, request_id: impl Into) -> Self {
- Self {
- request_id: request_id.into(),
- deadline: None,
- caps,
- }
- }
-
- /// Attach a deadline (builder-style).
- pub fn with_deadline(mut self, deadline: Instant) -> Self {
- self.deadline = Some(deadline);
- self
- }
-
- /// The shared capability bundle.
- pub fn caps(&self) -> &AppContext {
- self.caps
- }
-
- pub fn fs(&self) -> &dyn FilesystemOps {
- self.caps.fs()
- }
-
- pub fn network(&self) -> &dyn NetworkOps {
- self.caps.network()
- }
-}
diff --git a/crates/engine/src/gateway/config.rs b/crates/engine/src/gateway/config.rs
index a476754..3f43859 100644
--- a/crates/engine/src/gateway/config.rs
+++ b/crates/engine/src/gateway/config.rs
@@ -61,6 +61,21 @@ impl GatewayConfig {
Self::from_getter(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))
}
+ /// Resolve from the environment, then override the gateway origin with
+ /// `url` when it is `Some` and non-blank (the `--gateway-url` flag). A
+ /// trailing `/` is trimmed to match [`from_env`](Self::from_env); a blank
+ /// override is ignored so the env/default value stands.
+ pub fn from_env_with_url_override(url: Option) -> Self {
+ let mut cfg = Self::from_env();
+ if let Some(u) = url {
+ let u = u.trim();
+ if !u.is_empty() {
+ cfg.base_url = u.trim_end_matches('/').to_string();
+ }
+ }
+ cfg
+ }
+
/// Resolve from an arbitrary getter (the test seam).
pub fn from_getter(get: impl Fn(&str) -> Option) -> Self {
let base_url = get(env_keys::URL)
@@ -156,6 +171,23 @@ mod tests {
assert_eq!(c.ca_bundle.as_deref(), Some("/a.pem"));
}
+ #[test]
+ fn url_override_trims_trailing_slash_and_ignores_blank() {
+ // A non-blank override replaces base_url and drops the trailing slash.
+ let cfg =
+ GatewayConfig::from_env_with_url_override(Some("https://gw.example.com/".to_string()));
+ assert_eq!(cfg.base_url, "https://gw.example.com");
+
+ // A blank / whitespace-only override is ignored, leaving the resolved
+ // base_url identical to plain `from_env` (deterministic regardless of
+ // ambient SEALGATE_URL).
+ let base = GatewayConfig::from_env().base_url;
+ let cfg = GatewayConfig::from_env_with_url_override(Some(" ".to_string()));
+ assert_eq!(cfg.base_url, base);
+ let cfg = GatewayConfig::from_env_with_url_override(None);
+ assert_eq!(cfg.base_url, base);
+ }
+
#[test]
fn blank_values_are_treated_as_unset_via_from_env_filter() {
// from_getter itself does not filter, but from_env filters blanks; emulate.
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 018c663..8775b95 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1,22 +1,15 @@
//! Engine crate – the shared service core for SealGate.
//!
-//! This crate contains all real backend logic and OS integrations behind
-//! traits. It has NO transport dependency (no CLI, axum, or HTTP types), so the
-//! same commands run over the CLI, the HTTP API, and (later) MCP.
+//! `sealg` is a thin MCP client to the SealGate gateway. This crate holds the
+//! transport-agnostic pieces it needs: the gateway client/config
+//! ([`gateway`]), the environment diagnostics behind `doctor`, and the shared
+//! result contract in [`types`]. It carries no CLI or HTTP types.
-pub mod commands;
-pub mod context;
pub mod doctor;
mod env;
pub mod gateway;
-pub mod platform;
-pub mod probes;
-pub mod scenario;
-pub mod traits;
pub mod types;
// Re-exports for convenience
-pub use commands::{Command, CommandError, CommandRegistry, CommandSchema, ErasedCommand, Expose};
-pub use context::{AppContext, Ctx};
pub use gateway::{GatewayClient, GatewayConfig, GatewayError, ToolInfo};
pub use types::{CommandResult, ErrorCode, ErrorInfo, Status};
diff --git a/crates/engine/src/platform.rs b/crates/engine/src/platform.rs
deleted file mode 100644
index e6bd306..0000000
--- a/crates/engine/src/platform.rs
+++ /dev/null
@@ -1,124 +0,0 @@
-//! Platform-specific implementations of OS capability traits.
-//!
-//! - [`StdFilesystem`]: real std::fs operations
-//! - [`ReqwestNetwork`]: real HTTP via reqwest
-
-use crate::traits::*;
-use std::path::{Path, PathBuf};
-
-// ===========================================================================
-// Filesystem – wraps std::fs
-// ===========================================================================
-
-pub struct StdFilesystem;
-
-impl FilesystemOps for StdFilesystem {
- fn read_file(&self, path: &Path) -> CapResult> {
- std::fs::read(path).map_err(|e| match e.kind() {
- std::io::ErrorKind::NotFound => CapError::Io(e),
- std::io::ErrorKind::PermissionDenied => {
- CapError::PermissionDenied(format!("cannot read {}: {}", path.display(), e))
- }
- _ => CapError::Io(e),
- })
- }
-
- fn write_file(&self, path: &Path, data: &[u8]) -> CapResult<()> {
- if let Some(parent) = path.parent() {
- if !parent.exists() {
- std::fs::create_dir_all(parent)?;
- }
- }
- std::fs::write(path, data).map_err(|e| match e.kind() {
- std::io::ErrorKind::PermissionDenied => {
- CapError::PermissionDenied(format!("cannot write {}: {}", path.display(), e))
- }
- _ => CapError::Io(e),
- })
- }
-
- fn remove_file(&self, path: &Path) -> CapResult<()> {
- std::fs::remove_file(path).map_err(CapError::Io)
- }
-
- fn create_dir_all(&self, path: &Path) -> CapResult<()> {
- std::fs::create_dir_all(path).map_err(CapError::Io)
- }
-
- fn remove_dir_all(&self, path: &Path) -> CapResult<()> {
- std::fs::remove_dir_all(path).map_err(CapError::Io)
- }
-
- fn exists(&self, path: &Path) -> bool {
- path.exists()
- }
-
- fn temp_dir(&self) -> PathBuf {
- std::env::temp_dir()
- }
-
- fn list_dir(&self, path: &Path) -> CapResult> {
- let read_dir = std::fs::read_dir(path)?;
- let mut entries = Vec::new();
- for entry in read_dir {
- let entry = entry?;
- // Skip entries whose metadata is transiently unavailable
- let Ok(meta) = entry.metadata() else { continue };
- entries.push(DirEntry {
- name: entry.file_name().to_string_lossy().into_owned(),
- is_dir: meta.is_dir(),
- size_bytes: meta.len(),
- });
- }
- Ok(entries)
- }
-}
-
-// ===========================================================================
-// Network – wraps reqwest
-// ===========================================================================
-
-pub struct ReqwestNetwork;
-
-#[async_trait::async_trait]
-impl NetworkOps for ReqwestNetwork {
- async fn dns_resolve(&self, host: &str) -> CapResult> {
- use tokio::net::lookup_host;
- let addrs: Vec = lookup_host(format!("{}:443", host))
- .await
- .map_err(|e| CapError::Network(format!("DNS resolution failed for {}: {}", host, e)))?
- .map(|a| a.ip().to_string())
- .collect();
- if addrs.is_empty() {
- return Err(CapError::Network(format!(
- "DNS resolution returned no addresses for {}",
- host
- )));
- }
- Ok(addrs)
- }
-
- async fn https_get(&self, url: &str, timeout_ms: u64) -> CapResult<(u16, String)> {
- let client = reqwest::Client::builder()
- .timeout(std::time::Duration::from_millis(timeout_ms))
- .build()
- .map_err(|e| CapError::Network(format!("failed to build HTTP client: {}", e)))?;
-
- let resp = client.get(url).send().await.map_err(|e| {
- if e.is_timeout() {
- CapError::Timeout
- } else {
- CapError::Network(format!("HTTPS GET {}: {}", url, e))
- }
- })?;
-
- let status = resp.status().as_u16();
- // Read at most 4 KiB for the snippet
- let body = resp
- .text()
- .await
- .map_err(|e| CapError::Network(format!("reading body: {}", e)))?;
- let snippet: String = body.chars().take(4096).collect();
- Ok((status, snippet))
- }
-}
diff --git a/crates/engine/src/probes.rs b/crates/engine/src/probes.rs
deleted file mode 100644
index 436bbff..0000000
--- a/crates/engine/src/probes.rs
+++ /dev/null
@@ -1,205 +0,0 @@
-//! Targeted capability probes – filesystem, network.
-
-use crate::context::AppContext;
-use crate::traits::CapError;
-use crate::types::*;
-use std::collections::HashMap;
-use std::time::Instant;
-
-/// Run a probe by name and return a full CommandResult.
-pub async fn run_probe(name: &str, ctx: &AppContext) -> CommandResult {
- match name {
- "filesystem" => probe_filesystem(ctx),
- "network" => probe_network(ctx).await,
- _ => {
- let run_id = new_run_id();
- result_err(
- "probe",
- name,
- &run_id,
- 0,
- ErrorCode::InvalidInput,
- format!("unknown probe: {} (available: filesystem, network)", name),
- )
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Filesystem probe
-// ---------------------------------------------------------------------------
-
-fn probe_filesystem(ctx: &AppContext) -> CommandResult {
- let run_id = new_run_id();
- let start = Instant::now();
- let mut steps = HashMap::new();
-
- let tmp_dir = ctx
- .fs()
- .temp_dir()
- .join(format!("engine_probe_{}", &run_id[..8]));
-
- // Step 1: create temp directory
- let t0 = Instant::now();
- if let Err(e) = ctx.fs().create_dir_all(&tmp_dir) {
- return probe_fs_err(&run_id, start, steps, "create_dir", e);
- }
- steps.insert("create_dir".into(), t0.elapsed().as_millis() as u64);
-
- // Step 2: write a test file
- let test_file = tmp_dir.join("probe_test.txt");
- let payload = b"engine filesystem probe";
- let t1 = Instant::now();
- if let Err(e) = ctx.fs().write_file(&test_file, payload) {
- let _ = ctx.fs().remove_dir_all(&tmp_dir);
- return probe_fs_err(&run_id, start, steps, "write_file", e);
- }
- steps.insert("write_file".into(), t1.elapsed().as_millis() as u64);
-
- // Step 3: read it back and verify
- let t2 = Instant::now();
- match ctx.fs().read_file(&test_file) {
- Ok(data) => {
- if data != payload {
- let _ = ctx.fs().remove_dir_all(&tmp_dir);
- return result_err(
- "probe",
- "filesystem",
- &run_id,
- start.elapsed().as_millis() as u64,
- ErrorCode::ExternalInterference,
- "read-back data does not match written data",
- );
- }
- }
- Err(e) => {
- let _ = ctx.fs().remove_dir_all(&tmp_dir);
- return probe_fs_err(&run_id, start, steps, "read_file", e);
- }
- }
- steps.insert("read_verify".into(), t2.elapsed().as_millis() as u64);
-
- // Step 4: cleanup
- let t3 = Instant::now();
- let _ = ctx.fs().remove_dir_all(&tmp_dir);
- steps.insert("cleanup".into(), t3.elapsed().as_millis() as u64);
-
- let mut r = result_ok(
- "probe",
- "filesystem",
- &run_id,
- start.elapsed().as_millis() as u64,
- );
- r.timing_ms.steps = steps;
- r.data = Some(serde_json::json!({
- "temp_dir_used": tmp_dir.display().to_string(),
- }));
- r
-}
-
-fn probe_fs_err(
- run_id: &str,
- start: Instant,
- steps: HashMap,
- failed_step: &str,
- err: CapError,
-) -> CommandResult {
- let code = match &err {
- CapError::PermissionDenied(_) => ErrorCode::PermissionDenied,
- CapError::Io(_) => ErrorCode::IoError,
- _ => ErrorCode::InternalError,
- };
- let mut r = result_err(
- "probe",
- "filesystem",
- run_id,
- start.elapsed().as_millis() as u64,
- code,
- format!("filesystem probe failed at {}: {}", failed_step, err),
- );
- r.timing_ms.steps = steps;
- r
-}
-
-// ---------------------------------------------------------------------------
-// Network probe
-// ---------------------------------------------------------------------------
-
-async fn probe_network(ctx: &AppContext) -> CommandResult {
- let run_id = new_run_id();
- let start = Instant::now();
- let mut steps = HashMap::new();
-
- let host = &ctx.network_probe_host;
- // Extract hostname for DNS (strip scheme + path)
- let dns_host = host
- .trim_start_matches("https://")
- .trim_start_matches("http://")
- .split('/')
- .next()
- .unwrap_or(host);
-
- // Step 1: DNS resolve
- let t0 = Instant::now();
- let addrs = match ctx.network().dns_resolve(dns_host).await {
- Ok(addrs) => addrs,
- Err(e) => {
- steps.insert("dns_resolve".into(), t0.elapsed().as_millis() as u64);
- let msg = format!("DNS resolution failed: {}", e);
- return probe_net_err(&run_id, start, steps, ErrorCode::NetworkError, msg);
- }
- };
- steps.insert("dns_resolve".into(), t0.elapsed().as_millis() as u64);
-
- // Step 2: HTTPS GET
- let t1 = Instant::now();
- let status = match ctx.network().https_get(host, 10_000).await {
- Ok((status, _snippet)) => status,
- Err(e) => {
- steps.insert("https_get".into(), t1.elapsed().as_millis() as u64);
- let code = match &e {
- CapError::Timeout => ErrorCode::Timeout,
- _ => ErrorCode::NetworkError,
- };
- let msg = format!("HTTPS GET failed: {}", e);
- return probe_net_err(&run_id, start, steps, code, msg);
- }
- };
- steps.insert("https_get".into(), t1.elapsed().as_millis() as u64);
-
- let mut r = result_ok(
- "probe",
- "network",
- &run_id,
- start.elapsed().as_millis() as u64,
- );
- r.timing_ms.steps = steps;
- r.data = Some(serde_json::json!({
- "dns_addresses": addrs,
- "http_status": status,
- // Names only - values can embed credentials (e.g. an authenticated
- // HTTP_PROXY URL) and this probe is reachable over the HTTP API.
- "proxy_env_set": crate::env::proxy_env_names(),
- "target_url": host,
- }));
- r
-}
-
-fn probe_net_err(
- run_id: &str,
- start: Instant,
- steps: HashMap,
- code: ErrorCode,
- message: String,
-) -> CommandResult {
- let mut r = result_err(
- "probe",
- "network",
- run_id,
- start.elapsed().as_millis() as u64,
- code,
- message,
- );
- r.timing_ms.steps = steps;
- r
-}
diff --git a/crates/engine/src/scenario.rs b/crates/engine/src/scenario.rs
deleted file mode 100644
index 7a92e81..0000000
--- a/crates/engine/src/scenario.rs
+++ /dev/null
@@ -1,633 +0,0 @@
-//! Scenario runner – execute scripted flows from YAML files.
-
-use crate::commands::CommandRegistry;
-use crate::context::{AppContext, Ctx};
-use crate::probes;
-use crate::types::*;
-use std::collections::HashMap;
-use std::time::{Duration, Instant};
-
-/// Load a scenario from a YAML string.
-pub fn load_scenario(yaml: &str) -> Result {
- serde_yaml::from_str(yaml).map_err(|e| format!("failed to parse scenario YAML: {}", e))
-}
-
-/// User choice at each interactive step.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum StepChoice {
- Run,
- Skip,
- GoBack,
-}
-
-/// User choice after a step failure.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum FailureChoice {
- Continue,
- Abort,
-}
-
-/// Outcome stored per step so re-running overwrites the previous result.
-#[derive(Debug, Clone)]
-pub(crate) struct StepOutcome {
- pub(crate) status: StepStatus,
- pub(crate) result: CommandResult,
-}
-
-/// Disposition of a completed step.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum StepStatus {
- Completed,
- Skipped,
- Failed,
-}
-
-/// Return the label (target name) for a scenario step.
-fn step_label(step: &ScenarioStep) -> String {
- match step {
- ScenarioStep::Call { call, .. } => call.clone(),
- ScenarioStep::Probe { probe } => format!("probe:{}", probe),
- }
-}
-
-/// Execute a single scenario step and return the result plus whether the
-/// expectation was met.
-async fn execute_step(
- step: &ScenarioStep,
- idx: usize,
- ctx: &AppContext,
- registry: &CommandRegistry,
-) -> (CommandResult, bool) {
- match step {
- ScenarioStep::Call {
- call,
- args,
- expect_status,
- timeout_ms,
- } => {
- // Commands are async, so `tokio::time::timeout` now genuinely
- // preempts a command that yields at an `.await` point once the
- // deadline elapses. The per-step `Ctx` also carries the deadline so
- // a command can consult it.
- let deadline = Duration::from_millis(*timeout_ms);
- let cx = Ctx::new(ctx).with_deadline(Instant::now() + deadline);
-
- let timeout_result =
- tokio::time::timeout(deadline, registry.execute(call, args.clone(), &cx)).await;
-
- let r = match timeout_result {
- Ok(result) => result,
- Err(_elapsed) => result_err(
- "call",
- call,
- &cx.request_id,
- *timeout_ms,
- ErrorCode::Timeout,
- format!("step {} ('{}') timed out after {}ms", idx, call, timeout_ms),
- ),
- };
-
- let actual_status = serde_json::to_value(r.status)
- .ok()
- .and_then(|v| v.as_str().map(String::from))
- .unwrap_or_default();
- let met = actual_status == *expect_status;
- if !met {
- tracing::warn!(
- step = idx,
- expected = %expect_status,
- actual = %actual_status,
- "scenario step status mismatch"
- );
- }
- (r, met)
- }
- ScenarioStep::Probe { probe } => {
- let r = probes::run_probe(probe, ctx).await;
- let met = r.status == Status::Pass || r.status == Status::Skip;
- (r, met)
- }
- }
-}
-
-/// Execute a scenario non-interactively (forward-only).
-pub async fn run_scenario(
- scenario: &Scenario,
- ctx: &AppContext,
- registry: &CommandRegistry,
-) -> ScenarioResult {
- let mut step_results = Vec::new();
- let mut overall = Status::Pass;
-
- for (i, step) in scenario.steps.iter().enumerate() {
- let (result, expectation_met) = execute_step(step, i, ctx, registry).await;
- if !expectation_met {
- overall = Status::Fail;
- }
- step_results.push(result);
- }
-
- ScenarioResult {
- name: scenario.name.clone(),
- overall_status: overall,
- step_results,
- }
-}
-
-/// Execute a scenario interactively with go-back navigation.
-///
-/// - `prompt_fn` is called at each step to ask the user whether to run, skip,
-/// or go back. Returns `None` to abort the scenario.
-/// - `failure_fn` is called when a step fails, asking the user whether to
-/// continue or abort. Returns `None` to abort.
-///
-/// This keeps the engine crate free of direct terminal I/O dependencies -
-/// the CLI crate provides the real prompters.
-pub async fn run_scenario_interactive(
- scenario: &Scenario,
- ctx: &AppContext,
- registry: &CommandRegistry,
- mut prompt_fn: F,
- mut failure_fn: G,
-) -> ScenarioResult
-where
- F: FnMut(usize, usize, &str, bool) -> Option,
- G: FnMut(usize, usize, &str) -> Option,
-{
- let total = scenario.steps.len();
- let mut results: HashMap = HashMap::new();
-
- let mut idx = 0;
- while idx < total {
- let step = &scenario.steps[idx];
- let label = step_label(step);
- let can_go_back = idx > 0;
-
- let choice = match prompt_fn(idx, total, &label, can_go_back) {
- Some(c) => c,
- None => break, // user aborted
- };
-
- match choice {
- StepChoice::GoBack => {
- // Use an explicit guard rather than saturating_sub so the
- // intent - stay at step 0 when can_go_back is false - is
- // immediately visible without reading clippy docs.
- #[allow(clippy::implicit_saturating_sub)]
- if idx > 0 {
- // Invalidate all steps at or after the current position
- // (includes idx itself, which may hold a stale result
- // from a prior forward pass) so stale entries cannot
- // masquerade as a completed run if the user later aborts.
- // Note: results[idx-1] (the destination) may retain a
- // stale entry - it will be overwritten when the user
- // re-decides that step (Run/Skip both insert).
- for stale in idx..total {
- results.remove(&stale);
- }
- idx -= 1;
- }
- continue;
- }
- StepChoice::Skip => {
- // This entry will be overwritten if the user later revisits
- // this step via GoBack, or cleaned up by a GoBack from a
- // subsequent step (which invalidates idx..total).
- let run_id = new_run_id();
- results.insert(
- idx,
- StepOutcome {
- status: StepStatus::Skipped,
- result: {
- let mut r = result_skip("scenario", &label, &run_id, 0, "user skipped");
- // Override the default Unsupported code - this is
- // a deliberate user choice, not a platform limitation.
- if let Some(ref mut err) = r.error {
- err.code = ErrorCode::UserSkipped;
- }
- r
- },
- },
- );
- idx += 1;
- continue;
- }
- StepChoice::Run => {}
- }
-
- let (result, expectation_met) = execute_step(step, idx, ctx, registry).await;
-
- if !expectation_met {
- // Insert the failed outcome first so failure_fn sees a
- // consistent results map if it ever inspects it.
- results.insert(
- idx,
- StepOutcome {
- status: StepStatus::Failed,
- result,
- },
- );
- let decision = failure_fn(idx, total, &label);
- if decision != Some(FailureChoice::Continue) {
- break;
- }
- idx += 1;
- continue;
- }
-
- results.insert(
- idx,
- StepOutcome {
- status: StepStatus::Completed,
- result,
- },
- );
- idx += 1;
- }
-
- // Derive overall status from results
- let overall = if results.values().any(|o| o.status == StepStatus::Failed) {
- Status::Fail
- } else if results.len() < total
- || (total > 0 && results.values().all(|o| o.status == StepStatus::Skipped))
- {
- // User aborted before all steps were reached, or skipped every step
- Status::Skip
- } else {
- Status::Pass
- };
-
- // Collect results in step order
- let step_results: Vec = (0..total)
- .filter_map(|i| results.remove(&i).map(|o| o.result))
- .collect();
-
- ScenarioResult {
- name: scenario.name.clone(),
- overall_status: overall,
- step_results,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_parse_scenario() {
- let yaml = r#"
-name: basic test
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
- timeout_ms: 5000
- - probe: "filesystem"
-"#;
- let s = load_scenario(yaml).expect("should parse");
- assert_eq!(s.name, Some("basic test".into()));
- assert_eq!(s.steps.len(), 2);
- }
-
- #[tokio::test]
- async fn test_run_scenario_ping() {
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
- let result = run_scenario(&scenario, &ctx, ®).await;
- assert_eq!(result.overall_status, Status::Pass);
- assert_eq!(result.step_results.len(), 1);
- }
-
- #[test]
- fn test_parse_scenario_minimal() {
- let yaml = r#"
-steps:
- - call: "read_file"
- args:
- path: "/tmp/nope"
-"#;
- let s = load_scenario(yaml).expect("should parse");
- assert_eq!(s.steps.len(), 1);
- }
-
- #[tokio::test]
- async fn test_interactive_go_back() {
- // 3-step scenario: write_file (step 0), ping, ping
- // Simulate: run step 0, go back at step 1, run step 0 again, run step 1, skip step 2
- // Use write_file for step 0 so we can verify it actually executed twice
- // by checking the file exists (proves re-execution, not caching).
- let tmp_dir = tempfile::tempdir().unwrap();
- let tmp = tmp_dir.path().join("step0.txt");
- let tmp_str = tmp.to_str().unwrap().to_string();
-
- // Build the scenario struct directly instead of formatting a YAML
- // string, to avoid backslash-escape issues with Windows paths.
- let scenario = Scenario {
- name: None,
- steps: vec![
- ScenarioStep::Call {
- call: "write_file".to_string(),
- args: serde_json::json!({ "path": tmp_str, "content": "x" }),
- expect_status: "pass".to_string(),
- timeout_ms: 30_000,
- },
- ScenarioStep::Call {
- call: "ping".to_string(),
- args: serde_json::json!({}),
- expect_status: "pass".to_string(),
- timeout_ms: 30_000,
- },
- ScenarioStep::Call {
- call: "ping".to_string(),
- args: serde_json::json!({}),
- expect_status: "pass".to_string(),
- timeout_ms: 30_000,
- },
- ],
- };
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let call_count = std::cell::Cell::new(0usize);
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |idx, _total, _label, _can_go_back| {
- let n = call_count.get();
- call_count.set(n + 1);
- match n {
- 0 => {
- assert_eq!(idx, 0);
- Some(StepChoice::Run)
- }
- 1 => {
- assert_eq!(idx, 1);
- // Delete the file before going back, so re-execution
- // of step 0 must recreate it.
- std::fs::remove_file(&tmp).expect("file should exist after first run");
- Some(StepChoice::GoBack)
- }
- 2 => {
- assert_eq!(idx, 0);
- Some(StepChoice::Run)
- }
- 3 => {
- assert_eq!(idx, 1);
- Some(StepChoice::Run)
- }
- 4 => {
- assert_eq!(idx, 2);
- Some(StepChoice::Skip)
- }
- _ => panic!("unexpected call {}", n),
- }
- },
- |_idx, _total, _label| panic!("no failures expected"),
- )
- .await;
-
- assert_eq!(result.overall_status, Status::Pass);
- assert_eq!(result.step_results.len(), 3);
- assert_eq!(result.step_results[0].status, Status::Pass);
- assert_eq!(result.step_results[1].status, Status::Pass);
- assert_eq!(result.step_results[2].status, Status::Skip);
-
- // Verify step 0 actually re-executed (file recreated after deletion)
- assert!(tmp.exists(), "write_file should have been called twice");
- // tmp_dir auto-cleans on drop, even on panic
- }
-
- #[tokio::test]
- async fn test_interactive_skip_all() {
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |_idx, _total, _label, _can_go_back| Some(StepChoice::Skip),
- |_idx, _total, _label| panic!("no failures expected"),
- )
- .await;
-
- assert_eq!(result.overall_status, Status::Skip);
- assert_eq!(result.step_results.len(), 2);
- assert_eq!(result.step_results[0].status, Status::Skip);
- assert_eq!(result.step_results[1].status, Status::Skip);
- }
-
- #[tokio::test]
- async fn test_interactive_abort() {
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |idx, _total, _label, _can_go_back| {
- if idx == 0 {
- Some(StepChoice::Run)
- } else {
- None
- }
- },
- |_idx, _total, _label| panic!("no failures expected"),
- )
- .await;
-
- assert_eq!(result.overall_status, Status::Skip);
- assert_eq!(result.step_results.len(), 1);
- assert_eq!(result.step_results[0].status, Status::Pass);
- }
-
- #[tokio::test]
- async fn test_interactive_failure_continue() {
- // Step 0 passes, step 1 fails (expect "fail" but ping returns "pass"),
- // user continues, step 2 passes
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
- - call: "ping"
- args: {}
- expect_status: "fail"
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |_idx, _total, _label, _can_go_back| Some(StepChoice::Run),
- |idx, _total, _label| {
- assert_eq!(idx, 1); // only step 1 should fail
- Some(FailureChoice::Continue)
- },
- )
- .await;
-
- assert_eq!(result.overall_status, Status::Fail);
- assert_eq!(result.step_results.len(), 3);
- assert_eq!(result.step_results[0].status, Status::Pass);
- // step 1's CommandResult.status is Pass (ping succeeded) but its expectation
- // was "fail" - the only externally visible evidence is overall_status above.
- assert_eq!(result.step_results[1].status, Status::Pass);
- // step 1 failed expectation but we continued to step 2
- assert_eq!(result.step_results[2].status, Status::Pass);
- }
-
- #[tokio::test]
- async fn test_interactive_failure_abort() {
- // Step 0 fails (expect "fail" but ping returns "pass"), user aborts
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "fail"
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |_idx, _total, _label, _can_go_back| Some(StepChoice::Run),
- |idx, _total, _label| {
- assert_eq!(idx, 0);
- Some(FailureChoice::Abort)
- },
- )
- .await;
-
- assert_eq!(result.overall_status, Status::Fail);
- // Only step 0 recorded, step 1 never reached
- assert_eq!(result.step_results.len(), 1);
- // The failed step's CommandResult.status is Pass (ping executed OK)
- // even though its expectation ("fail") was not met.
- assert_eq!(result.step_results[0].status, Status::Pass);
- }
-
- #[tokio::test]
- async fn test_interactive_go_back_invalidates_stale_results() {
- // Run step 0, skip step 1, go back from step 2, go back from step 1,
- // then abort. The go-back should invalidate the stale Skip for step 1,
- // so the result only contains step 0 and overall is Skip (not Pass).
- let yaml = r#"
-steps:
- - call: "ping"
- args: {}
- expect_status: "pass"
- - call: "ping"
- args: {}
- expect_status: "pass"
- - call: "ping"
- args: {}
- expect_status: "pass"
-"#;
- let scenario = load_scenario(yaml).unwrap();
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
-
- let call_count = std::cell::Cell::new(0usize);
- let result = run_scenario_interactive(
- &scenario,
- &ctx,
- ®,
- |idx, _total, _label, _can_go_back| {
- let n = call_count.get();
- call_count.set(n + 1);
- match n {
- 0 => {
- assert_eq!(idx, 0);
- Some(StepChoice::Run)
- } // run step 0
- 1 => {
- assert_eq!(idx, 1);
- Some(StepChoice::Skip)
- } // skip step 1
- 2 => {
- assert_eq!(idx, 2);
- Some(StepChoice::GoBack)
- } // go back to step 1
- 3 => {
- assert_eq!(idx, 1);
- Some(StepChoice::GoBack)
- } // go back to step 0
- 4 => {
- assert_eq!(idx, 0);
- None
- } // abort
- _ => panic!("unexpected call {}", n),
- }
- },
- |_idx, _total, _label| panic!("no failures expected"),
- )
- .await;
-
- // Go-back invalidated the stale Skip for step 1, so only step 0 remains.
- assert_eq!(result.overall_status, Status::Skip);
- assert_eq!(result.step_results.len(), 1);
- assert_eq!(result.step_results[0].status, Status::Pass);
- }
-
- #[tokio::test]
- async fn test_generous_timeout_does_not_fire() {
- // Verify the timeout_ms field is accepted without panicking and that
- // a generous deadline (5 s) does NOT trigger a false timeout on ping.
- let scenario = Scenario {
- name: Some("timeout test".into()),
- steps: vec![ScenarioStep::Call {
- call: "ping".to_string(),
- args: serde_json::json!({}),
- expect_status: "pass".to_string(),
- timeout_ms: 5_000,
- }],
- };
- let ctx = AppContext::default();
- let reg = CommandRegistry::new();
- let result = run_scenario(&scenario, &ctx, ®).await;
- assert_eq!(result.overall_status, Status::Pass);
- assert_eq!(result.step_results[0].status, Status::Pass);
- }
-}
diff --git a/crates/engine/src/traits.rs b/crates/engine/src/traits.rs
deleted file mode 100644
index 903cff0..0000000
--- a/crates/engine/src/traits.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use std::path::{Path, PathBuf};
-
-/// Result type for trait operations that may be unsupported.
-pub type CapResult = Result;
-
-#[derive(Debug, thiserror::Error)]
-pub enum CapError {
- #[error("unsupported: {0}")]
- Unsupported(String),
-
- #[error("dependency missing: {0}")]
- DependencyMissing(String),
-
- #[error("permission denied: {0}")]
- PermissionDenied(String),
-
- #[error("io error: {0}")]
- Io(#[from] std::io::Error),
-
- #[error("network error: {0}")]
- Network(String),
-
- #[error("timeout")]
- Timeout,
-
- #[error("{0}")]
- Other(String),
-}
-
-// ---------------------------------------------------------------------------
-// Filesystem operations
-// ---------------------------------------------------------------------------
-
-/// Metadata for a single directory entry.
-pub struct DirEntry {
- pub name: String,
- pub is_dir: bool,
- pub size_bytes: u64,
-}
-
-pub trait FilesystemOps: Send + Sync {
- fn read_file(&self, path: &Path) -> CapResult>;
- fn write_file(&self, path: &Path, data: &[u8]) -> CapResult<()>;
- fn remove_file(&self, path: &Path) -> CapResult<()>;
- fn create_dir_all(&self, path: &Path) -> CapResult<()>;
- fn remove_dir_all(&self, path: &Path) -> CapResult<()>;
- fn exists(&self, path: &Path) -> bool;
- fn temp_dir(&self) -> PathBuf;
- fn list_dir(&self, path: &Path) -> CapResult>;
-}
-
-// ---------------------------------------------------------------------------
-// Network operations
-// ---------------------------------------------------------------------------
-
-#[async_trait::async_trait]
-pub trait NetworkOps: Send + Sync {
- /// Resolve a hostname to at least one IP address.
- async fn dns_resolve(&self, host: &str) -> CapResult>;
-
- /// Perform an HTTPS GET and return (status_code, body_snippet).
- async fn https_get(&self, url: &str, timeout_ms: u64) -> CapResult<(u16, String)>;
-}
diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs
index 1741df1..2d69eec 100644
--- a/crates/engine/src/types.rs
+++ b/crates/engine/src/types.rs
@@ -110,65 +110,6 @@ pub struct DoctorReport {
pub proxy_env_set: Vec,
}
-// ---------------------------------------------------------------------------
-// Scenario types
-// ---------------------------------------------------------------------------
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Scenario {
- #[serde(default)]
- pub name: Option,
- pub steps: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum ScenarioStep {
- Call {
- call: String,
- #[serde(default)]
- args: serde_json::Value,
- #[serde(default = "default_expect_status")]
- expect_status: String,
- #[serde(default = "default_timeout_ms")]
- timeout_ms: u64,
- },
- Probe {
- probe: String,
- },
-}
-
-fn default_expect_status() -> String {
- "pass".to_string()
-}
-
-fn default_timeout_ms() -> u64 {
- 30_000
-}
-
-// ---------------------------------------------------------------------------
-// Scenario result
-// ---------------------------------------------------------------------------
-
-/// Result of a scenario run.
-///
-/// When using `run_scenario_interactive`, `step_results` may contain fewer
-/// entries than the declared scenario steps if the user aborts mid-run or
-/// a step fails and the user chooses not to continue. `overall_status` values:
-/// - `Pass` – every step was decided (run or skip) and all executed steps
-/// met expectations. Note: individual steps may have been skipped by the
-/// user - `Pass` does not guarantee every step ran.
-/// - `Skip` – user aborted before all steps were reached, OR every step was
-/// explicitly skipped.
-/// - `Fail` – at least one step failed its expectation (run may be partial if
-/// user also aborted at the failure dialog).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ScenarioResult {
- pub name: Option,
- pub overall_status: Status,
- pub step_results: Vec,
-}
-
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
diff --git a/templates/command.rs.tpl b/templates/command.rs.tpl
deleted file mode 100644
index c17055e..0000000
--- a/templates/command.rs.tpl
+++ /dev/null
@@ -1,56 +0,0 @@
-//! `{{NAME}}` – {{DESCRIPTION}}
-//!
-//! Generated by `sealg new {{NAME}}`. Edit the Input/Output structs and the
-//! body of `run` to implement the command; it is already registered (via the
-//! `register_command!` at the bottom) and callable over the CLI and HTTP API.
-
-use crate::commands::{Command, CommandError};
-use crate::context::Ctx;
-use crate::register_command;
-use async_trait::async_trait;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-#[derive(Default)]
-pub struct {{STRUCT}};
-
-/// Typed request body. Fields become CLI flags / JSON body keys and appear in
-/// the auto-generated JSON Schema.
-#[derive(Debug, Deserialize, JsonSchema)]
-pub struct {{STRUCT}}Input {
- /// TODO: describe this field.
- pub message: String,
-}
-
-/// Typed response. This is the bare body returned over HTTP.
-#[derive(Debug, Serialize, JsonSchema)]
-pub struct {{STRUCT}}Output {
- pub echoed: String,
-}
-
-#[async_trait]
-impl Command for {{STRUCT}} {
- type Input = {{STRUCT}}Input;
- type Output = {{STRUCT}}Output;
-
- fn name(&self) -> &'static str {
- "{{NAME}}"
- }
-
- fn description(&self) -> &'static str {
- "{{DESCRIPTION}}"
- }
-
- async fn run(
- &self,
- input: {{STRUCT}}Input,
- _cx: &Ctx<'_>,
- ) -> Result<{{STRUCT}}Output, CommandError> {
- // TODO: implement. `_cx` exposes fs() and network() capabilities.
- Ok({{STRUCT}}Output {
- echoed: input.message,
- })
- }
-}
-
-register_command!({{STRUCT}});
From fac9b7a47515125ffa7b4e7712f5978838d9647c Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 15:48:27 +0000
Subject: [PATCH 02/10] docs(cli): match docs to the gateway-only `call`/`list`
surface
Update the root/cli/engine READMEs, CLAUDE.md, and the Makefile to describe the
thin MCP-client model (list/call/doctor/init) and drop references to the removed
local `call`, `gw-call`, `probe`, `run-scenario`, and `new` subcommands. Removes
the now-dead `make new` target.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
CLAUDE.md | 17 +++---
Makefile | 10 +--
README.md | 55 +++++++++--------
crates/cli/README.md | 132 ++++++++++++----------------------------
crates/engine/README.md | 81 ++++++------------------
5 files changed, 97 insertions(+), 198 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f6d8982..78c7d15 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,14 +14,14 @@ 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.
diff --git a/Makefile b/Makefile
index 570d9be..a3d3533 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 init banner logo
setup: ## Set up dev environment from scratch (installs deps, copies .env, checks tooling)
@echo "$(BLUE)🔧 Setting up dev environment...$(RESET)"
@@ -77,14 +77,6 @@ init: ## Onboard the template into a real project (sealg init). Bare = wizard; P
$(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
diff --git a/README.md b/README.md
index 7242c23..acc8606 100644
--- a/README.md
+++ b/README.md
@@ -27,14 +27,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 |
+| **Core** | `engine` crate - env-resolved gateway config + hand-rolled MCP client (no transport deps) |
+| **CLI** | `sealg` binary - `list` / `call` / `doctor` / `init` |
+| **Transport** | MCP Streamable HTTP to the gateway's `/mcp/{api_key}/` endpoint |
| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) |
| **Logging** | `tracing` + redaction layer |
| **Packaging** | `cargo-dist` (binaries + installers) |
@@ -45,30 +46,31 @@ crate and exposed through the `sealg` binary (with an MCP transport planned).
```
┌─────────────────────────────────────────────────────────┐
- │ TRANSPORT (crates/cli - one binary `sealg`) │
+ │ TRANSPORT (crates/cli - one binary `sealg`) │
│ │
- │ sealg call --args '{...}' one-shot JSON I/O │
- │ sealg doctor | probe | run-scenario │
- │ sealg mcp (stub - planned) │
+ │ sealg list tools/list │
+ │ sealg call --args '{...}' tools/call │
+ │ sealg doctor local env facts │
+ │ sealg init | mcp (stub) │
└───────────────────────────┬─────────────────────────────┘
- │ same registry + typed contract
+ │ engine::gateway (MCP over HTTP)
┌────────────────────────────▼─────────────────────────────┐
│ 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 │
+ │ GatewayConfig - coordinates resolved from the env │
+ │ GatewayClient - initialize / tools/list / tools/call │
+ │ doctor / types - env facts + stable result contract │
└───────────────────────────┬─────────────────────────────┘
- │
+ │ HTTPS
┌────────────────────────────▼─────────────────────────────┐
- │ crates/config (app-config) - AppConfig / FrontendConfig │
- │ YAML + APP__ env overrides + sanitizer │
+ │ 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/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.
- `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`.
@@ -80,13 +82,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
-```
+# 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"}'
-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.
+# ...or override the gateway per-invocation
+cargo run -p sealg -- list --gateway-url https://dashboard.sealgate.ai
+```
## Configuration
diff --git a/crates/cli/README.md b/crates/cli/README.md
index b33acc3..2d7d84f 100644
--- a/crates/cli/README.md
+++ b/crates/cli/README.md
@@ -1,8 +1,9 @@
# 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,
+`init` onboards the template, and `mcp` is a stub for the future MCP transport.
## Build
@@ -11,120 +12,63 @@ cargo build -p sealg
# Binary at target/debug/sealg (or target/release/sealg with --release)
```
-## Commands
+## Gateway configuration
-### doctor
+The gateway coordinates come from the environment so the binary stays stateless:
-Collect environment facts (OS, kernel, headless detection, proxy vars).
+- `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.
-```bash
-# Human-readable
-sealg doctor
+## Commands
-# JSON output
-sealg doctor --json
+### list
-# Write result to file
-sealg doctor --json --out /tmp/env.json
+List the user's tools from the live gateway (`tools/list`).
+
+```bash
+sealg list
+sealg list --json
+sealg list --gateway-url https://dashboard.sealgate.ai
```
### call
-Invoke a backend command by name with JSON arguments.
+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
-# 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 call some_tool
+sealg call some_tool --args '{"query": "hello"}'
+sealg call some_tool --args '{...}' --gateway-url https://dashboard.sealgate.ai
```
-### probe
+### doctor
-Targeted capability checks.
+Collect local environment facts (OS, kernel, headless detection, proxy vars).
```bash
-# Filesystem probe (create/read/write/delete in temp dir)
-sealg probe filesystem --json
-
-# Network probe (DNS resolve + HTTPS GET)
-sealg probe network --json
-```
+# Human-readable
+sealg doctor
-### 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"
-```
+# JSON output
+sealg doctor --json
-```bash
-sealg run-scenario scenario.yaml --json
-sealg run-scenario scenario.yaml --artifacts /tmp/artifacts
+# Write result to file
+sealg doctor --json --out /tmp/env.json
```
### 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": {}
-}
-```
-
-Error codes: `INVALID_INPUT`, `UNSUPPORTED`, `UNIMPLEMENTED`, `DEPENDENCY_MISSING`,
-`PERMISSION_DENIED`, `NETWORK_ERROR`, `IO_ERROR`, `TIMEOUT`, `EXTERNAL_INTERFERENCE`,
-`INTERNAL_ERROR`.
-
-## Artifacts
-
-When `--artifacts ` is provided, the CLI writes:
-
-```
-//
- result.json # Full result object
- events.jsonl # JSON Lines log of events
-```
-
## 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/engine/README.md b/crates/engine/README.md
index 5310445..9832cdf 100644
--- a/crates/engine/README.md
+++ b/crates/engine/README.md
@@ -1,79 +1,38 @@
# engine – Shared Backend Logic
-The transport-agnostic service core of SealGate - all real
-backend logic. Driven by `sealg` (`crates/cli`) over the CLI and the HTTP API,
-and (later) MCP; the same registry serves every transport.
+The transport-agnostic service core of SealGate. `sealg` (`crates/cli`) is a thin
+MCP client to the SealGate gateway; this crate holds the pieces it needs behind a
+clean, transport-free API (no CLI, axum, or HTTP types).
## Design Principles
-- **No transport dependency** – the engine never imports CLI, axum, or HTTP
- types, so it can run in any Rust context (CLI, HTTP API, tests, etc.).
-- **Trait-based OS access** – filesystem and network operations are behind
- traits (`FilesystemOps`, `NetworkOps`). Callers inject the implementation they
- need (real platform vs. headless stubs).
-- **Structured results** – every operation returns a `CommandResult` with a
- stable JSON schema including `run_id`, `status`, `error`, `timing_ms`, and
- `env_summary`.
-- **No panics on missing capabilities** – headless environments get `SKIP` or
- `UNSUPPORTED` error codes instead of crashes.
+- **No transport dependency** – the engine never imports CLI or HTTP framework
+ types, so it can run in any Rust context (CLI, tests, etc.).
+- **Thin gateway client** – `sealg` speaks only the small slice of MCP it needs
+ (`initialize`, `tools/list`, `tools/call`) directly to the gateway's per-user
+ `/mcp/{api_key}/` endpoint. All policy and enforcement live in the gateway.
+- **Structured results** – `doctor` returns a `CommandResult` with a stable JSON
+ schema including `run_id`, `status`, `error`, `timing_ms`, and `env_summary`.
## Modules
| Module | Purpose |
|--------|---------|
-| `types` | Output contract: `CommandResult`, `Status`, `ErrorCode`, `EnvSummary`, scenario types |
-| `traits` | OS capability traits: `FilesystemOps`, `NetworkOps` |
-| `platform` | Real implementations: `StdFilesystem`, `ReqwestNetwork` |
-| `context` | `AppContext` – holds trait objects and config; constructors for platform/headless |
-| `commands` | `CommandRegistry` with built-in commands: `ping`, `read_file`, `write_file`, … |
-| `probes` | Capability probes: `filesystem`, `network` |
+| `gateway` | The SealGate gateway transport: `GatewayConfig` (env-resolved coordinates) and `GatewayClient` (hand-rolled MCP-over-HTTP client) |
| `doctor` | Environment diagnostics (OS, kernel, headless detection, proxy vars) |
-| `scenario` | YAML scenario parser and async runner |
+| `types` | Output contract: `CommandResult`, `Status`, `ErrorCode`, `EnvSummary` |
## Usage
```rust
-use engine::{AppContext, CommandRegistry, Ctx};
+use engine::{GatewayClient, GatewayConfig};
+use std::time::Duration;
-// Shared capabilities built once; a lightweight Ctx is built per invocation.
-let caps = AppContext::default();
-let registry = CommandRegistry::new();
-let cx = Ctx::new(&caps);
+// Coordinates come from the environment ($SEALGATE_URL, $SEALGATE_API_KEY, …).
+let cfg = GatewayConfig::from_env();
+let client = GatewayClient::connect(cfg, Duration::from_secs(30)).await?;
-// `execute` returns the diagnostic CommandResult envelope (used by the CLI);
-// `call` returns the bare typed Output (used by the HTTP API).
-let result = registry.execute("ping", serde_json::json!({}), &cx).await;
-assert_eq!(result.status, engine::Status::Pass);
-
-// Run a probe
-let probe_result = engine::probes::run_probe("filesystem", &caps).await;
-```
-
-## Adding Commands
-
-Commands implement the typed, async `Command` trait and **self-register at link
-time** via `register_command!` - there is no hand-maintained registration list.
-The fastest path is `sealg new ` (or `make new name=`); see the
-`update-backend` skill for the full pattern.
-
-```rust
-register_command!(MyCommand); // at the bottom of commands/my_command.rs
-```
-
-## OS Traits
-
-Implement custom capability providers by implementing the traits:
-
-```rust
-use engine::traits::{FilesystemOps, CapResult, CapError, DirEntry};
-use std::path::{Path, PathBuf};
-
-struct ReadOnlyFs;
-
-impl FilesystemOps for ReadOnlyFs {
- fn write_file(&self, _path: &Path, _data: &[u8]) -> CapResult<()> {
- Err(CapError::PermissionDenied("read-only filesystem".into()))
- }
- // ...implement the remaining FilesystemOps methods...
-}
+// Discover and call the user's policy-filtered gateway tools.
+let tools = client.tools_list().await?;
+let result = client.tools_call("some_tool", serde_json::json!({})).await?;
```
From 6b03844d0f7607289442bb76c37fb03664bd2b0d Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 15:50:06 +0000
Subject: [PATCH 03/10] docs(cli): rewrite the mcp stub for the thin-client
world
The mcp subcommand stub referenced the deleted local command registry,
`sealg serve`, and `new`. Reword it as a placeholder for a future local
stdio MCP transport that bridges an MCP client to the gateway.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
crates/cli/src/main.rs | 2 +-
crates/cli/src/mcp.rs | 25 ++++++++++---------------
2 files changed, 11 insertions(+), 16 deletions(-)
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index dd69775..99b6b7c 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -35,7 +35,7 @@ enum Commands {
/// Onboard this template into a real project (rename, prune, .env).
Init(init::InitArgs),
- /// (stub) Serve the registry over MCP - designed-for, not yet implemented.
+ /// (stub) Expose the gateway over a local MCP transport - not yet implemented.
Mcp,
/// Collect environment facts and emit an env summary.
diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs
index 8911497..7a1718c 100644
--- a/crates/cli/src/mcp.rs
+++ b/crates/cli/src/mcp.rs
@@ -1,26 +1,21 @@
-//! `sealg mcp` - placeholder for the future MCP transport.
+//! `sealg mcp` - placeholder for a future local 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.
+//! Planned: expose the SealGate gateway over a local (stdio) MCP server so an
+//! MCP client (Claude Desktop, Cursor, ...) can point at `sealg mcp` and reach
+//! the user's gateway through the same `/mcp/{api_key}/` endpoint that `list`
+//! and `call` use. Not built yet; this stub keeps the subcommand surface stable.
/// 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\
+ Planned: a local MCP transport that bridges an MCP client (e.g. Claude\n\
+ Desktop, Cursor) to the SealGate gateway over stdio, forwarding\n\
+ tools/list and tools/call to the same per-user endpoint that\n\
+ `sealg list` and `sealg call` already use.\n\
\n\
- For now use `sealg serve` (HTTP API) or `sealg call ` (CLI)."
+ For now use `sealg list` and `sealg call `."
);
std::process::exit(69);
}
From f9aa33b9833f140dce87eff0b52a0b02a3447c2a Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 15:56:12 +0000
Subject: [PATCH 04/10] docs(readme): list mcp in the CLI surface row (match
the Commands enum)
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index acc8606..dcf9734 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ and enforcement live.
| Feature | Tech Stack |
|---------|:----------:|
| **Core** | `engine` crate - env-resolved gateway config + hand-rolled MCP client (no transport deps) |
-| **CLI** | `sealg` binary - `list` / `call` / `doctor` / `init` |
+| **CLI** | `sealg` binary - `list` / `call` / `doctor` / `mcp` / `init` |
| **Transport** | MCP Streamable HTTP to the gateway's `/mcp/{api_key}/` endpoint |
| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) |
| **Logging** | `tracing` + redaction layer |
From 182926cc9608c1343367e8ee14a515710e57d151 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 16:00:57 +0000
Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=94=A8=20feat(cli):=20remove=20the?=
=?UTF-8?q?=20leftover=20`init`=20template-onboarding=20subcommand?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`sealg init` was Rust-CLI-template machinery for onboarding the template
into a real project - no place in the shipped gateway client. Remove it so
the command surface is exactly mcp | doctor | list | call.
- Drop the `Init` variant, dispatch arm, and `mod init;` from main.rs;
update the crate doc comment.
- Delete the entire crates/cli/src/init/ module.
- Remove now-unreferenced deps from crates/cli/Cargo.toml: dialoguer,
walkdir, toml_edit, comfy-table, serde_yaml, anyhow, and the tempfile
dev-dependency (all were used only by init).
- Update README.md, crates/cli/README.md, and the Makefile to match the
mcp | doctor | list | call surface (drop the `init` make target).
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
Cargo.lock | 146 +-----------
Makefile | 9 +-
README.md | 4 +-
crates/cli/Cargo.toml | 9 -
crates/cli/README.md | 2 +-
crates/cli/src/init/config.rs | 431 ----------------------------------
crates/cli/src/init/env.rs | 91 -------
crates/cli/src/init/mod.rs | 404 -------------------------------
crates/cli/src/init/plan.rs | 87 -------
crates/cli/src/init/prune.rs | 250 --------------------
crates/cli/src/init/rename.rs | 159 -------------
crates/cli/src/init/wizard.rs | 90 -------
crates/cli/src/main.rs | 13 +-
13 files changed, 6 insertions(+), 1689 deletions(-)
delete mode 100644 crates/cli/src/init/config.rs
delete mode 100644 crates/cli/src/init/env.rs
delete mode 100644 crates/cli/src/init/mod.rs
delete mode 100644 crates/cli/src/init/plan.rs
delete mode 100644 crates/cli/src/init/prune.rs
delete mode 100644 crates/cli/src/init/rename.rs
delete mode 100644 crates/cli/src/init/wizard.rs
diff --git a/Cargo.lock b/Cargo.lock
index d370792..313a70f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -262,17 +262,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"
@@ -293,18 +282,6 @@ dependencies = [
"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"
@@ -378,29 +355,6 @@ 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"
@@ -417,18 +371,6 @@ dependencies = [
"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"
@@ -459,27 +401,12 @@ 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"
@@ -1071,12 +998,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"
@@ -1603,22 +1524,15 @@ checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
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]]
@@ -1779,12 +1693,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"
@@ -2038,17 +1946,11 @@ checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"serde_core",
"serde_spanned",
- "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_datetime",
"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"
@@ -2058,18 +1960,6 @@ 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"
@@ -2079,12 +1969,6 @@ 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"
@@ -2240,12 +2124,6 @@ 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"
@@ -2417,22 +2295,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 +2304,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"
diff --git a/Makefile b/Makefile
index a3d3533..87cf1e9 100644
--- a/Makefile
+++ b/Makefile
@@ -48,7 +48,7 @@ build-release: ## Build the whole workspace (release)
########################################################
### Initialization
-.PHONY: setup init banner logo
+.PHONY: setup banner logo
setup: ## Set up dev environment from scratch (installs deps, copies .env, checks tooling)
@echo "$(BLUE)🔧 Setting up dev environment...$(RESET)"
@@ -70,13 +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)
-
### Asset Generation
.PHONY: banner logo
diff --git a/README.md b/README.md
index dcf9734..037354b 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ and enforcement live.
| Feature | Tech Stack |
|---------|:----------:|
| **Core** | `engine` crate - env-resolved gateway config + hand-rolled MCP client (no transport deps) |
-| **CLI** | `sealg` binary - `list` / `call` / `doctor` / `mcp` / `init` |
+| **CLI** | `sealg` binary - `mcp` / `doctor` / `list` / `call` |
| **Transport** | MCP Streamable HTTP to the gateway's `/mcp/{api_key}/` endpoint |
| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) |
| **Logging** | `tracing` + redaction layer |
@@ -51,7 +51,7 @@ and enforcement live.
│ sealg list tools/list │
│ sealg call --args '{...}' tools/call │
│ sealg doctor local env facts │
- │ sealg init | mcp (stub) │
+ │ sealg mcp (stub) │
└───────────────────────────┬─────────────────────────────┘
│ engine::gateway (MCP over HTTP)
┌────────────────────────────▼─────────────────────────────┐
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 2d7d84f..1c58ba2 100644
--- a/crates/cli/README.md
+++ b/crates/cli/README.md
@@ -3,7 +3,7 @@
`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,
-`init` onboards the template, and `mcp` is a stub for the future MCP transport.
+and `mcp` is a stub for the future MCP transport.
## Build
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 99b6b7c..b26f4a7 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -3,13 +3,11 @@
//! 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,
-//! `init` onboards the template, and `mcp` is a stub for the future MCP
-//! transport.
+//! and `mcp` is a stub for the future MCP transport.
#[cfg(feature = "cli")]
mod diagnostics;
mod gateway_cmd;
-mod init;
mod mcp;
use clap::{Parser, Subcommand};
@@ -32,9 +30,6 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
- /// Onboard this template into a real project (rename, prune, .env).
- Init(init::InitArgs),
-
/// (stub) Expose the gateway over a local MCP transport - not yet implemented.
Mcp,
@@ -88,12 +83,6 @@ 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::Mcp => mcp::run(),
#[cfg(feature = "cli")]
Commands::Doctor { json, out } => diagnostics::cmd_doctor(json, out).await,
From 8e3c8c3a0fd7bbb08170a8e34f0561053efd11c9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 16:02:55 +0000
Subject: [PATCH 06/10] docs(readme): align the architecture diagram boxes and
connectors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Regenerate the ASCII diagram with uniform box widths and the vertical
connectors aligned under the ┬/▼ glyphs (cubic review on #16).
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
README.md | 40 ++++++++++++++++++++--------------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 037354b..8231898 100644
--- a/README.md
+++ b/README.md
@@ -45,26 +45,26 @@ and enforcement live.
## Architecture
```
- ┌─────────────────────────────────────────────────────────┐
- │ TRANSPORT (crates/cli - one binary `sealg`) │
- │ │
- │ sealg list tools/list │
- │ sealg call --args '{...}' tools/call │
- │ sealg doctor local env facts │
- │ sealg mcp (stub) │
- └───────────────────────────┬─────────────────────────────┘
- │ 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 │
- └───────────────────────────────────────────────────────────┘
+ ┌──────────────────────────────────────────────────────────┐
+ │ TRANSPORT (crates/cli - one binary `sealg`) │
+ │ │
+ │ sealg list tools/list │
+ │ sealg call --args '{...}' tools/call │
+ │ sealg doctor local env facts │
+ │ sealg mcp (stub) │
+ └───────────────────────────┬──────────────────────────────┘
+ │ 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/` - the gateway client + config, `doctor` env facts, and the
From eeec01ec47979d74f07f73a1451e6fa6f39c4ee4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 16:10:06 +0000
Subject: [PATCH 07/10] fix(engine): ignore a --gateway-url that trims to empty
An override like `/` or `///` passed the non-blank guard but
trim_end_matches('/') then yielded "", leaving base_url empty and mcp_url()
= /mcp/. Trim first, then check non-empty, so a degenerate override is
ignored like a blank one. Added a test.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
crates/engine/src/gateway/config.rs | 21 ++++++++++++++++-----
1 file changed, 16 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/gateway/config.rs b/crates/engine/src/gateway/config.rs
index 3f43859..82c4315 100644
--- a/crates/engine/src/gateway/config.rs
+++ b/crates/engine/src/gateway/config.rs
@@ -63,14 +63,15 @@ impl GatewayConfig {
/// Resolve from the environment, then override the gateway origin with
/// `url` when it is `Some` and non-blank (the `--gateway-url` flag). A
- /// trailing `/` is trimmed to match [`from_env`](Self::from_env); a blank
- /// override is ignored so the env/default value stands.
+ /// trailing `/` is trimmed to match [`from_env`](Self::from_env); an
+ /// override that is blank — or degenerate, like `/` or `///`, which trims to
+ /// empty — is ignored so the env/default value stands.
pub fn from_env_with_url_override(url: Option) -> Self {
let mut cfg = Self::from_env();
if let Some(u) = url {
- let u = u.trim();
- if !u.is_empty() {
- cfg.base_url = u.trim_end_matches('/').to_string();
+ let trimmed = u.trim().trim_end_matches('/');
+ if !trimmed.is_empty() {
+ cfg.base_url = trimmed.to_string();
}
}
cfg
@@ -186,6 +187,16 @@ mod tests {
assert_eq!(cfg.base_url, base);
let cfg = GatewayConfig::from_env_with_url_override(None);
assert_eq!(cfg.base_url, base);
+
+ // A degenerate override that trims to empty (all slashes) is ignored
+ // too, so base_url never becomes empty (which would break mcp_url()).
+ for degenerate in ["/", "///", " // "] {
+ let cfg = GatewayConfig::from_env_with_url_override(Some(degenerate.to_string()));
+ assert_eq!(
+ cfg.base_url, base,
+ "override {degenerate:?} should be ignored"
+ );
+ }
}
#[test]
From 7149eff70d0a961b179946d5a7570dca2abeb550 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 16:16:53 +0000
Subject: [PATCH 08/10] refactor(cli): remove the mcp stub subcommand
Drop the inherited-template `sealg mcp` stub (it did nothing but print a
not-implemented notice). The command surface is now `doctor | list | call`.
Removes crates/cli/src/mcp.rs, the Mcp variant + dispatch, and the doc
references (READMEs, architecture diagram, HUMANS_SHOULD_TEST). MCP-protocol
code in the gateway client is untouched.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y
---
HUMANS_SHOULD_TEST.md | 6 ++----
README.md | 3 +--
crates/cli/README.md | 7 +------
crates/cli/src/main.rs | 8 +-------
crates/cli/src/mcp.rs | 21 ---------------------
5 files changed, 5 insertions(+), 40 deletions(-)
delete mode 100644 crates/cli/src/mcp.rs
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/README.md b/README.md
index 8231898..7831a12 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ and enforcement live.
| Feature | Tech Stack |
|---------|:----------:|
| **Core** | `engine` crate - env-resolved gateway config + hand-rolled MCP client (no transport deps) |
-| **CLI** | `sealg` binary - `mcp` / `doctor` / `list` / `call` |
+| **CLI** | `sealg` binary - `list` / `call` / `doctor` |
| **Transport** | MCP Streamable HTTP to the gateway's `/mcp/{api_key}/` endpoint |
| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) |
| **Logging** | `tracing` + redaction layer |
@@ -51,7 +51,6 @@ and enforcement live.
│ sealg list tools/list │
│ sealg call --args '{...}' tools/call │
│ sealg doctor local env facts │
- │ sealg mcp (stub) │
└───────────────────────────┬──────────────────────────────┘
│ engine::gateway (MCP over HTTP)
┌───────────────────────────▼──────────────────────────────┐
diff --git a/crates/cli/README.md b/crates/cli/README.md
index 1c58ba2..1d76e0a 100644
--- a/crates/cli/README.md
+++ b/crates/cli/README.md
@@ -2,8 +2,7 @@
`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,
-and `mcp` is a stub for the future MCP transport.
+applies all policy and enforcement. `doctor` reports local environment facts.
## Build
@@ -62,10 +61,6 @@ sealg doctor --json
sealg doctor --json --out /tmp/env.json
```
-### mcp
-
-Stub for the future MCP transport - prints a notice and exits (`EX_UNAVAILABLE`).
-
## Exit Codes
- `0` -- success
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index b26f4a7..7db6d85 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -2,13 +2,11 @@
//!
//! 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,
-//! and `mcp` is a stub for the future MCP transport.
+//! enforcement live in the gateway). `doctor` reports local environment facts.
#[cfg(feature = "cli")]
mod diagnostics;
mod gateway_cmd;
-mod mcp;
use clap::{Parser, Subcommand};
@@ -30,9 +28,6 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
- /// (stub) Expose the gateway over a local MCP transport - not yet implemented.
- Mcp,
-
/// Collect environment facts and emit an env summary.
#[cfg(feature = "cli")]
Doctor {
@@ -83,7 +78,6 @@ async fn main() {
let cli = Cli::parse();
match cli.command {
- Commands::Mcp => mcp::run(),
#[cfg(feature = "cli")]
Commands::Doctor { json, out } => diagnostics::cmd_doctor(json, out).await,
Commands::List { json, gateway_url } => gateway_cmd::cmd_list(json, gateway_url).await,
diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs
deleted file mode 100644
index 7a1718c..0000000
--- a/crates/cli/src/mcp.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-//! `sealg mcp` - placeholder for a future local MCP transport.
-//!
-//! Planned: expose the SealGate gateway over a local (stdio) MCP server so an
-//! MCP client (Claude Desktop, Cursor, ...) can point at `sealg mcp` and reach
-//! the user's gateway through the same `/mcp/{api_key}/` endpoint that `list`
-//! and `call` use. Not built yet; this stub keeps the subcommand surface stable.
-
-/// Print the "not implemented" notice and exit with `EX_UNAVAILABLE` (69).
-pub fn run() -> ! {
- eprintln!(
- "sealg mcp is not implemented yet.\n\
- \n\
- Planned: a local MCP transport that bridges an MCP client (e.g. Claude\n\
- Desktop, Cursor) to the SealGate gateway over stdio, forwarding\n\
- tools/list and tools/call to the same per-user endpoint that\n\
- `sealg list` and `sealg call` already use.\n\
- \n\
- For now use `sealg list` and `sealg call `."
- );
- std::process::exit(69);
-}
From c42b80805349042f58795fe4db38d5fd3e0c50c4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 28 Aug 2026 16:23:40 +0000
Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Delete=20dead=20c?=
=?UTF-8?q?rates:=20assetgen=20and=20config?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove two isolated template scaffolding crates that were unused:
- crates/assetgen (asset-gen banner/logo generator)
- crates/config (app-config crate)
After deletion, workspace contains exactly two members:
- crates/engine (gateway client + config, doctor env facts, stable result contract)
- crates/cli (sealg binary with doctor/list/call surface)
Updated:
- Cargo.toml: removed both crates from [workspace] members
- Makefile: removed banner/logo targets and updated bump-version
- README.md: removed Config row from features table, removed config/assetgen
architecture bullets, removed entire Configuration section
- CLAUDE.md: removed crates/config reference from Architecture section,
removed Configuration Pattern section
Verified:
- cargo build --workspace passes
- cargo clippy --workspace --all-targets -- -D warnings passes
- cargo test --workspace passes (12 tests)
- cargo fmt --all passes
- sealg release binary unchanged: still lists doctor, list, call commands
---
CLAUDE.md | 13 -
Cargo.lock | 652 +------------------------------
Cargo.toml | 2 +-
Makefile | 17 +-
README.md | 16 -
crates/assetgen/Cargo.toml | 25 --
crates/assetgen/src/main.rs | 627 -----------------------------
crates/config/Cargo.toml | 18 -
crates/config/global_config.yaml | 86 ----
crates/config/src/lib.rs | 305 ---------------
crates/config/src/tests.rs | 215 ----------
11 files changed, 4 insertions(+), 1972 deletions(-)
delete mode 100644 crates/assetgen/Cargo.toml
delete mode 100644 crates/assetgen/src/main.rs
delete mode 100644 crates/config/Cargo.toml
delete mode 100644 crates/config/global_config.yaml
delete mode 100644 crates/config/src/lib.rs
delete mode 100644 crates/config/src/tests.rs
diff --git a/CLAUDE.md b/CLAUDE.md
index 78c7d15..df1a2f5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,8 +23,6 @@ sealg call --args '{...}' # Call a gateway tool via tools/call
- **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.
@@ -32,17 +30,6 @@ sealg call --args '{...}' # Call a gateway tool via tools/call
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);
-```
-
## Commit Message Convention
Use emoji prefixes indicating change type and magnitude (multiple emojis = 5+ files):
diff --git a/Cargo.lock b/Cargo.lock
index 313a70f..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,55 +186,6 @@ dependencies = [
"memchr",
]
-[[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 = "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"
@@ -337,50 +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 = "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 = "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"
@@ -392,15 +223,6 @@ 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 = "dyn-clone"
version = "1.0.20"
@@ -441,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"
@@ -468,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"
@@ -529,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"
@@ -565,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"
@@ -617,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"
@@ -869,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"
@@ -891,7 +617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
- "hashbrown 0.16.1",
+ "hashbrown",
]
[[package]]
@@ -963,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"
@@ -998,15 +713,6 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
-[[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"
@@ -1034,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"
@@ -1055,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"
@@ -1074,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"
@@ -1101,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"
@@ -1201,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"
@@ -1232,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"
@@ -1256,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"
@@ -1335,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"
@@ -1466,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"
@@ -1508,18 +1039,6 @@ 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"
@@ -1568,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"
@@ -1625,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"
@@ -1647,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"
@@ -1709,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"
@@ -1869,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"
@@ -1938,37 +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",
- "toml_parser",
- "winnow",
-]
-
-[[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_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 = "tower"
version = "0.5.3"
@@ -2094,36 +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 = "unsafe-libyaml"
version = "0.2.11"
@@ -2177,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"
@@ -2561,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"
@@ -2582,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"
@@ -2681,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/Makefile b/Makefile
index 87cf1e9..a757e81 100644
--- a/Makefile
+++ b/Makefile
@@ -48,7 +48,7 @@ build-release: ## Build the whole workspace (release)
########################################################
### Initialization
-.PHONY: setup 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,19 +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)"
-### 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)"
-
########################################################
@@ -204,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 7831a12..5a7c98c 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,6 @@
Key Features •
Architecture •
Quick Start •
- Configuration •
Agent Skills •
Credits
@@ -36,7 +35,6 @@ and enforcement live.
| **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 |
-| **Config** | `app-config` crate (YAML + `APP__` env overrides + sanitizer) |
| **Logging** | `tracing` + redaction layer |
| **Packaging** | `cargo-dist` (binaries + installers) |
| **Package Manager** | Bun |
@@ -70,9 +68,6 @@ and enforcement live.
shared result contract. No transport dependency.
- `crates/cli/` - the `sealg` binary. The `cli` surface (`doctor`) 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`.
## Quick Start
@@ -90,17 +85,6 @@ cargo run -p sealg -- call some_tool --args '{"query": "hello"}'
cargo run -p sealg -- list --gateway-url https://dashboard.sealgate.ai
```
-## Configuration
-
-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`.
-
## Agent Skills
Claude Code skills live in `.claude/skills/`. Invoke them with `/skill-name`
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/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