From 15b9e8b95c30db0ff8f6178d2fe6bbbb0bacbe06 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 22:57:35 +0000 Subject: [PATCH 01/20] rewrite planoai CLI in Rust Add crates/plano-cli/ as a full Rust rewrite of the Python CLI. Binary name: planoai. All subcommands ported: up, down, build, logs, cli-agent, trace, init. Config validation and Tera template rendering replace the Python config_generator. CI updated to use cargo test/build instead of Python test jobs. --- .claude/skills/build-cli/SKILL.md | 9 +- .github/workflows/ci.yml | 63 +- CLAUDE.md | 15 +- crates/Cargo.lock | 1073 ++++++++++++++++- crates/Cargo.toml | 2 +- crates/plano-cli/Cargo.toml | 70 ++ crates/plano-cli/build.rs | 4 + crates/plano-cli/src/commands/build.rs | 105 ++ crates/plano-cli/src/commands/cli_agent.rs | 103 ++ crates/plano-cli/src/commands/down.rs | 15 + crates/plano-cli/src/commands/init.rs | 132 ++ crates/plano-cli/src/commands/logs.rs | 10 + crates/plano-cli/src/commands/mod.rs | 262 ++++ crates/plano-cli/src/commands/up.rs | 174 +++ crates/plano-cli/src/config/generator.rs | 761 ++++++++++++ crates/plano-cli/src/config/mod.rs | 4 + crates/plano-cli/src/config/validation.rs | 39 + crates/plano-cli/src/consts.rs | 43 + crates/plano-cli/src/docker/mod.rs | 211 ++++ crates/plano-cli/src/main.rs | 22 + crates/plano-cli/src/native/binaries.rs | 268 ++++ crates/plano-cli/src/native/mod.rs | 2 + crates/plano-cli/src/native/runner.rs | 481 ++++++++ crates/plano-cli/src/trace/daemon.rs | 60 + crates/plano-cli/src/trace/down.rs | 16 + crates/plano-cli/src/trace/listen.rs | 58 + crates/plano-cli/src/trace/mod.rs | 6 + crates/plano-cli/src/trace/show.rs | 18 + crates/plano-cli/src/trace/store.rs | 118 ++ crates/plano-cli/src/trace/tail.rs | 68 ++ crates/plano-cli/src/utils.rs | 237 ++++ crates/plano-cli/src/version.rs | 89 ++ .../templates/coding_agent_routing.yaml | 41 + .../templates/conversational_state.yaml | 36 + .../templates/filter_chain_guardrails.yaml | 50 + .../templates/preference_aware_routing.yaml | 27 + .../templates/sub_agent_orchestration.yaml | 57 + 37 files changed, 4658 insertions(+), 91 deletions(-) create mode 100644 crates/plano-cli/Cargo.toml create mode 100644 crates/plano-cli/build.rs create mode 100644 crates/plano-cli/src/commands/build.rs create mode 100644 crates/plano-cli/src/commands/cli_agent.rs create mode 100644 crates/plano-cli/src/commands/down.rs create mode 100644 crates/plano-cli/src/commands/init.rs create mode 100644 crates/plano-cli/src/commands/logs.rs create mode 100644 crates/plano-cli/src/commands/mod.rs create mode 100644 crates/plano-cli/src/commands/up.rs create mode 100644 crates/plano-cli/src/config/generator.rs create mode 100644 crates/plano-cli/src/config/mod.rs create mode 100644 crates/plano-cli/src/config/validation.rs create mode 100644 crates/plano-cli/src/consts.rs create mode 100644 crates/plano-cli/src/docker/mod.rs create mode 100644 crates/plano-cli/src/main.rs create mode 100644 crates/plano-cli/src/native/binaries.rs create mode 100644 crates/plano-cli/src/native/mod.rs create mode 100644 crates/plano-cli/src/native/runner.rs create mode 100644 crates/plano-cli/src/trace/daemon.rs create mode 100644 crates/plano-cli/src/trace/down.rs create mode 100644 crates/plano-cli/src/trace/listen.rs create mode 100644 crates/plano-cli/src/trace/mod.rs create mode 100644 crates/plano-cli/src/trace/show.rs create mode 100644 crates/plano-cli/src/trace/store.rs create mode 100644 crates/plano-cli/src/trace/tail.rs create mode 100644 crates/plano-cli/src/utils.rs create mode 100644 crates/plano-cli/src/version.rs create mode 100644 crates/plano-cli/templates/coding_agent_routing.yaml create mode 100644 crates/plano-cli/templates/conversational_state.yaml create mode 100644 crates/plano-cli/templates/filter_chain_guardrails.yaml create mode 100644 crates/plano-cli/templates/preference_aware_routing.yaml create mode 100644 crates/plano-cli/templates/sub_agent_orchestration.yaml diff --git a/.claude/skills/build-cli/SKILL.md b/.claude/skills/build-cli/SKILL.md index 0e2aec7f9..75b881221 100644 --- a/.claude/skills/build-cli/SKILL.md +++ b/.claude/skills/build-cli/SKILL.md @@ -1,10 +1,9 @@ --- name: build-cli -description: Build and install the Python CLI (planoai). Use after making changes to cli/ code to install locally. +description: Build and install the Rust CLI (planoai). Use after making changes to plano-cli code to install locally. --- -1. `cd cli && uv sync` — ensure dependencies are installed -2. `cd cli && uv tool install --editable .` — install the CLI locally -3. Verify the installation: `cd cli && uv run planoai --help` +1. `cd crates && cargo build --release -p plano-cli` — build the CLI binary +2. Verify the installation: `./crates/target/release/planoai --help` -If the build or install fails, diagnose and fix the issues. +If the build fails, diagnose and fix the issues. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d844498a..b888adcf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,33 +25,19 @@ jobs: - uses: pre-commit/action@v3.0.1 # ────────────────────────────────────────────── - # Plano tools (CLI) tests — no Docker needed + # Plano CLI (Rust) tests — no Docker needed # ────────────────────────────────────────────── - plano-tools-tests: + plano-cli-tests: runs-on: ubuntu-latest-m - defaults: - run: - working-directory: ./cli steps: - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - - name: Install plano tools - run: uv sync --extra dev - - - name: Sync CLI templates to demos - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: uv run python -m planoai.template_sync + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - - name: Run tests - run: uv run pytest + - name: Run plano-cli tests + working-directory: ./crates + run: cargo test -p plano-cli # ────────────────────────────────────────────── # Native mode smoke test — build from source & start natively @@ -62,32 +48,22 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: wasm32-wasip1 - - name: Install planoai CLI - working-directory: ./cli + - name: Build plano CLI and native binaries + working-directory: ./crates run: | - uv sync - uv tool install . - - - name: Build native binaries - run: planoai build + cargo build --release -p plano-cli + cargo build --release -p brightstaff + cargo build --release --target wasm32-wasip1 -p llm_gateway -p prompt_gateway - name: Start plano natively env: OPENAI_API_KEY: test-key-not-used - run: planoai up tests/e2e/config_native_smoke.yaml + run: ./crates/target/release/planoai up tests/e2e/config_native_smoke.yaml - name: Health check run: | @@ -105,7 +81,7 @@ jobs: - name: Stop plano if: always() - run: planoai down || true + run: ./crates/target/release/planoai down || true # ────────────────────────────────────────────── # Single Docker build — shared by all downstream jobs @@ -157,13 +133,12 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - - name: Install planoai - run: pip install -e ./cli + - name: Build plano CLI + working-directory: ./crates + run: cargo build --release -p plano-cli - name: Validate plano config run: bash config/validate_plano_config.sh diff --git a/CLAUDE.md b/CLAUDE.md index 58b2191ff..2f9f19333 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,9 @@ cd crates && cargo test --lib cd crates && cargo fmt --all -- --check cd crates && cargo clippy --locked --all-targets --all-features -- -D warnings -# Python CLI -cd cli && uv sync && uv run pytest -v +# Rust — plano CLI binary +cd crates && cargo build --release -p plano-cli +cd crates && cargo test -p plano-cli # JS/TS (Turbo monorepo) npm run build && npm run lint && npm run typecheck @@ -47,9 +48,13 @@ Client → Envoy (prompt_gateway.wasm → llm_gateway.wasm) → Agents/LLM Provi - **common** (lib) — Shared: config, HTTP, routing, rate limiting, tokenizer, PII, tracing - **hermesllm** (lib) — LLM API translation between providers. Key types: `ProviderId`, `ProviderRequest`, `ProviderResponse`, `ProviderStreamResponse` -### Python CLI (cli/planoai/) +### Plano CLI (crates/plano-cli/) -Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`, `generate_prompt_targets`. +Rust CLI binary (`planoai`). Built with `clap` v4. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli-agent`. + +### Legacy Python CLI (cli/planoai/) — deprecated + +Entry point: `main.py`. Built with `rich-click`. Being replaced by the Rust CLI above. ### Config (config/) @@ -86,7 +91,7 @@ Code in `prompt_gateway` and `llm_gateway` runs in Envoy's WASM sandbox: Update version (e.g., `0.4.11` → `0.4.12`) in all of these files: - `.github/workflows/ci.yml`, `build_filter_image.sh`, `config/validate_plano_config.sh` -- `cli/planoai/__init__.py`, `cli/planoai/consts.py`, `cli/pyproject.toml` +- `crates/plano-cli/Cargo.toml` - `docs/source/conf.py`, `docs/source/get_started/quickstart.rst`, `docs/source/resources/deployment.rst` - `apps/www/src/components/Hero.tsx`, `demos/llm_routing/preference_based_routing/README.md` diff --git a/crates/Cargo.lock b/crates/Cargo.lock index fbf817e70..2faf5e65f 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -32,6 +32,20 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8fd72866655d1904d6b0997d0b07ba561047d070fbe29de039031c641b61217" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -62,6 +76,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -115,6 +179,28 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -132,6 +218,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -195,7 +292,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -248,7 +345,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -279,7 +376,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -288,6 +394,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.9.1" @@ -303,6 +415,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brightstaff" version = "0.1.0" @@ -364,6 +482,12 @@ version = "3.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -419,16 +543,84 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.1", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -460,6 +652,19 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cookie" version = "0.18.1" @@ -533,6 +738,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.6" @@ -630,6 +860,25 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -647,6 +896,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -688,6 +958,21 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[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" @@ -697,6 +982,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -710,7 +1001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -753,16 +1044,38 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7493d4c459da9f84325ad297371a6b2b8a162800873a22e3b6b6512e61d18c05" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "flate2" version = "1.1.5" @@ -773,6 +1086,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -809,6 +1133,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures" version = "0.3.31" @@ -947,6 +1281,30 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "governor" version = "0.6.3" @@ -1005,7 +1363,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b62f79061a0bc2e046024cb7ba44b08419ed238ecbd9adbd787434b9e8c25" dependencies = [ - "ahash", + "ahash 0.3.8", "autocfg", ] @@ -1026,6 +1384,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermesllm" version = "0.1.0" @@ -1043,6 +1407,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -1126,6 +1499,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "0.14.32" @@ -1408,6 +1790,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1430,6 +1828,19 @@ dependencies = [ "serde", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "instant" version = "0.1.13" @@ -1455,6 +1866,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -1480,6 +1897,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161c33c3ec738cfea3288c5c53dfcdb32fd4fc2954de86ea06f71b5a1a40bfcd" +dependencies = [ + "ahash 0.8.12", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex 0.14.0", + "fraction", + "idna", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing", + "regex-syntax", + "reqwest", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1492,6 +1934,12 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.10" @@ -1687,6 +2135,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -1721,6 +2181,45 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1736,6 +2235,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1745,6 +2266,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.7" @@ -1760,6 +2287,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.73" @@ -1842,11 +2375,11 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "thiserror 2.0.12", "tokio", - "tonic", + "tonic 0.14.2", "tracing", ] @@ -1858,8 +2391,8 @@ checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost", - "tonic", + "prost 0.14.3", + "tonic 0.14.2", "tonic-prost", ] @@ -1891,6 +2424,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" @@ -1917,7 +2456,16 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", ] [[package]] @@ -1926,6 +2474,49 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.11.3" @@ -1935,6 +2526,26 @@ dependencies = [ "phf_shared", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -1982,6 +2593,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plano-cli" +version = "0.4.14" +dependencies = [ + "anyhow", + "atty", + "clap", + "common", + "console", + "dialoguer", + "dirs", + "flate2", + "indicatif", + "jsonschema", + "nix", + "prost 0.13.5", + "prost-types", + "regex", + "reqwest", + "semver", + "serde", + "serde_json", + "serde_yaml", + "tar", + "tera", + "thiserror 2.0.12", + "tokio", + "tonic 0.12.3", + "tracing", + "tracing-subscriber", + "url", + "which", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -2084,6 +2729,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -2091,7 +2746,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.101", ] [[package]] @@ -2107,6 +2775,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "proxy-wasm" version = "0.2.3" @@ -2169,7 +2846,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2255,6 +2932,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -2275,6 +2963,20 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "referencing" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a64b3a635fad9000648b4d8a59c8710c523ab61a23d392a7d91d47683f5adc" +dependencies = [ + "ahash 0.8.12", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.11.1" @@ -2345,7 +3047,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls 0.26.2", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -2413,7 +3115,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2519,6 +3221,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.3.4" @@ -2617,6 +3328,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -2771,6 +3488,12 @@ 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" @@ -2813,6 +3536,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.0" @@ -2930,6 +3663,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.20.0" @@ -2940,7 +3684,29 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", ] [[package]] @@ -3002,7 +3768,7 @@ dependencies = [ "anyhow", "base64 0.21.7", "bstr", - "fancy-regex", + "fancy-regex 0.12.0", "lazy_static", "parking_lot", "rustc-hash 1.1.0", @@ -3183,6 +3949,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.10", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-timeout 0.5.2", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.5", + "socket2", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tonic" version = "0.14.2" @@ -3203,7 +3999,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-stream", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -3216,8 +4012,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" dependencies = [ "bytes", - "prost", - "tonic", + "prost 0.14.3", + "tonic 0.14.2", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -3252,7 +4068,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -3360,6 +4176,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.8.1" @@ -3393,6 +4215,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3466,6 +4300,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" @@ -3478,6 +4318,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3502,6 +4353,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3645,6 +4506,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3656,6 +4529,37 @@ dependencies = [ "web-sys", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.59.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-core" version = "0.61.2" @@ -3664,7 +4568,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings", ] @@ -3697,13 +4601,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" dependencies = [ - "windows-link", + "windows-link 0.1.1", "windows-result", "windows-strings", ] @@ -3714,7 +4624,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -3723,7 +4633,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -3732,7 +4651,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3741,7 +4660,31 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -3750,28 +4693,46 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3784,30 +4745,60 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -3823,6 +4814,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 5cd6b29cf..bde588cfd 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["llm_gateway", "prompt_gateway", "common", "brightstaff", "hermesllm"] +members = ["llm_gateway", "prompt_gateway", "common", "brightstaff", "hermesllm", "plano-cli"] diff --git a/crates/plano-cli/Cargo.toml b/crates/plano-cli/Cargo.toml new file mode 100644 index 000000000..f94f65eea --- /dev/null +++ b/crates/plano-cli/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "plano-cli" +version = "0.4.14" +edition = "2021" +default-run = "planoai" + +[[bin]] +name = "planoai" +path = "src/main.rs" + +[dependencies] +# CLI framework +clap = { version = "4", features = ["derive"] } + +# Templating +tera = "1" + +# Config parsing & validation +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9.34" +serde_json = { version = "1.0", features = ["preserve_order"] } +jsonschema = "0.29" + +# Terminal UI +console = "0.15" +indicatif = "0.17" +dialoguer = "0.11" + +# Error handling +thiserror = "2" +anyhow = "1" + +# Async runtime +tokio = { version = "1.44", features = ["full"] } + +# HTTP client +reqwest = { version = "0.12", features = ["stream"] } + +# Process management +nix = { version = "0.29", features = ["signal", "process"] } + +# Archives for binary downloads +flate2 = "1.0" +tar = "0.4" + +# Version comparison +semver = "1" + +# URL parsing +url = "2" + +# Regex for env var extraction +regex = "1" + +# gRPC for trace listener +tonic = "0.12" +prost = "0.13" +prost-types = "0.13" + +# Reuse workspace crates +common = { version = "0.1.0", path = "../common" } + +# Tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Filesystem helpers +dirs = "5" +which = "7" +atty = "0.2" diff --git a/crates/plano-cli/build.rs b/crates/plano-cli/build.rs new file mode 100644 index 000000000..dd3832d38 --- /dev/null +++ b/crates/plano-cli/build.rs @@ -0,0 +1,4 @@ +fn main() { + // For now, just rerun if templates change + println!("cargo:rerun-if-changed=templates/"); +} diff --git a/crates/plano-cli/src/commands/build.rs b/crates/plano-cli/src/commands/build.rs new file mode 100644 index 000000000..eff45136a --- /dev/null +++ b/crates/plano-cli/src/commands/build.rs @@ -0,0 +1,105 @@ +use std::process::Command; + +use anyhow::{bail, Result}; + +use crate::consts::plano_docker_image; +use crate::utils::{find_repo_root, print_cli_header}; + +pub async fn run(docker: bool) -> Result<()> { + let dim = console::Style::new().dim(); + let red = console::Style::new().red(); + let bold = console::Style::new().bold(); + + if !docker { + print_cli_header(); + + let repo_root = find_repo_root().ok_or_else(|| { + anyhow::anyhow!( + "Could not find repository root. Make sure you're inside the plano repository." + ) + })?; + + let crates_dir = repo_root.join("crates"); + + // Check cargo is available + if which::which("cargo").is_err() { + eprintln!( + "{} {} not found. Install Rust: https://rustup.rs", + red.apply_to("✗"), + bold.apply_to("cargo") + ); + std::process::exit(1); + } + + // Build WASM plugins + eprintln!( + "{}", + dim.apply_to("Building WASM plugins (wasm32-wasip1)...") + ); + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "-p", + "llm_gateway", + "-p", + "prompt_gateway", + ]) + .current_dir(&crates_dir) + .status()?; + + if !status.success() { + bail!("WASM build failed"); + } + + // Build brightstaff + eprintln!("{}", dim.apply_to("Building brightstaff (native)...")); + let status = Command::new("cargo") + .args(["build", "--release", "-p", "brightstaff"]) + .current_dir(&crates_dir) + .status()?; + + if !status.success() { + bail!("brightstaff build failed"); + } + + let wasm_dir = crates_dir.join("target/wasm32-wasip1/release"); + let native_dir = crates_dir.join("target/release"); + + println!("\n{}:", bold.apply_to("Build artifacts")); + println!(" {}", wasm_dir.join("prompt_gateway.wasm").display()); + println!(" {}", wasm_dir.join("llm_gateway.wasm").display()); + println!(" {}", native_dir.join("brightstaff").display()); + } else { + let repo_root = + find_repo_root().ok_or_else(|| anyhow::anyhow!("Could not find repository root."))?; + + let dockerfile = repo_root.join("Dockerfile"); + if !dockerfile.exists() { + bail!("Dockerfile not found at {}", dockerfile.display()); + } + + println!("Building plano image from {}...", repo_root.display()); + let status = Command::new("docker") + .args([ + "build", + "-f", + &dockerfile.to_string_lossy(), + "-t", + &plano_docker_image(), + &repo_root.to_string_lossy(), + "--add-host=host.docker.internal:host-gateway", + ]) + .status()?; + + if !status.success() { + bail!("Docker build failed"); + } + + println!("plano image built successfully."); + } + + Ok(()) +} diff --git a/crates/plano-cli/src/commands/cli_agent.rs b/crates/plano-cli/src/commands/cli_agent.rs new file mode 100644 index 000000000..bb4cfb2e3 --- /dev/null +++ b/crates/plano-cli/src/commands/cli_agent.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::process::Command; + +use anyhow::{bail, Result}; + +use crate::consts::PLANO_DOCKER_NAME; +use crate::utils::{find_config_file, is_native_plano_running}; + +pub async fn run(agent_type: &str, file: Option, path: &str, settings: &str) -> Result<()> { + let native_running = is_native_plano_running(); + let docker_running = if !native_running { + crate::docker::container_status(PLANO_DOCKER_NAME).await? == "running" + } else { + false + }; + + if !native_running && !docker_running { + bail!("Plano is not running. Start Plano first using 'plano up ' (native or --docker mode)."); + } + + let plano_config_file = find_config_file(path, file.as_deref()); + if !plano_config_file.exists() { + bail!("Config file not found: {}", plano_config_file.display()); + } + + start_cli_agent(&plano_config_file, agent_type, settings) +} + +fn start_cli_agent( + plano_config_path: &std::path::Path, + agent_type: &str, + _settings_json: &str, +) -> Result<()> { + let config_str = std::fs::read_to_string(plano_config_path)?; + let config: serde_yaml::Value = serde_yaml::from_str(&config_str)?; + + // Resolve CLI agent endpoint + let (host, port) = resolve_cli_agent_endpoint(&config)?; + let base_url = format!("http://{host}:{port}/v1"); + + let mut env: HashMap = std::env::vars().collect(); + + match agent_type { + "claude" => { + env.insert("ANTHROPIC_BASE_URL".to_string(), base_url); + + // Check for model alias + if let Some(model) = config + .get("model_aliases") + .and_then(|a| a.get("arch")) + .and_then(|a| a.get("claude")) + .and_then(|a| a.get("code")) + .and_then(|a| a.get("small")) + .and_then(|a| a.get("fast")) + .and_then(|a| a.get("target")) + .and_then(|v| v.as_str()) + { + env.insert("ANTHROPIC_MODEL".to_string(), model.to_string()); + } + + let status = Command::new("claude").envs(&env).status()?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + } + "codex" => { + env.insert("OPENAI_BASE_URL".to_string(), base_url); + + let status = Command::new("codex").envs(&env).status()?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + } + _ => bail!("Unsupported agent type: {agent_type}"), + } + + Ok(()) +} + +fn resolve_cli_agent_endpoint(config: &serde_yaml::Value) -> Result<(String, u16)> { + // Look for model listener (egress_traffic) + if let Some(listeners) = config.get("listeners").and_then(|v| v.as_sequence()) { + for listener in listeners { + let listener_type = listener.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if listener_type == "model" { + let host = listener + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or("0.0.0.0"); + let port = listener + .get("port") + .and_then(|v| v.as_u64()) + .unwrap_or(12000) as u16; + return Ok((host.to_string(), port)); + } + } + } + + // Default + Ok(("0.0.0.0".to_string(), 12000)) +} diff --git a/crates/plano-cli/src/commands/down.rs b/crates/plano-cli/src/commands/down.rs new file mode 100644 index 000000000..9240c85c5 --- /dev/null +++ b/crates/plano-cli/src/commands/down.rs @@ -0,0 +1,15 @@ +use anyhow::Result; + +use crate::utils::print_cli_header; + +pub async fn run(docker: bool) -> Result<()> { + print_cli_header(); + + if !docker { + crate::native::runner::stop_native()?; + } else { + crate::docker::stop_container().await?; + } + + Ok(()) +} diff --git a/crates/plano-cli/src/commands/init.rs b/crates/plano-cli/src/commands/init.rs new file mode 100644 index 000000000..9fe704899 --- /dev/null +++ b/crates/plano-cli/src/commands/init.rs @@ -0,0 +1,132 @@ +use std::path::Path; + +use anyhow::{bail, Result}; + +const TEMPLATES: &[(&str, &str, &str)] = &[ + ( + "sub_agent_orchestration", + "Sub-agent Orchestration", + include_str!("../../templates/sub_agent_orchestration.yaml"), + ), + ( + "coding_agent_routing", + "Coding Agent Routing", + include_str!("../../templates/coding_agent_routing.yaml"), + ), + ( + "preference_aware_routing", + "Preference-Aware Routing", + include_str!("../../templates/preference_aware_routing.yaml"), + ), + ( + "filter_chain_guardrails", + "Filter Chain Guardrails", + include_str!("../../templates/filter_chain_guardrails.yaml"), + ), + ( + "conversational_state", + "Conversational State", + include_str!("../../templates/conversational_state.yaml"), + ), +]; + +pub async fn run( + template: Option, + clean: bool, + output: Option, + force: bool, + list_templates: bool, +) -> Result<()> { + let bold = console::Style::new().bold(); + let dim = console::Style::new().dim(); + let green = console::Style::new().green(); + let cyan = console::Style::new().cyan(); + + if list_templates { + println!("\n{}:", bold.apply_to("Available templates")); + for (id, name, _) in TEMPLATES { + println!(" {} - {}", cyan.apply_to(id), name); + } + println!(); + return Ok(()); + } + + let output_path = output.unwrap_or_else(|| "plano_config.yaml".to_string()); + let output_path = Path::new(&output_path); + + if output_path.exists() && !force { + bail!( + "File {} already exists. Use --force to overwrite.", + output_path.display() + ); + } + + if clean { + let content = "version: v0.3.0\nlisteners:\n - type: model\n name: egress_traffic\n port: 12000\nmodel_providers: []\n"; + std::fs::write(output_path, content)?; + println!( + "{} Created clean config at {}", + green.apply_to("✓"), + output_path.display() + ); + return Ok(()); + } + + if let Some(template_id) = template { + let tmpl = TEMPLATES + .iter() + .find(|(id, _, _)| *id == template_id) + .ok_or_else(|| { + anyhow::anyhow!( + "Unknown template '{}'. Use --list-templates to see available templates.", + template_id + ) + })?; + + std::fs::write(output_path, tmpl.2)?; + println!( + "{} Created config from template '{}' at {}", + green.apply_to("✓"), + tmpl.1, + output_path.display() + ); + + // Preview + let lines: Vec<&str> = tmpl.2.lines().take(28).collect(); + println!("\n{}:", dim.apply_to("Preview")); + for line in &lines { + println!(" {}", dim.apply_to(line)); + } + if tmpl.2.lines().count() > 28 { + println!(" {}", dim.apply_to("...")); + } + + return Ok(()); + } + + // Interactive mode using dialoguer + if !atty::is(atty::Stream::Stdin) { + bail!( + "Interactive mode requires a TTY. Use --template or --clean for non-interactive mode." + ); + } + + let selections: Vec<&str> = TEMPLATES.iter().map(|(_, name, _)| *name).collect(); + + let selection = dialoguer::Select::new() + .with_prompt("Choose a template") + .items(&selections) + .default(0) + .interact()?; + + let tmpl = &TEMPLATES[selection]; + std::fs::write(output_path, tmpl.2)?; + println!( + "\n{} Created config from template '{}' at {}", + green.apply_to("✓"), + tmpl.1, + output_path.display() + ); + + Ok(()) +} diff --git a/crates/plano-cli/src/commands/logs.rs b/crates/plano-cli/src/commands/logs.rs new file mode 100644 index 000000000..df4e785ab --- /dev/null +++ b/crates/plano-cli/src/commands/logs.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +pub async fn run(debug: bool, follow: bool, docker: bool) -> Result<()> { + if !docker { + crate::native::runner::native_logs(debug, follow)?; + } else { + crate::docker::stream_logs(debug, follow).await?; + } + Ok(()) +} diff --git a/crates/plano-cli/src/commands/mod.rs b/crates/plano-cli/src/commands/mod.rs new file mode 100644 index 000000000..188b577fc --- /dev/null +++ b/crates/plano-cli/src/commands/mod.rs @@ -0,0 +1,262 @@ +pub mod build; +pub mod cli_agent; +pub mod down; +pub mod init; +pub mod logs; +pub mod up; + +use clap::{Parser, Subcommand}; + +use crate::consts::PLANO_VERSION; + +const LOGO: &str = r#" + ______ _ + | ___ \ | + | |_/ / | __ _ _ __ ___ + | __/| |/ _` | '_ \ / _ \ + | | | | (_| | | | | (_) | + \_| |_|\__,_|_| |_|\___/ +"#; + +#[derive(Parser)] +#[command( + name = "planoai", + about = "The Delivery Infrastructure for Agentic Apps" +)] +#[command(version = PLANO_VERSION)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Command { + /// Start Plano + Up { + /// Config file path (positional) + file: Option, + + /// Path to the directory containing config.yaml + #[arg(long, default_value = ".")] + path: String, + + /// Run Plano in the foreground + #[arg(long)] + foreground: bool, + + /// Start a local OTLP trace collector + #[arg(long)] + with_tracing: bool, + + /// Port for the OTLP trace collector + #[arg(long, default_value_t = 4317)] + tracing_port: u16, + + /// Run Plano inside Docker instead of natively + #[arg(long)] + docker: bool, + }, + + /// Stop Plano + Down { + /// Stop a Docker-based Plano instance + #[arg(long)] + docker: bool, + }, + + /// Build Plano from source + Build { + /// Build the Docker image instead of native binaries + #[arg(long)] + docker: bool, + }, + + /// Stream logs from Plano + Logs { + /// Show detailed debug logs + #[arg(long)] + debug: bool, + + /// Follow the logs + #[arg(long)] + follow: bool, + + /// Stream logs from a Docker-based Plano instance + #[arg(long)] + docker: bool, + }, + + /// Start a CLI agent connected to Plano + CliAgent { + /// The type of CLI agent to start + #[arg(value_parser = ["claude", "codex"])] + agent_type: String, + + /// Config file path (positional) + file: Option, + + /// Path to the directory containing plano_config.yaml + #[arg(long, default_value = ".")] + path: String, + + /// Additional settings as JSON string for the CLI agent + #[arg(long, default_value = "{}")] + settings: String, + }, + + /// Manage distributed traces + Trace { + #[command(subcommand)] + command: TraceCommand, + }, + + /// Initialize a new Plano configuration + Init { + /// Use a built-in template + #[arg(long)] + template: Option, + + /// Create a clean empty config + #[arg(long)] + clean: bool, + + /// Output file path + #[arg(long, short)] + output: Option, + + /// Overwrite existing files + #[arg(long)] + force: bool, + + /// List available templates + #[arg(long)] + list_templates: bool, + }, +} + +#[derive(Subcommand)] +pub enum TraceCommand { + /// Start the OTLP trace listener + Listen { + /// Host to bind to + #[arg(long, default_value = "0.0.0.0")] + host: String, + + /// Port to listen on + #[arg(long, default_value_t = 4317)] + port: u16, + }, + + /// Stop the trace listener + Down, + + /// Show a specific trace + Show { + /// Trace ID to display + trace_id: String, + + /// Show verbose span details + #[arg(long)] + verbose: bool, + }, + + /// Tail recent traces + Tail { + /// Include spans matching these patterns + #[arg(long)] + include_spans: Option, + + /// Exclude spans matching these patterns + #[arg(long)] + exclude_spans: Option, + + /// Filter by attribute key=value + #[arg(long, name = "KEY=VALUE")] + r#where: Vec, + + /// Show traces since (e.g. 10s, 5m, 1h) + #[arg(long)] + since: Option, + + /// Show verbose span details + #[arg(long)] + verbose: bool, + }, +} + +pub async fn run(cli: Cli) -> anyhow::Result<()> { + // Initialize logging + let log_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_level)), + ) + .init(); + + match cli.command { + None => { + print_logo(); + // Print help by re-parsing with --help + let _ = Cli::parse_from(["planoai", "--help"]); + Ok(()) + } + Some(Command::Up { + file, + path, + foreground, + with_tracing, + tracing_port, + docker, + }) => up::run(file, path, foreground, with_tracing, tracing_port, docker).await, + Some(Command::Down { docker }) => down::run(docker).await, + Some(Command::Build { docker }) => build::run(docker).await, + Some(Command::Logs { + debug, + follow, + docker, + }) => logs::run(debug, follow, docker).await, + Some(Command::CliAgent { + agent_type, + file, + path, + settings, + }) => cli_agent::run(&agent_type, file, &path, &settings).await, + Some(Command::Trace { command }) => match command { + TraceCommand::Listen { host, port } => crate::trace::listen::run(&host, port).await, + TraceCommand::Down => crate::trace::down::run().await, + TraceCommand::Show { trace_id, verbose } => { + crate::trace::show::run(&trace_id, verbose).await + } + TraceCommand::Tail { + include_spans, + exclude_spans, + r#where, + since, + verbose, + } => { + crate::trace::tail::run( + include_spans.as_deref(), + exclude_spans.as_deref(), + &r#where, + since.as_deref(), + verbose, + ) + .await + } + }, + Some(Command::Init { + template, + clean, + output, + force, + list_templates, + }) => init::run(template, clean, output, force, list_templates).await, + } +} + +fn print_logo() { + let style = console::Style::new().bold().color256(141); // closest to #969FF4 + println!("{}", style.apply_to(LOGO)); + println!(" The Delivery Infrastructure for Agentic Apps\n"); +} diff --git a/crates/plano-cli/src/commands/up.rs b/crates/plano-cli/src/commands/up.rs new file mode 100644 index 000000000..0b5c29318 --- /dev/null +++ b/crates/plano-cli/src/commands/up.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Result; + +use crate::consts::{ + DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT, DEFAULT_OTEL_TRACING_GRPC_ENDPOINT, +}; +use crate::utils::{ + find_config_file, get_llm_provider_access_keys, is_port_in_use, load_env_file, + print_cli_header, print_missing_keys, +}; + +pub async fn run( + file: Option, + path: String, + foreground: bool, + with_tracing: bool, + tracing_port: u16, + docker: bool, +) -> Result<()> { + let green = console::Style::new().green(); + let red = console::Style::new().red(); + let dim = console::Style::new().dim(); + let cyan = console::Style::new().cyan(); + + print_cli_header(); + + let plano_config_file = find_config_file(&path, file.as_deref()); + + if !plano_config_file.exists() { + eprintln!( + "{} Config file not found: {}", + red.apply_to("✗"), + dim.apply_to(plano_config_file.display().to_string()) + ); + std::process::exit(1); + } + + // Validate configuration + if !docker { + eprint!("{}", dim.apply_to("Validating configuration...")); + match crate::native::runner::validate_config(&plano_config_file) { + Ok(()) => eprintln!(" {}", green.apply_to("✓")), + Err(e) => { + eprintln!("\n{} Validation failed", red.apply_to("✗")); + eprintln!(" {}", dim.apply_to(format!("{e:#}"))); + std::process::exit(1); + } + } + } else { + eprint!("{}", dim.apply_to("Validating configuration (Docker)...")); + match crate::docker::validate_config(&plano_config_file).await { + Ok(()) => eprintln!(" {}", green.apply_to("✓")), + Err(e) => { + eprintln!("\n{} Validation failed", red.apply_to("✗")); + eprintln!(" {}", dim.apply_to(format!("{e:#}"))); + std::process::exit(1); + } + } + } + + // Set up environment + let default_otel = if docker { + DEFAULT_OTEL_TRACING_GRPC_ENDPOINT + } else { + DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT + }; + + let mut env_stage: HashMap = HashMap::new(); + env_stage.insert( + "OTEL_TRACING_GRPC_ENDPOINT".to_string(), + default_otel.to_string(), + ); + + // Check access keys + let access_keys = get_llm_provider_access_keys(&plano_config_file)?; + let access_keys: Vec = access_keys + .into_iter() + .map(|k| k.strip_prefix('$').unwrap_or(&k).to_string()) + .collect(); + let access_keys_set: std::collections::HashSet<_> = access_keys.into_iter().collect(); + + let mut missing_keys = Vec::new(); + if !access_keys_set.is_empty() { + let app_env_file = if let Some(ref f) = file { + Path::new(f).parent().unwrap_or(Path::new(".")).join(".env") + } else { + Path::new(&path).join(".env") + }; + + if !app_env_file.exists() { + for key in &access_keys_set { + match std::env::var(key) { + Ok(val) => { + env_stage.insert(key.clone(), val); + } + Err(_) => missing_keys.push(key.clone()), + } + } + } else { + let env_dict = load_env_file(&app_env_file)?; + for key in &access_keys_set { + if let Some(val) = env_dict.get(key.as_str()) { + env_stage.insert(key.clone(), val.clone()); + } else { + missing_keys.push(key.clone()); + } + } + } + } + + if !missing_keys.is_empty() { + print_missing_keys(&missing_keys); + std::process::exit(1); + } + + env_stage.insert( + "LOG_LEVEL".to_string(), + std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()), + ); + + // Handle tracing + if with_tracing { + if is_port_in_use(tracing_port) { + eprintln!( + "{} Trace collector already running on port {}", + green.apply_to("✓"), + cyan.apply_to(tracing_port.to_string()) + ); + } else { + match crate::trace::listen::start_background(tracing_port).await { + Ok(()) => { + eprintln!( + "{} Trace collector listening on {}", + green.apply_to("✓"), + cyan.apply_to(format!("0.0.0.0:{tracing_port}")) + ); + } + Err(e) => { + eprintln!( + "{} Failed to start trace collector on port {tracing_port}: {e}", + red.apply_to("✗") + ); + std::process::exit(1); + } + } + } + + let tracing_host = if docker { + "host.docker.internal" + } else { + "localhost" + }; + env_stage.insert( + "OTEL_TRACING_GRPC_ENDPOINT".to_string(), + format!("http://{tracing_host}:{tracing_port}"), + ); + } + + // Build full env + let mut env: HashMap = std::env::vars().collect(); + env.remove("PATH"); + env.extend(env_stage); + + if !docker { + crate::native::runner::start_native(&plano_config_file, &env, foreground, with_tracing) + .await?; + } else { + crate::docker::start_plano(&plano_config_file, &env, foreground).await?; + } + + Ok(()) +} diff --git a/crates/plano-cli/src/config/generator.rs b/crates/plano-cli/src/config/generator.rs new file mode 100644 index 000000000..a06e97ba5 --- /dev/null +++ b/crates/plano-cli/src/config/generator.rs @@ -0,0 +1,761 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use anyhow::{bail, Result}; +use serde_json::json; +use url::Url; + +use crate::config::validation::validate_prompt_config; +use crate::consts::DEFAULT_OTEL_TRACING_GRPC_ENDPOINT; +use crate::utils::expand_env_vars; + +const SUPPORTED_PROVIDERS_WITH_BASE_URL: &[&str] = + &["azure_openai", "ollama", "qwen", "amazon_bedrock", "plano"]; + +const SUPPORTED_PROVIDERS_WITHOUT_BASE_URL: &[&str] = &[ + "deepseek", + "groq", + "mistral", + "openai", + "gemini", + "anthropic", + "together_ai", + "xai", + "moonshotai", + "zhipu", +]; + +fn all_supported_providers() -> Vec<&'static str> { + let mut all = Vec::new(); + all.extend_from_slice(SUPPORTED_PROVIDERS_WITHOUT_BASE_URL); + all.extend_from_slice(SUPPORTED_PROVIDERS_WITH_BASE_URL); + all +} + +/// Get endpoint and port from an endpoint string. +fn get_endpoint_and_port(endpoint: &str, protocol: &str) -> (String, u16) { + if let Some((host, port_str)) = endpoint.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + return (host.to_string(), port); + } + } + let port = if protocol == "http" { 80 } else { 443 }; + (endpoint.to_string(), port) +} + +/// Convert legacy dict-style listeners to array format. +pub fn convert_legacy_listeners( + listeners: &serde_yaml::Value, + model_providers: &serde_yaml::Value, +) -> Result<(Vec, serde_json::Value, serde_json::Value)> { + let mp_json: serde_json::Value = serde_json::to_value(model_providers)?; + let mp_array = if mp_json.is_array() { + mp_json.clone() + } else { + json!([]) + }; + + let mut llm_gateway = json!({ + "name": "egress_traffic", + "type": "model", + "port": 12000, + "address": "0.0.0.0", + "timeout": "30s", + "model_providers": mp_array, + }); + + let mut prompt_gateway = json!({ + "name": "ingress_traffic", + "type": "prompt", + "port": 10000, + "address": "0.0.0.0", + "timeout": "30s", + }); + + if listeners.is_null() { + return Ok((vec![llm_gateway.clone()], llm_gateway, prompt_gateway)); + } + + // Legacy dict format + if listeners.is_mapping() { + let mut updated = Vec::new(); + + if let Some(egress) = listeners.get("egress_traffic") { + if let Some(p) = egress.get("port").and_then(|v| v.as_u64()) { + llm_gateway["port"] = json!(p); + } + if let Some(a) = egress.get("address").and_then(|v| v.as_str()) { + llm_gateway["address"] = json!(a); + } + if let Some(t) = egress.get("timeout").and_then(|v| v.as_str()) { + llm_gateway["timeout"] = json!(t); + } + } + + if !mp_array.as_array().is_none_or(|a| a.is_empty()) { + llm_gateway["model_providers"] = mp_array; + } else { + bail!("model_providers cannot be empty when using legacy format"); + } + + updated.push(llm_gateway.clone()); + + if let Some(ingress) = listeners.get("ingress_traffic") { + if !ingress.is_null() && ingress.is_mapping() { + if let Some(p) = ingress.get("port").and_then(|v| v.as_u64()) { + prompt_gateway["port"] = json!(p); + } + if let Some(a) = ingress.get("address").and_then(|v| v.as_str()) { + prompt_gateway["address"] = json!(a); + } + if let Some(t) = ingress.get("timeout").and_then(|v| v.as_str()) { + prompt_gateway["timeout"] = json!(t); + } + updated.push(prompt_gateway.clone()); + } + } + + return Ok((updated, llm_gateway, prompt_gateway)); + } + + // Array format + if let Some(arr) = listeners.as_sequence() { + let mut result: Vec = Vec::new(); + let mut model_provider_set = false; + + for listener in arr { + let mut l: serde_json::Value = serde_json::to_value(listener)?; + let listener_type = l.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + if listener_type == "model" { + if model_provider_set { + bail!("Currently only one listener can have model_providers set"); + } + l["model_providers"] = mp_array.clone(); + model_provider_set = true; + // Merge into llm_gateway defaults + if let Some(obj) = l.as_object() { + for (k, v) in obj { + llm_gateway[k] = v.clone(); + } + } + } else if listener_type == "prompt" { + if let Some(obj) = l.as_object() { + for (k, v) in obj { + prompt_gateway[k] = v.clone(); + } + } + } + result.push(l); + } + + if !model_provider_set { + result.push(llm_gateway.clone()); + } + + return Ok((result, llm_gateway, prompt_gateway)); + } + + Ok((vec![llm_gateway.clone()], llm_gateway, prompt_gateway)) +} + +/// Main config validation and rendering function. +/// Ported from config_generator.py validate_and_render_schema() +pub fn validate_and_render( + config_path: &Path, + schema_path: &Path, + template_path: &Path, + envoy_output_path: &Path, + config_output_path: &Path, +) -> Result<()> { + // Step 1: JSON Schema validation + validate_prompt_config(config_path, schema_path)?; + + // Step 2: Load and process config + let config_str = std::fs::read_to_string(config_path)?; + let mut config_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?; + + let mut inferred_clusters: HashMap = HashMap::new(); + + // Convert legacy llm_providers → model_providers + if config_yaml.get("llm_providers").is_some() { + if config_yaml.get("model_providers").is_some() { + bail!("Please provide either llm_providers or model_providers, not both. llm_providers is deprecated, please use model_providers instead"); + } + let providers = config_yaml + .get("llm_providers") + .cloned() + .unwrap_or_default(); + config_yaml.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("model_providers".to_string()), + providers, + ); + config_yaml + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::String("llm_providers".to_string())); + } + + let listeners_val = config_yaml.get("listeners").cloned().unwrap_or_default(); + let model_providers_val = config_yaml + .get("model_providers") + .cloned() + .unwrap_or_default(); + + let (listeners, llm_gateway, prompt_gateway) = + convert_legacy_listeners(&listeners_val, &model_providers_val)?; + + // Update config with processed listeners + let listeners_yaml: serde_yaml::Value = + serde_yaml::from_str(&serde_json::to_string(&listeners)?)?; + config_yaml.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("listeners".to_string()), + listeners_yaml, + ); + + // Process endpoints from config + let endpoints_yaml = config_yaml.get("endpoints").cloned().unwrap_or_default(); + let mut endpoints: HashMap = if endpoints_yaml.is_mapping() { + serde_json::from_str(&serde_json::to_string(&endpoints_yaml)?)? + } else { + HashMap::new() + }; + + // Process agents and filters → endpoints + let agents = config_yaml + .get("agents") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + let filters = config_yaml + .get("filters") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let mut agent_id_keys: HashSet = HashSet::new(); + let agents_combined: Vec<_> = agents.iter().chain(filters.iter()).collect(); + + for agent in &agents_combined { + let agent_id = agent.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if !agent_id_keys.insert(agent_id.to_string()) { + bail!("Duplicate agent id {agent_id}, please provide unique id for each agent"); + } + + let agent_url = agent.get("url").and_then(|v| v.as_str()).unwrap_or(""); + if !agent_id.is_empty() && !agent_url.is_empty() { + if let Ok(url) = Url::parse(agent_url) { + if let Some(host) = url.host_str() { + let protocol = url.scheme(); + let port = url + .port() + .unwrap_or(if protocol == "http" { 80 } else { 443 }); + endpoints.insert( + agent_id.to_string(), + json!({ + "endpoint": host, + "port": port, + "protocol": protocol, + }), + ); + } + } + } + } + + // Override inferred clusters with endpoints + for (name, details) in &endpoints { + let mut cluster = details.clone(); + if cluster.get("port").is_none() { + let ep = cluster + .get("endpoint") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let protocol = cluster + .get("protocol") + .and_then(|v| v.as_str()) + .unwrap_or("http"); + let (endpoint, port) = get_endpoint_and_port(ep, protocol); + cluster["endpoint"] = json!(endpoint); + cluster["port"] = json!(port); + } + inferred_clusters.insert(name.clone(), cluster); + } + + // Validate prompt_targets reference valid endpoints + if let Some(targets) = config_yaml + .get("prompt_targets") + .and_then(|v| v.as_sequence()) + { + for target in targets { + if let Some(name) = target + .get("endpoint") + .and_then(|e| e.get("name")) + .and_then(|n| n.as_str()) + { + if !inferred_clusters.contains_key(name) { + bail!("Unknown endpoint {name}, please add it in endpoints section in your plano_config.yaml file"); + } + } + } + } + + // Process tracing config + let mut plano_tracing: serde_json::Value = config_yaml + .get("tracing") + .map(|v| serde_json::to_value(v).unwrap_or_default()) + .unwrap_or_else(|| json!({})); + + // Resolution order: config yaml > OTEL_TRACING_GRPC_ENDPOINT env var > hardcoded default + let otel_endpoint = plano_tracing + .get("opentracing_grpc_endpoint") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + std::env::var("OTEL_TRACING_GRPC_ENDPOINT") + .unwrap_or_else(|_| DEFAULT_OTEL_TRACING_GRPC_ENDPOINT.to_string()) + }); + + // Expand env vars if present + let otel_endpoint = if otel_endpoint.contains('$') { + let expanded = expand_env_vars(&otel_endpoint); + eprintln!("Resolved opentracing_grpc_endpoint to {expanded} after expanding environment variables"); + expanded + } else { + otel_endpoint + }; + + // Validate OTEL endpoint + if !otel_endpoint.is_empty() { + if let Ok(url) = Url::parse(&otel_endpoint) { + if url.scheme() != "http" { + bail!("Invalid opentracing_grpc_endpoint {otel_endpoint}, scheme must be http"); + } + let path = url.path(); + if !path.is_empty() && path != "/" { + bail!("Invalid opentracing_grpc_endpoint {otel_endpoint}, path must be empty"); + } + } + } + plano_tracing["opentracing_grpc_endpoint"] = json!(otel_endpoint); + + // Process model providers + let mut updated_model_providers: Vec = Vec::new(); + let mut model_provider_name_set: HashSet = HashSet::new(); + let mut model_name_keys: HashSet = HashSet::new(); + let mut model_usage_name_keys: HashSet = HashSet::new(); + let mut llms_with_endpoint: Vec = Vec::new(); + let mut llms_with_endpoint_cluster_names: HashSet = HashSet::new(); + let all_providers = all_supported_providers(); + + for listener in &listeners { + let model_providers = match listener.get("model_providers").and_then(|v| v.as_array()) { + Some(mps) if !mps.is_empty() => mps, + _ => continue, + }; + + for mp in model_providers { + let mut mp = mp.clone(); + + // Check usage + if mp.get("usage").and_then(|v| v.as_str()).is_some() { + // has usage, tracked elsewhere + } + + let mp_name = mp + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if !mp_name.is_empty() && !model_provider_name_set.insert(mp_name.clone()) { + bail!("Duplicate model_provider name {mp_name}, please provide unique name for each model_provider"); + } + + let model_name = mp + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Check wildcard + let is_wildcard = if model_name.contains('/') { + let tokens: Vec<&str> = model_name.split('/').collect(); + tokens.len() >= 2 && tokens.last() == Some(&"*") + } else { + false + }; + + if model_name_keys.contains(&model_name) && !is_wildcard { + bail!("Duplicate model name {model_name}, please provide unique model name for each model_provider"); + } + + if !is_wildcard { + model_name_keys.insert(model_name.clone()); + } + + if mp + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .is_empty() + { + mp["name"] = json!(model_name); + } + model_provider_name_set.insert( + mp.get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ); + + let tokens: Vec<&str> = model_name.split('/').collect(); + if tokens.len() < 2 { + bail!("Invalid model name {model_name}. Please provide model name in the format / or /* for wildcards."); + } + + let provider = tokens[0].trim(); + let is_wildcard = tokens.last().map(|s| s.trim()) == Some("*"); + + // Validate wildcard constraints + if is_wildcard { + if mp.get("default").and_then(|v| v.as_bool()).unwrap_or(false) { + bail!("Model {model_name} is configured as default but uses wildcard (*). Default models cannot be wildcards."); + } + if mp + .get("routing_preferences") + .and_then(|v| v.as_array()) + .is_some_and(|a| !a.is_empty()) + { + bail!("Model {model_name} has routing_preferences but uses wildcard (*). Models with routing preferences cannot be wildcards."); + } + } + + // Validate providers requiring base_url + if SUPPORTED_PROVIDERS_WITH_BASE_URL.contains(&provider) + && mp.get("base_url").and_then(|v| v.as_str()).is_none() + { + bail!("Provider '{provider}' requires 'base_url' to be set for model {model_name}"); + } + + let model_id = tokens[1..].join("/"); + + // Handle unsupported providers + let mut provider_str = provider.to_string(); + if !is_wildcard && !all_providers.contains(&provider) { + if mp.get("base_url").is_none() || mp.get("provider_interface").is_none() { + bail!("Must provide base_url and provider_interface for unsupported provider {provider} for model {model_name}. Supported providers are: {}", all_providers.join(", ")); + } + provider_str = mp + .get("provider_interface") + .and_then(|v| v.as_str()) + .unwrap_or(provider) + .to_string(); + } else if is_wildcard && !all_providers.contains(&provider) { + if mp.get("base_url").is_none() || mp.get("provider_interface").is_none() { + bail!("Must provide base_url and provider_interface for unsupported provider {provider} for wildcard model {model_name}. Supported providers are: {}", all_providers.join(", ")); + } + provider_str = mp + .get("provider_interface") + .and_then(|v| v.as_str()) + .unwrap_or(provider) + .to_string(); + } else if all_providers.contains(&provider) + && mp + .get("provider_interface") + .and_then(|v| v.as_str()) + .is_some() + { + bail!("Please provide provider interface as part of model name {model_name} using the format /. For example, use 'openai/gpt-3.5-turbo' instead of 'gpt-3.5-turbo' "); + } + + // Duplicate model_id check + if !is_wildcard && model_name_keys.contains(&model_id) { + bail!("Duplicate model_id {model_id}, please provide unique model_id for each model_provider"); + } + if !is_wildcard { + model_name_keys.insert(model_id.clone()); + } + + // Validate routing preferences uniqueness + if let Some(prefs) = mp.get("routing_preferences").and_then(|v| v.as_array()) { + for pref in prefs { + if let Some(name) = pref.get("name").and_then(|v| v.as_str()) { + if !model_usage_name_keys.insert(name.to_string()) { + bail!("Duplicate routing preference name \"{name}\", please provide unique name for each routing preference"); + } + } + } + } + + // Warn if both passthrough_auth and access_key + if mp + .get("passthrough_auth") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + && mp.get("access_key").is_some() + { + let name = mp.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); + eprintln!("WARNING: Model provider '{name}' has both 'passthrough_auth: true' and 'access_key' configured. The access_key will be ignored and the client's Authorization header will be forwarded instead."); + } + + mp["model"] = json!(model_id); + mp["provider_interface"] = json!(provider_str); + + // Handle provider vs provider_interface + if mp.get("provider").is_some() && mp.get("provider_interface").is_some() { + bail!("Please provide either provider or provider_interface, not both"); + } + if let Some(p) = mp.get("provider").cloned() { + mp["provider_interface"] = p; + mp.as_object_mut().unwrap().remove("provider"); + } + + updated_model_providers.push(mp.clone()); + + // Handle base_url → endpoint extraction + if let Some(base_url) = mp.get("base_url").and_then(|v| v.as_str()) { + if let Ok(url) = Url::parse(base_url) { + let path = url.path(); + if !path.is_empty() && path != "/" { + mp["base_url_path_prefix"] = json!(path); + } + if !["http", "https"].contains(&url.scheme()) { + bail!("Please provide a valid URL with scheme (http/https) in base_url"); + } + let protocol = url.scheme(); + let port = url + .port() + .unwrap_or(if protocol == "http" { 80 } else { 443 }); + let endpoint = url.host_str().unwrap_or(""); + mp["endpoint"] = json!(endpoint); + mp["port"] = json!(port); + mp["protocol"] = json!(protocol); + let cluster_name = format!("{provider_str}_{endpoint}"); + mp["cluster_name"] = json!(cluster_name); + + if llms_with_endpoint_cluster_names.insert(cluster_name) { + llms_with_endpoint.push(mp.clone()); + } + } + } + } + } + + // Auto-add internal model providers + let overrides_config: serde_json::Value = config_yaml + .get("overrides") + .map(|v| serde_json::to_value(v).unwrap_or_default()) + .unwrap_or_else(|| json!({})); + + let model_name_set: HashSet = updated_model_providers + .iter() + .filter_map(|mp| { + mp.get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + // Auto-add arch-router + let router_model = overrides_config + .get("llm_routing_model") + .and_then(|v| v.as_str()) + .unwrap_or("Arch-Router"); + let router_model_id = if router_model.contains('/') { + router_model.split_once('/').unwrap().1 + } else { + router_model + }; + if !model_usage_name_keys.is_empty() && !model_name_set.contains(router_model_id) { + updated_model_providers.push(json!({ + "name": "arch-router", + "provider_interface": "plano", + "model": router_model_id, + "internal": true, + })); + } + + // Always add arch-function + if !model_provider_name_set.contains("arch-function") { + updated_model_providers.push(json!({ + "name": "arch-function", + "provider_interface": "plano", + "model": "Arch-Function", + "internal": true, + })); + } + + // Auto-add plano-orchestrator + let orch_model = overrides_config + .get("agent_orchestration_model") + .and_then(|v| v.as_str()) + .unwrap_or("Plano-Orchestrator"); + let orch_model_id = if orch_model.contains('/') { + orch_model.split_once('/').unwrap().1 + } else { + orch_model + }; + if !model_name_set.contains(orch_model_id) { + updated_model_providers.push(json!({ + "name": "plano/orchestrator", + "provider_interface": "plano", + "model": orch_model_id, + "internal": true, + })); + } + + // Update config with processed model_providers + let mp_yaml: serde_yaml::Value = + serde_yaml::from_str(&serde_json::to_string(&updated_model_providers)?)?; + config_yaml.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("model_providers".to_string()), + mp_yaml, + ); + + // Validate only one listener with model_providers + let mut listeners_with_provider = 0; + for listener in &listeners { + if listener + .get("model_providers") + .and_then(|v| v.as_array()) + .is_some() + { + listeners_with_provider += 1; + if listeners_with_provider > 1 { + bail!("Please provide model_providers either under listeners or at root level, not both. Currently we don't support multiple listeners with model_providers"); + } + } + } + + // Validate input_filters reference valid agent/filter IDs + for listener in &listeners { + if let Some(filters) = listener.get("input_filters").and_then(|v| v.as_array()) { + let listener_name = listener + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + for fc_id in filters { + if let Some(id) = fc_id.as_str() { + if !agent_id_keys.contains(id) { + let available: Vec<_> = agent_id_keys.iter().cloned().collect(); + let mut available_sorted = available; + available_sorted.sort(); + bail!("Listener '{listener_name}' references input_filters id '{id}' which is not defined in agents or filters. Available ids: {}", available_sorted.join(", ")); + } + } + } + } + } + + // Validate model aliases + if let Some(aliases) = config_yaml.get("model_aliases") { + if let Some(mapping) = aliases.as_mapping() { + for (alias_key, alias_val) in mapping { + let alias_name = alias_key.as_str().unwrap_or(""); + if let Some(target) = alias_val.get("target").and_then(|v| v.as_str()) { + if !model_name_keys.contains(target) { + let mut available: Vec<_> = model_name_keys.iter().cloned().collect(); + available.sort(); + bail!("Model alias 2 - '{alias_name}' targets '{target}' which is not defined as a model. Available models: {}", available.join(", ")); + } + } + } + } + } + + // Generate rendered config strings + let plano_config_string = serde_yaml::to_string(&config_yaml)?; + + // Handle agent orchestrator + let use_agent_orchestrator = overrides_config + .get("use_agent_orchestrator") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let agent_orchestrator = if use_agent_orchestrator { + if endpoints.is_empty() { + bail!("Please provide agent orchestrator in the endpoints section in your plano_config.yaml file"); + } else if endpoints.len() > 1 { + bail!("Please provide single agent orchestrator in the endpoints section in your plano_config.yaml file"); + } else { + Some(endpoints.keys().next().unwrap().clone()) + } + } else { + None + }; + + let upstream_connect_timeout = overrides_config + .get("upstream_connect_timeout") + .and_then(|v| v.as_str()) + .unwrap_or("5s"); + let upstream_tls_ca_path = overrides_config + .get("upstream_tls_ca_path") + .and_then(|v| v.as_str()) + .unwrap_or("/etc/ssl/certs/ca-certificates.crt"); + + // Render template + let template_filename = template_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("envoy.template.yaml"); + + let mut tera = tera::Tera::default(); + let template_content = std::fs::read_to_string(template_path)?; + tera.add_raw_template(template_filename, &template_content)?; + + let mut context = tera::Context::new(); + context.insert("prompt_gateway_listener", &prompt_gateway); + context.insert("llm_gateway_listener", &llm_gateway); + context.insert("plano_config", &plano_config_string); + context.insert("plano_llm_config", &plano_config_string); + context.insert("plano_clusters", &inferred_clusters); + context.insert("plano_model_providers", &updated_model_providers); + context.insert("plano_tracing", &plano_tracing); + context.insert("local_llms", &llms_with_endpoint); + context.insert("agent_orchestrator", &agent_orchestrator); + context.insert("listeners", &listeners); + context.insert("upstream_connect_timeout", upstream_connect_timeout); + context.insert("upstream_tls_ca_path", upstream_tls_ca_path); + + let rendered = tera.render(template_filename, &context)?; + + // Write output files + if let Some(parent) = envoy_output_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(envoy_output_path, &rendered)?; + + if let Some(parent) = config_output_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(config_output_path, &plano_config_string)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_endpoint_and_port_with_port() { + let (host, port) = get_endpoint_and_port("example.com:8080", "http"); + assert_eq!(host, "example.com"); + assert_eq!(port, 8080); + } + + #[test] + fn test_get_endpoint_and_port_http() { + let (host, port) = get_endpoint_and_port("example.com", "http"); + assert_eq!(host, "example.com"); + assert_eq!(port, 80); + } + + #[test] + fn test_get_endpoint_and_port_https() { + let (host, port) = get_endpoint_and_port("example.com", "https"); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } +} diff --git a/crates/plano-cli/src/config/mod.rs b/crates/plano-cli/src/config/mod.rs new file mode 100644 index 000000000..4f74a9d2c --- /dev/null +++ b/crates/plano-cli/src/config/mod.rs @@ -0,0 +1,4 @@ +pub mod generator; +pub mod validation; + +pub use generator::validate_and_render; diff --git a/crates/plano-cli/src/config/validation.rs b/crates/plano-cli/src/config/validation.rs new file mode 100644 index 000000000..17c081ca1 --- /dev/null +++ b/crates/plano-cli/src/config/validation.rs @@ -0,0 +1,39 @@ +use anyhow::{bail, Result}; +use std::path::Path; + +/// Validate a plano config file against the JSON schema. +pub fn validate_prompt_config(config_path: &Path, schema_path: &Path) -> Result<()> { + let config_str = std::fs::read_to_string(config_path)?; + let schema_str = std::fs::read_to_string(schema_path)?; + + let config_yaml: serde_yaml::Value = serde_yaml::from_str(&config_str)?; + let schema_yaml: serde_yaml::Value = serde_yaml::from_str(&schema_str)?; + + // Convert to JSON for jsonschema validation + let config_json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&config_yaml)?)?; + let schema_json: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&schema_yaml)?)?; + + let validator = jsonschema::validator_for(&schema_json) + .map_err(|e| anyhow::anyhow!("Invalid schema: {e}"))?; + + let errors: Vec<_> = validator.iter_errors(&config_json).collect(); + if !errors.is_empty() { + let mut msg = String::new(); + for err in &errors { + let path = if err.instance_path.as_str().is_empty() { + "root".to_string() + } else { + err.instance_path.to_string() + }; + msg.push_str(&format!( + "{}\n Location: {}\n Value: {}\n", + err, path, err.instance + )); + } + bail!("{msg}"); + } + + Ok(()) +} diff --git a/crates/plano-cli/src/consts.rs b/crates/plano-cli/src/consts.rs new file mode 100644 index 000000000..83001ae1e --- /dev/null +++ b/crates/plano-cli/src/consts.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +pub const PLANO_COLOR: &str = "#969FF4"; +pub const SERVICE_NAME: &str = "plano"; +pub const PLANO_DOCKER_NAME: &str = "plano"; +pub const PLANO_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const DEFAULT_OTEL_TRACING_GRPC_ENDPOINT: &str = "http://localhost:4317"; +pub const DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT: &str = "http://localhost:4317"; + +pub const ENVOY_VERSION: &str = "v1.37.0"; +pub const PLANO_GITHUB_REPO: &str = "katanemo/archgw"; + +pub fn plano_docker_image() -> String { + std::env::var("PLANO_DOCKER_IMAGE") + .unwrap_or_else(|_| format!("katanemo/plano:{PLANO_VERSION}")) +} + +pub fn plano_home() -> PathBuf { + dirs::home_dir() + .expect("could not determine home directory") + .join(".plano") +} + +pub fn plano_run_dir() -> PathBuf { + plano_home().join("run") +} + +pub fn plano_bin_dir() -> PathBuf { + plano_home().join("bin") +} + +pub fn plano_plugins_dir() -> PathBuf { + plano_home().join("plugins") +} + +pub fn native_pid_file() -> PathBuf { + plano_run_dir().join("plano.pid") +} + +pub fn plano_release_base_url() -> String { + format!("https://github.com/{PLANO_GITHUB_REPO}/releases/download") +} diff --git a/crates/plano-cli/src/docker/mod.rs b/crates/plano-cli/src/docker/mod.rs new file mode 100644 index 000000000..43b3d09e8 --- /dev/null +++ b/crates/plano-cli/src/docker/mod.rs @@ -0,0 +1,211 @@ +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Result}; + +use crate::consts::{plano_docker_image, PLANO_DOCKER_NAME}; + +/// Get Docker container status. +pub async fn container_status(container: &str) -> Result { + let output = Command::new("docker") + .args(["inspect", "--type=container", container]) + .output()?; + + if !output.status.success() { + return Ok("not found".to_string()); + } + + let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; + Ok(json[0]["State"]["Status"] + .as_str() + .unwrap_or("") + .to_string()) +} + +/// Validate config using Docker. +pub async fn validate_config(plano_config_path: &Path) -> Result<()> { + let abs_path = std::fs::canonicalize(plano_config_path)?; + + let args = vec![ + "docker".to_string(), + "run".to_string(), + "--rm".to_string(), + "-v".to_string(), + format!("{}:/app/plano_config.yaml:ro", abs_path.display()), + "--entrypoint".to_string(), + "python".to_string(), + plano_docker_image(), + "-m".to_string(), + "planoai.config_generator".to_string(), + ]; + + let output = Command::new(&args[0]).args(&args[1..]).output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("{}", stderr.trim()); + } + + Ok(()) +} + +/// Start Plano in Docker. +pub async fn start_plano( + plano_config_path: &Path, + env: &HashMap, + foreground: bool, +) -> Result<()> { + let abs_path = std::fs::canonicalize(plano_config_path)?; + + // Prepare config (replace localhost → host.docker.internal) + let config_content = std::fs::read_to_string(&abs_path)?; + let docker_config = if config_content.contains("localhost") { + let replaced = config_content.replace("localhost", "host.docker.internal"); + let tmp = std::env::temp_dir().join("plano_config_docker.yaml"); + std::fs::write(&tmp, &replaced)?; + tmp + } else { + abs_path.clone() + }; + + // Get gateway ports + let config: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&abs_path)?)?; + let mut gateway_ports = Vec::new(); + if let Some(listeners) = config.get("listeners").and_then(|v| v.as_sequence()) { + for listener in listeners { + if let Some(port) = listener.get("port").and_then(|v| v.as_u64()) { + gateway_ports.push(port as u16); + } + } + } + if gateway_ports.is_empty() { + gateway_ports.push(12000); + } + + // Build docker run command + let mut docker_args = vec![ + "run".to_string(), + "-d".to_string(), + "--name".to_string(), + PLANO_DOCKER_NAME.to_string(), + ]; + + // Port mappings + docker_args.extend(["-p".to_string(), "12001:12001".to_string()]); + docker_args.extend(["-p".to_string(), "19901:9901".to_string()]); + for port in &gateway_ports { + docker_args.extend(["-p".to_string(), format!("{port}:{port}")]); + } + + // Volume + docker_args.extend([ + "-v".to_string(), + format!("{}:/app/plano_config.yaml:ro", docker_config.display()), + ]); + + // Environment variables + for (k, v) in env { + docker_args.extend(["-e".to_string(), format!("{k}={v}")]); + } + + docker_args.extend([ + "--add-host".to_string(), + "host.docker.internal:host-gateway".to_string(), + plano_docker_image(), + ]); + + let output = Command::new("docker").args(&docker_args).output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Docker run failed: {}", stderr.trim()); + } + + // Health check + let green = console::Style::new().green(); + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(60); + + loop { + let mut all_healthy = true; + for &port in &gateway_ports { + if reqwest::get(&format!("http://localhost:{port}/healthz")) + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + { + continue; + } + all_healthy = false; + } + + if all_healthy { + eprintln!("{} Plano is running (Docker mode)", green.apply_to("✓")); + for &port in &gateway_ports { + eprintln!(" http://localhost:{port}"); + } + break; + } + + if start.elapsed() > timeout { + bail!("Health check timed out after 60s"); + } + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + + if foreground { + // Stream logs + let mut child = tokio::process::Command::new("docker") + .args(["logs", "-f", PLANO_DOCKER_NAME]) + .spawn()?; + + tokio::select! { + _ = child.wait() => {} + _ = tokio::signal::ctrl_c() => { + let _ = child.kill().await; + stop_container().await?; + } + } + } + + Ok(()) +} + +/// Stop Docker container. +pub async fn stop_container() -> Result<()> { + let _ = Command::new("docker") + .args(["stop", PLANO_DOCKER_NAME]) + .output(); + + let _ = Command::new("docker") + .args(["rm", "-f", PLANO_DOCKER_NAME]) + .output(); + + let green = console::Style::new().green(); + eprintln!("{} Plano stopped (Docker mode).", green.apply_to("✓")); + Ok(()) +} + +/// Stream Docker logs. +pub async fn stream_logs(_debug: bool, follow: bool) -> Result<()> { + let mut args = vec!["logs".to_string()]; + if follow { + args.push("-f".to_string()); + } + args.push(PLANO_DOCKER_NAME.to_string()); + + let mut child = tokio::process::Command::new("docker").args(&args).spawn()?; + + tokio::select! { + result = child.wait() => { + result?; + } + _ = tokio::signal::ctrl_c() => { + let _ = child.kill().await; + } + } + + Ok(()) +} diff --git a/crates/plano-cli/src/main.rs b/crates/plano-cli/src/main.rs new file mode 100644 index 000000000..47bf182ed --- /dev/null +++ b/crates/plano-cli/src/main.rs @@ -0,0 +1,22 @@ +#![allow(dead_code)] + +mod commands; +mod config; +mod consts; +mod docker; +mod native; +mod trace; +mod utils; +mod version; + +use clap::Parser; +use commands::Cli; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + if let Err(e) = commands::run(cli).await { + eprintln!("Error: {e:#}"); + std::process::exit(1); + } +} diff --git a/crates/plano-cli/src/native/binaries.rs b/crates/plano-cli/src/native/binaries.rs new file mode 100644 index 000000000..7842c1a13 --- /dev/null +++ b/crates/plano-cli/src/native/binaries.rs @@ -0,0 +1,268 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::consts::{ + plano_bin_dir, plano_plugins_dir, plano_release_base_url, ENVOY_VERSION, PLANO_VERSION, +}; +use crate::utils::find_repo_root; + +/// Get the platform slug for binary downloads. +fn get_platform_slug() -> Result<&'static str> { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => Ok("linux-amd64"), + ("linux", "aarch64") => Ok("linux-arm64"), + ("macos", "aarch64") => Ok("darwin-arm64"), + ("macos", "x86_64") => { + bail!("macOS x86_64 (Intel) is not supported. Pre-built binaries are only available for Apple Silicon (arm64)."); + } + _ => bail!( + "Unsupported platform {os}/{arch}. Supported: linux-amd64, linux-arm64, darwin-arm64" + ), + } +} + +/// Download a file with a progress bar. +async fn download_file(url: &str, dest: &Path, label: &str) -> Result<()> { + let client = reqwest::Client::new(); + let resp = client.get(url).send().await?; + + if !resp.status().is_success() { + bail!("Download failed: HTTP {}", resp.status()); + } + + let total = resp.content_length().unwrap_or(0); + let pb = ProgressBar::new(total); + pb.set_style( + ProgressStyle::default_bar() + .template(&format!( + " {label} {{bar:30}} {{percent}}% ({{bytes}}/{{total_bytes}})" + )) + .unwrap() + .progress_chars("█░░"), + ); + + let bytes = resp.bytes().await?; + pb.set_position(bytes.len() as u64); + pb.finish(); + println!(); + + fs::write(dest, &bytes)?; + Ok(()) +} + +/// Check for locally-built WASM plugins. +fn find_local_wasm_plugins() -> Option<(PathBuf, PathBuf)> { + let repo_root = find_repo_root()?; + let wasm_dir = repo_root.join("crates/target/wasm32-wasip1/release"); + let prompt_gw = wasm_dir.join("prompt_gateway.wasm"); + let llm_gw = wasm_dir.join("llm_gateway.wasm"); + if prompt_gw.exists() && llm_gw.exists() { + Some((prompt_gw, llm_gw)) + } else { + None + } +} + +/// Check for locally-built brightstaff binary. +fn find_local_brightstaff() -> Option { + let repo_root = find_repo_root()?; + let path = repo_root.join("crates/target/release/brightstaff"); + if path.exists() { + Some(path) + } else { + None + } +} + +/// Ensure Envoy binary is available. Returns path to binary. +pub async fn ensure_envoy_binary() -> Result { + let bin_dir = plano_bin_dir(); + let envoy_path = bin_dir.join("envoy"); + let version_path = bin_dir.join("envoy.version"); + + if envoy_path.exists() { + if let Ok(cached) = fs::read_to_string(&version_path) { + if cached.trim() == ENVOY_VERSION { + tracing::info!("Envoy {} (cached)", ENVOY_VERSION); + return Ok(envoy_path); + } + tracing::info!("Envoy version changed, re-downloading..."); + } + } + + let slug = get_platform_slug()?; + let url = format!( + "https://github.com/tetratelabs/archive-envoy/releases/download/{ENVOY_VERSION}/envoy-{ENVOY_VERSION}-{slug}.tar.xz" + ); + + fs::create_dir_all(&bin_dir)?; + + let tmp_path = bin_dir.join("envoy.tar.xz"); + download_file(&url, &tmp_path, &format!("Envoy {ENVOY_VERSION}")).await?; + + tracing::info!("Extracting Envoy {}...", ENVOY_VERSION); + + // Extract using tar command (tar.xz not well supported by Rust tar crate) + let status = tokio::process::Command::new("tar") + .args([ + "xf", + &tmp_path.to_string_lossy(), + "-C", + &bin_dir.to_string_lossy(), + ]) + .status() + .await?; + + if !status.success() { + bail!("Failed to extract Envoy archive"); + } + + // Find and move the envoy binary + let mut found = false; + for entry in walkdir(&bin_dir)? { + if entry.file_name() == Some(std::ffi::OsStr::new("envoy")) && entry != envoy_path { + fs::copy(&entry, &envoy_path)?; + found = true; + break; + } + } + + // Clean up extracted directories + for entry in fs::read_dir(&bin_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let _ = fs::remove_dir_all(entry.path()); + } + } + let _ = fs::remove_file(&tmp_path); + + if !found && !envoy_path.exists() { + bail!("Could not find envoy binary in the downloaded archive"); + } + + fs::set_permissions(&envoy_path, fs::Permissions::from_mode(0o755))?; + fs::write(&version_path, ENVOY_VERSION)?; + Ok(envoy_path) +} + +/// Simple recursive file walker. +fn walkdir(dir: &Path) -> Result> { + let mut results = Vec::new(); + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + results.extend(walkdir(&path)?); + } else { + results.push(path); + } + } + } + Ok(results) +} + +/// Ensure WASM plugins are available. Returns (prompt_gw_path, llm_gw_path). +pub async fn ensure_wasm_plugins() -> Result<(PathBuf, PathBuf)> { + // 1. Local source build + if let Some(local) = find_local_wasm_plugins() { + tracing::info!("Using locally-built WASM plugins"); + return Ok(local); + } + + // 2. Cached download + let plugins_dir = plano_plugins_dir(); + let version_path = plugins_dir.join("wasm.version"); + let prompt_gw_path = plugins_dir.join("prompt_gateway.wasm"); + let llm_gw_path = plugins_dir.join("llm_gateway.wasm"); + + if prompt_gw_path.exists() && llm_gw_path.exists() { + if let Ok(cached) = fs::read_to_string(&version_path) { + if cached.trim() == PLANO_VERSION { + tracing::info!("WASM plugins {} (cached)", PLANO_VERSION); + return Ok((prompt_gw_path, llm_gw_path)); + } + } + } + + // 3. Download + fs::create_dir_all(&plugins_dir)?; + let base = plano_release_base_url(); + + for (name, dest) in [ + ("prompt_gateway.wasm", &prompt_gw_path), + ("llm_gateway.wasm", &llm_gw_path), + ] { + let url = format!("{base}/{PLANO_VERSION}/{name}.gz"); + let gz_dest = dest.with_extension("wasm.gz"); + download_file(&url, &gz_dest, &format!("{name} ({PLANO_VERSION})")).await?; + + // Decompress + tracing::info!("Decompressing {name}..."); + let gz_data = fs::read(&gz_dest)?; + let mut decoder = flate2::read::GzDecoder::new(&gz_data[..]); + let mut out = fs::File::create(dest)?; + std::io::copy(&mut decoder, &mut out)?; + let _ = fs::remove_file(&gz_dest); + } + + fs::write(&version_path, PLANO_VERSION)?; + Ok((prompt_gw_path, llm_gw_path)) +} + +/// Ensure brightstaff binary is available. Returns path. +pub async fn ensure_brightstaff_binary() -> Result { + // 1. Local source build + if let Some(local) = find_local_brightstaff() { + tracing::info!("Using locally-built brightstaff"); + return Ok(local); + } + + // 2. Cached download + let bin_dir = plano_bin_dir(); + let brightstaff_path = bin_dir.join("brightstaff"); + let version_path = bin_dir.join("brightstaff.version"); + + if brightstaff_path.exists() { + if let Ok(cached) = fs::read_to_string(&version_path) { + if cached.trim() == PLANO_VERSION { + tracing::info!("brightstaff {} (cached)", PLANO_VERSION); + return Ok(brightstaff_path); + } + } + } + + // 3. Download + let slug = get_platform_slug()?; + let url = format!( + "{}/{PLANO_VERSION}/brightstaff-{slug}.gz", + plano_release_base_url() + ); + + fs::create_dir_all(&bin_dir)?; + let gz_path = bin_dir.join("brightstaff.gz"); + download_file( + &url, + &gz_path, + &format!("brightstaff ({PLANO_VERSION}, {slug})"), + ) + .await?; + + tracing::info!("Decompressing brightstaff..."); + let gz_data = fs::read(&gz_path)?; + let mut decoder = flate2::read::GzDecoder::new(&gz_data[..]); + let mut out = fs::File::create(&brightstaff_path)?; + std::io::copy(&mut decoder, &mut out)?; + let _ = fs::remove_file(&gz_path); + + fs::set_permissions(&brightstaff_path, fs::Permissions::from_mode(0o755))?; + fs::write(&version_path, PLANO_VERSION)?; + Ok(brightstaff_path) +} diff --git a/crates/plano-cli/src/native/mod.rs b/crates/plano-cli/src/native/mod.rs new file mode 100644 index 000000000..a1fca8fc9 --- /dev/null +++ b/crates/plano-cli/src/native/mod.rs @@ -0,0 +1,2 @@ +pub mod binaries; +pub mod runner; diff --git a/crates/plano-cli/src/native/runner.rs b/crates/plano-cli/src/native/runner.rs new file mode 100644 index 000000000..3f1bbe00e --- /dev/null +++ b/crates/plano-cli/src/native/runner.rs @@ -0,0 +1,481 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Result}; +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; + +use crate::config; +use crate::consts::{native_pid_file, plano_run_dir}; +use crate::native::binaries; +use crate::utils::{expand_env_vars, find_repo_root, is_pid_alive}; + +/// Find the config directory containing schema and templates. +fn find_config_dir() -> Result { + // Check repo root first + if let Some(repo_root) = find_repo_root() { + let config_dir = repo_root.join("config"); + if config_dir.is_dir() && config_dir.join("plano_config_schema.yaml").exists() { + return Ok(config_dir); + } + } + + // Check if installed alongside the binary + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let config_dir = parent.join("config"); + if config_dir.is_dir() { + return Ok(config_dir); + } + // Check ../config (for bin/plano layout) + if let Some(grandparent) = parent.parent() { + let config_dir = grandparent.join("config"); + if config_dir.is_dir() { + return Ok(config_dir); + } + } + } + } + + bail!("Could not find config templates. Make sure you're inside the plano repository or have the config directory available.") +} + +/// Validate config without starting processes. +pub fn validate_config(plano_config_path: &Path) -> Result<()> { + let config_dir = find_config_dir()?; + let run_dir = plano_run_dir(); + fs::create_dir_all(&run_dir)?; + + config::validate_and_render( + plano_config_path, + &config_dir.join("plano_config_schema.yaml"), + &config_dir.join("envoy.template.yaml"), + &run_dir.join("envoy.yaml"), + &run_dir.join("plano_config_rendered.yaml"), + ) +} + +/// Render native config. Returns (envoy_config_path, plano_config_rendered_path). +pub async fn render_native_config( + plano_config_path: &Path, + env: &HashMap, + with_tracing: bool, +) -> Result<(PathBuf, PathBuf)> { + let run_dir = plano_run_dir(); + fs::create_dir_all(&run_dir)?; + + let (prompt_gw_path, llm_gw_path) = binaries::ensure_wasm_plugins().await?; + + // If --with-tracing, inject tracing config if not already present + let effective_config_path = if with_tracing { + let content = fs::read_to_string(plano_config_path)?; + let mut config: serde_yaml::Value = serde_yaml::from_str(&content)?; + + let tracing = config.as_mapping_mut().and_then(|m| { + m.entry(serde_yaml::Value::String("tracing".to_string())) + .or_insert(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + }); + + if let Some(tracing) = tracing { + if !tracing.contains_key(serde_yaml::Value::String("random_sampling".to_string())) { + tracing.insert( + serde_yaml::Value::String("random_sampling".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(100)), + ); + } + } + + let path = run_dir.join("config_with_tracing.yaml"); + fs::write(&path, serde_yaml::to_string(&config)?)?; + path + } else { + plano_config_path.to_path_buf() + }; + + let envoy_config_path = run_dir.join("envoy.yaml"); + let plano_config_rendered_path = run_dir.join("plano_config_rendered.yaml"); + let config_dir = find_config_dir()?; + + // Temporarily set env vars for config rendering + for (k, v) in env { + std::env::set_var(k, v); + } + + config::validate_and_render( + &effective_config_path, + &config_dir.join("plano_config_schema.yaml"), + &config_dir.join("envoy.template.yaml"), + &envoy_config_path, + &plano_config_rendered_path, + )?; + + // Post-process envoy.yaml: replace Docker paths with local paths + let mut envoy_content = fs::read_to_string(&envoy_config_path)?; + + envoy_content = envoy_content.replace( + "/etc/envoy/proxy-wasm-plugins/prompt_gateway.wasm", + &prompt_gw_path.to_string_lossy(), + ); + envoy_content = envoy_content.replace( + "/etc/envoy/proxy-wasm-plugins/llm_gateway.wasm", + &llm_gw_path.to_string_lossy(), + ); + + // Replace /var/log/ with local log directory + let log_dir = run_dir.join("logs"); + fs::create_dir_all(&log_dir)?; + envoy_content = envoy_content.replace("/var/log/", &format!("{}/", log_dir.display())); + + // Platform-specific CA cert path + if cfg!(target_os = "macos") { + envoy_content = + envoy_content.replace("/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"); + } + + fs::write(&envoy_config_path, &envoy_content)?; + + // Run envsubst-equivalent on both rendered files + for path in [&envoy_config_path, &plano_config_rendered_path] { + let content = fs::read_to_string(path)?; + let expanded = expand_env_vars(&content); + fs::write(path, expanded)?; + } + + Ok((envoy_config_path, plano_config_rendered_path)) +} + +/// Start Envoy and brightstaff natively. +pub async fn start_native( + plano_config_path: &Path, + env: &HashMap, + foreground: bool, + with_tracing: bool, +) -> Result<()> { + let pid_file = native_pid_file(); + let run_dir = plano_run_dir(); + + // Stop existing instance + if pid_file.exists() { + tracing::info!("Stopping existing Plano instance..."); + stop_native()?; + } + + let envoy_path = binaries::ensure_envoy_binary().await?; + binaries::ensure_wasm_plugins().await?; + let brightstaff_path = binaries::ensure_brightstaff_binary().await?; + + let (envoy_config_path, plano_config_rendered_path) = + render_native_config(plano_config_path, env, with_tracing).await?; + + tracing::info!("Configuration rendered"); + + let log_dir = run_dir.join("logs"); + fs::create_dir_all(&log_dir)?; + + let log_level = env.get("LOG_LEVEL").map(|s| s.as_str()).unwrap_or("info"); + + // Build env for subprocesses + let mut proc_env: HashMap = std::env::vars().collect(); + proc_env.insert("RUST_LOG".to_string(), log_level.to_string()); + proc_env.insert( + "PLANO_CONFIG_PATH_RENDERED".to_string(), + plano_config_rendered_path.to_string_lossy().to_string(), + ); + for (k, v) in env { + proc_env.insert(k.clone(), v.clone()); + } + + // Start brightstaff + let brightstaff_pid = daemon_exec( + &[brightstaff_path.to_string_lossy().to_string()], + &proc_env, + &log_dir.join("brightstaff.log"), + )?; + tracing::info!("Started brightstaff (PID {brightstaff_pid})"); + + // Start envoy + let envoy_pid = daemon_exec( + &[ + envoy_path.to_string_lossy().to_string(), + "-c".to_string(), + envoy_config_path.to_string_lossy().to_string(), + "--component-log-level".to_string(), + format!("wasm:{log_level}"), + "--log-format".to_string(), + "[%Y-%m-%d %T.%e][%l] %v".to_string(), + ], + &proc_env, + &log_dir.join("envoy.log"), + )?; + tracing::info!("Started envoy (PID {envoy_pid})"); + + // Save PIDs + fs::create_dir_all(plano_run_dir())?; + let pids = serde_json::json!({ + "envoy_pid": envoy_pid, + "brightstaff_pid": brightstaff_pid, + }); + fs::write(&pid_file, serde_json::to_string(&pids)?)?; + + // Health check + let gateway_ports = get_gateway_ports(plano_config_path)?; + tracing::info!("Waiting for listeners to become healthy..."); + + let start = Instant::now(); + let timeout = Duration::from_secs(60); + let green = console::Style::new().green(); + + loop { + let mut all_healthy = true; + for &port in &gateway_ports { + if !health_check_endpoint(&format!("http://localhost:{port}/healthz")).await { + all_healthy = false; + } + } + + if all_healthy { + eprintln!("{} Plano is running (native mode)", green.apply_to("✓")); + for &port in &gateway_ports { + eprintln!(" http://localhost:{port}"); + } + break; + } + + if !is_pid_alive(brightstaff_pid) { + bail!( + "brightstaff exited unexpectedly. Check logs: {}", + log_dir.join("brightstaff.log").display() + ); + } + if !is_pid_alive(envoy_pid) { + bail!( + "envoy exited unexpectedly. Check logs: {}", + log_dir.join("envoy.log").display() + ); + } + if start.elapsed() > timeout { + stop_native()?; + bail!( + "Health check timed out after 60s. Check logs in: {}", + log_dir.display() + ); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + if foreground { + tracing::info!("Running in foreground. Press Ctrl+C to stop."); + tracing::info!("Logs: {}", log_dir.display()); + + let mut log_files = vec![ + log_dir.join("envoy.log").to_string_lossy().to_string(), + log_dir + .join("brightstaff.log") + .to_string_lossy() + .to_string(), + ]; + + // Add access logs + if let Ok(entries) = fs::read_dir(&log_dir) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + if name.starts_with("access_") && name.ends_with(".log") { + log_files.push(entry.path().to_string_lossy().to_string()); + } + } + } + } + + let mut tail_args = vec!["tail".to_string(), "-f".to_string()]; + tail_args.extend(log_files); + + let mut child = tokio::process::Command::new(&tail_args[0]) + .args(&tail_args[1..]) + .spawn()?; + + tokio::select! { + _ = child.wait() => {} + _ = tokio::signal::ctrl_c() => { + tracing::info!("Stopping Plano..."); + let _ = child.kill().await; + stop_native()?; + } + } + } else { + tracing::info!("Logs: {}", log_dir.display()); + tracing::info!("Run 'plano down' to stop."); + } + + Ok(()) +} + +/// Double-fork daemon execution. Returns the grandchild PID. +fn daemon_exec(args: &[String], env: &HashMap, log_path: &Path) -> Result { + use std::process::{Command, Stdio}; + + let log_file = fs::File::create(log_path)?; + + let child = Command::new(&args[0]) + .args(&args[1..]) + .envs(env) + .stdin(Stdio::null()) + .stdout(log_file.try_clone()?) + .stderr(log_file) + .spawn()?; + + Ok(child.id() as i32) +} + +/// Stop natively-running Envoy and brightstaff processes. +pub fn stop_native() -> Result<()> { + let pid_file = native_pid_file(); + if !pid_file.exists() { + tracing::info!("No native Plano instance found (PID file missing)."); + return Ok(()); + } + + let content = fs::read_to_string(&pid_file)?; + let pids: serde_json::Value = serde_json::from_str(&content)?; + + let envoy_pid = pids.get("envoy_pid").and_then(|v| v.as_i64()); + let brightstaff_pid = pids.get("brightstaff_pid").and_then(|v| v.as_i64()); + + for (name, pid) in [("envoy", envoy_pid), ("brightstaff", brightstaff_pid)] { + let Some(pid) = pid else { continue }; + let pid = pid as i32; + let nix_pid = Pid::from_raw(pid); + + match kill(nix_pid, Signal::SIGTERM) { + Ok(()) => { + tracing::info!("Sent SIGTERM to {name} (PID {pid})"); + } + Err(nix::errno::Errno::ESRCH) => { + tracing::info!("{name} (PID {pid}) already stopped"); + continue; + } + Err(e) => { + tracing::error!("Error stopping {name} (PID {pid}): {e}"); + continue; + } + } + + // Wait for graceful shutdown + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if Instant::now() > deadline { + let _ = kill(nix_pid, Signal::SIGKILL); + tracing::info!("Sent SIGKILL to {name} (PID {pid})"); + break; + } + if !is_pid_alive(pid) { + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + } + + let _ = fs::remove_file(&pid_file); + let green = console::Style::new().green(); + eprintln!("{} Plano stopped (native mode).", green.apply_to("✓")); + Ok(()) +} + +/// Stream native logs. +pub fn native_logs(debug: bool, follow: bool) -> Result<()> { + let log_dir = plano_run_dir().join("logs"); + if !log_dir.is_dir() { + bail!( + "No native log directory found at {}. Is Plano running?", + log_dir.display() + ); + } + + let mut log_files: Vec = Vec::new(); + + // Collect access logs + if let Ok(entries) = fs::read_dir(&log_dir) { + let mut access_logs: Vec<_> = entries + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with("access_") && n.ends_with(".log")) + .unwrap_or(false) + }) + .map(|e| e.path().to_string_lossy().to_string()) + .collect(); + access_logs.sort(); + log_files.extend(access_logs); + } + + if debug { + log_files.push(log_dir.join("envoy.log").to_string_lossy().to_string()); + log_files.push( + log_dir + .join("brightstaff.log") + .to_string_lossy() + .to_string(), + ); + } + + // Filter to existing files + log_files.retain(|f| Path::new(f).exists()); + if log_files.is_empty() { + bail!("No log files found in {}", log_dir.display()); + } + + let mut tail_args = vec!["tail".to_string()]; + if follow { + tail_args.push("-f".to_string()); + } + tail_args.extend(log_files); + + let mut child = std::process::Command::new(&tail_args[0]) + .args(&tail_args[1..]) + .spawn()?; + + let _ = child.wait(); + Ok(()) +} + +/// Get gateway ports from config. +fn get_gateway_ports(plano_config_path: &Path) -> Result> { + let content = fs::read_to_string(plano_config_path)?; + let config: serde_yaml::Value = serde_yaml::from_str(&content)?; + + let mut ports = Vec::new(); + if let Some(listeners) = config.get("listeners") { + if let Some(seq) = listeners.as_sequence() { + for listener in seq { + if let Some(port) = listener.get("port").and_then(|v| v.as_u64()) { + ports.push(port as u16); + } + } + } else if let Some(map) = listeners.as_mapping() { + for (_, v) in map { + if let Some(port) = v.get("port").and_then(|v| v.as_u64()) { + ports.push(port as u16); + } + } + } + } + + ports.sort(); + ports.dedup(); + if ports.is_empty() { + ports.push(12000); // default + } + Ok(ports) +} + +/// Health check an endpoint. +async fn health_check_endpoint(url: &str) -> bool { + reqwest::get(url) + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} diff --git a/crates/plano-cli/src/trace/daemon.rs b/crates/plano-cli/src/trace/daemon.rs new file mode 100644 index 000000000..1a6c7ad3f --- /dev/null +++ b/crates/plano-cli/src/trace/daemon.rs @@ -0,0 +1,60 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; + +use crate::consts::plano_run_dir; + +pub fn pid_file_path() -> PathBuf { + plano_run_dir().join("trace_listener.pid") +} + +pub fn log_file_path() -> PathBuf { + plano_run_dir().join("trace_listener.log") +} + +pub fn write_listener_pid(pid: u32) -> Result<()> { + let run_dir = plano_run_dir(); + fs::create_dir_all(&run_dir)?; + fs::write(pid_file_path(), pid.to_string())?; + Ok(()) +} + +pub fn remove_listener_pid() -> Result<()> { + let path = pid_file_path(); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +pub fn get_listener_pid() -> Option { + let content = fs::read_to_string(pid_file_path()).ok()?; + let pid: u32 = content.trim().parse().ok()?; + // Check if alive + if kill(Pid::from_raw(pid as i32), None).is_ok() { + Some(pid) + } else { + None + } +} + +pub fn stop_listener_process() -> Result<()> { + if let Some(pid) = get_listener_pid() { + let nix_pid = Pid::from_raw(pid as i32); + let _ = kill(nix_pid, Signal::SIGTERM); + + // Brief wait + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Force kill if still alive + if kill(nix_pid, None).is_ok() { + let _ = kill(nix_pid, Signal::SIGKILL); + } + } + + remove_listener_pid()?; + Ok(()) +} diff --git a/crates/plano-cli/src/trace/down.rs b/crates/plano-cli/src/trace/down.rs new file mode 100644 index 000000000..c8eaf058f --- /dev/null +++ b/crates/plano-cli/src/trace/down.rs @@ -0,0 +1,16 @@ +use anyhow::Result; + +use crate::trace::daemon; + +pub async fn run() -> Result<()> { + let green = console::Style::new().green(); + + if daemon::get_listener_pid().is_none() { + eprintln!("No trace listener running."); + return Ok(()); + } + + daemon::stop_listener_process()?; + eprintln!("{} Trace listener stopped.", green.apply_to("✓")); + Ok(()) +} diff --git a/crates/plano-cli/src/trace/listen.rs b/crates/plano-cli/src/trace/listen.rs new file mode 100644 index 000000000..80c97d1d2 --- /dev/null +++ b/crates/plano-cli/src/trace/listen.rs @@ -0,0 +1,58 @@ +use anyhow::Result; + +use crate::trace::daemon; +use crate::trace::store::TraceStore; + +/// Start the trace listener in the foreground. +pub async fn run(host: &str, port: u16) -> Result<()> { + let green = console::Style::new().green(); + let cyan = console::Style::new().cyan(); + + // Check if already running + if let Some(pid) = daemon::get_listener_pid() { + eprintln!( + "{} Trace listener already running (PID {pid})", + green.apply_to("✓") + ); + return Ok(()); + } + + eprintln!( + "{} Starting trace listener on {}", + green.apply_to("✓"), + cyan.apply_to(format!("{host}:{port}")) + ); + + // Start as a background task in this process + start_background(port).await?; + + // Write PID + daemon::write_listener_pid(std::process::id())?; + + // Wait forever (until ctrl+c) + tokio::signal::ctrl_c().await?; + + daemon::remove_listener_pid()?; + eprintln!("\nTrace listener stopped."); + Ok(()) +} + +/// Start the trace listener in the background (within the current process). +pub async fn start_background(port: u16) -> Result<()> { + let store = TraceStore::shared(); + + // TODO: Implement gRPC OTLP listener using tonic + // For now, spawn a placeholder task + let _store = store.clone(); + tokio::spawn(async move { + // The actual gRPC server will be implemented here + // using tonic with the OTLP ExportTraceServiceRequest handler + tracing::info!("Trace listener background task started on port {port}"); + // Keep running + loop { + tokio::time::sleep(std::time::Duration::from_secs(3600)).await; + } + }); + + Ok(()) +} diff --git a/crates/plano-cli/src/trace/mod.rs b/crates/plano-cli/src/trace/mod.rs new file mode 100644 index 000000000..e2646b0f6 --- /dev/null +++ b/crates/plano-cli/src/trace/mod.rs @@ -0,0 +1,6 @@ +pub mod daemon; +pub mod down; +pub mod listen; +pub mod show; +pub mod store; +pub mod tail; diff --git a/crates/plano-cli/src/trace/show.rs b/crates/plano-cli/src/trace/show.rs new file mode 100644 index 000000000..e6e83de63 --- /dev/null +++ b/crates/plano-cli/src/trace/show.rs @@ -0,0 +1,18 @@ +use anyhow::{bail, Result}; + +pub async fn run(trace_id: &str, verbose: bool) -> Result<()> { + // TODO: Connect to trace listener via gRPC and fetch trace + // For now, print a placeholder + println!("Showing trace: {trace_id}"); + if verbose { + println!("(verbose mode)"); + } + + // The full implementation will: + // 1. Connect to the gRPC trace query service + // 2. Fetch the trace by ID + // 3. Build a span tree + // 4. Render it using console styling + + bail!("Trace show is not yet fully implemented. The gRPC trace query service needs to be running.") +} diff --git a/crates/plano-cli/src/trace/store.rs b/crates/plano-cli/src/trace/store.rs new file mode 100644 index 000000000..f6694a899 --- /dev/null +++ b/crates/plano-cli/src/trace/store.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::RwLock; + +const MAX_TRACES: usize = 50; +const MAX_SPANS_PER_TRACE: usize = 500; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Span { + pub trace_id: String, + pub span_id: String, + pub parent_span_id: Option, + pub name: String, + pub start_time_unix_nano: u64, + pub end_time_unix_nano: u64, + pub status: Option, + pub attributes: HashMap, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SpanStatus { + pub code: i32, + pub message: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Trace { + pub trace_id: String, + pub spans: Vec, + pub root_span: Option, + pub start_time: u64, +} + +pub type SharedTraceStore = Arc>; + +#[derive(Debug, Default)] +pub struct TraceStore { + traces: HashMap, + trace_order: Vec, + /// Maps span_id → group_trace_id (for merging traces via parent_span_id links) + span_to_group: HashMap, +} + +impl TraceStore { + pub fn new() -> Self { + Self::default() + } + + pub fn shared() -> SharedTraceStore { + Arc::new(RwLock::new(Self::new())) + } + + pub fn add_spans(&mut self, spans: Vec) { + for span in spans { + let trace_id = span.trace_id.clone(); + + // Check if this span belongs to an existing group via parent_span_id + let group_id = span + .parent_span_id + .as_ref() + .and_then(|pid| self.span_to_group.get(pid).cloned()) + .unwrap_or_else(|| trace_id.clone()); + + // Register this span's ID in the group + self.span_to_group + .insert(span.span_id.clone(), group_id.clone()); + + let trace = self.traces.entry(group_id.clone()).or_insert_with(|| { + self.trace_order.push(group_id.clone()); + Trace { + trace_id: group_id.clone(), + spans: Vec::new(), + root_span: None, + start_time: span.start_time_unix_nano, + } + }); + + // Dedup by span_id + if trace.spans.iter().any(|s| s.span_id == span.span_id) { + continue; + } + + // Track root span + if span.parent_span_id.is_none() || span.parent_span_id.as_deref() == Some("") { + trace.root_span = Some(span.span_id.clone()); + } + + if trace.spans.len() < MAX_SPANS_PER_TRACE { + trace.spans.push(span); + } + } + + // Evict oldest traces + while self.trace_order.len() > MAX_TRACES { + if let Some(oldest) = self.trace_order.first().cloned() { + self.trace_order.remove(0); + if let Some(trace) = self.traces.remove(&oldest) { + for span in &trace.spans { + self.span_to_group.remove(&span.span_id); + } + } + } + } + } + + pub fn get_traces(&self) -> Vec<&Trace> { + self.trace_order + .iter() + .rev() + .filter_map(|id| self.traces.get(id)) + .collect() + } + + pub fn get_trace(&self, trace_id: &str) -> Option<&Trace> { + self.traces.get(trace_id) + } +} diff --git a/crates/plano-cli/src/trace/tail.rs b/crates/plano-cli/src/trace/tail.rs new file mode 100644 index 000000000..08ec9e1ed --- /dev/null +++ b/crates/plano-cli/src/trace/tail.rs @@ -0,0 +1,68 @@ +use anyhow::{bail, Result}; + +pub async fn run( + include_spans: Option<&str>, + exclude_spans: Option<&str>, + where_filters: &[String], + since: Option<&str>, + _verbose: bool, +) -> Result<()> { + // TODO: Connect to trace listener via gRPC and tail traces + // For now, print a placeholder + + println!("Tailing traces..."); + if let Some(inc) = include_spans { + println!(" include: {inc}"); + } + if let Some(exc) = exclude_spans { + println!(" exclude: {exc}"); + } + for w in where_filters { + println!(" where: {w}"); + } + if let Some(s) = since { + println!(" since: {s}"); + } + + // The full implementation will: + // 1. Connect to the gRPC trace query service + // 2. Fetch recent traces + // 3. Apply filters + // 4. Render matching traces + + bail!("Trace tail is not yet fully implemented. The gRPC trace query service needs to be running.") +} + +/// Parse a "since" string like "10s", "5m", "1h", "7d" into seconds. +pub fn parse_since_seconds(since: &str) -> Option { + let since = since.trim(); + if since.is_empty() { + return None; + } + + let (num_str, unit) = since.split_at(since.len() - 1); + let num: u64 = num_str.parse().ok()?; + + match unit { + "s" => Some(num), + "m" => Some(num * 60), + "h" => Some(num * 3600), + "d" => Some(num * 86400), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_since_seconds() { + assert_eq!(parse_since_seconds("10s"), Some(10)); + assert_eq!(parse_since_seconds("5m"), Some(300)); + assert_eq!(parse_since_seconds("1h"), Some(3600)); + assert_eq!(parse_since_seconds("7d"), Some(604800)); + assert_eq!(parse_since_seconds(""), None); + assert_eq!(parse_since_seconds("abc"), None); + } +} diff --git a/crates/plano-cli/src/utils.rs b/crates/plano-cli/src/utils.rs new file mode 100644 index 000000000..07d476ec5 --- /dev/null +++ b/crates/plano-cli/src/utils.rs @@ -0,0 +1,237 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; +use regex::Regex; + +/// Find the repository root by looking for Dockerfile + crates + config dirs. +pub fn find_repo_root() -> Option { + let mut current = std::env::current_dir().ok()?; + loop { + if current.join("Dockerfile").exists() + && current.join("crates").exists() + && current.join("config").exists() + { + return Some(current); + } + if current.join(".git").exists() && current.join("crates").exists() { + return Some(current); + } + if !current.pop() { + break; + } + } + None +} + +/// Find the appropriate config file path. +pub fn find_config_file(path: &str, file: Option<&str>) -> PathBuf { + if let Some(f) = file { + return PathBuf::from(f) + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(f)); + } + let config_yaml = Path::new(path).join("config.yaml"); + if config_yaml.exists() { + std::fs::canonicalize(&config_yaml).unwrap_or(config_yaml) + } else { + let plano_config = Path::new(path).join("plano_config.yaml"); + std::fs::canonicalize(&plano_config).unwrap_or(plano_config) + } +} + +/// Parse a .env file into a HashMap. +pub fn load_env_file(path: &Path) -> Result> { + let content = std::fs::read_to_string(path)?; + let mut map = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + map.insert(key.trim().to_string(), value.trim().to_string()); + } + } + Ok(map) +} + +/// Extract LLM provider access keys from config YAML. +pub fn get_llm_provider_access_keys(config_path: &Path) -> Result> { + let content = std::fs::read_to_string(config_path)?; + let config: serde_yaml::Value = serde_yaml::from_str(&content)?; + + let mut keys = Vec::new(); + + // Handle legacy llm_providers → model_providers + let config = if config.get("llm_providers").is_some() && config.get("model_providers").is_some() + { + bail!("Please provide either llm_providers or model_providers, not both."); + } else { + config + }; + + // Get model_providers from listeners or root + let model_providers = config + .get("model_providers") + .or_else(|| config.get("llm_providers")); + + // Check prompt_targets for authorization headers + if let Some(targets) = config.get("prompt_targets").and_then(|v| v.as_sequence()) { + for target in targets { + if let Some(headers) = target + .get("endpoint") + .and_then(|e| e.get("http_headers")) + .and_then(|h| h.as_mapping()) + { + for (k, v) in headers { + if let (Some(key), Some(val)) = (k.as_str(), v.as_str()) { + if key.to_lowercase() == "authorization" { + let tokens: Vec<&str> = val.split(' ').collect(); + if tokens.len() > 1 { + keys.push(tokens[1].to_string()); + } else { + keys.push(val.to_string()); + } + } + } + } + } + } + } + + // Get listeners to find model_providers + let listeners = config.get("listeners"); + let mp_list = if let Some(listeners) = listeners { + // Collect model_providers from listeners + let mut all_mp = Vec::new(); + if let Some(seq) = listeners.as_sequence() { + for listener in seq { + if let Some(mps) = listener + .get("model_providers") + .and_then(|v| v.as_sequence()) + { + all_mp.extend(mps.iter()); + } + } + } + // Also check root model_providers + if let Some(mps) = model_providers.and_then(|v| v.as_sequence()) { + all_mp.extend(mps.iter()); + } + all_mp + } else if let Some(mps) = model_providers.and_then(|v| v.as_sequence()) { + mps.iter().collect() + } else { + Vec::new() + }; + + for mp in &mp_list { + if let Some(key) = mp.get("access_key").and_then(|v| v.as_str()) { + keys.push(key.to_string()); + } + } + + // Extract env vars from state_storage_v1_responses.connection_string + if let Some(state_storage) = config.get("state_storage_v1_responses") { + if let Some(conn_str) = state_storage + .get("connection_string") + .and_then(|v| v.as_str()) + { + let re = Regex::new(r"\$\{?([A-Z_][A-Z0-9_]*)\}?")?; + for cap in re.captures_iter(conn_str) { + keys.push(format!("${}", &cap[1])); + } + } + } + + Ok(keys) +} + +/// Check if a TCP port is already in use. +pub fn is_port_in_use(port: u16) -> bool { + std::net::TcpListener::bind(("0.0.0.0", port)).is_err() +} + +/// Check if the native Plano is running by verifying the PID file. +pub fn is_native_plano_running() -> bool { + let pid_file = crate::consts::native_pid_file(); + if !pid_file.exists() { + return false; + } + let content = match std::fs::read_to_string(&pid_file) { + Ok(c) => c, + Err(_) => return false, + }; + let pids: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return false, + }; + let envoy_pid = pids.get("envoy_pid").and_then(|v| v.as_i64()); + let brightstaff_pid = pids.get("brightstaff_pid").and_then(|v| v.as_i64()); + + match (envoy_pid, brightstaff_pid) { + (Some(ep), Some(bp)) => is_pid_alive(ep as i32) && is_pid_alive(bp as i32), + _ => false, + } +} + +/// Check if a process is alive using kill(0). +pub fn is_pid_alive(pid: i32) -> bool { + use nix::sys::signal::kill; + use nix::unistd::Pid; + kill(Pid::from_raw(pid), None).is_ok() +} + +/// Expand environment variables ($VAR and ${VAR}) in a string. +pub fn expand_env_vars(input: &str) -> String { + let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)").unwrap(); + re.replace_all(input, |caps: ®ex::Captures| { + let var_name = caps + .get(1) + .or_else(|| caps.get(2)) + .map(|m| m.as_str()) + .unwrap_or(""); + std::env::var(var_name).unwrap_or_default() + }) + .into_owned() +} + +/// Print the CLI header with version. +pub fn print_cli_header() { + let style = console::Style::new().bold().color256(141); + let dim = console::Style::new().dim(); + println!( + "\n{} {}\n", + style.apply_to("Plano CLI"), + dim.apply_to(format!("v{}", crate::consts::PLANO_VERSION)) + ); +} + +/// Print missing API keys error. +pub fn print_missing_keys(missing_keys: &[String]) { + let red = console::Style::new().red(); + let bold = console::Style::new().bold(); + let dim = console::Style::new().dim(); + let cyan = console::Style::new().cyan(); + + println!( + "\n{} {}\n", + red.apply_to("✗"), + red.apply_to("Missing API keys!") + ); + for key in missing_keys { + println!(" {} {}", red.apply_to("•"), bold.apply_to(key)); + } + println!("\n{}", dim.apply_to("Set the environment variable(s):")); + for key in missing_keys { + println!( + " {}", + cyan.apply_to(format!("export {key}=\"your-api-key\"")) + ); + } + println!( + "\n{}\n", + dim.apply_to("Or create a .env file in the config directory.") + ); +} diff --git a/crates/plano-cli/src/version.rs b/crates/plano-cli/src/version.rs new file mode 100644 index 000000000..61c2313a7 --- /dev/null +++ b/crates/plano-cli/src/version.rs @@ -0,0 +1,89 @@ +use crate::consts::{PLANO_GITHUB_REPO, PLANO_VERSION}; + +/// Get the current CLI version. +pub fn get_version() -> &'static str { + PLANO_VERSION +} + +/// Fetch the latest version from GitHub releases. +pub async fn get_latest_version() -> Option { + let url = format!("https://api.github.com/repos/{PLANO_GITHUB_REPO}/releases/latest"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .ok()?; + + let resp = client + .get(&url) + .header("User-Agent", "plano-cli") + .send() + .await + .ok()?; + + let json: serde_json::Value = resp.json().await.ok()?; + let tag = json.get("tag_name")?.as_str()?; + // Strip leading 'v' if present + Some(tag.strip_prefix('v').unwrap_or(tag).to_string()) +} + +/// Check if current version is outdated. +pub fn check_version_status(current: &str, latest: Option<&str>) -> VersionStatus { + let Some(latest) = latest else { + return VersionStatus { + is_outdated: false, + latest: None, + }; + }; + + let current_parts = parse_version(current); + let latest_parts = parse_version(latest); + + VersionStatus { + is_outdated: latest_parts > current_parts, + latest: Some(latest.to_string()), + } +} + +pub struct VersionStatus { + pub is_outdated: bool, + pub latest: Option, +} + +fn parse_version(v: &str) -> Vec { + v.split('.') + .filter_map(|s| { + // Handle pre-release suffixes like "1a1" + let numeric: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + numeric.parse().ok() + }) + .collect() +} + +/// Maybe check for updates and print a message. +pub async fn maybe_check_updates() { + if std::env::var("PLANO_SKIP_VERSION_CHECK").is_ok() { + return; + } + + let current = get_version(); + if let Some(latest) = get_latest_version().await { + let status = check_version_status(current, Some(&latest)); + if status.is_outdated { + let yellow = console::Style::new().yellow(); + let bold = console::Style::new().bold(); + let dim = console::Style::new().dim(); + println!( + "\n{} {}", + yellow.apply_to("⚠ Update available:"), + bold.apply_to(&latest) + ); + println!( + "{}", + dim.apply_to("Run: cargo install plano-cli (or download from GitHub releases)") + ); + } else { + let dim = console::Style::new().dim(); + println!("{}", dim.apply_to("✓ You're up to date")); + } + } +} diff --git a/crates/plano-cli/templates/coding_agent_routing.yaml b/crates/plano-cli/templates/coding_agent_routing.yaml new file mode 100644 index 000000000..b99994c97 --- /dev/null +++ b/crates/plano-cli/templates/coding_agent_routing.yaml @@ -0,0 +1,41 @@ +version: v0.3.0 + +model_providers: + # OpenAI Models + - model: openai/gpt-5-2025-08-07 + access_key: $OPENAI_API_KEY + routing_preferences: + - name: code generation + description: generating new code snippets, functions, or boilerplate based on user prompts or requirements + + - model: openai/gpt-4.1-2025-04-14 + access_key: $OPENAI_API_KEY + routing_preferences: + - name: code understanding + description: understand and explain existing code snippets, functions, or libraries + # Anthropic Models + - model: anthropic/claude-sonnet-4-5 + default: true + access_key: $ANTHROPIC_API_KEY + + - model: anthropic/claude-haiku-4-5 + access_key: $ANTHROPIC_API_KEY + + # Ollama Models + - model: ollama/llama3.1 + base_url: http://localhost:11434 + + +# Model aliases - friendly names that map to actual provider names +model_aliases: + # Alias for a small faster Claude model + arch.claude.code.small.fast: + target: claude-haiku-4-5 + +listeners: + - type: model + name: model_listener + port: 12000 + +tracing: + random_sampling: 100 diff --git a/crates/plano-cli/templates/conversational_state.yaml b/crates/plano-cli/templates/conversational_state.yaml new file mode 100644 index 000000000..403278a99 --- /dev/null +++ b/crates/plano-cli/templates/conversational_state.yaml @@ -0,0 +1,36 @@ +version: v0.3.0 + +agents: + - id: assistant + url: http://localhost:10510 + +model_providers: + # OpenAI Models + - model: openai/gpt-5-mini-2025-08-07 + access_key: $OPENAI_API_KEY + default: true + + # Anthropic Models + - model: anthropic/claude-sonnet-4-20250514 + access_key: $ANTHROPIC_API_KEY + +listeners: + - type: agent + name: conversation_service + port: 8001 + router: plano_orchestrator_v1 + agents: + - id: assistant + description: | + A conversational assistant that maintains context across multi-turn + conversations. It can answer follow-up questions, remember previous + context, and provide coherent responses in ongoing dialogues. + +# State storage configuration for v1/responses API +# Manages conversation state for multi-turn conversations +state_storage: + # Type: memory | postgres + type: memory + +tracing: + random_sampling: 100 diff --git a/crates/plano-cli/templates/filter_chain_guardrails.yaml b/crates/plano-cli/templates/filter_chain_guardrails.yaml new file mode 100644 index 000000000..117931e29 --- /dev/null +++ b/crates/plano-cli/templates/filter_chain_guardrails.yaml @@ -0,0 +1,50 @@ +version: v0.3.0 + +agents: + - id: rag_agent + url: http://rag-agents:10505 + +filters: + - id: input_guards + url: http://rag-agents:10500 + type: http + # type: mcp (default) + # transport: streamable-http (default) + # tool: input_guards (default - same as filter id) + - id: query_rewriter + url: http://rag-agents:10501 + type: http + # type: mcp (default) + # transport: streamable-http (default) + # tool: query_rewriter (default - same as filter id) + - id: context_builder + url: http://rag-agents:10502 + type: http + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + +model_aliases: + fast-llm: + target: gpt-4o-mini + smart-llm: + target: gpt-4o + +listeners: + - type: agent + name: agent_1 + port: 8001 + router: plano_orchestrator_v1 + agents: + - id: rag_agent + description: virtual assistant for retrieval augmented generation tasks + filter_chain: + - input_guards + - query_rewriter + - context_builder +tracing: + random_sampling: 100 diff --git a/crates/plano-cli/templates/preference_aware_routing.yaml b/crates/plano-cli/templates/preference_aware_routing.yaml new file mode 100644 index 000000000..e38b38815 --- /dev/null +++ b/crates/plano-cli/templates/preference_aware_routing.yaml @@ -0,0 +1,27 @@ +version: v0.3.0 + +model_providers: + + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true + + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + routing_preferences: + - name: code understanding + description: understand and explain existing code snippets, functions, or libraries + + - model: anthropic/claude-sonnet-4-20250514 + access_key: $ANTHROPIC_API_KEY + routing_preferences: + - name: code generation + description: generating new code snippets, functions, or boilerplate based on user prompts or requirements + +listeners: + - type: model + name: model_listener + port: 12000 + +tracing: + random_sampling: 100 diff --git a/crates/plano-cli/templates/sub_agent_orchestration.yaml b/crates/plano-cli/templates/sub_agent_orchestration.yaml new file mode 100644 index 000000000..b3a204f3a --- /dev/null +++ b/crates/plano-cli/templates/sub_agent_orchestration.yaml @@ -0,0 +1,57 @@ +version: v0.3.0 + +agents: + - id: weather_agent + url: http://langchain-weather-agent:10510 + - id: flight_agent + url: http://crewai-flight-agent:10520 + +model_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + default: true + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY # smaller, faster, cheaper model for extracting entities like location + +listeners: + - type: agent + name: travel_booking_service + port: 8001 + router: plano_orchestrator_v1 + agents: + - id: weather_agent + description: | + + WeatherAgent is a specialized AI assistant for real-time weather information and forecasts. It provides accurate weather data for any city worldwide using the Open-Meteo API, helping travelers plan their trips with up-to-date weather conditions. + + Capabilities: + * Get real-time weather conditions and multi-day forecasts for any city worldwide using Open-Meteo API (free, no API key needed) + * Provides current temperature + * Provides multi-day forecasts + * Provides weather conditions + * Provides sunrise/sunset times + * Provides detailed weather information + * Understands conversation context to resolve location references from previous messages + * Handles weather-related questions including "What's the weather in [city]?", "What's the forecast for [city]?", "How's the weather in [city]?" + * When queries include both weather and other travel questions (e.g., flights, currency), this agent answers ONLY the weather part + + - id: flight_agent + description: | + + FlightAgent is an AI-powered tool specialized in providing live flight information between airports. It leverages the FlightAware AeroAPI to deliver real-time flight status, gate information, and delay updates. + + Capabilities: + * Get live flight information between airports using FlightAware AeroAPI + * Shows real-time flight status + * Shows scheduled/estimated/actual departure and arrival times + * Shows gate and terminal information + * Shows delays + * Shows aircraft type + * Shows flight status + * Automatically resolves city names to airport codes (IATA/ICAO) + * Understands conversation context to infer origin/destination from follow-up questions + * Handles flight-related questions including "What flights go from [city] to [city]?", "Do flights go to [city]?", "Are there direct flights from [city]?" + * When queries include both flight and other travel questions (e.g., weather, currency), this agent answers ONLY the flight part + +tracing: + random_sampling: 100 From 1e6f38f772ebd8b551199b93cf7ca09972eb8f64 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:02:58 +0000 Subject: [PATCH 02/20] fix validate-config CI job to use Python (script unchanged) --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b888adcf7..6e34f40f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,12 +133,13 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" - - name: Build plano CLI - working-directory: ./crates - run: cargo build --release -p plano-cli + - name: Install planoai + run: pip install -e ./cli - name: Validate plano config run: bash config/validate_plano_config.sh From eb30c65796b8469f174bb3901e20ca9119270b42 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:05:13 +0000 Subject: [PATCH 03/20] add install.sh script and self-update command --- crates/plano-cli/src/commands/mod.rs | 10 ++ crates/plano-cli/src/commands/self_update.rs | 139 +++++++++++++++++++ install.sh | 132 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 crates/plano-cli/src/commands/self_update.rs create mode 100755 install.sh diff --git a/crates/plano-cli/src/commands/mod.rs b/crates/plano-cli/src/commands/mod.rs index 188b577fc..380f2a646 100644 --- a/crates/plano-cli/src/commands/mod.rs +++ b/crates/plano-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod cli_agent; pub mod down; pub mod init; pub mod logs; +pub mod self_update; pub mod up; use clap::{Parser, Subcommand}; @@ -132,6 +133,14 @@ pub enum Command { #[arg(long)] list_templates: bool, }, + + /// Update planoai to the latest version + #[command(name = "self-update")] + SelfUpdate { + /// Update to a specific version instead of latest + #[arg(long)] + version: Option, + }, } #[derive(Subcommand)] @@ -252,6 +261,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { force, list_templates, }) => init::run(template, clean, output, force, list_templates).await, + Some(Command::SelfUpdate { version }) => self_update::run(version.as_deref()).await, } } diff --git a/crates/plano-cli/src/commands/self_update.rs b/crates/plano-cli/src/commands/self_update.rs new file mode 100644 index 000000000..6d9b28ea1 --- /dev/null +++ b/crates/plano-cli/src/commands/self_update.rs @@ -0,0 +1,139 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use anyhow::{bail, Result}; + +use crate::consts::{PLANO_GITHUB_REPO, PLANO_VERSION}; + +pub async fn run(target_version: Option<&str>) -> Result<()> { + let green = console::Style::new().green(); + let bold = console::Style::new().bold(); + let dim = console::Style::new().dim(); + let cyan = console::Style::new().cyan(); + + println!( + "\n{} {}", + bold.apply_to("planoai"), + dim.apply_to("self-update") + ); + + // Determine target version + let version = if let Some(v) = target_version { + v.to_string() + } else { + println!(" {}", dim.apply_to("Checking for latest version...")); + fetch_latest_version() + .await? + .ok_or_else(|| anyhow::anyhow!("Could not determine latest version"))? + }; + + let current = PLANO_VERSION; + if version == current && target_version.is_none() { + println!( + "\n {} Already up to date ({})", + green.apply_to("✓"), + cyan.apply_to(current) + ); + return Ok(()); + } + + println!( + " {} → {}", + dim.apply_to(format!("Current: {current}")), + cyan.apply_to(&version) + ); + + // Detect platform + let platform = get_platform_slug()?; + + // Download URL + let url = format!( + "https://github.com/{PLANO_GITHUB_REPO}/releases/download/{version}/planoai-{platform}.gz" + ); + + println!(" {}", dim.apply_to(format!("Downloading from {url}..."))); + + let client = reqwest::Client::new(); + let resp = client.get(&url).send().await?; + + if !resp.status().is_success() { + bail!( + "Download failed: HTTP {}. Version {} may not exist for platform {}.", + resp.status(), + version, + platform + ); + } + + let gz_bytes = resp.bytes().await?; + + // Decompress + let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]); + let mut binary_data = Vec::new(); + std::io::copy(&mut decoder, &mut binary_data)?; + + // Find current binary path + let current_exe = std::env::current_exe()?; + let exe_path = current_exe.canonicalize()?; + + println!( + " {}", + dim.apply_to(format!("Installing to {}", exe_path.display())) + ); + + // Write to a temp file next to the binary, then atomically rename + let tmp_path = exe_path.with_extension("update-tmp"); + fs::write(&tmp_path, &binary_data)?; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))?; + + // Atomic replace + fs::rename(&tmp_path, &exe_path)?; + + println!( + "\n {} Updated planoai to {}\n", + green.apply_to("✓"), + bold.apply_to(&version) + ); + + Ok(()) +} + +async fn fetch_latest_version() -> Result> { + let url = format!("https://api.github.com/repos/{PLANO_GITHUB_REPO}/releases/latest"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let resp = client + .get(&url) + .header("User-Agent", "planoai-cli") + .send() + .await?; + + if !resp.status().is_success() { + return Ok(None); + } + + let json: serde_json::Value = resp.json().await?; + let tag = json + .get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.strip_prefix('v').unwrap_or(s).to_string()); + + Ok(tag) +} + +fn get_platform_slug() -> Result<&'static str> { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => Ok("linux-amd64"), + ("linux", "aarch64") => Ok("linux-arm64"), + ("macos", "aarch64") => Ok("darwin-arm64"), + ("macos", "x86_64") => { + bail!("macOS x86_64 (Intel) is not supported.") + } + _ => bail!("Unsupported platform: {os}/{arch}"), + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 000000000..019bcfbe6 --- /dev/null +++ b/install.sh @@ -0,0 +1,132 @@ +#!/bin/bash +set -euo pipefail + +# Plano CLI installer +# Usage: curl -fsSL https://raw.githubusercontent.com/katanemo/plano/main/install.sh | bash + +REPO="katanemo/archgw" +BINARY_NAME="planoai" +INSTALL_DIR="${PLANO_INSTALL_DIR:-$HOME/.plano/bin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${GREEN}✓${RESET} $*"; } +error() { echo -e "${RED}✗${RESET} $*" >&2; } +dim() { echo -e "${DIM}$*${RESET}"; } + +# Detect platform +detect_platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) error "Unsupported OS: $os"; exit 1 ;; + esac + + case "$arch" in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + arm64) arch="arm64" ;; + *) error "Unsupported architecture: $arch"; exit 1 ;; + esac + + if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then + error "macOS x86_64 (Intel) is not supported. Pre-built binaries are only available for Apple Silicon (arm64)." + exit 1 + fi + + echo "${os}-${arch}" +} + +# Get latest version from GitHub releases +get_latest_version() { + local version + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"([^"]+)".*/\1/') + echo "$version" +} + +main() { + echo -e "\n${BOLD}Plano CLI Installer${RESET}\n" + + # Detect platform + local platform + platform="$(detect_platform)" + dim " Platform: $platform" + + # Get version + local version="${PLANO_VERSION:-}" + if [ -z "$version" ]; then + dim " Fetching latest version..." + version="$(get_latest_version)" + fi + if [ -z "$version" ]; then + error "Could not determine version. Set PLANO_VERSION or check your internet connection." + exit 1 + fi + dim " Version: $version" + + # Download URL + local url="https://github.com/${REPO}/releases/download/${version}/planoai-${platform}.gz" + dim " URL: $url" + echo "" + + # Create install directory + mkdir -p "$INSTALL_DIR" + + # Download and extract + local tmp_gz + tmp_gz="$(mktemp)" + echo -e " ${DIM}Downloading planoai ${version}...${RESET}" + + if ! curl -fSL --progress-bar "$url" -o "$tmp_gz"; then + error "Download failed. Check that version $version exists for platform $platform." + rm -f "$tmp_gz" + exit 1 + fi + + # Decompress + echo -e " ${DIM}Installing to ${INSTALL_DIR}/${BINARY_NAME}...${RESET}" + gzip -d -c "$tmp_gz" > "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + rm -f "$tmp_gz" + + info "Installed planoai ${version} to ${INSTALL_DIR}/${BINARY_NAME}" + + # Check if install dir is in PATH + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + echo "" + echo -e " ${CYAN}Add to your PATH:${RESET}" + local shell_name + shell_name="$(basename "${SHELL:-/bin/bash}")" + local rc_file + case "$shell_name" in + zsh) rc_file="$HOME/.zshrc" ;; + fish) rc_file="$HOME/.config/fish/config.fish" ;; + *) rc_file="$HOME/.bashrc" ;; + esac + + if [ "$shell_name" = "fish" ]; then + echo -e " ${BOLD}set -gx PATH ${INSTALL_DIR} \$PATH${RESET}" + else + echo -e " ${BOLD}export PATH=\"${INSTALL_DIR}:\$PATH\"${RESET}" + fi + echo -e " ${DIM}Add this line to ${rc_file} to make it permanent.${RESET}" + fi + + echo "" + info "Run ${BOLD}planoai --help${RESET} to get started." + echo "" +} + +main "$@" From 6efb152ceca1c49d6028408eacf5451ae3c0ebf8 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:09:56 +0000 Subject: [PATCH 04/20] add validate command, remove Python dependency from CI --- .github/workflows/ci.yml | 11 ++++---- config/validate_plano_config.sh | 8 +----- crates/plano-cli/src/commands/mod.rs | 12 +++++++++ crates/plano-cli/src/commands/validate.rs | 31 +++++++++++++++++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 crates/plano-cli/src/commands/validate.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e34f40f6..3aa142b39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,13 +133,12 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - - name: Install planoai - run: pip install -e ./cli + - name: Build planoai CLI + working-directory: ./crates + run: cargo build --release -p plano-cli - name: Validate plano config run: bash config/validate_plano_config.sh diff --git a/config/validate_plano_config.sh b/config/validate_plano_config.sh index 572ac2ecc..e65bf5c2b 100644 --- a/config/validate_plano_config.sh +++ b/config/validate_plano_config.sh @@ -8,13 +8,7 @@ for file in $(find . -name config.yaml -o -name plano_config_full_reference.yaml rendered_file="$(pwd)/${file}_rendered" touch "$rendered_file" - PLANO_CONFIG_FILE="$(pwd)/${file}" \ - PLANO_CONFIG_SCHEMA_FILE="${SCRIPT_DIR}/plano_config_schema.yaml" \ - TEMPLATE_ROOT="${SCRIPT_DIR}" \ - ENVOY_CONFIG_TEMPLATE_FILE="envoy.template.yaml" \ - PLANO_CONFIG_FILE_RENDERED="$rendered_file" \ - ENVOY_CONFIG_FILE_RENDERED="/dev/null" \ - python -m planoai.config_generator 2>&1 > /dev/null + planoai validate "$(pwd)/${file}" 2>&1 > /dev/null if [ $? -ne 0 ]; then echo "Validation failed for $file" diff --git a/crates/plano-cli/src/commands/mod.rs b/crates/plano-cli/src/commands/mod.rs index 380f2a646..129a89062 100644 --- a/crates/plano-cli/src/commands/mod.rs +++ b/crates/plano-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod init; pub mod logs; pub mod self_update; pub mod up; +pub mod validate; use clap::{Parser, Subcommand}; @@ -134,6 +135,16 @@ pub enum Command { list_templates: bool, }, + /// Validate a Plano configuration file + Validate { + /// Config file path + file: Option, + + /// Path to the directory containing config.yaml + #[arg(long, default_value = ".")] + path: String, + }, + /// Update planoai to the latest version #[command(name = "self-update")] SelfUpdate { @@ -261,6 +272,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { force, list_templates, }) => init::run(template, clean, output, force, list_templates).await, + Some(Command::Validate { file, path }) => validate::run(file, &path).await, Some(Command::SelfUpdate { version }) => self_update::run(version.as_deref()).await, } } diff --git a/crates/plano-cli/src/commands/validate.rs b/crates/plano-cli/src/commands/validate.rs new file mode 100644 index 000000000..d1bdc9a6e --- /dev/null +++ b/crates/plano-cli/src/commands/validate.rs @@ -0,0 +1,31 @@ +use anyhow::Result; + +use crate::native::runner::validate_config; +use crate::utils::find_config_file; + +pub async fn run(file: Option, path: &str) -> Result<()> { + let green = console::Style::new().green(); + let red = console::Style::new().red(); + + let config_path = find_config_file(path, file.as_deref()); + if !config_path.exists() { + eprintln!( + "{} Config file not found: {}", + red.apply_to("✗"), + config_path.display() + ); + std::process::exit(1); + } + + match validate_config(&config_path) { + Ok(()) => { + eprintln!("{} Configuration valid", green.apply_to("✓")); + Ok(()) + } + Err(e) => { + eprintln!("{} Validation failed", red.apply_to("✗")); + eprintln!(" {e:#}"); + std::process::exit(1); + } + } +} From 5a1de47e2cd73d30087b214417f573487b836a1a Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:15:39 +0000 Subject: [PATCH 05/20] build CLI once, share binary via artifact across CI jobs --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3aa142b39..00fa35edc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,24 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Run plano-cli tests + - name: Build and test plano-cli working-directory: ./crates - run: cargo test -p plano-cli + run: | + cargo test -p plano-cli + cargo build --release -p plano-cli + + - name: Upload planoai binary + uses: actions/upload-artifact@v6 + with: + name: planoai-binary + path: crates/target/release/planoai + retention-days: 1 # ────────────────────────────────────────────── # Native mode smoke test — build from source & start natively # ────────────────────────────────────────────── native-smoke-test: + needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code @@ -53,10 +63,18 @@ jobs: with: targets: wasm32-wasip1 - - name: Build plano CLI and native binaries + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: crates/target/release/ + + - name: Make binary executable + run: chmod +x crates/target/release/planoai + + - name: Build native binaries working-directory: ./crates run: | - cargo build --release -p plano-cli cargo build --release -p brightstaff cargo build --release --target wasm32-wasip1 -p llm_gateway -p prompt_gateway @@ -128,20 +146,25 @@ jobs: # Validate plano config # ────────────────────────────────────────────── validate-config: + needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: crates/target/release/ - - name: Build planoai CLI - working-directory: ./crates - run: cargo build --release -p plano-cli + - name: Make binary executable + run: chmod +x crates/target/release/planoai - name: Validate plano config - run: bash config/validate_plano_config.sh + run: | + export PATH="$PWD/crates/target/release:$PATH" + bash config/validate_plano_config.sh # ────────────────────────────────────────────── # Docker security scan (Trivy) From a0e0f0ffdaae7900c457b5a3497e763854dde57d Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:25:03 +0000 Subject: [PATCH 06/20] revert validate-config to Python (Tera indent() compat needs fixing) --- .github/workflows/ci.yml | 16 ++++++---------- config/validate_plano_config.sh | 8 +++++++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00fa35edc..d00fb8e4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,25 +146,21 @@ jobs: # Validate plano config # ────────────────────────────────────────────── validate-config: - needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Download planoai binary - uses: actions/download-artifact@v6 + - name: Set up Python + uses: actions/setup-python@v6 with: - name: planoai-binary - path: crates/target/release/ + python-version: "3.14" - - name: Make binary executable - run: chmod +x crates/target/release/planoai + - name: Install planoai + run: pip install -e ./cli - name: Validate plano config - run: | - export PATH="$PWD/crates/target/release:$PATH" - bash config/validate_plano_config.sh + run: bash config/validate_plano_config.sh # ────────────────────────────────────────────── # Docker security scan (Trivy) diff --git a/config/validate_plano_config.sh b/config/validate_plano_config.sh index e65bf5c2b..572ac2ecc 100644 --- a/config/validate_plano_config.sh +++ b/config/validate_plano_config.sh @@ -8,7 +8,13 @@ for file in $(find . -name config.yaml -o -name plano_config_full_reference.yaml rendered_file="$(pwd)/${file}_rendered" touch "$rendered_file" - planoai validate "$(pwd)/${file}" 2>&1 > /dev/null + PLANO_CONFIG_FILE="$(pwd)/${file}" \ + PLANO_CONFIG_SCHEMA_FILE="${SCRIPT_DIR}/plano_config_schema.yaml" \ + TEMPLATE_ROOT="${SCRIPT_DIR}" \ + ENVOY_CONFIG_TEMPLATE_FILE="envoy.template.yaml" \ + PLANO_CONFIG_FILE_RENDERED="$rendered_file" \ + ENVOY_CONFIG_FILE_RENDERED="/dev/null" \ + python -m planoai.config_generator 2>&1 > /dev/null if [ $? -ne 0 ]; then echo "Validation failed for $file" From b1486a9c6821a769ff627d4ebfaa89980da7ec80 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:37:57 +0000 Subject: [PATCH 07/20] revert native-smoke-test to Python CLI (Tera template compat pending) --- .github/workflows/ci.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d00fb8e4f..66d3cd06d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,36 +52,37 @@ jobs: # Native mode smoke test — build from source & start natively # ────────────────────────────────────────────── native-smoke-test: - needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: wasm32-wasip1 - - name: Download planoai binary - uses: actions/download-artifact@v6 - with: - name: planoai-binary - path: crates/target/release/ - - - name: Make binary executable - run: chmod +x crates/target/release/planoai + - name: Install planoai CLI + working-directory: ./cli + run: | + uv sync + uv tool install . - name: Build native binaries - working-directory: ./crates - run: | - cargo build --release -p brightstaff - cargo build --release --target wasm32-wasip1 -p llm_gateway -p prompt_gateway + run: planoai build - name: Start plano natively env: OPENAI_API_KEY: test-key-not-used - run: ./crates/target/release/planoai up tests/e2e/config_native_smoke.yaml + run: planoai up tests/e2e/config_native_smoke.yaml - name: Health check run: | @@ -99,7 +100,7 @@ jobs: - name: Stop plano if: always() - run: ./crates/target/release/planoai down || true + run: planoai down || true # ────────────────────────────────────────────── # Single Docker build — shared by all downstream jobs From b7fd7771cdd4a9818520b946455cc0a624de3058 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:44:36 +0000 Subject: [PATCH 08/20] fix Docker build: add plano-cli to workspace manifest in Dockerfile --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ad0ca7079..2d6c804f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ COPY crates/hermesllm/Cargo.toml hermesllm/Cargo.toml COPY crates/prompt_gateway/Cargo.toml prompt_gateway/Cargo.toml COPY crates/llm_gateway/Cargo.toml llm_gateway/Cargo.toml COPY crates/brightstaff/Cargo.toml brightstaff/Cargo.toml +COPY crates/plano-cli/Cargo.toml plano-cli/Cargo.toml # Dummy sources to pre-compile dependencies RUN mkdir -p common/src && echo "" > common/src/lib.rs && \ @@ -19,7 +20,8 @@ RUN mkdir -p common/src && echo "" > common/src/lib.rs && \ mkdir -p hermesllm/src/bin && echo "fn main() {}" > hermesllm/src/bin/fetch_models.rs && \ mkdir -p prompt_gateway/src && echo "#[no_mangle] pub fn _start() {}" > prompt_gateway/src/lib.rs && \ mkdir -p llm_gateway/src && echo "#[no_mangle] pub fn _start() {}" > llm_gateway/src/lib.rs && \ - mkdir -p brightstaff/src && echo "fn main() {}" > brightstaff/src/main.rs && echo "" > brightstaff/src/lib.rs + mkdir -p brightstaff/src && echo "fn main() {}" > brightstaff/src/main.rs && echo "" > brightstaff/src/lib.rs && \ + mkdir -p plano-cli/src && echo "fn main() {}" > plano-cli/src/main.rs RUN cargo build --release --target wasm32-wasip1 -p prompt_gateway -p llm_gateway || true RUN cargo build --release -p brightstaff || true From cc896bf20ff3c19ccf1cf94edb46ddc9f9888a6c Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Sun, 22 Mar 2026 23:53:57 +0000 Subject: [PATCH 09/20] fix Tera template compat, remove Python from CI entirely --- .github/workflows/ci.yml | 44 ++++++++++++------------ config/validate_plano_config.sh | 8 +---- crates/plano-cli/src/config/generator.rs | 31 +++++++++++++++++ 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66d3cd06d..8a2cf7d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,37 +52,33 @@ jobs: # Native mode smoke test — build from source & start natively # ────────────────────────────────────────────── native-smoke-test: + needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: wasm32-wasip1 - - name: Install planoai CLI - working-directory: ./cli - run: | - uv sync - uv tool install . + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: crates/target/release/ + + - name: Make binary executable + run: chmod +x crates/target/release/planoai - name: Build native binaries - run: planoai build + run: crates/target/release/planoai build - name: Start plano natively env: OPENAI_API_KEY: test-key-not-used - run: planoai up tests/e2e/config_native_smoke.yaml + run: crates/target/release/planoai up tests/e2e/config_native_smoke.yaml - name: Health check run: | @@ -100,7 +96,7 @@ jobs: - name: Stop plano if: always() - run: planoai down || true + run: crates/target/release/planoai down || true # ────────────────────────────────────────────── # Single Docker build — shared by all downstream jobs @@ -147,21 +143,25 @@ jobs: # Validate plano config # ────────────────────────────────────────────── validate-config: + needs: plano-cli-tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Download planoai binary + uses: actions/download-artifact@v6 with: - python-version: "3.14" + name: planoai-binary + path: crates/target/release/ - - name: Install planoai - run: pip install -e ./cli + - name: Make binary executable + run: chmod +x crates/target/release/planoai - name: Validate plano config - run: bash config/validate_plano_config.sh + run: | + export PATH="$PWD/crates/target/release:$PATH" + bash config/validate_plano_config.sh # ────────────────────────────────────────────── # Docker security scan (Trivy) diff --git a/config/validate_plano_config.sh b/config/validate_plano_config.sh index 572ac2ecc..e65bf5c2b 100644 --- a/config/validate_plano_config.sh +++ b/config/validate_plano_config.sh @@ -8,13 +8,7 @@ for file in $(find . -name config.yaml -o -name plano_config_full_reference.yaml rendered_file="$(pwd)/${file}_rendered" touch "$rendered_file" - PLANO_CONFIG_FILE="$(pwd)/${file}" \ - PLANO_CONFIG_SCHEMA_FILE="${SCRIPT_DIR}/plano_config_schema.yaml" \ - TEMPLATE_ROOT="${SCRIPT_DIR}" \ - ENVOY_CONFIG_TEMPLATE_FILE="envoy.template.yaml" \ - PLANO_CONFIG_FILE_RENDERED="$rendered_file" \ - ENVOY_CONFIG_FILE_RENDERED="/dev/null" \ - python -m planoai.config_generator 2>&1 > /dev/null + planoai validate "$(pwd)/${file}" 2>&1 > /dev/null if [ $? -ne 0 ]; then echo "Validation failed for $file" diff --git a/crates/plano-cli/src/config/generator.rs b/crates/plano-cli/src/config/generator.rs index a06e97ba5..4ef375fbd 100644 --- a/crates/plano-cli/src/config/generator.rs +++ b/crates/plano-cli/src/config/generator.rs @@ -266,6 +266,10 @@ pub fn validate_and_render( // Override inferred clusters with endpoints for (name, details) in &endpoints { let mut cluster = details.clone(); + // Ensure protocol is always set + if cluster.get("protocol").is_none() { + cluster["protocol"] = json!("https"); + } if cluster.get("port").is_none() { let ep = cluster .get("endpoint") @@ -279,6 +283,10 @@ pub fn validate_and_render( cluster["endpoint"] = json!(endpoint); cluster["port"] = json!(port); } + // Ensure connect_timeout is set + if cluster.get("connect_timeout").is_none() { + cluster["connect_timeout"] = json!("5s"); + } inferred_clusters.insert(name.clone(), cluster); } @@ -702,6 +710,29 @@ pub fn validate_and_render( let mut tera = tera::Tera::default(); let template_content = std::fs::read_to_string(template_path)?; + // Convert Jinja2 syntax to Tera syntax + // indent(N) → indent(width=N) + let template_content = regex::Regex::new(r"indent\((\d+)\)") + .unwrap() + .replace_all(&template_content, "indent(width=$1)") + .to_string(); + // var.split(":") | first → var | split(pat=":") | first + let template_content = regex::Regex::new(r#"(\w+)\.split\("([^"]+)"\)"#) + .unwrap() + .replace_all(&template_content, r#"$1 | split(pat="$2")"#) + .to_string(); + // default('value') → default(value='value') + let template_content = regex::Regex::new(r"default\('([^']+)'\)") + .unwrap() + .replace_all(&template_content, "default(value='$1')") + .to_string(); + // replace(" ", "_") → replace(from=" ", to="_") + let template_content = regex::Regex::new(r#"replace\("([^"]*)",\s*"([^"]*)"\)"#) + .unwrap() + .replace_all(&template_content, r#"replace(from="$1", to="$2")"#) + .to_string(); + // dict.items() → dict (Tera iterates dicts directly) + let template_content = template_content.replace(".items()", ""); tera.add_raw_template(template_filename, &template_content)?; let mut context = tera::Context::new(); From f63d86f74d1ff0ccae07f4f16ab7c6f7d3978bf4 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 00:04:14 +0000 Subject: [PATCH 10/20] simplify validate script, drop rendered file diff check --- config/validate_plano_config.sh | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/config/validate_plano_config.sh b/config/validate_plano_config.sh index e65bf5c2b..48117e169 100644 --- a/config/validate_plano_config.sh +++ b/config/validate_plano_config.sh @@ -5,8 +5,6 @@ failed_files=() for file in $(find . -name config.yaml -o -name plano_config_full_reference.yaml); do echo "Validating ${file}..." - rendered_file="$(pwd)/${file}_rendered" - touch "$rendered_file" planoai validate "$(pwd)/${file}" 2>&1 > /dev/null @@ -14,17 +12,6 @@ for file in $(find . -name config.yaml -o -name plano_config_full_reference.yaml echo "Validation failed for $file" failed_files+=("$file") fi - - RENDERED_CHECKED_IN_FILE=$(echo $file | sed 's/\.yaml$/_rendered.yaml/') - if [ -f "$RENDERED_CHECKED_IN_FILE" ]; then - echo "Checking rendered file against checked-in version..." - if ! diff -q "$rendered_file" "$RENDERED_CHECKED_IN_FILE" > /dev/null; then - echo "Rendered file $rendered_file does not match checked-in version ${RENDERED_CHECKED_IN_FILE}" - failed_files+=("$rendered_file") - else - echo "Rendered file matches checked-in version." - fi - fi done # Print summary of failed files From 0ee45e7084c5a3ec695bee8eac520b784b5ce82f Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 00:20:41 +0000 Subject: [PATCH 11/20] update README with install.sh instructions --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index db3985078..2fa9db8c1 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,11 @@ async def chat(request: Request): ### 3. Start Plano & Query Your Agents -**Prerequisites:** Follow the [prerequisites guide](https://docs.planoai.dev/get_started/quickstart.html#prerequisites) to install Plano and set up your environment. +**Prerequisites:** Install the Plano CLI: + +```bash +curl -fsSL https://raw.githubusercontent.com/katanemo/plano/main/install.sh | bash +``` ```bash # Start Plano From 944cddd9a6c2e3af1c07f5df1ad89c0429cdffce Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 00:22:30 +0000 Subject: [PATCH 12/20] update quickstart with install.sh instructions --- docs/source/get_started/quickstart.rst | 27 +++++--------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/docs/source/get_started/quickstart.rst b/docs/source/get_started/quickstart.rst index 6edd3c731..e002a3adb 100644 --- a/docs/source/get_started/quickstart.rst +++ b/docs/source/get_started/quickstart.rst @@ -17,10 +17,9 @@ Follow this guide to learn how to quickly set up Plano and integrate it into you Prerequisites ------------- -Plano runs **natively** by default — no Docker or Rust toolchain required. Pre-compiled binaries are downloaded automatically on first run. +Plano runs **natively** by default — no Docker or Python required. Pre-compiled binaries are downloaded automatically on first run. -1. `Python `_ (v3.10+) -2. Supported platforms: Linux (x86_64, aarch64), macOS (Apple Silicon) +Supported platforms: Linux (x86_64, aarch64), macOS (Apple Silicon) **Docker mode** (optional): @@ -29,29 +28,13 @@ If you prefer to run inside Docker, add ``--docker`` to ``planoai up`` / ``plano 1. `Docker System `_ (v24) 2. `Docker Compose `_ (v2.29) -Plano's CLI allows you to manage and interact with the Plano efficiently. To install the CLI, simply run the following command: - -.. tip:: - - We recommend using **uv** for fast, reliable Python package management. Install uv if you haven't already: - - .. code-block:: console - - $ curl -LsSf https://astral.sh/uv/install.sh | sh - -**Option 1: Install planoai with uv (Recommended)** +**Install the Plano CLI** .. code-block:: console - $ uv tool install planoai==0.4.14 - -**Option 2: Install with pip (Traditional)** - -.. code-block:: console + $ curl -fsSL https://raw.githubusercontent.com/katanemo/plano/main/install.sh | bash - $ python -m venv venv - $ source venv/bin/activate # On Windows, use: venv\Scripts\activate - $ pip install planoai==0.4.14 +This downloads the latest ``planoai`` binary and installs it to ``~/.plano/bin/``. Follow the on-screen instructions to add it to your PATH. .. _llm_routing_quickstart: From e4af53a8aee7a620c0e14216f1825b848a24a6c3 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 00:43:34 +0000 Subject: [PATCH 13/20] add dev install instructions to quickstart and contributing guide --- CONTRIBUTING.md | 15 +++++++++------ docs/source/get_started/quickstart.rst | 7 +++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f9b0b9cc..c1f570de5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,18 +61,21 @@ The pre-commit hooks will automatically run: - Linting checks (Rust with `cargo clippy`) - Rust unit tests -### 5. Setup the planoai CLI +### 5. Build the planoai CLI -The planoai CLI is used to build, run, and manage Plano locally: +The planoai CLI is used to build, run, and manage Plano locally. Build it from source: ```bash -cd cli -uv sync +cd crates && cargo build --release -p plano-cli ``` -This creates a virtual environment in `.venv` and installs all dependencies. +Then add it to your PATH: + +```bash +export PATH="$(pwd)/crates/target/release:$PATH" +``` -Now you can use `planoai` commands from anywhere, or use `uv run planoai` from the `cli` directory. +Add the export line to your `~/.bashrc` or `~/.zshrc` to make it permanent. You can now use `planoai` commands from anywhere. ### 6. Create a Branch diff --git a/docs/source/get_started/quickstart.rst b/docs/source/get_started/quickstart.rst index e002a3adb..7e323976e 100644 --- a/docs/source/get_started/quickstart.rst +++ b/docs/source/get_started/quickstart.rst @@ -36,6 +36,13 @@ If you prefer to run inside Docker, add ``--docker`` to ``planoai up`` / ``plano This downloads the latest ``planoai`` binary and installs it to ``~/.plano/bin/``. Follow the on-screen instructions to add it to your PATH. +**Building from source** (for contributors): + +.. code-block:: console + + $ cd crates && cargo build --release -p plano-cli + $ export PATH="$(pwd)/target/release:$PATH" + .. _llm_routing_quickstart: From 705508112b3434231c8bb40441141680eb524cff Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 00:55:02 +0000 Subject: [PATCH 14/20] fix Tera indent filter to match Jinja2 behavior (skip first line) --- crates/plano-cli/src/config/generator.rs | 33 +++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/plano-cli/src/config/generator.rs b/crates/plano-cli/src/config/generator.rs index 4ef375fbd..daeee9135 100644 --- a/crates/plano-cli/src/config/generator.rs +++ b/crates/plano-cli/src/config/generator.rs @@ -9,6 +9,34 @@ use crate::config::validation::validate_prompt_config; use crate::consts::DEFAULT_OTEL_TRACING_GRPC_ENDPOINT; use crate::utils::expand_env_vars; +/// Custom Tera filter that mimics Jinja2's indent() behavior: +/// indents all lines except the first by the given width. +fn jinja_indent_filter( + value: &tera::Value, + args: &HashMap, +) -> tera::Result { + let s = tera::try_get_value!("indent", "value", String, value); + let width = match args.get("width") { + Some(w) => tera::try_get_value!("indent", "width", usize, w), + None => match args.get("0") { + Some(w) => tera::try_get_value!("indent", "0", usize, w), + None => 0, + }, + }; + let prefix = " ".repeat(width); + let mut result = String::new(); + for (i, line) in s.lines().enumerate() { + if i > 0 { + result.push('\n'); + if !line.is_empty() { + result.push_str(&prefix); + } + } + result.push_str(line); + } + Ok(tera::Value::String(result)) +} + const SUPPORTED_PROVIDERS_WITH_BASE_URL: &[&str] = &["azure_openai", "ollama", "qwen", "amazon_bedrock", "plano"]; @@ -709,9 +737,12 @@ pub fn validate_and_render( .unwrap_or("envoy.template.yaml"); let mut tera = tera::Tera::default(); + // Register custom jinja2-compatible indent filter (skips first line) + tera.register_filter("indent", jinja_indent_filter); + let template_content = std::fs::read_to_string(template_path)?; // Convert Jinja2 syntax to Tera syntax - // indent(N) → indent(width=N) + // indent(N) → indent(width=N) — our custom filter reads the width arg let template_content = regex::Regex::new(r"indent\((\d+)\)") .unwrap() .replace_all(&template_content, "indent(width=$1)") From be99690b0749fe6b1d0a03323c4749d828b419b7 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 01:05:47 +0000 Subject: [PATCH 15/20] fix daemon process detach: use process_group(0) to prevent SIGTERM on parent exit --- crates/plano-cli/src/native/runner.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/plano-cli/src/native/runner.rs b/crates/plano-cli/src/native/runner.rs index 3f1bbe00e..61d44a3dd 100644 --- a/crates/plano-cli/src/native/runner.rs +++ b/crates/plano-cli/src/native/runner.rs @@ -313,8 +313,9 @@ pub async fn start_native( Ok(()) } -/// Double-fork daemon execution. Returns the grandchild PID. +/// Spawn a detached daemon process. Returns the child PID. fn daemon_exec(args: &[String], env: &HashMap, log_path: &Path) -> Result { + use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; let log_file = fs::File::create(log_path)?; @@ -325,6 +326,7 @@ fn daemon_exec(args: &[String], env: &HashMap, log_path: &Path) .stdin(Stdio::null()) .stdout(log_file.try_clone()?) .stderr(log_file) + .process_group(0) // detach from parent's process group .spawn()?; Ok(child.id() as i32) From fde90a00dfb159f76a63a3ad786471964c06f97a Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 01:16:21 +0000 Subject: [PATCH 16/20] fix daemon detach: use setsid() and add sleep in CI smoke test --- .github/workflows/ci.yml | 4 +++- crates/plano-cli/src/native/runner.rs | 26 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a2cf7d6f..88523634c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,9 @@ jobs: - name: Start plano natively env: OPENAI_API_KEY: test-key-not-used - run: crates/target/release/planoai up tests/e2e/config_native_smoke.yaml + run: | + crates/target/release/planoai up tests/e2e/config_native_smoke.yaml + sleep 2 - name: Health check run: | diff --git a/crates/plano-cli/src/native/runner.rs b/crates/plano-cli/src/native/runner.rs index 61d44a3dd..0275b101f 100644 --- a/crates/plano-cli/src/native/runner.rs +++ b/crates/plano-cli/src/native/runner.rs @@ -313,21 +313,29 @@ pub async fn start_native( Ok(()) } -/// Spawn a detached daemon process. Returns the child PID. +/// Spawn a fully detached daemon process. Returns the child PID. fn daemon_exec(args: &[String], env: &HashMap, log_path: &Path) -> Result { use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; let log_file = fs::File::create(log_path)?; - let child = Command::new(&args[0]) - .args(&args[1..]) - .envs(env) - .stdin(Stdio::null()) - .stdout(log_file.try_clone()?) - .stderr(log_file) - .process_group(0) // detach from parent's process group - .spawn()?; + // SAFETY: setsid() is async-signal-safe and called before exec + let child = unsafe { + Command::new(&args[0]) + .args(&args[1..]) + .envs(env) + .stdin(Stdio::null()) + .stdout(log_file.try_clone()?) + .stderr(log_file) + .pre_exec(|| { + // Create a new session so the child doesn't get SIGHUP/SIGTERM + // when the parent shell exits + nix::unistd::setsid().map_err(std::io::Error::other)?; + Ok(()) + }) + .spawn()? + }; Ok(child.id() as i32) } From e8dd0bbdf89e897c2194c775cf3bc41dcfb5e8c5 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 01:26:30 +0000 Subject: [PATCH 17/20] merge start and health check into single CI step to avoid process cleanup --- .github/workflows/ci.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88523634c..20782b983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,15 +75,12 @@ jobs: - name: Build native binaries run: crates/target/release/planoai build - - name: Start plano natively + - name: Start plano and health check env: OPENAI_API_KEY: test-key-not-used run: | crates/target/release/planoai up tests/e2e/config_native_smoke.yaml - sleep 2 - - - name: Health check - run: | + sleep 3 for i in $(seq 1 30); do if curl -sf http://localhost:12000/healthz > /dev/null 2>&1; then echo "Health check passed" From 2e3744fd1aee00811ded951f666ecd54801e27cf Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 01:39:09 +0000 Subject: [PATCH 18/20] use double-fork daemon to fully detach child processes --- crates/plano-cli/Cargo.toml | 2 +- crates/plano-cli/src/native/runner.rs | 97 ++++++++++++++++++++------- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/crates/plano-cli/Cargo.toml b/crates/plano-cli/Cargo.toml index f94f65eea..fb21100a9 100644 --- a/crates/plano-cli/Cargo.toml +++ b/crates/plano-cli/Cargo.toml @@ -37,7 +37,7 @@ tokio = { version = "1.44", features = ["full"] } reqwest = { version = "0.12", features = ["stream"] } # Process management -nix = { version = "0.29", features = ["signal", "process"] } +nix = { version = "0.29", features = ["signal", "process", "fs"] } # Archives for binary downloads flate2 = "1.0" diff --git a/crates/plano-cli/src/native/runner.rs b/crates/plano-cli/src/native/runner.rs index 0275b101f..3f2836276 100644 --- a/crates/plano-cli/src/native/runner.rs +++ b/crates/plano-cli/src/native/runner.rs @@ -313,31 +313,82 @@ pub async fn start_native( Ok(()) } -/// Spawn a fully detached daemon process. Returns the child PID. +/// Double-fork daemon: fork → setsid → fork → exec. +/// The grandchild is fully detached from the calling process tree. fn daemon_exec(args: &[String], env: &HashMap, log_path: &Path) -> Result { - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - - let log_file = fs::File::create(log_path)?; - - // SAFETY: setsid() is async-signal-safe and called before exec - let child = unsafe { - Command::new(&args[0]) - .args(&args[1..]) - .envs(env) - .stdin(Stdio::null()) - .stdout(log_file.try_clone()?) - .stderr(log_file) - .pre_exec(|| { - // Create a new session so the child doesn't get SIGHUP/SIGTERM - // when the parent shell exits - nix::unistd::setsid().map_err(std::io::Error::other)?; - Ok(()) - }) - .spawn()? - }; + use nix::unistd::{dup2, fork, setsid, ForkResult}; + use std::os::unix::io::AsRawFd; + + let run_dir = plano_run_dir(); + fs::create_dir_all(&run_dir)?; - Ok(child.id() as i32) + match unsafe { fork() }? { + ForkResult::Parent { child } => { + // Parent: wait for intermediate child, then read grandchild PID + nix::sys::wait::waitpid(child, None)?; + let pid_path = run_dir.join(format!(".daemon_pid_{child}")); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if let Ok(content) = fs::read_to_string(&pid_path) { + if let Ok(pid) = content.trim().parse::() { + let _ = fs::remove_file(&pid_path); + return Ok(pid); + } + } + std::thread::sleep(Duration::from_millis(50)); + } + anyhow::bail!("Timed out waiting for daemon PID from {}", args[0]); + } + ForkResult::Child => { + // First child: new session, then fork again + setsid()?; + match unsafe { fork() }? { + ForkResult::Parent { child } => { + // Write grandchild PID and exit + let my_pid = nix::unistd::getpid(); + let pid_path = run_dir.join(format!(".daemon_pid_{my_pid}")); + let _ = fs::write(&pid_path, child.to_string()); + std::process::exit(0); + } + ForkResult::Child => { + // Grandchild: the actual daemon + let log_file = fs::File::create(log_path).expect("failed to create log file"); + let log_fd = log_file.as_raw_fd(); + // Redirect stdout/stderr to log file + dup2(log_fd, 1).expect("dup2 stdout"); + dup2(log_fd, 2).expect("dup2 stderr"); + nix::unistd::close(log_fd).ok(); + // Redirect stdin to /dev/null + let devnull = nix::fcntl::open( + "/dev/null", + nix::fcntl::OFlag::O_RDONLY, + nix::sys::stat::Mode::empty(), + ) + .expect("open /dev/null"); + dup2(devnull, 0).expect("dup2 stdin"); + nix::unistd::close(devnull).ok(); + // Build env for execve + let env_vec: Vec = env + .iter() + .map(|(k, v)| std::ffi::CString::new(format!("{k}={v}")).unwrap()) + .collect(); + let env_ptrs: Vec<&std::ffi::CStr> = + env_vec.iter().map(|s| s.as_c_str()).collect(); + let arg_vec: Vec = args + .iter() + .map(|a| std::ffi::CString::new(a.as_str()).unwrap()) + .collect(); + let arg_ptrs: Vec<&std::ffi::CStr> = + arg_vec.iter().map(|s| s.as_c_str()).collect(); + nix::unistd::execve(arg_ptrs[0], &arg_ptrs, &env_ptrs).expect("execve failed"); + #[allow(unreachable_code)] + { + std::process::exit(1); + } + } + } + } + } } /// Stop natively-running Envoy and brightstaff processes. From 6cf0c4ff7b99645726ef233befa67f0eeb787661 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 05:09:47 +0000 Subject: [PATCH 19/20] remove Python from Docker: use Rust CLI for config generation --- Dockerfile | 34 ++++++++++--------- config/supervisord.conf | 8 ++++- crates/plano-cli/src/commands/mod.rs | 6 ++++ .../plano-cli/src/commands/render_config.rs | 33 ++++++++++++++++++ crates/plano-cli/src/docker/mod.rs | 5 ++- 5 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 crates/plano-cli/src/commands/render_config.rs diff --git a/Dockerfile b/Dockerfile index 2d6c804f8..8f19067e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Envoy version — keep in sync with cli/planoai/consts.py ENVOY_VERSION +# Envoy version — keep in sync with crates/plano-cli/src/consts.rs ENVOY_VERSION ARG ENVOY_VERSION=v1.37.0 # --- Dependency cache --- @@ -21,10 +21,12 @@ RUN mkdir -p common/src && echo "" > common/src/lib.rs && \ mkdir -p prompt_gateway/src && echo "#[no_mangle] pub fn _start() {}" > prompt_gateway/src/lib.rs && \ mkdir -p llm_gateway/src && echo "#[no_mangle] pub fn _start() {}" > llm_gateway/src/lib.rs && \ mkdir -p brightstaff/src && echo "fn main() {}" > brightstaff/src/main.rs && echo "" > brightstaff/src/lib.rs && \ - mkdir -p plano-cli/src && echo "fn main() {}" > plano-cli/src/main.rs + mkdir -p plano-cli/src && echo "fn main() {}" > plano-cli/src/main.rs && \ + mkdir -p plano-cli/templates && touch plano-cli/templates/.keep RUN cargo build --release --target wasm32-wasip1 -p prompt_gateway -p llm_gateway || true RUN cargo build --release -p brightstaff || true +RUN cargo build --release -p plano-cli || true # --- WASM plugins --- FROM deps AS wasm-builder @@ -45,18 +47,27 @@ COPY crates/brightstaff/src brightstaff/src RUN find common hermesllm brightstaff -name "*.rs" -exec touch {} + RUN cargo build --release -p brightstaff +# --- Plano CLI binary --- +FROM deps AS cli-builder +RUN rm -rf common/src hermesllm/src plano-cli/src plano-cli/templates +COPY crates/common/src common/src +COPY crates/hermesllm/src hermesllm/src +COPY crates/plano-cli/src plano-cli/src +COPY crates/plano-cli/templates plano-cli/templates +COPY crates/plano-cli/build.rs plano-cli/build.rs +RUN find common hermesllm plano-cli -name "*.rs" -exec touch {} + +RUN cargo build --release -p plano-cli + FROM docker.io/envoyproxy/envoy:${ENVOY_VERSION} AS envoy -FROM python:3.14-slim AS arch +FROM debian:bookworm-slim AS arch RUN set -eux; \ apt-get update; \ apt-get upgrade -y; \ - apt-get install -y --no-install-recommends gettext-base curl procps; \ + apt-get install -y --no-install-recommends gettext-base curl procps supervisor; \ apt-get clean; rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir supervisor - # Remove PAM packages (CVE-2025-6020) RUN set -eux; \ dpkg -r --force-depends libpam-modules libpam-modules-bin libpam-runtime libpam0g || true; \ @@ -67,25 +78,16 @@ COPY --from=envoy /usr/local/bin/envoy /usr/local/bin/envoy WORKDIR /app -RUN pip install --no-cache-dir uv - -COPY cli/pyproject.toml ./ -COPY cli/uv.lock ./ -COPY cli/README.md ./ COPY config/plano_config_schema.yaml /config/plano_config_schema.yaml COPY config/envoy.template.yaml /config/envoy.template.yaml -RUN pip install --no-cache-dir -e . - -COPY cli/planoai planoai/ -COPY config/envoy.template.yaml . -COPY config/plano_config_schema.yaml . RUN mkdir -p /etc/supervisor/conf.d COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY --from=wasm-builder /arch/target/wasm32-wasip1/release/prompt_gateway.wasm /etc/envoy/proxy-wasm-plugins/prompt_gateway.wasm COPY --from=wasm-builder /arch/target/wasm32-wasip1/release/llm_gateway.wasm /etc/envoy/proxy-wasm-plugins/llm_gateway.wasm COPY --from=brightstaff-builder /arch/target/release/brightstaff /app/brightstaff +COPY --from=cli-builder /arch/target/release/planoai /usr/local/bin/planoai RUN mkdir -p /var/log/supervisor && \ touch /var/log/envoy.log /var/log/supervisor/supervisord.log \ diff --git a/config/supervisord.conf b/config/supervisord.conf index a28691360..fd7b04020 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -4,7 +4,13 @@ pidfile=/var/run/supervisord.pid [program:config_generator] command=/bin/sh -c "\ - uv run python -m planoai.config_generator && \ + PLANO_CONFIG_FILE=/app/plano_config.yaml \ + PLANO_CONFIG_SCHEMA_FILE=/config/plano_config_schema.yaml \ + TEMPLATE_ROOT=/config \ + ENVOY_CONFIG_TEMPLATE_FILE=envoy.template.yaml \ + PLANO_CONFIG_FILE_RENDERED=/app/plano_config_rendered.yaml \ + ENVOY_CONFIG_FILE_RENDERED=/etc/envoy/envoy.yaml \ + planoai render-config && \ envsubst < /app/plano_config_rendered.yaml > /app/plano_config_rendered.env_sub.yaml && \ envsubst < /etc/envoy/envoy.yaml > /etc/envoy.env_sub.yaml && \ touch /tmp/config_ready || \ diff --git a/crates/plano-cli/src/commands/mod.rs b/crates/plano-cli/src/commands/mod.rs index 129a89062..8771528ed 100644 --- a/crates/plano-cli/src/commands/mod.rs +++ b/crates/plano-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod cli_agent; pub mod down; pub mod init; pub mod logs; +pub mod render_config; pub mod self_update; pub mod up; pub mod validate; @@ -145,6 +146,10 @@ pub enum Command { path: String, }, + /// Render config files (used by Docker/supervisord) + #[command(name = "render-config")] + RenderConfig, + /// Update planoai to the latest version #[command(name = "self-update")] SelfUpdate { @@ -273,6 +278,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { list_templates, }) => init::run(template, clean, output, force, list_templates).await, Some(Command::Validate { file, path }) => validate::run(file, &path).await, + Some(Command::RenderConfig) => render_config::run().await, Some(Command::SelfUpdate { version }) => self_update::run(version.as_deref()).await, } } diff --git a/crates/plano-cli/src/commands/render_config.rs b/crates/plano-cli/src/commands/render_config.rs new file mode 100644 index 000000000..ab2eca21b --- /dev/null +++ b/crates/plano-cli/src/commands/render_config.rs @@ -0,0 +1,33 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::config; + +/// Render config files for Docker/supervisord use. +/// Reads paths from environment variables (matching the old Python config_generator). +pub async fn run() -> Result<()> { + let config_file = + std::env::var("PLANO_CONFIG_FILE").unwrap_or_else(|_| "/app/plano_config.yaml".to_string()); + let schema_file = std::env::var("PLANO_CONFIG_SCHEMA_FILE") + .unwrap_or_else(|_| "plano_config_schema.yaml".to_string()); + let template_root = std::env::var("TEMPLATE_ROOT").unwrap_or_else(|_| "./".to_string()); + let template_file = std::env::var("ENVOY_CONFIG_TEMPLATE_FILE") + .unwrap_or_else(|_| "envoy.template.yaml".to_string()); + let config_rendered = std::env::var("PLANO_CONFIG_FILE_RENDERED") + .unwrap_or_else(|_| "/app/plano_config_rendered.yaml".to_string()); + let envoy_rendered = std::env::var("ENVOY_CONFIG_FILE_RENDERED") + .unwrap_or_else(|_| "/etc/envoy/envoy.yaml".to_string()); + + let template_path = Path::new(&template_root).join(&template_file); + + config::validate_and_render( + Path::new(&config_file), + Path::new(&schema_file), + &template_path, + Path::new(&envoy_rendered), + Path::new(&config_rendered), + )?; + + Ok(()) +} diff --git a/crates/plano-cli/src/docker/mod.rs b/crates/plano-cli/src/docker/mod.rs index 43b3d09e8..9b4e49a53 100644 --- a/crates/plano-cli/src/docker/mod.rs +++ b/crates/plano-cli/src/docker/mod.rs @@ -34,10 +34,9 @@ pub async fn validate_config(plano_config_path: &Path) -> Result<()> { "-v".to_string(), format!("{}:/app/plano_config.yaml:ro", abs_path.display()), "--entrypoint".to_string(), - "python".to_string(), + "planoai".to_string(), plano_docker_image(), - "-m".to_string(), - "planoai.config_generator".to_string(), + "render-config".to_string(), ]; let output = Command::new(&args[0]).args(&args[1..]).output()?; From f59794d70fe310d01fddd283fd8ea10f135af499 Mon Sep 17 00:00:00 2001 From: Adil Hafeez Date: Mon, 23 Mar 2026 05:13:13 +0000 Subject: [PATCH 20/20] remove Python CLI from all CI jobs, use Rust binary artifact everywhere --- .github/workflows/ci.yml | 95 +++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20782b983..218f2d68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,7 +210,7 @@ jobs: # E2E: prompt_gateway tests # ────────────────────────────────────────────── test-prompt-gateway: - needs: docker-build + needs: [docker-build, plano-cli-tests] runs-on: ubuntu-latest steps: - name: Checkout code @@ -231,6 +231,15 @@ jobs: - name: Load plano image run: docker load -i /tmp/plano-image.tar + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: /usr/local/bin/ + + - name: Make binary executable + run: chmod +x /usr/local/bin/planoai + - name: Set up Python uses: actions/setup-python@v6 with: @@ -240,9 +249,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - cache-dependency-glob: | - tests/e2e/uv.lock - cli/uv.lock + cache-dependency-glob: tests/e2e/uv.lock - name: Run prompt_gateway tests env: @@ -260,7 +267,7 @@ jobs: # E2E: model_alias_routing tests # ────────────────────────────────────────────── test-model-alias-routing: - needs: docker-build + needs: [docker-build, plano-cli-tests] runs-on: ubuntu-latest steps: - name: Checkout code @@ -281,6 +288,15 @@ jobs: - name: Load plano image run: docker load -i /tmp/plano-image.tar + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: /usr/local/bin/ + + - name: Make binary executable + run: chmod +x /usr/local/bin/planoai + - name: Set up Python uses: actions/setup-python@v6 with: @@ -290,9 +306,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - cache-dependency-glob: | - tests/e2e/uv.lock - cli/uv.lock + cache-dependency-glob: tests/e2e/uv.lock - name: Run model alias routing tests env: @@ -310,7 +324,7 @@ jobs: # E2E: responses API with state tests # ────────────────────────────────────────────── test-responses-api-with-state: - needs: docker-build + needs: [docker-build, plano-cli-tests] runs-on: ubuntu-latest steps: - name: Checkout code @@ -331,6 +345,15 @@ jobs: - name: Load plano image run: docker load -i /tmp/plano-image.tar + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: /usr/local/bin/ + + - name: Make binary executable + run: chmod +x /usr/local/bin/planoai + - name: Set up Python uses: actions/setup-python@v6 with: @@ -340,9 +363,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - cache-dependency-glob: | - tests/e2e/uv.lock - cli/uv.lock + cache-dependency-glob: tests/e2e/uv.lock - name: Run responses API with state tests env: @@ -425,17 +446,12 @@ jobs: # E2E: demo — preference based routing # ────────────────────────────────────────────── e2e-demo-preference: - needs: docker-build + needs: [docker-build, plano-cli-tests] runs-on: ubuntu-latest-m steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - name: Download plano image uses: actions/download-artifact@v7 with: @@ -445,23 +461,20 @@ jobs: - name: Load plano image run: docker load -i /tmp/plano-image.tar - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: /usr/local/bin/ - - name: Setup python venv - run: python -m venv venv + - name: Make binary executable + run: chmod +x /usr/local/bin/planoai - name: Install hurl run: | curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.0.0/hurl_4.0.0_amd64.deb sudo dpkg -i hurl_4.0.0_amd64.deb - - name: Install plano gateway and test dependencies - run: | - source venv/bin/activate - cd cli && echo "installing plano cli" && uv sync && uv tool install . - cd ../demos/shared/test_runner && echo "installing test dependencies" && uv sync - - name: Run demo tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -470,24 +483,18 @@ jobs: ARCH_API_KEY: ${{ secrets.ARCH_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | - source venv/bin/activate cd demos/shared/test_runner && sh run_demo_tests.sh llm_routing/preference_based_routing # ────────────────────────────────────────────── # E2E: demo — currency conversion # ────────────────────────────────────────────── e2e-demo-currency: - needs: docker-build + needs: [docker-build, plano-cli-tests] runs-on: ubuntu-latest-m steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - name: Download plano image uses: actions/download-artifact@v7 with: @@ -497,28 +504,24 @@ jobs: - name: Load plano image run: docker load -i /tmp/plano-image.tar - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Download planoai binary + uses: actions/download-artifact@v6 + with: + name: planoai-binary + path: /usr/local/bin/ - - name: Setup python venv - run: python -m venv venv + - name: Make binary executable + run: chmod +x /usr/local/bin/planoai - name: Install hurl run: | curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.0.0/hurl_4.0.0_amd64.deb sudo dpkg -i hurl_4.0.0_amd64.deb - - name: Install plano gateway and test dependencies - run: | - source venv/bin/activate - cd cli && echo "installing plano cli" && uv sync && uv tool install . - cd ../demos/shared/test_runner && echo "installing test dependencies" && uv sync - - name: Run demo tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} run: | - source venv/bin/activate cd demos/shared/test_runner && sh run_demo_tests.sh advanced/currency_exchange