Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What Switchyard is

A local desktop workbench that drives the AI coding CLIs already installed and
logged in on the user's machine (`codex`, `claude`, `gemini`, `agy`). Switchyard
provides **no model accounts** and does not host code or count tokens — model
capability, auth, billing, and context-window/token budgeting all belong to each
provider CLI. Switchyard owns local concerns: routing turns, the canonical
session store, local diffs, artifacts, permissions, and peer delegation.

## Toolchain (Windows MSVC baseline)

- Dev baseline is `stable-x86_64-pc-windows-msvc` (VS 2019 Build Tools). GNU is
**not** a supported target — do not assume `x86_64-pc-windows-gnu` works.
- In the **Bash tool**, cargo may not be on `PATH`; prefix sessions with
`export PATH="$HOME/.cargo/bin:$PATH"`. PowerShell generally has it already.
- The repo-root `cargo` (bash) / `cargo.cmd` wrappers rewrite a
`--target-dir target-*` arg or `CARGO_TARGET_DIR=target-*` to live under
`target/` so parallel builds don't clobber the default target dir. Plain
`cargo` (the real one on PATH) is fine for normal work.

## Common commands

Rust workspace (run from repo root):

```bash
cargo build --workspace
cargo test --workspace --all-targets # full suite
cargo test -p switchyard-tests --test integration_e2e # one integration file
cargo test -p switchyard-core router:: # filter by name
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings # CI treats warnings as errors
```

CI (`.github/workflows/rust-windows.yml`) gates merges on, in order: `ruff format
--check .`, `ruff check .`, `python tools/validate_ci_config.py`, `cargo fmt`,
`cargo clippy ... -D warnings`, `cargo test --workspace --all-targets`. Match
these locally before pushing. (Ruff only lints `tools/` — the lone Python there.)

Desktop GUI (Tauri 2 + React/Vite frontend):

```powershell
.\start-gui.ps1 # installs frontend deps, starts Vite (:5173), launches Tauri dev
```

Frontend-only (in `crates/switchyard-gui/frontend/`):

```bash
npm run dev # Vite dev server
npm run build # tsc -b && vite build
npm run lint # eslint
npm run test:turn-merge # the one frontend unit test (turnMerge)
```

CLI / TUI (binary is `switchyard`):

```bash
cargo run -p switchyard-cli -- run --provider claude -m "review auth"
cargo run -p switchyard-cli -- tui --resume-latest
cargo run -p switchyard-cli -- check --json # probe provider availability + host surfaces
cargo run -p switchyard-cli -- host list # HYARD bridge (see below)
```

## Live provider tests

Default `cargo test` uses in-repo **fake providers** and never touches real CLIs.
Tests that hit installed providers are gated behind env vars and self-skip when
unset/unauthenticated: `SWITCHYARD_RUN_LIVE_PROVIDER_TESTS=1` plus
`SWITCHYARD_TEST_{CODEX,CLAUDE,GEMINI}=1`. End-to-end smoke entry points are
`scripts/smoke-hyard-host.ps1` (single) and `scripts/smoke-hyard-matrix.ps1`.

## Architecture

A layered Rust workspace; one layer ≈ one or more `switchyard-*` crates. Data
flows: user turn → Router picks the active `core` provider → Context Composer
assembles the send payload → Provider adapter runs a headless turn → native
output is mapped to internal events → UI renders + Store/Artifacts persist.

| Layer | Crate(s) | Responsibility |
|-------|----------|----------------|
| Interface | `switchyard-cli`, `switchyard-tui` | CLI commands, ratatui TUI, diff/event views |
| Application | `switchyard-core` | Router, turn runner, event dispatch, write to canonical session. Router is **not** its own crate — it's a subsystem here |
| Orchestrator | `switchyard-orchestrator` | Validates `delegate` requests, supervises peer jobs (retry/backoff), normalizes results. Hosts `WorkerSupervisor` (moved out of core to break a dependency cycle) |
| Context | `switchyard-context` | Context Composer: summary + recent-window assembly, peer-state injection. **No tokenizer / token counting** by design |
| Provider | `switchyard-provider-api`, `switchyard-provider-{codex,claude,gemini,antigravity,subprocess}` | Adapter trait (probe / start_turn / finalize), maps native output to events; `subprocess` = shared process plumbing |
| Artifact | `switchyard-artifacts` | File-change records, **locally computed** diffs, command/review summaries |
| Store | `switchyard-session`, `switchyard-store` | `session` = domain model only; `store` = persistence/query interfaces + SQLite |
| Runtime | `switchyard-runtime`, `switchyard-host-jobs` | Durable runtime authority: commits lifecycle change + ordered event to SQLite *before* IPC broadcast (event log is exactly-once; IPC is at-least-once). Backs GUI live updates and async HYARD jobs |
| Config | `switchyard-config` | Parses `switchyard.toml`; leaf dep, never depends back on business crates |
| Registry | `switchyard-app-providers` | Central `build_provider_registry(&config)` wiring all adapters |
| Desktop | `switchyard-gui` | Tauri shell; `src/main.rs` holds Tauri commands + state, plus `git.rs` / `file_watcher.rs` / `pty.rs`. React frontend under `frontend/` |

### Two delegation protocols (don't conflate them)

- **Sentinel** — in-process, used by Switchyard's own CLI/TUI. A model emits a
`<<<SWITCHYARD_JSON_BEGIN>>>…<<<SWITCHYARD_JSON_END>>>` block; the Router parses
it. See `switchyard-provider-api/src/sentinel.rs`.
- **HYARD** — provider-native host surface (slash commands / skills) for peers.
Host packs in `host-packs/{claude,codex,gemini}/` translate `/hyard:*` calls to
`switchyard host <subcommand>` subprocesses, the authoritative async-job
transport. Each invocation prints exactly one JSON doc to stdout; exit code only
decides success-doc vs error-doc. Spec: `docs/protocol/HYARD_COMMAND_PROTOCOL_V1.md`
(filename says V1, content is V2). Delegation is **leaf-only**: peers cannot
re-delegate, one delegate at a time per session.

## Invariants (enforced; see `docs/development/ENGINEERING_RULES.md`)

- **Canonical session is the single source of truth.** Provider-native session
state is never authoritative; only Switchyard writes the canonical session.
- **Diffs shown in UI are always locally computed** by the Artifact layer.
Provider-reported file changes are hints only.
- **Provider differences stay inside adapters** — no provider special-casing in
higher layers. Keep `store`/`session`/`context` strictly separated (store holds
no runtime state machine; context reads no config paths / does no workspace IO).
- **Delegation must be replayable** — every delegate is visible in the log/session.
- **Headless by default** — providers run non-interactively; PTY is a gated
fallback only (`portable-pty`), not the default path.
- Docs/ADRs lead implementation: update `docs/` before landing new modules,
protocols, or behavior changes.

## Local data

Sessions, artifacts, and the SQLite store live under the workspace's
`.switchyard/` (paths configurable in `switchyard.toml`). It may contain chat
history and attachments — keep it out of public commits (it's gitignored).
Templates seeded into user workspaces live in `templates/` and `host-packs/`.
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ members = [
"crates/switchyard-provider-claude",
"crates/switchyard-provider-gemini",
"crates/switchyard-provider-antigravity",
"crates/switchyard-provider-kohaku",
"crates/switchyard-gui",
"tests",
]
Expand Down Expand Up @@ -51,6 +52,7 @@ switchyard-provider-codex = { path = "crates/switchyard-provider-codex" }
switchyard-provider-claude = { path = "crates/switchyard-provider-claude" }
switchyard-provider-gemini = { path = "crates/switchyard-provider-gemini" }
switchyard-provider-antigravity = { path = "crates/switchyard-provider-antigravity" }
switchyard-provider-kohaku = { path = "crates/switchyard-provider-kohaku" }

# External dependencies
tokio = { version = "1", features = ["full"] }
Expand Down
1 change: 1 addition & 0 deletions crates/switchyard-app-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ switchyard-provider-codex = { workspace = true }
switchyard-provider-claude = { workspace = true }
switchyard-provider-gemini = { workspace = true }
switchyard-provider-antigravity = { workspace = true }
switchyard-provider-kohaku = { workspace = true }
34 changes: 32 additions & 2 deletions crates/switchyard-app-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ use switchyard_provider_api::Provider;
use switchyard_provider_claude::ClaudeProvider;
use switchyard_provider_codex::CodexProvider;
use switchyard_provider_gemini::GeminiProvider;
use switchyard_provider_kohaku::KohakuProvider;

/// Built-in provider aliases exposed even when the local config omits them.
pub const BUILT_IN_PROVIDER_ALIASES: &[&str] = &["codex", "claude", "gemini", "antigravity"];
pub const BUILT_IN_PROVIDER_ALIASES: &[&str] =
&["codex", "claude", "gemini", "antigravity", "kohaku"];

/// Default timeout for built-in provider fallbacks.
///
Expand Down Expand Up @@ -58,6 +60,8 @@ pub fn inferred_provider_backend(name: &str) -> Option<&'static str> {
Some("antigravity")
} else if name.contains("gemini") {
Some("gemini")
} else if name.contains("kohaku") {
Some("kohaku")
} else {
None
}
Expand All @@ -70,6 +74,7 @@ pub fn default_provider_command(backend: &str) -> String {
"claude" => "claude".to_string(),
"gemini" => "gemini".to_string(),
"antigravity" => "agy".to_string(),
"kohaku" => "kt".to_string(),
other => other.to_string(),
}
}
Expand All @@ -81,9 +86,17 @@ pub fn default_provider_config(provider_id: &str) -> ProviderConfig {
}

fn default_provider_config_for_backend(backend: &str) -> ProviderConfig {
// KohakuTerrarium needs a creature as args[0]; default to the official
// `general` creature from the kt-biome pack (`kt install @kt-biome`) so
// kohaku works out of the box instead of erroring on an empty creature.
let args = if backend == "kohaku" {
vec!["@kt-biome/creatures/general".to_string()]
} else {
Vec::new()
};
ProviderConfig {
command: default_provider_command(backend),
args: Vec::new(),
args,
env: std::collections::HashMap::new(),
model: None,
thinking_level: None,
Expand Down Expand Up @@ -150,6 +163,19 @@ fn register_provider_backend(
p
}),
),
"kohaku" => registry.register(
name,
Box::new(|cfg| {
let p: Box<dyn Provider> = match cfg {
Some(c) => Box::new(KohakuProvider::from_config(c)),
None => {
let c = default_provider_config_for_backend("kohaku");
Box::new(KohakuProvider::from_config(&c))
}
};
p
}),
),
_ => {}
}
}
Expand All @@ -161,6 +187,7 @@ mod tests {
use switchyard_provider_claude::ClaudeProvider;
use switchyard_provider_codex::CodexProvider;
use switchyard_provider_gemini::GeminiProvider;
use switchyard_provider_kohaku::KohakuProvider;

#[test]
fn built_in_fallback_configs_have_no_hard_timeout() {
Expand All @@ -176,11 +203,13 @@ mod tests {
let claude = ClaudeProvider::from_config(&default_provider_config("claude"));
let gemini = GeminiProvider::from_config(&default_provider_config("gemini"));
let antigravity = AntigravityProvider::from_config(&default_provider_config("antigravity"));
let kohaku = KohakuProvider::from_config(&default_provider_config("kohaku"));

assert_eq!(codex.timeout_secs, 0);
assert_eq!(claude.timeout_secs, 0);
assert_eq!(gemini.timeout_secs, 0);
assert_eq!(antigravity.timeout_secs, 0);
assert_eq!(kohaku.timeout_secs, 0);
}

#[test]
Expand All @@ -198,6 +227,7 @@ mod tests {
assert_eq!(inferred_provider_backend("CLAUDE"), Some("claude"));
assert_eq!(inferred_provider_backend("agy"), Some("antigravity"));
assert_eq!(inferred_provider_backend("google-gemini"), Some("gemini"));
assert_eq!(inferred_provider_backend("kohaku"), Some("kohaku"));
assert_eq!(inferred_provider_backend("unknown"), None);
}
}
69 changes: 69 additions & 0 deletions crates/switchyard-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ enum Commands {
#[arg(long, default_value = ".")]
cwd: PathBuf,
},
/// Launch the Switchyard desktop GUI app (spawns the `switchyard-gui`
/// binary located next to this executable).
App {
/// Extra arguments forwarded to the GUI executable.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Validate configuration and check provider availability.
Check {
/// Output machine-readable JSON report after probes complete.
Expand Down Expand Up @@ -267,6 +274,9 @@ enum HookCli {
}

/// Known CLI command names for auto-discovery.
// `kt` is intentionally absent: the KohakuTerrarium provider is registered as
// `kohaku` (command `kt`), so it already appears via the registry. Listing the
// bare `kt` here would add a confusing "adapter not implemented" duplicate.
const KNOWN_CLIS: &[&str] = &["codex", "claude", "gemini", "agy"];

#[derive(Clone, Serialize)]
Expand Down Expand Up @@ -429,6 +439,15 @@ async fn main() {
process::exit(1);
}
}
Commands::App { args } => match launch_gui(&args) {
Ok(path) => {
println!("launched Switchyard GUI: {}", path.display());
}
Err(err) => {
eprintln!("{err}");
process::exit(1);
}
},
Commands::Check { json } => {
let config_issues = config.validate();

Expand Down Expand Up @@ -756,3 +775,53 @@ async fn main() {
}
}
}

/// Launch the Switchyard desktop GUI by spawning the `switchyard-gui`
/// executable. Searched in order: next to the running `switchyard` binary
/// (packaged install / same target dir), then `<cwd>/target/{release,debug}`
/// (source checkout — `switchyard` may be installed in ~/.cargo/bin while the
/// GUI lives in the repo target dir), then PATH. Spawns detached so the
/// terminal is freed; returns the launched path.
fn launch_gui(extra: &[String]) -> Result<PathBuf, String> {
const NAMES: [&str; 2] = ["switchyard-gui.exe", "switchyard-gui"];

let exe = std::env::current_exe().map_err(|e| format!("cannot resolve current exe: {e}"))?;
let exe_dir = exe
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

let mut candidates: Vec<PathBuf> = Vec::new();
for n in NAMES {
candidates.push(exe_dir.join(n));
}
for profile in ["release", "debug"] {
for n in NAMES {
candidates.push(cwd.join("target").join(profile).join(n));
}
}

let gui = candidates
.into_iter()
.find(|p| p.exists())
.or_else(|| find_on_path("switchyard-gui").map(PathBuf::from));

let Some(gui) = gui else {
return Err(format!(
"switchyard-gui not found (looked next to {}, under {}\\target\\{{release,debug}}, \
and on PATH).\n\
Build it with `cargo build -p switchyard-gui` (and \
`npm --prefix crates/switchyard-gui/frontend run build` so the bundled \
frontend exists), or use `.\\start-gui.ps1` for a dev run.",
exe_dir.display(),
cwd.display()
));
};

process::Command::new(&gui)
.args(extra)
.spawn()
.map_err(|e| format!("failed to spawn {}: {e}", gui.display()))?;
Ok(gui)
}
Loading
Loading