diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6db0492 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,21 @@ +# Cargo configuration for cross-compilation and build optimization + +[target.x86_64-unknown-linux-musl] +linker = "x86_64-linux-musl-gcc" + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-mmacosx-version-min=10.15"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-mmacosx-version-min=11.0"] + +[build] +# Use release optimizations for all builds in CI +incremental = true + +[net] +# Retry failed downloads +retry = 3 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..acaa920 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Build artifacts +target/ +**/*.rs.bk + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Test artifacts +coverage/ +*.profraw +*.profdata + +# Node +node_modules/ + +# Python +__pycache__/ +*.pyc +.venv/ + +# Env files +.env +.env.local +.env.*.local + +# Credentials (never include in image) +.claude/ +*.pem +*.key + +# Docs and packaging (not needed in runtime image) +docs/ +packaging/ +image.png + +# CI +.github/ + +# Scripts (not needed in runtime) +scripts/ +tests/ + +# Makefile and build config +Makefile +.cargo/ diff --git a/.env.example b/.env.example index c817ad3..950fe7e 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,13 @@ PROXY_API_KEY=your-proxy-key-or-routing-key # Example: claude-haiku-4-5-20251001=kimi-k2.5 MODEL_MAP=claude-haiku-4-5-20251001=kimi-k2.5 +# Proxy target model (simpler alternative to MODEL_MAP) +# When set, ALL Claude model names (claude-*, opus, sonnet, haiku) map to this single target. +# The proxy also strips ANSI escape codes from model names before mapping. +# Recommended for Fireworks AI users — set once and forget. +# Example: PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 +PROXY_TARGET_MODEL= + # ═══════════════════════════════════════════════════════════════════════════ # DIRECT MODE (Individual API Keys) # ═══════════════════════════════════════════════════════════════════════════ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f299049..b661001 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,3 +81,34 @@ jobs: - uses: actions/checkout@v4 - name: Check for typos uses: crate-ci/typos@master + + release-binaries: + name: Release Binaries + if: startsWith(github.ref, 'refs/tags/') + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + - name: Build binaries + run: cargo build --release --target ${{ matrix.target }} --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + - name: Upload binaries + uses: softprops/action-gh-release@v1 + with: + files: | + target/${{ matrix.target }}/release/agentflow + target/${{ matrix.target }}/release/agentflow-setup + target/${{ matrix.target }}/release/agentflow-dashboard + target/${{ matrix.target }}/release/agentflow-doctor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d388a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,223 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., v0.1.0)' + required: true + type: string + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Build binaries for all platforms ───────────────────────────── + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + archive_ext: tar.gz + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + archive_ext: tar.gz + - os: macos-latest + target: x86_64-apple-darwin + archive_ext: tar.gz + - os: macos-latest + target: aarch64-apple-darwin + archive_ext: tar.gz + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + + # Install musl tools for Linux static builds + - name: Install musl tools (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools libssl-dev pkg-config + if [[ "${{ matrix.target }}" == "aarch64"* ]]; then + sudo apt-get install -y gcc-aarch64-linux-gnu + fi + + - name: Build binaries + run: > + cargo build --release + --target ${{ matrix.target }} + --bin agentflow + --bin agentflow-setup + --bin agentflow-dashboard + --bin agentflow-doctor + --bin anthropic-proxy + + - name: Create tarball + run: | + VERSION="${{ github.event.inputs.version || github.ref_name }}" + PLATFORM="${{ matrix.target }}" + ARCHIVE="openflows-${VERSION}-${PLATFORM}" + mkdir -p "dist/${ARCHIVE}" + + for bin in agentflow agentflow-setup agentflow-dashboard agentflow-doctor anthropic-proxy; do + cp "target/${{ matrix.target }}/release/${bin}" "dist/${ARCHIVE}/" + done + + # Include orchestration config + cp -r orchestration "dist/${ARCHIVE}/" + cp README.md LICENSE "dist/${ARCHIVE}/" 2>/dev/null || true + + tar -czf "dist/${ARCHIVE}.tar.gz" -C dist "${ARCHIVE}" + echo "ARCHIVE_NAME=${ARCHIVE}.tar.gz" >> $GITHUB_ENV + + - name: Generate checksum + run: | + cd dist + sha256sum "${{ env.ARCHIVE_NAME }}" > "${{ env.ARCHIVE_NAME }}.sha256" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.target }} + path: dist/* + retention-days: 5 + + # ── Docker image ───────────────────────────────────────────────── + docker: + name: Build Docker Image + runs-on: ubuntu-latest + needs: build + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Sanitize version for Docker tags (replace / with -) + VERSION=$(echo "$VERSION" | sed 's|/|-|g') + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ghcr.io/the-agenticflow/openflows:latest + ghcr.io/the-agenticflow/openflows:${{ steps.version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── Create GitHub Release ──────────────────────────────────────── + release: + name: Create Release + runs-on: ubuntu-latest + needs: [build, docker] + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist/ + pattern: binaries-* + merge-multiple: true + + - name: Generate changelog + id: changelog + run: | + VERSION="${{ github.event.inputs.version || github.ref_name }}" + PREV_TAG=$(git describe --tags --abbrev=0 "${VERSION}^" 2>/dev/null || echo "") + + echo "## What's Changed" >> CHANGELOG.md + echo "" >> CHANGELOG.md + + if [ -n "$PREV_TAG" ]; then + echo "### Changes since $PREV_TAG" >> CHANGELOG.md + echo "" >> CHANGELOG.md + git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${VERSION}" >> CHANGELOG.md + else + echo "### Initial Release" >> CHANGELOG.md + echo "" >> CHANGELOG.md + git log --pretty=format:"- %s (%h)" >> CHANGELOG.md + fi + + echo "" >> CHANGELOG.md + echo "## Installation" >> CHANGELOG.md + echo "" >> CHANGELOG.md + echo '```bash' >> CHANGELOG.md + echo 'curl -fsSL https://raw.githubusercontent.com/The-AgenticFlow/AgentFlow/main/scripts/install.sh | bash' >> CHANGELOG.md + echo '```' >> CHANGELOG.md + + cat CHANGELOG.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version || github.ref_name }} + name: "OpenFlows ${{ github.event.inputs.version || github.ref_name }}" + body_path: CHANGELOG.md + files: | + dist/*.tar.gz + dist/*.sha256 + draft: false + prerelease: ${{ contains(github.event.inputs.version || github.ref_name, 'rc') || contains(github.event.inputs.version || github.ref_name, 'beta') || contains(github.event.inputs.version || github.ref_name, 'alpha') }} + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── Publish to crates.io ───────────────────────────────────────── + publish-crates: + name: Publish to crates.io + runs-on: ubuntu-latest + needs: build + if: ${{ !contains(github.event.inputs.version || github.ref_name, 'rc') && !contains(github.event.inputs.version || github.ref_name, 'beta') }} + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Publish crates + run: | + cargo publish -p pocketflow-core --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p config --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-client --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p github --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-nexus --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-forge --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-sentinel --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-vessel --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agent-lore --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p pair-harness --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p agentflow-tui --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish -p openflows --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d84a1bb..97937fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,9 +129,9 @@ This uses local mock servers for the LLM and MCP, and a mock Claude script for F ``` 2. **Run Demo**: - ```bash - cargo run -p agent-team --bin demo - ``` + ```bash + cargo run -p openflows --bin demo + ``` ### Option B: Real-World Orchestration This connects to live GitHub and live LLM providers. @@ -139,7 +139,7 @@ This connects to live GitHub and live LLM providers. **If your gateway supports Anthropic protocol** (LiteLLM, native Anthropic API): ```bash # Just run — no proxy needed -cargo run -p agent-team --bin agentflow +cargo run -p openflows --bin agentflow ``` **If your gateway only supports OpenAI protocol** (common for third-party gateways): @@ -148,7 +148,7 @@ cargo run -p agent-team --bin agentflow ./scripts/start_proxy.sh # Terminal 2: Run the orchestration -cargo run -p agent-team --bin agentflow +cargo run -p openflows --bin agentflow ``` The proxy reads `GATEWAY_URL` and `GATEWAY_API_KEY` from `.env` automatically, translates Claude CLI's Anthropic-format requests into OpenAI format, and forwards them to your gateway. See [Local Anthropic Proxy](#local-anthropic-proxy-openai-only-gateways) below for details. @@ -278,7 +278,7 @@ If you want to contribute, please follow these steps: 2. **Verify the Environment**: Run all tests (unit and E2E) to ensure the current flow is running fine on your side: ```bash cargo test --workspace - cargo run -p agent-team --bin demo + cargo run -p openflows --bin demo ``` 3. **Get Assigned**: Create a new issue or comment on an existing one to express your interest. I will then add you to the repository as a contributor. 4. **Implement**: Follow the standard agentic coding workflow (Plan -> Implement -> Verify -> Walkthrough). diff --git a/Cargo.lock b/Cargo.lock index 07d1d15..ab15721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,41 +66,40 @@ dependencies = [ ] [[package]] -name = "agent-team" +name = "agent-vessel" version = "0.1.0" dependencies = [ - "agent-forge", - "agent-lore", - "agent-nexus", - "agent-vessel", "anyhow", + "async-trait", "config", - "dotenvy", + "github", "mockito", - "pair-harness", "pocketflow-core", + "reqwest", + "serde", "serde_json", + "tempfile", "tokio", "tracing", - "tracing-subscriber", ] [[package]] -name = "agent-vessel" +name = "agentflow-tui" version = "0.1.0" dependencies = [ "anyhow", "async-trait", "config", - "github", - "mockito", + "crossterm", "pocketflow-core", + "ratatui 0.29.0", + "regex", "reqwest", "serde", "serde_json", - "tempfile", "tokio", "tracing", + "tui-input", ] [[package]] @@ -112,6 +111,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -128,6 +133,7 @@ dependencies = [ "axum", "dotenvy", "futures", + "regex", "reqwest", "serde", "serde_json", @@ -288,6 +294,21 @@ dependencies = [ "either", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -333,6 +354,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "config" version = "0.2.0" @@ -350,26 +385,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -406,6 +421,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio 1.2.0", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -416,6 +456,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -449,15 +523,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "env_home" version = "0.1.0" @@ -524,21 +589,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -772,6 +822,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -876,22 +928,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -910,11 +946,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1029,6 +1063,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1062,6 +1102,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.9.6" @@ -1082,6 +1131,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1098,6 +1160,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1166,6 +1237,12 @@ dependencies = [ "redox_syscall 0.7.4", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1193,6 +1270,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1251,6 +1337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1280,23 +1367,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nom" version = "7.1.3" @@ -1351,47 +1421,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openssl" -version = "0.10.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.113" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +name = "openflows" +version = "0.1.0" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "agent-forge", + "agent-lore", + "agent-nexus", + "agent-vessel", + "agentflow-tui", + "anyhow", + "config", + "dotenvy", + "mockito", + "pair-harness", + "pocketflow-core", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1438,6 +1485,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1450,12 +1503,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "plain" version = "0.2.3" @@ -1648,6 +1695,48 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags 2.11.1", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redis-protocol" version = "6.0.0" @@ -1717,22 +1806,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1743,7 +1827,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -1777,6 +1860,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1786,7 +1882,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1846,44 +1942,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -1995,6 +2059,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.2.0", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2049,6 +2134,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2086,27 +2205,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2116,7 +2214,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2202,16 +2300,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2360,6 +2448,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd137780d743c103a391e06fe952487f914b299a4fe2c3626677f6a6339a7c6b" +dependencies = [ + "ratatui 0.28.1", + "unicode-width 0.1.14", +] + [[package]] name = "typenum" version = "1.20.0" @@ -2372,6 +2470,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2420,12 +2547,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2614,10 +2735,26 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix", + "rustix 1.1.4", "winsafe", ] +[[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" @@ -2627,6 +2764,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.62.2" @@ -2668,17 +2811,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index af215a2..e0beaff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/pair-harness", "binary", "crates/anthropic-mock", + "crates/agentflow-tui", ] [workspace.dependencies] diff --git a/Dockerfile b/Dockerfile index 19682be..c81cafe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,62 @@ -FROM rust:1.82-bookworm AS builder +# ────────────────────────────────────────────── +# Stage 1: Build +# ────────────────────────────────────────────── +FROM rust:1.88-bookworm AS builder WORKDIR /app + +# Install musl tools for static linking (optional, for smaller images) +RUN apt-get update && apt-get install -y --no-install-recommends \ + musl-tools \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace manifests first for layer caching COPY Cargo.toml Cargo.lock ./ COPY crates ./crates COPY binary ./binary -COPY src ./src -RUN cargo build --release -p agent-team +# Build release binaries +RUN cargo build --release -p openflows -FROM debian:bookworm-slim +# ────────────────────────────────────────────── +# Stage 2: Runtime +# ────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime -RUN apt-get update && apt-get install -y \ +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ curl \ git \ nodejs \ npm \ && rm -rf /var/lib/apt/lists/* +# Install Claude Code CLI globally RUN npm install -g @anthropic-ai/claude-code -COPY --from=builder /app/target/release/agent-team /usr/local/bin/ +# Create non-root user +RUN groupadd -r openflows && useradd -r -g openflows -m -d /home/openflows openflows + +# Copy binaries from builder +COPY --from=builder /app/target/release/agentflow /usr/local/bin/ +COPY --from=builder /app/target/release/agentflow-setup /usr/local/bin/ +COPY --from=builder /app/target/release/agentflow-dashboard /usr/local/bin/ +COPY --from=builder /app/target/release/agentflow-doctor /usr/local/bin/ +# Set permissions +RUN chmod +x /usr/local/bin/agentflow* + +# Create workspace directory +RUN mkdir -p /workspace && chown -R openflows:openflows /workspace + +# Switch to non-root user +USER openflows WORKDIR /workspace -CMD ["agent-team"] + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD pgrep -x agentflow > /dev/null || exit 1 + +ENTRYPOINT ["agentflow"] +CMD ["--help"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b89ea96 --- /dev/null +++ b/Makefile @@ -0,0 +1,102 @@ +.PHONY: help build release install clean test lint fmt check docker-setup docker-build docker-run cross-linux cross-mac + +SHELL := /bin/bash +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +BINARIES := agentflow agentflow-setup agentflow-dashboard agentflow-doctor + +help: + @echo "OpenFlows Build System" + @echo "======================" + @echo "" + @echo " make build Build all binaries (debug)" + @echo " make release Build all binaries (release)" + @echo " make install Install binaries to ~/.local/bin" + @echo " make clean Remove build artifacts" + @echo " make test Run all tests" + @echo " make lint Run clippy + fmt check" + @echo " make fmt Format code" + @echo " make check Full CI check (fmt + clippy + build + test)" + @echo " make docker-build Build Docker image" + @echo " make docker-run Run via Docker Compose" + @echo " make cross-linux Cross-compile for Linux (x86_64 + aarch64)" + @echo " make cross-mac Cross-compile for macOS (x86_64 + aarch64)" + @echo " make dist Create release tarballs for all platforms" + @echo "" + +build: + @echo "Building OpenFlows (debug)..." + cargo build --workspace + +release: + @echo "Building OpenFlows $(VERSION) (release)..." + cargo build --release -p openflows + +install: release + @echo "Installing OpenFlows binaries..." + @INSTALL_DIR="$${AGENTFLOW_INSTALL_DIR:-$$HOME/.local/bin}"; \ + mkdir -p "$$INSTALL_DIR"; \ + for bin in $(BINARIES); do \ + cp "target/release/$$bin" "$$INSTALL_DIR/"; \ + chmod +x "$$INSTALL_DIR/$$bin"; \ + echo " Installed $$INSTALL_DIR/$$bin"; \ + done + @echo "" + @echo "Make sure $$INSTALL_DIR is in your PATH." + +clean: + cargo clean + rm -rf dist/ + +test: + cargo nextest run --workspace --all-targets --all-features || \ + cargo test --workspace --all-targets --all-features + +lint: + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + +fmt: + cargo fmt --all + +check: fmt lint test + @echo "All checks passed!" + +docker-build: + docker build -t openflows:$(VERSION) . + +docker-run: + docker compose up -d + +cross-linux: + @echo "Cross-compiling for Linux..." + rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-gnu + cargo build --release --target x86_64-unknown-linux-musl --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + cargo build --release --target aarch64-unknown-linux-gnu --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + @echo "Linux binaries built:" + @ls -lh target/x86_64-unknown-linux-musl/release/agentflow* + @ls -lh target/aarch64-unknown-linux-gnu/release/agentflow* + +cross-mac: + @echo "Cross-compiling for macOS..." + rustup target add x86_64-apple-darwin aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + cargo build --release --target aarch64-apple-darwin --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + @echo "macOS binaries built:" + @ls -lh target/x86_64-apple-darwin/release/agentflow* + @ls -lh target/aarch64-apple-darwin/release/agentflow* + +dist: release + @echo "Creating distribution tarballs..." + @mkdir -p dist + @PLATFORM="$$(uname -s | tr '[:upper:]' '[:lower:]')"; \ + ARCH="$$(uname -m)"; \ + ARCHIVE="openflows-$(VERSION)-$${PLATFORM}-$${ARCH}"; \ + mkdir -p "dist/$$ARCHIVE"; \ + for bin in $(BINARIES); do \ + cp "target/release/$$bin" "dist/$$ARCHIVE/"; \ + done; \ + cp -r orchestration "dist/$$ARCHIVE/"; \ + cp README.md LICENSE "dist/$$ARCHIVE/" 2>/dev/null || true; \ + tar -czf "dist/$$ARCHIVE.tar.gz" -C dist "$$ARCHIVE"; \ + rm -rf "dist/$$ARCHIVE"; \ + echo "Created dist/$$ARCHIVE.tar.gz" diff --git a/PACKAGING.md b/PACKAGING.md new file mode 100644 index 0000000..b012823 --- /dev/null +++ b/PACKAGING.md @@ -0,0 +1,170 @@ +# Distribution & Packaging + +This document describes how OpenFlows is packaged and distributed across platforms. + +## Installation Methods + +### 1. One-Line Installer (All Platforms) + +```bash +curl -fsSL https://raw.githubusercontent.com/The-AgenticFlow/AgentFlow/main/scripts/install.sh | bash +``` + +**What it does:** +- Detects OS and architecture automatically +- Checks for and installs prerequisites (Rust, Node.js, Git, Claude Code CLI) +- Downloads pre-built binaries from GitHub Releases (falls back to building from source) +- Installs to `~/.local/bin` (or `$AGENTFLOW_INSTALL_DIR`) +- Adds the install directory to PATH if needed +- Offers to run the setup wizard + +**Environment variables:** +| Variable | Default | Description | +|----------|---------|-------------| +| `AGENTFLOW_INSTALL_DIR` | `~/.local/bin` | Installation directory | + +### 2. Homebrew (macOS) + +```bash +brew tap The-AgenticFlow/openflows +brew install openflows +``` + +The Homebrew formula is maintained at `packaging/homebrew/openflows.rb`. + +### 3. Docker + +```bash +# Pull and run +docker pull ghcr.io/the-agenticflow/openflows:latest +docker run -it --rm \ + -v "$HOME/.agentflow:/home/openflows/.agentflow" \ + -v "$(pwd):/workspace" \ + -e ANTHROPIC_API_KEY=your_key \ + -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token \ + ghcr.io/the-agenticflow/openflows:latest setup + +# Or use Docker Compose (includes LiteLLM proxy + Redis) +docker compose up -d +``` + +The Docker image is multi-stage, uses a non-root user, and includes a health check. + +### 4. Cargo (Rust Package Manager) + +```bash +cargo install openflows +``` + +All crates are published to crates.io. The `openflows` package includes all binaries. + +### 5. npm (Node.js Package Manager) + +```bash +npm install -g openflows +``` + +The npm package downloads the correct pre-built binary for your platform during installation (with a fallback to building from source if Rust is available). Supports Linux and macOS on x64 and arm64. + +### 6. Build from Source + +```bash +git clone https://github.com/The-AgenticFlow/AgentFlow.git +cd AgentFlow +make release # Builds all binaries in release mode +make install # Copies to ~/.local/bin +``` + +**Available Make targets:** +| Target | Description | +|--------|-------------| +| `make build` | Debug build of all crates | +| `make release` | Release build of binaries | +| `make install` | Install binaries to `~/.local/bin` | +| `make clean` | Remove build artifacts | +| `make test` | Run all tests | +| `make lint` | Run clippy + fmt check | +| `make fmt` | Format code | +| `make check` | Full CI check (fmt + clippy + build + test) | +| `make docker-build` | Build Docker image | +| `make docker-run` | Run via Docker Compose | +| `make cross-linux` | Cross-compile for Linux (x86_64 + aarch64) | +| `make cross-mac` | Cross-compile for macOS (x86_64 + aarch64) | +| `make dist` | Create release tarballs | + +## Release Process + +### Automated Releases (GitHub Actions) + +Tagging a release triggers the full release pipeline: + +```bash +git tag v0.2.0 +git push origin v0.2.0 +``` + +This triggers `.github/workflows/release.yml` which: + +1. **Builds binaries** for 4 platforms: + - `x86_64-unknown-linux-musl` (Linux x86_64, static) + - `aarch64-unknown-linux-gnu` (Linux ARM64) + - `x86_64-apple-darwin` (macOS Intel) + - `aarch64-apple-darwin` (macOS Apple Silicon) + +2. **Creates tarballs** with all binaries, orchestration config, and README + +3. **Generates SHA256 checksums** for each tarball + +4. **Builds and pushes Docker image** to GHCR + +5. **Creates GitHub Release** with: + - Auto-generated changelog from git history + - All tarballs and checksums as assets + - Installation instructions in the release body + +6. **Publishes crates** to crates.io (for stable releases only) + +### Manual Release Dispatch + +You can also trigger a release manually via GitHub Actions: + +1. Go to Actions → Release → Run workflow +2. Enter version (e.g., `v0.2.0`) +3. Click "Run workflow" + +## Binary Contents + +Each release tarball contains: + +``` +openflows-v0.1.0-x86_64-unknown-linux-musl/ +├── agentflow # Main orchestration binary +├── agentflow-setup # Setup wizard TUI +├── agentflow-dashboard # Live monitoring TUI +├── agentflow-doctor # Diagnostic tool +├── orchestration/ # Agent personas, registry, hooks, skills +├── README.md +└── LICENSE +``` + +## Platform Support + +| Platform | Architecture | Binary | Docker | Homebrew | +|----------|-------------|--------|--------|----------| +| Linux | x86_64 | ✅ Static (musl) | ✅ | — | +| Linux | aarch64 | ✅ Dynamic (glibc) | ✅ | — | +| macOS | x86_64 (Intel) | ✅ | ✅ | ✅ | +| macOS | aarch64 (Apple Silicon) | ✅ | ✅ | ✅ | + +## Prerequisites + +All installation methods require: + +| Dependency | Version | Required By | +|------------|---------|-------------| +| Rust | 1.70+ | Build from source, cargo install | +| Node.js | 18+ | GitHub MCP server, Claude Code CLI | +| Git | Any | All methods | +| Claude Code CLI | Latest | Agent execution | + +The one-line installer handles all of these automatically. diff --git a/README.md b/README.md index 705af0c..371b43b 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,225 @@ An autonomous software development team composed of AI agents working in a unifi ## Quick Start +### Option 1: One-Line Install (Recommended) +```bash +curl -fsSL https://raw.githubusercontent.com/The-AgenticFlow/AgentFlow/main/scripts/install.sh | bash +``` +This installs all binaries to `~/.local/bin` and offers to run the setup wizard. + +### Option 2: Homebrew (macOS) +```bash +brew tap The-AgenticFlow/openflows +brew install openflows +``` + +### Option 3: Docker +```bash +docker run -it --rm \ + -v "$HOME/.agentflow:/home/openflows/.agentflow" \ + -v "$(pwd):/workspace" \ + -e ANTHROPIC_API_KEY=your_key \ + -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token \ + ghcr.io/the-agenticflow/openflows:latest setup +``` + +### Option 4: npm (Node.js Package Manager) + +Install globally via npm for easy updates and cross-platform support with automatic binary downloads: + +```bash +# Install the package globally +npm install -g @the-agenticflow/openflows + +# Verify installation +openflows --version + +# Run the interactive setup wizard +openflows-setup + +# Start the autonomous orchestration +openflows + +# Monitor with the dashboard (optional, separate terminal) +openflows-dashboard + +# Diagnose issues +openflows-doctor +``` + +**What the npm install does:** +1. Downloads platform-specific native binaries (`agentflow`, `agentflow-setup`, `agentflow-dashboard`, `agentflow-doctor`, `anthropic-proxy`) from GitHub Releases +2. Installs `mcp-proxy` via the postinstall script for GitHub MCP connectivity +3. Places wrapper scripts (`openflows`, `openflows-setup`, etc.) in your npm global bin directory +4. The `openflows` wrapper auto-detects your API provider and starts the built-in proxy when needed (e.g., for Fireworks AI) + +**Updating via npm:** +```bash +npm update -g @the-agenticflow/openflows +``` + +**Uninstalling:** +```bash +npm uninstall -g @the-agenticflow/openflows +``` + +**Note:** The npm package includes platform-specific native binaries as optional dependencies. The correct binary for your platform (Linux x86_64/aarch64, macOS x86_64/Apple Silicon) is automatically downloaded during installation via the `postinstall` script. + +### Option 5: Build from Source ```bash -# 1. Clone and setup git clone https://github.com/The-AgenticFlow/AgentFlow.git cd AgentFlow -cp .env.example .env -# Edit .env with your API keys +make release # or: cargo build --release -p openflows +make install # installs to ~/.local/bin +openflows-setup # Guided setup wizard +openflows # Start orchestration +``` -# 2. Start the local proxy (required when gateway doesn't support Anthropic format) -source .env && ./scripts/start_proxy.sh & -# Or if your provider supports Anthropic directly, skip this step +### Option 6: Cargo Install -# 3. Verify setup (optional but recommended) -./scripts/check_setup.sh +Build and install from crates.io: -# 4. Run the orchestration -cargo run --bin agentflow +```bash +cargo install openflows +openflows-setup +openflows +``` + +**What `cargo install` does:** +1. Downloads the `openflows` crate source from crates.io +2. Compiles all binaries from source (`agentflow`, `agentflow-setup`, `agentflow-dashboard`, `agentflow-doctor`, `anthropic-proxy`) +3. Installs them to `~/.cargo/bin/` +4. You still need to set up environment variables manually (no `.env` file is created automatically) + +**Note:** Cargo install compiles from source, which takes several minutes. The npm package provides pre-built binaries for faster installation. + +### After Installation + +#### Standard Commands (All Install Methods) + +1. **Configure** — Run `openflows-setup` (or `agentflow-setup`) for the guided TUI wizard +2. **Verify** — Run `openflows-doctor` (or `agentflow-doctor`) to check your environment +3. **Run** — Run `openflows` (or `agentflow`) to start the autonomous team +4. **Monitor** — Run `openflows-dashboard` (or `agentflow-dashboard`) for live worker status + +#### Setup Wizard Flow + +The `openflows-setup` wizard guides you through these steps: + +1. **Welcome** — Introduction screen +2. **Security Disclaimer** — Confirm understanding of security implications +3. **Setup Mode** — Choose QuickStart (essentials only) or Advanced (full config including proxy) +4. **Existing Config Check** — Detect and offer to use/edit existing configuration +5. **Environment Check** — Verify system requirements +6. **LLM Provider Selection** — Choose your AI backend: + - **Anthropic (Claude)** — Direct API access, no proxy needed + - **OpenAI** — Direct API access + - **Google Gemini** — Direct API access + - **Fireworks AI** — Auto-configures proxy with `PROXY_TARGET_MODEL` for model mapping + - **LiteLLM Proxy** — Custom proxy URL + - **Ollama (Local)** — Local model hosting +7. **API Key Input** — Enter credentials for your chosen provider +8. **Agent Configuration** — Set up team members, instances, and model backends +9. **GitHub Authentication** — Configure PAT tokens for each agent +10. **Repository Config** — Set target GitHub repository +11. **Proxy Config** — *Always shown for Fireworks users, otherwise Advanced mode only:* + - **Proxy URL** — Local proxy endpoint (default: `http://localhost:8765/v1`) + - **Proxy API Key** — Authentication for the proxy + - **Target Model (PROXY_TARGET_MODEL)** — Single target model for all Claude requests + - **Gateway URL** — Upstream LLM gateway (default: Fireworks endpoint) + - **Gateway API Key** — Upstream gateway authentication +12. **Completion** — Writes `.env`, `registry.json`, and agent files + +#### Fireworks AI Setup Details + +When you select **Fireworks AI** as your provider, the wizard automatically: +- Shows the proxy configuration step (regardless of QuickStart/Advanced mode) +- Pre-fills `PROXY_URL` as `http://localhost:8765/v1` +- Pre-fills `GATEWAY_URL` as `https://api.fireworks.ai/inference/v1/` +- Uses your Fireworks API key for both `PROXY_API_KEY` and `GATEWAY_API_KEY` +- Writes `PROXY_TARGET_MODEL` to `.env` for dynamic model mapping +- Also writes legacy `MODEL_MAP` entries for backward compatibility + +The built-in `anthropic-proxy` binary handles: +1. **Protocol translation** — Converts Anthropic Messages API to OpenAI Chat Completions +2. **ANSI code stripping** — Cleans model names that may contain terminal formatting +3. **Model mapping** — Routes all Claude model names (`claude-*`, `opus`, `sonnet`, `haiku`) to your `PROXY_TARGET_MODEL` +4. **Fallback** — Falls back to `MODEL_MAP` if `PROXY_TARGET_MODEL` is not set + +#### npm-Specific Workflow + +If you installed via npm, you can also use npx without global installation: + +```bash +# Run setup wizard without installing (uses @the-agenticflow scope) +npx @the-agenticflow/openflows-setup + +# Start orchestration directly +npx @the-agenticflow/openflows + +# Check status +npx @the-agenticflow/openflows-doctor +``` + +**Using npx with specific versions:** +```bash +# Run a specific version +npx @the-agenticflow/openflows@0.1.2 + +# Run the latest version +npx @the-agenticflow/openflows@latest +``` + +**Package Scripts (if integrating into a Node.js project):** + +Add to your `package.json`: + +```json +{ + "scripts": { + "agent:setup": "openflows-setup", + "agent:start": "openflows", + "agent:doctor": "openflows-doctor", + "agent:dashboard": "openflows-dashboard" + }, + "devDependencies": { + "@the-agenticflow/openflows": "^0.1.2" + } +} +``` + +Or install as a dev dependency: +```bash +npm install --save-dev @the-agenticflow/openflows +``` + +Then run: +```bash +npm run agent:setup # Configure the system +npm run agent:start # Start the orchestration +npm run agent:doctor # Check environment +npm run agent:dashboard # Monitor workers +``` + +**Programmatic API (Node.js):** + +```javascript +const { spawn } = require('child_process'); +const path = require('path'); + +// Run openflows commands programmatically +const openflows = spawn('openflows', ['--version'], { + stdio: 'inherit', + env: { + ...process.env, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_PERSONAL_ACCESS_TOKEN + } +}); + +openflows.on('exit', (code) => { + console.log(`OpenFlows exited with code ${code}`); +}); ``` ## Getting Started @@ -381,6 +584,52 @@ When your provider adds native Anthropic support, change `PROXY_URL` to point di If `PROXY_URL` is not set, all agents use direct API access with `ANTHROPIC_API_KEY` — this is the default behavior and requires no proxy setup. +### Fireworks AI Setup (Recommended for Cost-Effective Development) + +Fireworks AI provides OpenAI-compatible endpoints at lower cost. Since Claude Code CLI speaks the Anthropic Messages API, AgentFlow includes a built-in protocol translator (`anthropic-proxy`) that automatically starts when needed. + +**Quick setup via the wizard:** + +```bash +openflows-setup +# Select "Fireworks AI" as your provider +# Enter your FIREWORKS_API_KEY +# Enter your PROXY_TARGET_MODEL (e.g., accounts/fireworks/models/glm-5) +# Complete the remaining setup steps +``` + +The wizard automatically configures: +- `PORT=8765` — local proxy port +- `PROXY_URL=http://localhost:8765/v1` — where Claude CLI sends requests +- `PROXY_API_KEY=` — proxy authentication +- `GATEWAY_URL=https://api.fireworks.ai/inference/v1/` — upstream Fireworks endpoint +- `PROXY_TARGET_MODEL=` — all Claude model names map to this single target +- Legacy `MODEL_MAP` entries for backward compatibility + +**How `PROXY_TARGET_MODEL` works:** + +Instead of manually mapping each Claude model name to a Fireworks model, set `PROXY_TARGET_MODEL` once. The local proxy will: +1. Strip any ANSI escape codes from incoming model names +2. Detect Claude model patterns (`claude-*`, `opus`, `sonnet`, `haiku`) +3. Route all of them to your specified target model + +```env +# Simple — one variable replaces all MODEL_MAP entries +PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 +``` + +**Manual configuration (if not using the wizard):** + +```env +FIREWORKS_API_KEY=fw_your_key_here +PORT=8765 +PROXY_URL=http://localhost:8765/v1 +PROXY_API_KEY=fw_your_key_here +GATEWAY_URL=https://api.fireworks.ai/inference/v1/ +GATEWAY_API_KEY=fw_your_key_here +PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 +``` + ## Requirements - Rust 1.70+ diff --git a/binary/Cargo.toml b/binary/Cargo.toml index 924788c..1ea622d 100644 --- a/binary/Cargo.toml +++ b/binary/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-team" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "openflows" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "OpenFlows - Autonomous AI Dev Team orchestration framework" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agents", "orchestration", "automation", "development"] +categories = ["command-line-utilities", "development-tools"] [[bin]] name = "agentflow" @@ -16,7 +20,20 @@ path = "src/main.rs" name = "demo" path = "src/bin/demo.rs" +[[bin]] +name = "agentflow-setup" +path = "src/bin/setup.rs" + +[[bin]] +name = "agentflow-dashboard" +path = "src/bin/dashboard.rs" + +[[bin]] +name = "agentflow-doctor" +path = "src/bin/doctor.rs" + [dependencies] +agentflow-tui = { path = "../crates/agentflow-tui" } pocketflow-core = { path = "../crates/pocketflow-core" } config = { path = "../crates/config" } agent-nexus = { path = "../crates/agent-nexus" } diff --git a/binary/src/bin/agentflow.rs b/binary/src/bin/agentflow.rs index 3ad4ce9..5404e51 100644 --- a/binary/src/bin/agentflow.rs +++ b/binary/src/bin/agentflow.rs @@ -20,13 +20,32 @@ async fn main() -> Result<()> { Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err.into()), } - tracing_subscriber::fmt::init(); + // Initialize logging with INFO level by default (can override via RUST_LOG env var) + let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter)) + .init(); info!("Starting REAL End-to-End Orchestration (Event-Driven FORGE-SENTINEL Pairs + VESSEL)"); // 1. Validate Environment - // Use registry to resolve per-agent token for FORGE - let registry_path = std::env::current_dir()? + // Resolve orchestrator_dir: first try relative to the binary itself (npm install), + // then fall back to current directory (dev mode). + let orchestrator_dir = { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + if let Some(ref dir) = exe_dir { + if dir.join("orchestration/agent/registry.json").exists() { + dir.clone() + } else { + std::env::current_dir()? + } + } else { + std::env::current_dir()? + } + }; + let registry_path = orchestrator_dir .join("orchestration") .join("agent") .join("registry.json"); @@ -59,8 +78,6 @@ async fn main() -> Result<()> { std::env::set_var("AGENTFLOW_WORKSPACE_ROOT", &workspace_dir); // Set ORCHESTRATOR_DIR so pair harness can find the plugin - // This is needed because the workspace is a separate cloned repo - let orchestrator_dir = std::env::current_dir()?; std::env::set_var("ORCHESTRATOR_DIR", &orchestrator_dir); // 3. Initialize Nodes diff --git a/binary/src/bin/dashboard.rs b/binary/src/bin/dashboard.rs new file mode 100644 index 0000000..9b9ce0b --- /dev/null +++ b/binary/src/bin/dashboard.rs @@ -0,0 +1,12 @@ +use agentflow_tui::app::run_app; +use agentflow_tui::app::{App, AppMode}; +use agentflow_tui::restore_tui; +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + let mut app = App::new(AppMode::Dashboard); + let result = run_app(&mut app).await; + restore_tui(); + result +} diff --git a/binary/src/bin/doctor.rs b/binary/src/bin/doctor.rs new file mode 100644 index 0000000..1f6f378 --- /dev/null +++ b/binary/src/bin/doctor.rs @@ -0,0 +1,12 @@ +use agentflow_tui::app::run_app; +use agentflow_tui::app::{App, AppMode}; +use agentflow_tui::restore_tui; +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + let mut app = App::new(AppMode::Doctor); + let result = run_app(&mut app).await; + restore_tui(); + result +} diff --git a/binary/src/bin/setup.rs b/binary/src/bin/setup.rs new file mode 100644 index 0000000..b797578 --- /dev/null +++ b/binary/src/bin/setup.rs @@ -0,0 +1,12 @@ +use agentflow_tui::app::run_app; +use agentflow_tui::app::{App, AppMode}; +use agentflow_tui::restore_tui; +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + let mut app = App::new(AppMode::Setup); + let result = run_app(&mut app).await; + restore_tui(); + result +} diff --git a/binary/src/main.rs b/binary/src/main.rs index 0e51d09..fd95267 100644 --- a/binary/src/main.rs +++ b/binary/src/main.rs @@ -23,7 +23,11 @@ async fn main() -> Result<()> { Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err.into()), } - tracing_subscriber::fmt::init(); + // Initialize logging with INFO level by default (can override via RUST_LOG env var) + let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter)) + .init(); info!("Autonomous AI Dev Team starting (Phase 3 Integration with VESSEL)..."); @@ -99,7 +103,22 @@ async fn main() -> Result<()> { store.set(KEY_PENDING_PRS, serde_json::json!([])).await; // 4. Build Flow - use orchestration/agent directory for personas - let orchestrator_dir = std::env::current_dir()?; + // Resolve orchestrator_dir: first try relative to the binary itself (npm install), + // then fall back to current directory (dev mode). + let orchestrator_dir = { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + if let Some(ref dir) = exe_dir { + if dir.join("orchestration/agent/registry.json").exists() { + dir.clone() + } else { + std::env::current_dir()? + } + } else { + std::env::current_dir()? + } + }; let registry_path = orchestrator_dir.join("orchestration/agent/registry.json"); let nexus = Arc::new(NexusNode::new( orchestrator_dir.join("orchestration/agent/agents/nexus.agent.md"), diff --git a/crates/agent-client/Cargo.toml b/crates/agent-client/Cargo.toml index 75b0243..9d1bad6 100644 --- a/crates/agent-client/Cargo.toml +++ b/crates/agent-client/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-client" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-client" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "LLM client library with multi-provider support (Anthropic, OpenAI, Gemini, Fireworks)" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["llm", "ai", "anthropic", "openai", "gemini", "fireworks"] +categories = ["api-bindings", "ai"] [dependencies] anyhow = { workspace = true } diff --git a/crates/agent-client/src/anthropic.rs b/crates/agent-client/src/anthropic.rs index 37ab5a3..839b598 100644 --- a/crates/agent-client/src/anthropic.rs +++ b/crates/agent-client/src/anthropic.rs @@ -205,7 +205,12 @@ impl LlmClient for AnthropicClient { .await .context("Failed to read Anthropic response body")?; let truncation_len = raw_text.len().min(500); - let truncation_len = raw_text.floor_char_boundary(truncation_len); + let truncation_len = raw_text + .char_indices() + .take_while(|(i, _)| *i <= truncation_len) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); debug!(stop_reason = %raw_text.len(), status = %status, body = %&raw_text[..truncation_len], "← Anthropic raw response"); let raw: Value = serde_json::from_str(&raw_text).context(format!( diff --git a/crates/agent-client/src/fireworks.rs b/crates/agent-client/src/fireworks.rs index a5350e6..f349f63 100644 --- a/crates/agent-client/src/fireworks.rs +++ b/crates/agent-client/src/fireworks.rs @@ -1,3 +1,10 @@ +// crates/agent-client/src/fireworks.rs +// +// FireworksClient — supports both OpenAI and Anthropic API formats. +// +// By default uses Anthropic format for Claude CLI compatibility. +// Set FIREWORKS_API_FORMAT=openai to use OpenAI format. + use anyhow::{bail, Context, Result}; use async_trait::async_trait; use reqwest::Client; @@ -6,25 +13,45 @@ use tracing::debug; use crate::types::{ContentBlock, LlmClient, LlmResponse, Message, ToolSchema}; -const FIREWORKS_API_URL: &str = "https://api.fireworks.ai/inference/v1/chat/completions"; +const FIREWORKS_OPENAI_URL: &str = "https://api.fireworks.ai/inference/v1/chat/completions"; +const FIREWORKS_ANTHROPIC_URL: &str = "https://api.fireworks.ai/inference/v1/anthropic/messages"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; const DEFAULT_MAX_TOKENS: u32 = 4096; +/// API format for Fireworks requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FireworksApiFormat { + /// OpenAI-compatible format (chat/completions endpoint) + OpenAi, + /// Anthropic-compatible format (anthropic/messages endpoint) + Anthropic, +} + +impl Default for FireworksApiFormat { + fn default() -> Self { + // Default to OpenAI since Fireworks doesn't support Anthropic endpoint directly + Self::OpenAi + } +} + pub struct FireworksClient { http: Client, api_key: String, api_url: String, pub model: String, max_tokens: u32, + api_format: FireworksApiFormat, } impl FireworksClient { pub fn new(api_key: impl Into, model: impl Into) -> Self { Self { http: Client::new(), - api_url: FIREWORKS_API_URL.to_string(), + api_url: FIREWORKS_ANTHROPIC_URL.to_string(), api_key: api_key.into(), model: model.into(), max_tokens: DEFAULT_MAX_TOKENS, + api_format: FireworksApiFormat::Anthropic, } } @@ -32,21 +59,37 @@ impl FireworksClient { let key = std::env::var("FIREWORKS_API_KEY").context("FIREWORKS_API_KEY not set")?; let model = std::env::var("FIREWORKS_MODEL") .unwrap_or_else(|_| "accounts/fireworks/models/llama-v3p1-8b-instruct".to_string()); - let api_url = - std::env::var("FIREWORKS_API_URL").unwrap_or_else(|_| FIREWORKS_API_URL.to_string()); + + // Fireworks only supports OpenAI format (no native Anthropic endpoint) + // Default to OpenAI, ignore FIREWORKS_API_FORMAT env var + let api_format = FireworksApiFormat::OpenAi; + + // Determine API URL based on format + let api_url = std::env::var("FIREWORKS_API_URL").unwrap_or_else(|_| { + FIREWORKS_OPENAI_URL.to_string() + }); + + tracing::info!( + api_format = ?api_format, + api_url = %api_url, + model = %model, + "FireworksClient initialized" + ); + Ok(Self { http: Client::new(), api_url, api_key: key, model, max_tokens: DEFAULT_MAX_TOKENS, + api_format, }) } pub fn from_env_with_model(model_override: &str) -> Result { let mut client = Self::from_env()?; client.model = model_override.to_string(); - tracing::info!(model = %model_override, "FireworksClient model overridden"); + tracing::info!(model = %model_override, api_format = ?client.api_format, "FireworksClient model overridden"); Ok(client) } @@ -58,9 +101,15 @@ impl FireworksClient { self.max_tokens = n; self } + + pub fn api_format(&self) -> FireworksApiFormat { + self.api_format + } } -fn messages_to_json(messages: &[Message]) -> Value { +// ── OpenAI format message serialization ────────────────────────────────────── + +fn messages_to_openai_json(messages: &[Message]) -> Value { let mut turns: Vec = Vec::new(); for msg in messages { @@ -125,10 +174,73 @@ fn messages_to_json(messages: &[Message]) -> Value { json!(turns) } +// ── Anthropic format message serialization ──────────────────────────────────── + +fn messages_to_anthropic_json(messages: &[Message]) -> Value { + let mut system_prompt = String::new(); + let mut turns: Vec = Vec::new(); + + for msg in messages { + match msg { + Message::System { content } => { + system_prompt = content.clone(); + } + Message::User { content } => { + turns.push(json!({ "role": "user", "content": content })); + } + Message::Assistant { content } => { + let blocks: Vec = content + .iter() + .map(|b| match b { + ContentBlock::Text { text } => json!({ "type": "text", "text": text }), + ContentBlock::ToolUse { id, name, input } => json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + }), + }) + .collect(); + turns.push(json!({ "role": "assistant", "content": blocks })); + } + Message::ToolResult { + tool_use_id, + content, + } => { + turns.push(json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + }] + })); + } + } + } + + json!({ "system": system_prompt, "messages": turns }) +} + +// ── Main API call ───────────────────────────────────────────────────────────── + #[async_trait] impl LlmClient for FireworksClient { async fn send(&self, messages: &[Message], tools: &[ToolSchema]) -> Result { - let messages_json = messages_to_json(messages); + match self.api_format { + FireworksApiFormat::OpenAi => self.send_openai(messages, tools).await, + FireworksApiFormat::Anthropic => self.send_anthropic(messages, tools).await, + } + } + + fn model(&self) -> &str { + &self.model + } +} + +impl FireworksClient { + async fn send_openai(&self, messages: &[Message], tools: &[ToolSchema]) -> Result { + let messages_json = messages_to_openai_json(messages); let mut body = json!({ "model": self.model, @@ -168,7 +280,7 @@ impl LlmClient for FireworksClient { .text() .await .context("Failed to read Fireworks response body")?; - debug!(model = %self.model, status = %status, body = %raw_text, "← Fireworks raw response"); + debug!(model = %self.model, status = %status, body = %&raw_text[..raw_text.len().min(500)], "← Fireworks OpenAI raw response"); let raw: Value = serde_json::from_str(&raw_text).context(format!( "Failed to parse Fireworks response (status={}, body={})", @@ -181,8 +293,6 @@ impl LlmClient for FireworksClient { bail!("Fireworks API error {}: {}", status, error_msg); } - debug!(model = %self.model, "← Fireworks response"); - let choice = raw["choices"] .get(0) .context("Fireworks returned no choices")?; @@ -206,7 +316,105 @@ impl LlmClient for FireworksClient { Ok(LlmResponse::Text(text)) } - fn model(&self) -> &str { - &self.model + async fn send_anthropic(&self, messages: &[Message], tools: &[ToolSchema]) -> Result { + let msg_json = messages_to_anthropic_json(messages); + + let tools_json: Vec = tools + .iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "input_schema": t.input_schema, + }) + }) + .collect(); + + let body = json!({ + "model": self.model, + "max_tokens": self.max_tokens, + "system": msg_json["system"], + "messages": msg_json["messages"], + "tools": tools_json, + }); + + let resp = self + .http + .post(&self.api_url) + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("HTTP request to Fireworks Anthropic API failed")?; + + let status = resp.status(); + let raw_text = resp + .text() + .await + .context("Failed to read Fireworks Anthropic response body")?; + + let truncation_len = raw_text.len().min(500); + let truncation_len = raw_text + .char_indices() + .take_while(|(i, _)| *i <= truncation_len) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + debug!(stop_reason = %raw_text.len(), status = %status, body = %&raw_text[..truncation_len], "← Fireworks Anthropic raw response"); + + let raw: Value = serde_json::from_str(&raw_text).context(format!( + "Failed to parse Fireworks Anthropic response (status={}, body={})", + status, + &raw_text[..truncation_len] + ))?; + + if !status.is_success() { + bail!( + "Fireworks Anthropic API error {}: {}", + status, + raw["error"]["message"].as_str().unwrap_or("unknown") + ); + } + + debug!( + stop_reason = raw["stop_reason"].as_str(), + "← Fireworks Anthropic response" + ); + + let content = &raw["content"]; + let blocks = content + .as_array() + .context("Fireworks Anthropic returned no content array")?; + + let stop_reason = raw["stop_reason"].as_str().unwrap_or(""); + + if stop_reason == "tool_use" { + for block in blocks { + if block["type"].as_str() == Some("tool_use") { + return Ok(LlmResponse::ToolCall { + id: block["id"].as_str().unwrap_or("").to_string(), + name: block["name"].as_str().unwrap_or("").to_string(), + args: block["input"].clone(), + }); + } + } + bail!("Fireworks Anthropic stop_reason was tool_use but no tool_use block found"); + } + + let text = blocks + .iter() + .filter_map(|b| { + if b["type"].as_str() == Some("text") { + b["text"].as_str() + } else { + None + } + }) + .collect::>() + .join("\n"); + + Ok(LlmResponse::Text(text)) } } diff --git a/crates/agent-client/src/mcp.rs b/crates/agent-client/src/mcp.rs index 6c9be45..eea2abe 100644 --- a/crates/agent-client/src/mcp.rs +++ b/crates/agent-client/src/mcp.rs @@ -116,18 +116,21 @@ impl McpSession { } _ => { info!("Spawning hosted GitHub MCP server via mcp-proxy bridge"); + // Use sparfenyuk/mcp-proxy (Python tool from PyPI) + // It bridges stdio to HTTP MCP servers. + // Format: mcp-proxy --transport streamablehttp --headers Authorization 'Bearer TOKEN' URL let mut child = Command::new("mcp-proxy") - .arg("convert") - .arg("https://api.githubcopilot.com/mcp/") - .arg("--auth") + .arg("--transport") + .arg("streamablehttp") + .arg("--headers") + .arg("Authorization") .arg(format!("Bearer {}", pat)) - .arg("--protocol") - .arg("stream") + .arg("https://api.githubcopilot.com/mcp") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .spawn() - .context("Failed to spawn hosted MCP npx bridge")?; + .context("Failed to spawn hosted MCP bridge (mcp-proxy not installed? Run: uv tool install mcp-proxy)")?; let stdin = child.stdin.take().context("Failed to open MCP stdin")?; let stdout = diff --git a/crates/agent-forge/Cargo.toml b/crates/agent-forge/Cargo.toml index 7c20a1c..820d3c5 100644 --- a/crates/agent-forge/Cargo.toml +++ b/crates/agent-forge/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-forge" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-forge" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Forge implementation agent for OpenFlows - executes code changes" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agent", "forge", "implementation", "coding"] +categories = ["development-tools"] [dependencies] pocketflow-core = { path = "../pocketflow-core" } diff --git a/crates/agent-forge/src/lib.rs b/crates/agent-forge/src/lib.rs index cc7f232..87e1daa 100644 --- a/crates/agent-forge/src/lib.rs +++ b/crates/agent-forge/src/lib.rs @@ -1,7 +1,7 @@ // crates/agent-forge/src/lib.rs use anyhow::{anyhow, Result}; use async_trait::async_trait; -use config::{ +use openflows_config::{ state::{ ACTION_EMPTY, ACTION_FAILED, ACTION_PR_OPENED, KEY_COMMAND_GATE, KEY_PENDING_PRS, KEY_TICKETS, KEY_WORKER_SLOTS, @@ -93,7 +93,7 @@ impl ForgeNode { /// Resolve GitHub token for a specific worker. fn resolve_token_for_worker(&self, worker_id: &str) -> Result { if let Some(registry_path) = &self.registry_path { - let registry = config::Registry::load(registry_path)?; + let registry = openflows_config::Registry::load(registry_path)?; registry.resolve_github_token(worker_id) } else { Ok(self.github_token.clone()) @@ -513,7 +513,7 @@ impl ForgePairNode { /// Resolve GitHub token for a specific worker. fn resolve_token_for_worker(&self, worker_id: &str) -> Result { if let Some(registry_path) = &self.registry_path { - let registry = config::Registry::load(registry_path)?; + let registry = openflows_config::Registry::load(registry_path)?; registry.resolve_github_token(worker_id) } else { Ok(self.github_token.clone()) @@ -595,7 +595,7 @@ impl ForgePairNode { async fn fetch_issue(&self, owner: &str, repo: &str, number: u64) -> Result { // Use registry token if available, otherwise use the fallback token let token = if let Some(registry_path) = &self.registry_path { - config::Registry::load(registry_path)? + openflows_config::Registry::load(registry_path)? .resolve_github_token("forge") .unwrap_or_else(|_| self.github_token.clone()) } else { @@ -1419,7 +1419,53 @@ impl BatchNode for ForgePairNode { // Resolve token for this specific worker let worker_token = self.resolve_token_for_worker(&worker_id)?; - let config = PairConfig::new(&worker_id, &ticket_id, &self.workspace_root, &worker_token); + // Check for proxy configuration from environment (set by openflows) + let proxy_url = std::env::var("PROXY_URL").ok(); + let redis_url = std::env::var("REDIS_URL").ok(); + + // Get model from registry (e.g., "accounts/fireworks/models/glm-5") + let model_backend = if let Some(registry_path) = &self.registry_path { + openflows_config::Registry::load(registry_path)? + .get("forge") + .and_then(|e| e.model_backend.clone()) + } else { + None + }; + + let config = match (&proxy_url, &redis_url) { + (Some(proxy), Some(redis)) => PairConfig::with_proxy( + &worker_id, + &ticket_id, + &self.workspace_root, + Some(redis.clone()), + proxy, + &worker_token, + ), + (Some(proxy), None) => PairConfig::with_proxy( + &worker_id, + &ticket_id, + &self.workspace_root, + None, + proxy, + &worker_token, + ), + (None, Some(redis)) => PairConfig::with_redis( + &worker_id, + &ticket_id, + &self.workspace_root, + redis, + &worker_token, + ), + (None, None) => PairConfig::new(&worker_id, &ticket_id, &self.workspace_root, &worker_token), + }; + + // Apply model from registry if available + let config = if let Some(model) = model_backend { + info!(worker = worker_id, model = %model, "Using model from registry for FORGE/SENTINEL"); + config.with_model(model) + } else { + config + }; let mut pair = ForgeSentinelPair::new(config); let outcome = pair diff --git a/crates/agent-lore/Cargo.toml b/crates/agent-lore/Cargo.toml index 88c1ec6..32669c4 100644 --- a/crates/agent-lore/Cargo.toml +++ b/crates/agent-lore/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-lore" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-lore" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Lore documentation agent for OpenFlows - maintains project documentation" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agent", "lore", "documentation", "changelog"] +categories = ["development-tools"] [dependencies] anyhow = { workspace = true } diff --git a/crates/agent-nexus/Cargo.toml b/crates/agent-nexus/Cargo.toml index 8baede5..2b1d46b 100644 --- a/crates/agent-nexus/Cargo.toml +++ b/crates/agent-nexus/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-nexus" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-nexus" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Nexus orchestration agent for OpenFlows - assigns work to workers" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agent", "nexus", "orchestration", "scheduler"] +categories = ["development-tools"] [dependencies] pocketflow-core = { path = "../pocketflow-core" } diff --git a/crates/agent-sentinel/Cargo.toml b/crates/agent-sentinel/Cargo.toml index 11cb0e8..a6a1a88 100644 --- a/crates/agent-sentinel/Cargo.toml +++ b/crates/agent-sentinel/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-sentinel" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-sentinel" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Sentinel review agent for OpenFlows - reviews and validates code changes" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agent", "sentinel", "review", "validation"] +categories = ["development-tools"] [dependencies] anyhow = "1.0" diff --git a/crates/agent-vessel/Cargo.toml b/crates/agent-vessel/Cargo.toml index b334d70..d078ecf 100644 --- a/crates/agent-vessel/Cargo.toml +++ b/crates/agent-vessel/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "agent-vessel" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "agent-vessel" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Vessel merge agent for OpenFlows - handles PR merges and CI integration" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agent", "vessel", "merge", "ci"] +categories = ["development-tools"] [dependencies] pocketflow-core = { path = "../pocketflow-core" } diff --git a/crates/agentflow-tui/Cargo.toml b/crates/agentflow-tui/Cargo.toml new file mode 100644 index 0000000..5ffb186 --- /dev/null +++ b/crates/agentflow-tui/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agentflow-tui" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Terminal UI for OpenFlows setup and configuration" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["tui", "terminal", "setup", "wizard"] +categories = ["command-line-interface", "command-line-utilities"] + +[dependencies] +ratatui = "0.29" +crossterm = "0.28" +tui-input = "0.10" +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +config = { path = "../config" } +pocketflow-core = { path = "../pocketflow-core" } +serde = { workspace = true } +serde_json = { workspace = true } +regex = { workspace = true } +reqwest = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/agentflow-tui/src/app.rs b/crates/agentflow-tui/src/app.rs new file mode 100644 index 0000000..e1489ee --- /dev/null +++ b/crates/agentflow-tui/src/app.rs @@ -0,0 +1,50 @@ +use anyhow::Result; + +#[derive(Debug, Clone, PartialEq)] +pub enum AppMode { + Setup, + Dashboard, + Doctor, +} + +pub struct App { + pub mode: AppMode, + pub running: bool, + pub current_step: usize, + pub total_steps: usize, +} + +impl App { + pub fn new(mode: AppMode) -> Self { + Self { + mode, + running: true, + current_step: 0, + total_steps: 1, + } + } + + pub fn quit(&mut self) { + self.running = false; + } + + pub fn next_step(&mut self) { + if self.current_step < self.total_steps.saturating_sub(1) { + self.current_step += 1; + } + } + + pub fn prev_step(&mut self) { + if self.current_step > 0 { + self.current_step -= 1; + } + } +} + +pub async fn run_app(app: &mut App) -> Result<()> { + match app.mode { + AppMode::Setup => crate::setup::run_wizard(app).await, + AppMode::Dashboard => crate::dashboard::run_dashboard(app).await, + AppMode::Doctor => crate::doctor::run_doctor(app).await, + } +} diff --git a/crates/agentflow-tui/src/dashboard/events.rs b/crates/agentflow-tui/src/dashboard/events.rs new file mode 100644 index 0000000..9fe336c --- /dev/null +++ b/crates/agentflow-tui/src/dashboard/events.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone)] +pub struct LogEvent { + pub timestamp: String, + pub agent: String, + pub message: String, +} diff --git a/crates/agentflow-tui/src/dashboard/mod.rs b/crates/agentflow-tui/src/dashboard/mod.rs new file mode 100644 index 0000000..914e797 --- /dev/null +++ b/crates/agentflow-tui/src/dashboard/mod.rs @@ -0,0 +1,150 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::Widget; +use ratatui::Terminal; +use std::collections::VecDeque; +use std::io; +use std::time::Instant; + +use crate::app::App; +use crate::util::theme::Theme; +use crate::widgets::table::WorkerTable; + +mod events; +mod workers; + +use events::LogEvent; +use workers::WorkerInfo; + +pub struct DashboardState { + workers: Vec, + events: VecDeque, + repo: String, + status: String, + last_refresh: Instant, + selected_row: usize, + show_log: bool, +} + +impl Default for DashboardState { + fn default() -> Self { + Self::new() + } +} + +impl DashboardState { + pub fn new() -> Self { + Self { + workers: Vec::new(), + events: VecDeque::new(), + repo: String::new(), + status: "Unknown".to_string(), + last_refresh: Instant::now(), + selected_row: 0, + show_log: false, + } + } + + pub async fn refresh(&mut self) -> Result<()> { + self.last_refresh = Instant::now(); + Ok(()) + } +} + +pub async fn run_dashboard(_app: &mut App) -> Result<()> { + let terminal = crate::init_tui()?; + let result = run_dashboard_inner(terminal).await; + crate::restore_tui(); + result +} + +async fn run_dashboard_inner(mut terminal: Terminal>) -> Result<()> { + let theme = Theme::default(); + let mut state = DashboardState::new(); + let refresh_interval = std::time::Duration::from_secs(2); + + state.refresh().await?; + + loop { + terminal.draw(|f| { + let area = f.area(); + + let header = format!( + "AgentFlow Dashboard | Repository: {} | Status: {} | [q:quit r:refresh]", + state.repo, state.status + ); + let header_widget = ratatui::widgets::Paragraph::new(header).style(theme.title_style()); + let header_area = ratatui::layout::Rect { + x: 1, + y: 0, + width: area.width.saturating_sub(2), + height: 1, + }; + header_widget.render(header_area, f.buffer_mut()); + + let worker_data: Vec<(String, String, String)> = state + .workers + .iter() + .map(|w| (w.id.clone(), w.status.clone(), w.detail.clone())) + .collect(); + + let table_area = ratatui::layout::Rect { + x: 1, + y: 2, + width: area.width.saturating_sub(2), + height: area.height / 2, + }; + let table = WorkerTable::new(worker_data).selected(state.selected_row); + table.render(table_area, f.buffer_mut()); + + if state.show_log { + let events_area = ratatui::layout::Rect { + x: 1, + y: table_area.bottom(), + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(table_area.bottom()) - 1, + }; + let events_text: Vec = state + .events + .iter() + .map(|e| format!("{} {} {}", e.timestamp, e.agent, e.message)) + .collect(); + let events_widget = ratatui::widgets::Paragraph::new(events_text.join("\n")) + .style(theme.text_style()); + events_widget.render(events_area, f.buffer_mut()); + } + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char('q') => break, + KeyCode::Char('r') => { + state.refresh().await?; + } + KeyCode::Char('l') => { + state.show_log = !state.show_log; + } + KeyCode::Up => { + if state.selected_row > 0 { + state.selected_row -= 1; + } + } + KeyCode::Down => { + if state.selected_row < state.workers.len().saturating_sub(1) { + state.selected_row += 1; + } + } + _ => {} + } + } + } + + if state.last_refresh.elapsed() >= refresh_interval { + state.refresh().await?; + } + } + + Ok(()) +} diff --git a/crates/agentflow-tui/src/dashboard/workers.rs b/crates/agentflow-tui/src/dashboard/workers.rs new file mode 100644 index 0000000..3af2fde --- /dev/null +++ b/crates/agentflow-tui/src/dashboard/workers.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone)] +pub struct WorkerInfo { + pub id: String, + pub status: String, + pub detail: String, +} diff --git a/crates/agentflow-tui/src/doctor/mod.rs b/crates/agentflow-tui/src/doctor/mod.rs new file mode 100644 index 0000000..f38c5ce --- /dev/null +++ b/crates/agentflow-tui/src/doctor/mod.rs @@ -0,0 +1,378 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::Widget; +use ratatui::Terminal; +use std::io; + +use crate::app::App; +use crate::util::env_check::{self, EnvIssue}; +use crate::util::theme::Theme; +use crate::widgets::check::{CheckList, CheckState}; + +pub async fn run_doctor(_app: &mut App) -> Result<()> { + let terminal = crate::init_tui()?; + let result = run_doctor_inner(terminal).await; + crate::restore_tui(); + result +} + +async fn run_doctor_inner(mut terminal: Terminal>) -> Result<()> { + let theme = Theme::default(); + let mut checks = Vec::new(); + + checks.push(("── Environment ──".to_string(), CheckState::Pending)); + + if let Some(version) = env_check::check_rustc() { + checks.push((format!("Rust {}", version), CheckState::Pass)); + } else { + checks.push(("Rust not found".to_string(), CheckState::Fail)); + } + + if let Some(version) = env_check::check_git() { + checks.push((format!("Git {}", version), CheckState::Pass)); + } else { + checks.push(("Git not found".to_string(), CheckState::Fail)); + } + + if let Some(version) = env_check::check_node() { + checks.push((format!("Node.js {}", version), CheckState::Pass)); + } else { + checks.push(("Node.js not found".to_string(), CheckState::Warn)); + } + + if let Some(version) = env_check::check_claude() { + checks.push((format!("Claude CLI {}", version), CheckState::Pass)); + } else { + checks.push(("Claude CLI not found".to_string(), CheckState::Warn)); + } + + // ── Pre-flight: .env File Validation ────────────────────────────────────── + checks.push(("── Pre-flight Checks ──".to_string(), CheckState::Pending)); + + let env_path = std::path::PathBuf::from(".env"); + let env_issues = env_check::scan_env_file(&env_path); + + if env_issues.is_empty() { + checks.push((".env validation passed".to_string(), CheckState::Pass)); + } else { + for issue in &env_issues { + match issue { + EnvIssue::DuplicateKey { key, count } => { + checks.push(( + format!("⚠ DUPLICATE: '{}' defined {} times", key, count), + CheckState::Fail, + )); + } + EnvIssue::InvalidToken { env_var, error } => { + checks.push(( + format!("⚠ INVALID TOKEN: {} - {}", env_var, error), + CheckState::Fail, + )); + } + EnvIssue::MissingFile => { + checks.push((".env file missing".to_string(), CheckState::Fail)); + } + } + } + } + + // ── Configuration ────────────────────────────────────────────────────────── + checks.push(("── Configuration ──".to_string(), CheckState::Pending)); + + if std::path::Path::new(".env").exists() { + checks.push((".env file exists".to_string(), CheckState::Pass)); + } else { + checks.push((".env file missing".to_string(), CheckState::Fail)); + } + + // Check GitHub token format + if let Ok(token) = std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN") { + match env_check::validate_github_token(&token) { + Ok(()) => { + checks.push(( + format!("GITHUB_TOKEN valid format ({} chars)", token.len()), + CheckState::Pass, + )); + } + Err(e) => { + checks.push(( + format!("GITHUB_TOKEN INVALID: {}", e), + CheckState::Fail, + )); + } + } + } else { + checks.push(( + "GITHUB_PERSONAL_ACCESS_TOKEN not set".to_string(), + CheckState::Fail, + )); + } + + if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") { + checks.push((format!("GITHUB_REPOSITORY = {}", repo), CheckState::Pass)); + } else { + checks.push(("GITHUB_REPOSITORY not set".to_string(), CheckState::Fail)); + } + + let registry_path = std::env::current_dir()? + .join("orchestration") + .join("agent") + .join("registry.json"); + + if registry_path.exists() { + match config::Registry::load(®istry_path) { + Ok(registry) => { + let agent_count = registry.active_agents().count(); + let slot_count = registry.all_worker_slots().len(); + checks.push(( + format!( + "registry.json valid ({} agents, {} slots)", + agent_count, slot_count + ), + CheckState::Pass, + )); + } + Err(e) => { + checks.push(( + format!("registry.json parse error: {}", e), + CheckState::Fail, + )); + } + } + } else { + checks.push(("registry.json not found".to_string(), CheckState::Fail)); + } + + if std::env::var("PROXY_URL").is_ok() { + checks.push(("PROXY_URL set".to_string(), CheckState::Pass)); + } else { + checks.push(( + "PROXY_URL not set (using direct mode)".to_string(), + CheckState::Warn, + )); + } + + // Check for mcp-proxy installation (Python tool from PyPI) + if env_check::check_command("mcp-proxy").is_some() { + checks.push(("mcp-proxy installed".to_string(), CheckState::Pass)); + } else { + checks.push(( + "mcp-proxy not found (install: uv tool install mcp-proxy)".to_string(), + CheckState::Fail, + )); + } + + // Check API configuration + checks.push(("── API Configuration ──".to_string(), CheckState::Pending)); + + // Check for any valid API key + let has_api_key = std::env::var("FIREWORKS_API_KEY").is_ok() + || std::env::var("ANTHROPIC_API_KEY").is_ok() + || std::env::var("OPENAI_API_KEY").is_ok() + || std::env::var("GEMINI_API_KEY").is_ok() + || std::env::var("GATEWAY_API_KEY").is_ok() + || std::env::var("PROXY_URL").is_ok(); + + if has_api_key { + if std::env::var("FIREWORKS_API_KEY").is_ok() { + checks.push(("FIREWORKS_API_KEY set".to_string(), CheckState::Pass)); + } + if std::env::var("ANTHROPIC_API_KEY").is_ok() { + checks.push(("ANTHROPIC_API_KEY set".to_string(), CheckState::Pass)); + } + if std::env::var("GATEWAY_API_KEY").is_ok() { + checks.push(("GATEWAY_API_KEY set".to_string(), CheckState::Pass)); + } + } else { + checks.push(( + "No API key configured (need FIREWORKS_API_KEY, ANTHROPIC_API_KEY, etc.)".to_string(), + CheckState::Fail, + )); + } + + checks.push(("── Connectivity ──".to_string(), CheckState::Pending)); + + // Test GitHub API with the configured token + if let Ok(token) = std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN") { + let client = reqwest::Client::new(); + match client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {}", token)) + .header("User-Agent", "OpenFlows-Doctor") + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + checks.push(("GitHub API: token valid".to_string(), CheckState::Pass)); + } + Ok(resp) if resp.status() == 401 => { + checks.push(( + "GitHub API: 401 Unauthorized (bad token)".to_string(), + CheckState::Fail, + )); + } + Ok(resp) => { + checks.push(( + format!("GitHub API returned {}", resp.status()), + CheckState::Warn, + )); + } + Err(e) => { + checks.push((format!("GitHub API unreachable: {}", e), CheckState::Fail)); + } + } + } else { + // Basic connectivity test without auth + match reqwest::get("https://api.github.com").await { + Ok(resp) if resp.status().is_success() => { + checks.push(("GitHub API reachable (no token)".to_string(), CheckState::Warn)); + } + Ok(resp) => { + checks.push(( + format!("GitHub API returned {}", resp.status()), + CheckState::Warn, + )); + } + Err(e) => { + checks.push((format!("GitHub API unreachable: {}", e), CheckState::Fail)); + } + } + } + + // Test Fireworks API if configured + if let Ok(key) = std::env::var("FIREWORKS_API_KEY") { + let client = reqwest::Client::new(); + match client + .get("https://api.fireworks.ai/inference/v1/models") + .header("Authorization", format!("Bearer {}", key)) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + checks.push(("Fireworks API: token valid".to_string(), CheckState::Pass)); + } + Ok(resp) if resp.status() == 401 => { + checks.push(( + "Fireworks API: 401 Unauthorized".to_string(), + CheckState::Fail, + )); + } + Ok(resp) => { + checks.push(( + format!("Fireworks API returned {}", resp.status()), + CheckState::Warn, + )); + } + Err(e) => { + checks.push(( + format!("Fireworks API unreachable: {}", e), + CheckState::Warn, + )); + } + } + } + + // Test Anthropic API if configured + if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") { + let client = reqwest::Client::new(); + match client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &key) + .header("anthropic-version", "2023-06-01") + .timeout(std::time::Duration::from_secs(5)) + .json(&serde_json::json!({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 1, + "messages": [] + })) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + checks.push(("Anthropic API: token valid".to_string(), CheckState::Pass)); + } + Ok(resp) if resp.status() == 401 => { + checks.push(( + "Anthropic API: 401 Unauthorized".to_string(), + CheckState::Fail, + )); + } + Ok(resp) => { + checks.push(( + format!("Anthropic API returned {}", resp.status()), + CheckState::Warn, + )); + } + Err(e) => { + checks.push(( + format!("Anthropic API unreachable: {}", e), + CheckState::Warn, + )); + } + } + } + + checks.push(("── Workspace ──".to_string(), CheckState::Pending)); + + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + + let workspace_root = std::path::PathBuf::from(home) + .join(".agentflow") + .join("workspaces"); + if workspace_root.exists() { + checks.push(( + format!("{} exists", workspace_root.display()), + CheckState::Pass, + )); + } else { + checks.push(( + format!("{} not found", workspace_root.display()), + CheckState::Warn, + )); + } + + let issue_count = checks + .iter() + .filter(|(_, state)| state == &CheckState::Fail) + .count(); + let warn_count = checks + .iter() + .filter(|(_, state)| state == &CheckState::Warn) + .count(); + + terminal.draw(|f| { + let area = f.area(); + let check_list = CheckList::new(checks); + check_list.render(area, f.buffer_mut()); + + let summary = format!( + "Summary: {} issues, {} warnings found", + issue_count, warn_count + ); + let summary_area = ratatui::layout::Rect { + x: 2, + y: area.height.saturating_sub(2), + width: area.width.saturating_sub(4), + height: 1, + }; + let summary_style = if issue_count > 0 { + theme.error_style() + } else if warn_count > 0 { + theme.warning_style() + } else { + theme.success_style() + }; + let summary_widget = ratatui::widgets::Paragraph::new(summary).style(summary_style); + summary_widget.render(summary_area, f.buffer_mut()); + })?; + + println!("\n[Fix API Key] [View Details] [Exit]"); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + Ok(()) +} diff --git a/crates/agentflow-tui/src/lib.rs b/crates/agentflow-tui/src/lib.rs new file mode 100644 index 0000000..14941c2 --- /dev/null +++ b/crates/agentflow-tui/src/lib.rs @@ -0,0 +1,28 @@ +pub mod app; +pub mod dashboard; +pub mod doctor; +pub mod setup; +pub mod util; +pub mod widgets; + +use anyhow::Result; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::io; + +pub fn init_tui() -> Result>> { + execute!(io::stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +pub fn restore_tui() { + disable_raw_mode().ok(); + execute!(io::stdout(), LeaveAlternateScreen).ok(); +} diff --git a/crates/agentflow-tui/src/setup/mod.rs b/crates/agentflow-tui/src/setup/mod.rs new file mode 100644 index 0000000..00bd90e --- /dev/null +++ b/crates/agentflow-tui/src/setup/mod.rs @@ -0,0 +1,362 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::io; + +pub mod step_agents; +pub mod step_api; +pub mod step_done; +pub mod step_env; +pub mod step_existing; +pub mod step_github; +pub mod step_mode; +pub mod step_provider; +pub mod step_proxy; +pub mod step_repo; +pub mod step_security; +pub mod step_welcome; + +use step_agents::AgentsStep; +use step_api::ApiStep; +use step_done::DoneStep; +use step_env::EnvStep; +use step_existing::{ConfigAction, ExistingConfigStep}; +use step_github::GitHubStep; +use step_mode::{ModeStep, SetupMode}; +use step_provider::ProviderStep; +use step_proxy::ProxyStep; +use step_repo::RepoStep; +use step_security::SecurityStep; +use step_welcome::WelcomeStep; + +#[derive(Debug, Clone)] +pub struct AgentConfig { + pub id: String, + pub cli: String, + pub active: bool, + pub instances: u32, + pub model_backend: Option, + pub routing_key: Option, + pub github_token_env: Option, +} + +#[derive(Debug, Clone)] +pub struct SetupConfig { + pub anthropic_key: String, + pub github_pat: String, + pub gemini_key: Option, + pub openai_key: Option, + pub fireworks_key: Option, + pub fireworks_api_format: String, // "anthropic" (default) or "openai" + pub repo: String, + pub workspace_dir: String, + pub proxy_enabled: bool, + pub proxy_url: Option, + pub proxy_api_key: Option, + pub proxy_target_model: Option, + pub gateway_url: Option, + pub gateway_api_key: Option, + pub selected_provider: Option, + pub agent_tokens: Vec<(String, String)>, + pub agents: Vec, +} + +impl Default for SetupConfig { + fn default() -> Self { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + + Self { + anthropic_key: String::new(), + github_pat: String::new(), + gemini_key: None, + openai_key: None, + fireworks_key: None, + fireworks_api_format: "anthropic".to_string(), // Default to Anthropic compatibility + repo: "owner/repo".to_string(), + workspace_dir: format!("{}/.agentflow/workspaces", home), + proxy_enabled: false, + proxy_url: Some("http://localhost:8765/v1".to_string()), + proxy_api_key: None, + proxy_target_model: None, + gateway_url: Some("https://api.fireworks.ai/inference/v1/".to_string()), + gateway_api_key: None, + selected_provider: None, + agent_tokens: Vec::new(), + agents: Vec::new(), + } + } +} + +pub async fn run_wizard(_app: &mut crate::app::App) -> Result<()> { + let terminal = crate::init_tui()?; + let result = run_wizard_inner(terminal).await; + crate::restore_tui(); + result +} + +async fn run_wizard_inner(mut terminal: Terminal>) -> Result<()> { + let mut config = SetupConfig::default(); + let theme = crate::util::theme::Theme::default(); + + // Step 0: Welcome screen with logo + let welcome_step = WelcomeStep::new(); + welcome_step.render(&mut terminal, &theme)?; + + // Step 1: Security disclaimer + let mut security_step = SecurityStep::new(); + security_step.render(&mut terminal, &theme)?; + + if !security_step.is_confirmed() { + return Err(anyhow::anyhow!("Security disclaimer not accepted")); + } + + // Step 2: Setup mode selection + let mut mode_step = ModeStep::new(); + mode_step.render(&mut terminal, &theme)?; + + let setup_mode = mode_step.selected_mode(); + + // Step 3: Check for existing config + let mut existing_step = ExistingConfigStep::new(); + existing_step.render(&mut terminal, &theme, &mut config)?; + + match existing_step.action() { + ConfigAction::Cancel => { + return Err(anyhow::anyhow!("Setup cancelled by user")); + } + ConfigAction::UseExisting => { + // Config already populated from existing, skip to completion + let done_step = DoneStep::new(); + done_step.render(&mut terminal, &theme, &config).await?; + return Ok(()); + } + ConfigAction::EditExisting => { + // Config already populated from existing, continue with full setup + // This allows user to edit existing values through the wizard + } + ConfigAction::Reconfigure => { + // Continue with full setup (fresh config) + } + } + + // Step 4: Environment check + let env_step = EnvStep::new(); + env_step.render(&mut terminal, &theme)?; + + // Step 5: Provider selection (must come before agent config) + let mut provider_step = ProviderStep::new(); + provider_step.render(&mut terminal, &theme, &mut config).await?; + + // Step 6: LLM API Key Input (based on selected provider) + let mut api_step = ApiStep::new(); + api_step.render(&mut terminal, &theme, &mut config).await?; + + // Step 7: Agent Configuration (instances, model backend filtered by provider) + let agents_step = AgentsStep::new(); + agents_step.render(&mut terminal, &theme, &mut config).await?; + + // Step 8: GitHub Authentication (uses agent config to determine token fields) + let github_step = GitHubStep::new(); + github_step.render(&mut terminal, &theme, &mut config).await?; + + // Step 9: Repository Config + let repo_step = RepoStep::new(); + repo_step.render(&mut terminal, &theme, &mut config).await?; + + // Step 10: Proxy Config (always shown for Fireworks, otherwise advanced mode only) + let is_fireworks = config.selected_provider.as_deref() == Some("Fireworks AI"); + if is_fireworks || setup_mode == SetupMode::Advanced { + let proxy_step = ProxyStep::new(); + proxy_step + .render(&mut terminal, &theme, &mut config) + .await?; + } + + // Step 11: Completion + let done_step = DoneStep::new(); + done_step.render(&mut terminal, &theme, &config).await?; + + Ok(()) +} + +pub fn write_env_file(config: &SetupConfig, project_dir: &std::path::Path) -> Result<()> { + let mut content = String::new(); + content.push_str("# Generated by OpenFlow setup\n\n"); + + // Check if Fireworks is selected + let is_fireworks = config.selected_provider.as_deref() == Some("Fireworks AI"); + + // Write the appropriate API key based on provider + if is_fireworks { + if let Some(ref key) = config.fireworks_key { + content.push_str("# Fireworks AI Configuration\n"); + content.push_str(&format!("FIREWORKS_API_KEY={}\n", key)); + content.push_str("FIREWORKS_API_FORMAT=openai\n"); // Fireworks only supports OpenAI format + content.push_str("\n# Proxy Configuration (required for Claude CLI with Fireworks)\n"); + content.push_str("PORT=8765\n"); + content.push_str("PROXY_URL=http://localhost:8765/v1\n"); + content.push_str(&format!("PROXY_API_KEY={}\n", key)); + content.push_str("\n# Fireworks Gateway\n"); + content.push_str("GATEWAY_URL=https://api.fireworks.ai/inference/v1/\n"); + content.push_str(&format!("GATEWAY_API_KEY={}\n", key)); + + // Write PROXY_TARGET_MODEL for dynamic model mapping + let target_model = config.proxy_target_model.as_deref() + .unwrap_or("accounts/fireworks/models/glm-5"); + content.push_str("\n# Model Mapping: ALL Claude model names → target model\n"); + content.push_str("# The proxy strips ANSI codes and maps claude-*, opus, sonnet, haiku → target\n"); + content.push_str(&format!("PROXY_TARGET_MODEL={}\n", target_model)); + + // Also write legacy MODEL_MAP for backward compatibility + let fallback_model = target_model; + content.push_str(&format!("MODEL_MAP=claude-haiku-4-5-20251001={fallback_model},claude-3-5-haiku-20241022={fallback_model}\n")); + } + } else if !config.anthropic_key.is_empty() { + content.push_str("# Anthropic API Configuration\n"); + content.push_str(&format!("ANTHROPIC_API_KEY={}\n", config.anthropic_key)); + } + + if let Some(ref key) = config.gemini_key { + if !is_fireworks { + content.push_str(&format!("GEMINI_API_KEY={}\n", key)); + } + } + if let Some(ref key) = config.openai_key { + if !is_fireworks { + content.push_str(&format!("OPENAI_API_KEY={}\n", key)); + } + } + + content.push_str("\n# GitHub Configuration\n"); + // Write per-agent GitHub tokens + for (env_key, token) in &config.agent_tokens { + content.push_str(&format!("{}={}\n", env_key, token)); + } + + // Also write the general GitHub PAT if set + if !config.github_pat.is_empty() { + content.push_str(&format!("GITHUB_PERSONAL_ACCESS_TOKEN={}\n", config.github_pat)); + } + content.push_str(&format!("GITHUB_REPOSITORY={}\n", config.repo)); + + // Legacy proxy config (only if explicitly enabled in advanced mode) + if config.proxy_enabled && !is_fireworks { + content.push_str("\n# Advanced Proxy Configuration\n"); + if let Some(ref url) = config.proxy_url { + content.push_str(&format!("PROXY_URL={}\n", url)); + } + if let Some(ref key) = config.proxy_api_key { + content.push_str(&format!("PROXY_API_KEY={}\n", key)); + } + if let Some(ref url) = config.gateway_url { + content.push_str(&format!("GATEWAY_URL={}\n", url)); + } + if let Some(ref key) = config.gateway_api_key { + content.push_str(&format!("GATEWAY_API_KEY={}\n", key)); + } + } + + content.push_str("\n# Logging\n"); + content.push_str("RUST_LOG=info\n"); + content.push_str(&format!("\n# Workspace\n")); + content.push_str(&format!("AGENTFLOW_WORKSPACE_ROOT={}\n", config.workspace_dir)); + + std::fs::write(project_dir.join(".env"), content)?; + Ok(()) +} + +pub fn write_registry_file(config: &SetupConfig, project_dir: &std::path::Path) -> Result<()> { + let registry_dir = project_dir.join("orchestration").join("agent"); + std::fs::create_dir_all(®istry_dir)?; + + // Determine default model based on selected provider + let is_fireworks = config.selected_provider.as_deref() == Some("Fireworks AI"); + let default_model = if is_fireworks { + "accounts/fireworks/models/glm-5" + } else { + "anthropic/claude-sonnet-4-5" + }; + + let registry = config::Registry { + team: if config.agents.is_empty() { + // Default agents if none configured - use provider-appropriate models + vec![ + config::RegistryEntry { + id: "nexus".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("nexus-key".to_string()), + github_token_env: Some("AGENT_NEXUS_GITHUB_TOKEN".to_string()), + }, + config::RegistryEntry { + id: "forge".to_string(), + cli: "claude".to_string(), + active: true, + instances: 2, + model_backend: Some(default_model.to_string()), + routing_key: Some("forge-key".to_string()), + github_token_env: Some("AGENT_FORGE_GITHUB_TOKEN".to_string()), + }, + config::RegistryEntry { + id: "sentinel".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("sentinel-key".to_string()), + github_token_env: Some("AGENT_SENTINEL_GITHUB_TOKEN".to_string()), + }, + config::RegistryEntry { + id: "vessel".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("vessel-key".to_string()), + github_token_env: Some("AGENT_VESSEL_GITHUB_TOKEN".to_string()), + }, + config::RegistryEntry { + id: "lore".to_string(), + cli: "claude".to_string(), + active: false, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("lore-key".to_string()), + github_token_env: None, // No token needed for inactive agents + }, + ] + } else { + config + .agents + .iter() + .map(|agent| { + // Ensure github_token_env is set - use agent's value or generate default + let token_env = agent.github_token_env.clone().or_else(|| { + if agent.active { + Some(format!("AGENT_{}_GITHUB_TOKEN", agent.id.to_uppercase())) + } else { + None + } + }); + config::RegistryEntry { + id: agent.id.clone(), + cli: agent.cli.clone(), + active: agent.active, + instances: agent.instances, + model_backend: agent.model_backend.clone(), + routing_key: agent.routing_key.clone(), + github_token_env: token_env, + } + }) + .collect() + }, + }; + + let content = serde_json::to_string_pretty(®istry)?; + std::fs::write(registry_dir.join("registry.json"), content)?; + Ok(()) +} diff --git a/crates/agentflow-tui/src/setup/step_agents.rs b/crates/agentflow-tui/src/setup/step_agents.rs new file mode 100644 index 0000000..6106d2a --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_agents.rs @@ -0,0 +1,465 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; + +use crate::setup::{AgentConfig, SetupConfig}; +use crate::util::theme::Theme; +use crate::widgets::select::SelectableListState; + +const MODELS_ANTHROPIC: &[&str] = &[ + "anthropic/claude-sonnet-4-5", + "anthropic/claude-3-5-sonnet", + "anthropic/claude-3-haiku-20240307", +]; + +const MODELS_GEMINI: &[&str] = &[ + "gemini/gemini-2.5-pro", + "gemini/gemini-2.5-flash", + "gemini/gemini-2.0-flash-exp", +]; + +const MODELS_OPENAI: &[&str] = &[ + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-4-turbo", +]; + +const MODELS_GROQ: &[&str] = &[ + "groq/llama-3.3-70b-versatile", + "groq/llama-3.1-8b-instant", +]; + +const MODELS_FIREWORKS: &[&str] = &[ + "accounts/fireworks/models/llama-v3p1-8b-instruct", + "accounts/fireworks/models/glm-5", + "accounts/fireworks/models/kimi-k2p5", + "accounts/fireworks/models/qwen3-235b-a22b", +]; + +const MODELS_ALL: &[&str] = &[ + "anthropic/claude-sonnet-4-5", + "anthropic/claude-3-5-sonnet", + "gemini/gemini-2.5-pro", + "openai/gpt-4o", + "groq/llama-3.3-70b-versatile", +]; + +fn get_models_for_provider(provider: Option<&str>) -> Vec<&'static str> { + match provider { + Some(p) if p.contains("Anthropic") => MODELS_ANTHROPIC.to_vec(), + Some(p) if p.contains("Gemini") || p.contains("Google") => MODELS_GEMINI.to_vec(), + Some(p) if p.contains("OpenAI") => MODELS_OPENAI.to_vec(), + Some(p) if p.contains("Groq") => MODELS_GROQ.to_vec(), + Some(p) if p.contains("Fireworks") => MODELS_FIREWORKS.to_vec(), + _ => MODELS_ALL.to_vec(), + } +} + +enum AgentConfigState { + MainList { + agents: Vec, + selected: usize, + focused_field: usize, + available_models: Vec<&'static str>, + }, + ModelPicker { + agents: Vec, + agent_idx: usize, + selected: usize, + available_models: Vec<&'static str>, + }, +} + +pub struct AgentsStep; + +impl AgentsStep { + pub fn new() -> Self { + Self + } + + pub async fn render( + &self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let mut agents: Vec = Vec::new(); + + let registry_path = std::env::current_dir()? + .join("orchestration") + .join("agent") + .join("registry.json"); + + if registry_path.exists() { + if let Ok(registry) = config::Registry::load(®istry_path) { + for entry in registry.team { + agents.push(AgentConfig { + id: entry.id, + cli: entry.cli, + active: entry.active, + instances: entry.instances, + model_backend: entry.model_backend, + routing_key: entry.routing_key, + github_token_env: entry.github_token_env, + }); + } + } + } + + // Default agents if registry doesn't exist + // Get provider-specific models or default models + let available_models = get_models_for_provider(config.selected_provider.as_deref()); + let default_model = available_models.first().unwrap_or(&"anthropic/claude-sonnet-4-5"); + + // Nexus always has exactly 1 instance (immutable) + if agents.is_empty() { + agents.push(AgentConfig { + id: "nexus".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, // Nexus is always 1 instance (orchestrator singleton) + model_backend: Some(default_model.to_string()), + routing_key: Some("nexus-key".to_string()), + github_token_env: Some("AGENT_NEXUS_GITHUB_TOKEN".to_string()), + }); + agents.push(AgentConfig { + id: "forge".to_string(), + cli: "claude".to_string(), + active: true, + instances: 2, + model_backend: Some(default_model.to_string()), + routing_key: Some("forge-key".to_string()), + github_token_env: Some("AGENT_FORGE_GITHUB_TOKEN".to_string()), + }); + agents.push(AgentConfig { + id: "sentinel".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("sentinel-key".to_string()), + github_token_env: Some("AGENT_SENTINEL_GITHUB_TOKEN".to_string()), + }); + agents.push(AgentConfig { + id: "vessel".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("vessel-key".to_string()), + github_token_env: Some("AGENT_VESSEL_GITHUB_TOKEN".to_string()), + }); + agents.push(AgentConfig { + id: "lore".to_string(), + cli: "claude".to_string(), + active: true, + instances: 1, + model_backend: Some(default_model.to_string()), + routing_key: Some("lore-key".to_string()), + github_token_env: Some("AGENT_LORE_GITHUB_TOKEN".to_string()), + }); + } + + let mut state = AgentConfigState::MainList { + agents, + selected: 0, + focused_field: 0, + available_models, + }; + + loop { + match &mut state { + AgentConfigState::MainList { agents, selected, focused_field, available_models } => { + loop { + terminal.draw(|f| { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ CONFIGURE AGENTS", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Edit instances, model, and active status per agent", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render(inner_title, f.buffer_mut()); + + // Header row + let header = Line::styled( + format!( + " {:<12} {:<10} {:<12} {}", + "AGENT", "ACTIVE", "INSTANCES", "MODEL BACKEND" + ), + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let header_para = Paragraph::new(header); + header_para.render(chunks[1], f.buffer_mut()); + + // Agent rows + let mut current_y = chunks[2].y; + let row_height = 1u16; + + for (i, agent) in agents.iter().enumerate() { + if current_y + row_height > chunks[2].y + chunks[2].height { + break; + } + + let active_str = if agent.active { "✓ ON " } else { "✗ OFF" }; + let instances_str = format!("{}", agent.instances); + let model_str = agent.model_backend.as_deref().unwrap_or("none"); + + let is_selected = i == *selected; + let row_style = if is_selected { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg()) + }; + + let prefix = if is_selected { "▶ " } else { " " }; + + // Build the row with field highlighting + let mut row_text = String::new(); + row_text.push_str(&format!("{}{:<12}", prefix, agent.id)); + + // Active field + if is_selected && *focused_field == 0 { + row_text.push_str(&format!("[{}]", active_str)); + } else { + row_text.push_str(&format!(" {:<10}", active_str)); + } + + // Instances field (nexus is locked at 1) + let is_nexus = agent.id == "nexus"; + let instances_editable = !is_nexus; + let instances_display = if is_nexus { + format!("{} (locked)", instances_str) + } else { + instances_str.clone() + }; + if is_selected && *focused_field == 1 { + if instances_editable { + row_text.push_str(&format!("[{:<12}]", instances_str)); + } else { + row_text.push_str(&format!(" {:<12}", instances_display)); + } + } else { + row_text.push_str(&format!(" {:<12}", instances_display)); + } + + // Model field + if is_selected && *focused_field == 2 { + row_text.push_str(&format!("[{}]", model_str)); + } else { + row_text.push_str(&format!(" {}", model_str)); + } + + let row_line = Line::styled(row_text, row_style); + let row_para = Paragraph::new(row_line); + row_para.render( + ratatui::layout::Rect::new( + chunks[2].x, + current_y, + chunks[2].width, + row_height, + ), + f.buffer_mut(), + ); + current_y += row_height; + } + + // Help text + let help_lines = vec![ + Line::styled( + " ↑↓ select agent │ Tab: next field │ Space: toggle active │ ←→: adjust instances", + Style::default().fg(theme.muted()), + ), + Line::styled( + " Enter on model: pick model │ Shift+Tab: finish │ Nexus: always 1 instance (locked)", + Style::default().fg(theme.muted()), + ), + ]; + let help_para = Paragraph::new(help_lines); + help_para.render(chunks[3], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + use crossterm::event::KeyModifiers; + + match key.code { + KeyCode::Up => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down => { + if *selected + 1 < agents.len() { + *selected += 1; + } + } + KeyCode::Tab => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + config.agents = agents.clone(); + return Ok(()); + } else { + *focused_field = (*focused_field + 1) % 3; + } + } + KeyCode::BackTab => { + config.agents = agents.clone(); + return Ok(()); + } + KeyCode::Char(' ') => { + if *focused_field == 0 { + agents[*selected].active = !agents[*selected].active; + } + } + KeyCode::Left => { + if *focused_field == 1 && agents[*selected].id != "nexus" && agents[*selected].instances > 1 { + agents[*selected].instances -= 1; + } + } + KeyCode::Right => { + if *focused_field == 1 && agents[*selected].id != "nexus" && agents[*selected].instances < 10 { + agents[*selected].instances += 1; + } + } + KeyCode::Enter => { + if *focused_field == 2 { + let current_model = agents[*selected] + .model_backend + .as_deref() + .unwrap_or(""); + let initial_idx = available_models + .iter() + .position(|m| *m == current_model) + .unwrap_or(0); + state = AgentConfigState::ModelPicker { + agents: agents.clone(), + agent_idx: *selected, + selected: initial_idx, + available_models: available_models.clone(), + }; + break; + } + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => {} + } + } + } + } + } + AgentConfigState::ModelPicker { agents, agent_idx, selected, available_models } => { + let agent_idx_val = *agent_idx; + let mut list_state = SelectableListState::new( + available_models.iter().map(|s| s.to_string()).collect(), + ); + list_state.selected = *selected; + + loop { + terminal.draw(|f| { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(3) + .constraints([ + Constraint::Length(4), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ SELECT MODEL BACKEND", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + format!(" Choose model for agent: {}", agents[agent_idx_val].id), + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render(inner_title, f.buffer_mut()); + + let list_widget = crate::widgets::select::SelectableList::new( + &list_state.items, + list_state.selected, + ).title("Select model backend"); + list_widget.render(chunks[1], f.buffer_mut()); + + let help = Line::styled( + " ↑↓ navigate │ Enter: select │ Esc: cancel", + Style::default().fg(theme.muted()), + ); + let help_para = Paragraph::new(help); + help_para.render(chunks[2], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => list_state.move_up(), + KeyCode::Down => list_state.move_down(), + KeyCode::Enter => { + agents[agent_idx_val].model_backend = + Some(available_models[list_state.selected].to_string()); + state = AgentConfigState::MainList { + agents: agents.clone(), + selected: agent_idx_val, + focused_field: 2, + available_models: available_models.clone(), + }; + break; + } + KeyCode::Esc => { + state = AgentConfigState::MainList { + agents: agents.clone(), + selected: agent_idx_val, + focused_field: 2, + available_models: available_models.clone(), + }; + break; + } + _ => {} + } + } + } + } + } + } + } + } +} diff --git a/crates/agentflow-tui/src/setup/step_api.rs b/crates/agentflow-tui/src/setup/step_api.rs new file mode 100644 index 0000000..f3fed4d --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_api.rs @@ -0,0 +1,394 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::input::InputWidget; +use crate::widgets::select::SelectableListState; + +#[derive(Clone)] +struct ApiField { + label: String, + env_key: String, + input: Input, + required: bool, +} + +/// API format options for Fireworks +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FireworksApiFormat { + Anthropic, + OpenAi, +} + +impl FireworksApiFormat { + fn as_str(&self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::OpenAi => "openai", + } + } + + fn display_name(&self) -> &'static str { + match self { + Self::Anthropic => "Anthropic (Claude-compatible)", + Self::OpenAi => "OpenAI (OpenAI-compatible)", + } + } + + fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "openai" | "open_ai" => Self::OpenAi, + _ => Self::Anthropic, + } + } +} + +pub struct ApiStep { + fireworks_format: FireworksApiFormat, +} + +impl ApiStep { + pub fn new() -> Self { + Self { + fireworks_format: FireworksApiFormat::Anthropic, + } + } + + pub async fn render( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let provider_name = config.selected_provider.clone().unwrap_or_default(); + + let mut fields: Vec = Vec::new(); + + let provider_field = match provider_name.as_str() { + "Anthropic (Claude)" => Some(ApiField { + label: "Anthropic API Key".to_string(), + env_key: "ANTHROPIC_API_KEY".to_string(), + input: Input::new(config.anthropic_key.clone()), + required: true, + }), + "OpenAI" => Some(ApiField { + label: "OpenAI API Key".to_string(), + env_key: "OPENAI_API_KEY".to_string(), + input: Input::new(config.openai_key.clone().unwrap_or_default()), + required: true, + }), + "Google Gemini" => Some(ApiField { + label: "Google Gemini API Key".to_string(), + env_key: "GEMINI_API_KEY".to_string(), + input: Input::new(config.gemini_key.clone().unwrap_or_default()), + required: true, + }), + "Fireworks AI" => { + // Fireworks only supports OpenAI format (no native Anthropic endpoint) + let key_fields = vec![ + ApiField { + label: "Fireworks API Key".to_string(), + env_key: "FIREWORKS_API_KEY".to_string(), + input: Input::new(config.fireworks_key.clone().unwrap_or_default()), + required: true, + }, + ]; + + // Render key input (no format selector needed) + config.fireworks_api_format = "openai".to_string(); + return self.render_fields(terminal, theme, config, key_fields, &provider_name, false).await; + } + "LiteLLM Proxy" => { + let mut proxy_fields = Vec::new(); + proxy_fields.push(ApiField { + label: "LiteLLM Proxy URL".to_string(), + env_key: "LITELLM_URL".to_string(), + input: Input::new(std::env::var("LITELLM_URL").unwrap_or_default()), + required: true, + }); + proxy_fields.push(ApiField { + label: "LiteLLM API Key (optional)".to_string(), + env_key: "LITELLM_API_KEY".to_string(), + input: Input::new(std::env::var("LITELLM_API_KEY").unwrap_or_default()), + required: false, + }); + return self.render_fields(terminal, theme, config, proxy_fields, &provider_name, false).await; + } + "Ollama (Local)" => Some(ApiField { + label: "Ollama Host URL".to_string(), + env_key: "OLLAMA_HOST".to_string(), + input: Input::new(std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".to_string())), + required: true, + }), + "Skip for now" => return Ok(()), + _ => return Ok(()), + }; + + if let Some(pf) = provider_field { + fields.push(pf); + } + + if fields.is_empty() { + return Ok(()); + } + + self.render_fields(terminal, theme, config, fields, &provider_name, false).await + } + + async fn render_format_selector( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + _provider_name: &str, + ) -> Result { + let formats = [FireworksApiFormat::Anthropic, FireworksApiFormat::OpenAi]; + let format_names: Vec = formats.iter().map(|f| f.display_name().to_string()).collect(); + let mut list_state = SelectableListState::new(format_names.clone()); + list_state.selected = 0; + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(3) + .constraints([ + Constraint::Length(6), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ FIREWORKS API FORMAT", + Style::default() + .fg(theme.accent_alt()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Choose API compatibility mode", + Style::default().fg(theme.muted()), + ); + let description = Line::styled( + " Anthropic = Claude-compatible | OpenAI = OpenAI-compatible", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle, description]); + title_para.render(inner_title, f.buffer_mut()); + + let list_widget = crate::widgets::select::SelectableList::new( + &list_state.items, + list_state.selected, + ); + + list_widget.render(chunks[1], f.buffer_mut()); + + let help = " ↑/↓: navigate │ Enter: select │ Esc: cancel"; + let help_line = Line::styled(help, Style::default().fg(theme.muted())); + let help_para = Paragraph::new(help_line); + help_para.render(chunks[2], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => { + list_state.move_up(); + } + KeyCode::Down => { + list_state.move_down(); + } + KeyCode::Enter => { + let idx = list_state.selected; + return Ok(formats[idx]); + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => {} + } + } + } + } + } + + async fn render_fields( + &self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + mut fields: Vec, + provider_name: &str, + _show_format: bool, + ) -> Result<()> { + let total_fields = fields.len(); + let mut focused_field: usize = 0; + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(3) + .constraints([ + Constraint::Length(4), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ LLM PROVIDER", + Style::default() + .fg(theme.accent_alt()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + format!(" Configure {} API credentials", provider_name), + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render(inner_title, f.buffer_mut()); + + let input_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, chunks[1].height); + + let mut current_y = input_area.y; + + let field_height = 3u16; + + for (i, field) in fields.iter().enumerate() { + if current_y + field_height > input_area.y + input_area.height { + break; + } + + let widget = InputWidget::new(&field.input, &field.label) + .masked(true) + .focused(focused_field == i) + .optional(!field.required); + widget.render( + Rect::new(input_area.x, current_y, input_area.width, field_height), + f.buffer_mut(), + ); + current_y += field_height + 1; + } + + let help = " Tab/Arrows: navigate │ Enter: continue │ Esc: cancel"; + let help_line = Line::styled(help, Style::default().fg(theme.muted())); + let help_para = Paragraph::new(help_line); + help_para.render(chunks[2], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Tab => { + focused_field = (focused_field + 1) % total_fields; + } + KeyCode::BackTab => { + focused_field = if focused_field == 0 { + total_fields - 1 + } else { + focused_field - 1 + }; + } + KeyCode::Enter => { + let all_required_filled = fields.iter() + .filter(|f| f.required) + .all(|f| !f.input.value().is_empty()); + + if all_required_filled { + for field in &fields { + let value = field.input.value().to_string(); + match field.env_key.as_str() { + "ANTHROPIC_API_KEY" => config.anthropic_key = value, + "OPENAI_API_KEY" => { + config.openai_key = if value.is_empty() { + None + } else { + Some(value) + }; + } + "GEMINI_API_KEY" => { + config.gemini_key = if value.is_empty() { + None + } else { + Some(value) + }; + } + "FIREWORKS_API_KEY" => { + config.fireworks_key = if value.is_empty() { + None + } else { + Some(value) + }; + } + "LITELLM_URL" => { + config.proxy_url = if value.is_empty() { + None + } else { + Some(value) + }; + } + "LITELLM_API_KEY" => { + config.proxy_api_key = if value.is_empty() { + None + } else { + Some(value) + }; + } + "OLLAMA_HOST" => { + config.gateway_url = if value.is_empty() { + None + } else { + Some(value) + }; + } + _ => {} + } + } + break; + } + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => { + let event = crossterm::event::Event::Key(key); + if focused_field < fields.len() { + fields[focused_field].input.handle_event(&event); + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_done.rs b/crates/agentflow-tui/src/setup/step_done.rs new file mode 100644 index 0000000..8d6c8e2 --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_done.rs @@ -0,0 +1,154 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; +use std::path::Path; + +use crate::setup::write_env_file; +use crate::setup::write_registry_file; +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::check::{CheckList, CheckState}; + +pub struct DoneStep; + +impl DoneStep { + pub fn new() -> Self { + Self + } + + pub async fn render( + &self, + terminal: &mut Terminal>, + theme: &Theme, + config: &SetupConfig, + ) -> Result<()> { + let current_dir = std::env::current_dir()?; + write_env_file(config, ¤t_dir)?; + write_registry_file(config, ¤t_dir)?; + + let registry_path = current_dir + .join("orchestration") + .join("agent") + .join("registry.json"); + + let mut checks = Vec::new(); + + if Path::new(".env").exists() { + checks.push((".env file written".to_string(), CheckState::Pass)); + } else { + checks.push((".env file write failed".to_string(), CheckState::Fail)); + } + + if registry_path.exists() { + match config::Registry::load(®istry_path) { + Ok(registry) => { + let agent_count = registry.active_agents().count(); + let slot_count = registry.all_worker_slots().len(); + checks.push(( + format!( + "Registry loaded ({} agents, {} slots)", + agent_count, slot_count + ), + CheckState::Pass, + )); + } + Err(e) => { + checks.push((format!("Registry parse error: {}", e), CheckState::Fail)); + } + } + } else { + checks.push(("Registry file not found".to_string(), CheckState::Warn)); + } + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), + Constraint::Length(6), + Constraint::Length(7), + Constraint::Min(1), + ]) + .split(area); + + let title_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(chunks[0]); + + let title_line = Line::styled( + "Setup complete!", + Style::default() + .fg(theme.success()) + .add_modifier(Modifier::BOLD), + ); + let title_para = Paragraph::new(title_line).alignment(Alignment::Left); + title_para.render(title_chunks[0], f.buffer_mut()); + + let sep_line = Line::styled( + "─────────────────────────────────────", + Style::default().fg(theme.border()), + ); + let sep_para = Paragraph::new(sep_line); + sep_para.render(title_chunks[1], f.buffer_mut()); + + let check_list = CheckList::new(checks.clone()); + check_list.render(chunks[1], f.buffer_mut()); + + let next_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(5), + ]) + .split(chunks[2]); + + let next_title = Line::styled( + "Next steps:", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let next_para = Paragraph::new(next_title); + next_para.render(next_chunks[0], f.buffer_mut()); + + let steps = vec![ + Line::styled(" 1. Review your .env and registry.json files", theme.text_style()), + Line::styled(" 2. Run 'openflows-setup' to reconfigure", theme.text_style()), + Line::styled(" 3. Run 'openflows-doctor' to validate setup", theme.text_style()), + Line::styled(" 4. Run 'openflows' to start", theme.text_style()), + ]; + let steps_para = Paragraph::new(steps); + steps_para.render(next_chunks[1], f.buffer_mut()); + + let help_line = Line::styled( + "Press Enter to exit...", + Style::default().fg(theme.muted()), + ); + let help_para = Paragraph::new(help_line).alignment(Alignment::Center); + help_para.render(chunks[3], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + if key.code == crossterm::event::KeyCode::Enter { + break; + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_env.rs b/crates/agentflow-tui/src/setup/step_env.rs new file mode 100644 index 0000000..850081f --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_env.rs @@ -0,0 +1,130 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; + +use crate::util::env_check; +use crate::util::theme::Theme; +use crate::widgets::check::{CheckList, CheckState}; + +pub struct EnvStep { + checks: Vec<(String, CheckState)>, +} + +impl EnvStep { + pub fn new() -> Self { + let mut checks = Vec::new(); + + if let Some(version) = env_check::check_rustc() { + checks.push((format!("Rust {}", version), CheckState::Pass)); + } else { + checks.push(("Rust not found".to_string(), CheckState::Fail)); + } + + if let Some(version) = env_check::check_git() { + checks.push((format!("Git {}", version), CheckState::Pass)); + } else { + checks.push(("Git not found".to_string(), CheckState::Fail)); + } + + if let Some(version) = env_check::check_node() { + checks.push((format!("Node.js {}", version), CheckState::Pass)); + } else { + checks.push(("Node.js not found (for GitHub MCP)".to_string(), CheckState::Warn)); + } + + if let Some(version) = env_check::check_claude() { + checks.push((format!("Claude Code CLI {}", version), CheckState::Pass)); + } else { + checks.push(("Claude Code CLI not found (required)".to_string(), CheckState::Fail)); + } + + Self { checks } + } + + pub fn render( + &self, + terminal: &mut Terminal>, + theme: &Theme, + ) -> Result<()> { + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + let title_line = Line::styled( + "┌ OpenFlow Setup", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let title_para = Paragraph::new(title_line); + title_para.render(chunks[0], f.buffer_mut()); + + let sep_line = Line::styled( + "│", + Style::default().fg(theme.border()), + ); + let sep_para = Paragraph::new(sep_line); + sep_para.render(chunks[1], f.buffer_mut()); + + let prompt_line = Line::styled( + "◆ Environment Check", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let prompt_para = Paragraph::new(prompt_line); + prompt_para.render(chunks[2], f.buffer_mut()); + + let check_list = CheckList::new(self.checks.clone()); + check_list.render( + Rect::new(chunks[2].x, chunks[2].y + 2, chunks[2].width, chunks[2].height - 2), + f.buffer_mut(), + ); + + let has_failures = self + .checks + .iter() + .any(|(_, state)| state == &CheckState::Fail); + + let help_text = if has_failures { + "Some required tools are missing. Press Enter to continue anyway, or Esc to cancel." + } else { + "Press Enter to continue..." + }; + + let help_line = Line::styled( + help_text, + Style::default().fg(theme.muted()), + ); + let help_para = Paragraph::new(help_line).alignment(Alignment::Center); + help_para.render(chunks[3], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + if key.code == crossterm::event::KeyCode::Enter { + break; + } + if key.code == crossterm::event::KeyCode::Esc { + return Err(anyhow::anyhow!("Setup cancelled")); + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_existing.rs b/crates/agentflow-tui/src/setup/step_existing.rs new file mode 100644 index 0000000..05656ef --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_existing.rs @@ -0,0 +1,246 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; +use std::path::Path; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::infobox::KeyValueBox; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ConfigAction { + UseExisting, + EditExisting, + Reconfigure, + Cancel, +} + +pub struct ExistingConfigStep { + action: ConfigAction, + existing_config: Option, +} + +impl ExistingConfigStep { + pub fn new() -> Self { + Self { + action: ConfigAction::UseExisting, + existing_config: None, + } + } + + pub fn detect_existing_config(project_dir: &Path) -> Option { + let env_path = project_dir.join(".env"); + if !env_path.exists() { + return None; + } + + let content = std::fs::read_to_string(env_path).ok()?; + let mut config = SetupConfig::default(); + + for line in content.lines() { + if line.starts_with('#') || line.is_empty() { + continue; + } + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "ANTHROPIC_API_KEY" => { + config.anthropic_key = value.to_string(); + config.selected_provider = Some("Anthropic (Claude)".to_string()); + } + "GITHUB_PERSONAL_ACCESS_TOKEN" => config.github_pat = value.to_string(), + "GITHUB_REPOSITORY" => config.repo = value.to_string(), + "GEMINI_API_KEY" => { + config.gemini_key = Some(value.to_string()); + config.selected_provider = Some("Google Gemini".to_string()); + } + "OPENAI_API_KEY" => { + config.openai_key = Some(value.to_string()); + config.selected_provider = Some("OpenAI".to_string()); + } + "FIREWORKS_API_KEY" => { + config.fireworks_key = Some(value.to_string()); + config.selected_provider = Some("Fireworks AI".to_string()); + } + "AGENTFLOW_WORKSPACE_ROOT" => config.workspace_dir = value.to_string(), + "PROXY_URL" => { + config.proxy_enabled = true; + config.proxy_url = Some(value.to_string()); + } + "PROXY_API_KEY" => config.proxy_api_key = Some(value.to_string()), + "GATEWAY_URL" => config.gateway_url = Some(value.to_string()), + "GATEWAY_API_KEY" => config.gateway_api_key = Some(value.to_string()), + _ => { + // Capture per-agent tokens + if key.trim().starts_with("AGENT_") && key.trim().ends_with("_GITHUB_TOKEN") { + config.agent_tokens.push((key.trim().to_string(), value.to_string())); + } + } + } + } + } + + // Also load agents from registry.json + let registry_path = project_dir + .join("orchestration") + .join("agent") + .join("registry.json"); + + if registry_path.exists() { + if let Ok(registry) = config::Registry::load(®istry_path) { + config.agents = registry.team.iter().map(|entry| { + crate::setup::AgentConfig { + id: entry.id.clone(), + cli: entry.cli.clone(), + active: entry.active, + instances: entry.instances, + model_backend: entry.model_backend.clone(), + routing_key: entry.routing_key.clone(), + github_token_env: entry.github_token_env.clone(), + } + }).collect(); + } + } + + Some(config) + } + + pub fn action(&self) -> ConfigAction { + self.action + } + + pub fn existing_config(&self) -> Option<&SetupConfig> { + self.existing_config.as_ref() + } + + pub fn render( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let project_dir = std::env::current_dir()?; + if let Some(existing) = Self::detect_existing_config(&project_dir) { + self.existing_config = Some(existing.clone()); + } else { + return Ok(()); + } + + let actions = vec![ + "Use existing values (skip setup)".to_string(), + "Edit existing values".to_string(), + "Reconfigure everything from scratch".to_string(), + "Cancel setup".to_string(), + ]; + let mut selected = 0; + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(8), + ]) + .split(area); + + let title_line = Line::styled( + "┌ OpenFlow Setup", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let title_para = Paragraph::new(title_line); + title_para.render(chunks[0], f.buffer_mut()); + + let sep_line = Line::styled( + "│", + Style::default().fg(theme.border()), + ); + let sep_para = Paragraph::new(sep_line); + sep_para.render(chunks[1], f.buffer_mut()); + + let prompt_line = Line::styled( + "◇ Existing config detected", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let prompt_para = Paragraph::new(prompt_line); + prompt_para.render(Rect::new(chunks[2].x, chunks[2].y, chunks[2].width, 1), f.buffer_mut()); + + if let Some(ref existing) = self.existing_config { + let kv_box = KeyValueBox::new("Current configuration") + .item("workspace", &existing.workspace_dir) + .item("repo", &existing.repo); + kv_box.render( + Rect::new(chunks[2].x, chunks[2].y + 2, chunks[2].width, 5), + f.buffer_mut(), + ); + } + + let mut action_lines = Vec::new(); + for (i, action) in actions.iter().enumerate() { + let icon = if i == selected { "●" } else { "○" }; + let style = if i == selected { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + theme.text_style() + }; + action_lines.push(Line::from(vec![ + Span::styled(format!(" {} ", icon), style), + Span::styled(action.as_str(), style), + ])); + } + let action_para = Paragraph::new(action_lines); + action_para.render( + Rect::new(chunks[2].x, chunks[2].y + 8, chunks[2].width, 5), + f.buffer_mut(), + ); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => { + selected = if selected == 0 { actions.len() - 1 } else { selected - 1 }; + } + KeyCode::Down => { + selected = (selected + 1) % actions.len(); + } + KeyCode::Enter => { + self.action = match selected { + 0 => ConfigAction::UseExisting, + 1 => ConfigAction::EditExisting, + 2 => ConfigAction::Reconfigure, + 3 => ConfigAction::Cancel, + _ => ConfigAction::UseExisting, + }; + if matches!(self.action, ConfigAction::UseExisting | ConfigAction::EditExisting) { + if let Some(ref existing) = self.existing_config { + *config = existing.clone(); + } + } + break; + } + KeyCode::Esc => { + self.action = ConfigAction::Cancel; + break; + } + _ => {} + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_github.rs b/crates/agentflow-tui/src/setup/step_github.rs new file mode 100644 index 0000000..51f78a4 --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_github.rs @@ -0,0 +1,247 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::input::InputWidget; + +struct GitHubField { + label: String, + env_key: String, + input: Input, + required: bool, +} + +pub struct GitHubStep; + +impl GitHubStep { + pub fn new() -> Self { + Self + } + + pub async fn render( + &self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let mut fields: Vec = Vec::new(); + + // Use configured agents from SetupConfig if available, otherwise fall back to registry file + if !config.agents.is_empty() { + for agent in &config.agents { + if agent.active { + let env_key = format!("AGENT_{}_GITHUB_TOKEN", agent.id.to_uppercase()); + let existing = std::env::var(&env_key).unwrap_or_default(); + fields.push(GitHubField { + label: format!("{} GitHub PAT", agent.id.to_uppercase()), + env_key, + input: Input::new(existing), + required: true, + }); + } + } + } else { + let registry_path = std::env::current_dir()? + .join("orchestration") + .join("agent") + .join("registry.json"); + + if registry_path.exists() { + if let Ok(registry) = config::Registry::load(®istry_path) { + for entry in registry.active_agents() { + let env_key = entry.github_token_env.clone() + .unwrap_or_else(|| "GITHUB_PERSONAL_ACCESS_TOKEN".to_string()); + let existing = std::env::var(&env_key).unwrap_or_default(); + fields.push(GitHubField { + label: format!("{} GitHub PAT", entry.id.to_uppercase()), + env_key, + input: Input::new(existing), + required: true, + }); + } + } + } + } + + if fields.is_empty() { + fields.push(GitHubField { + label: "GitHub PAT".to_string(), + env_key: "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(), + input: Input::new(config.github_pat.clone()), + required: true, + }); + } + + let total_fields = fields.len(); + let mut focused_field: usize = 0; + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(3) + .constraints([ + Constraint::Length(4), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ GITHUB AUTHENTICATION", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Configure tokens for agent operations", + Style::default().fg(theme.muted()), + ); + let perm_hint = Line::styled( + " Tokens need: FORGE (contents+PRs+issues rw), SENTINEL (PRs rw), VESSEL (contents+PRs+workflows rw), LORE (contents rw)", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle, perm_hint]); + title_para.render(inner_title, f.buffer_mut()); + + let input_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, chunks[1].height); + + let field_height = 3u16; + let field_spacing = 0u16; + let total_per_field = field_height + field_spacing; + + let visible_count = ((input_area.height) / total_per_field).max(1) as usize; + let scroll_offset = focused_field.saturating_sub(visible_count.saturating_sub(1)); + + let mut current_y = input_area.y; + + for (i, field) in fields.iter().enumerate() { + if i < scroll_offset || i >= scroll_offset + visible_count { + continue; + } + + if current_y + field_height > input_area.y + input_area.height { + break; + } + + let label = if field.required { + field.label.clone() + } else { + field.label.clone() + }; + + let widget = InputWidget::new(&field.input, &label) + .masked(true) + .focused(focused_field == i) + .optional(!field.required); + widget.render( + Rect::new(input_area.x, current_y, input_area.width, field_height), + f.buffer_mut(), + ); + current_y += total_per_field; + } + + let help = if fields.len() > visible_count { + format!(" ◄ ► navigate │ {} of {} │ Enter: continue │ Esc: cancel", focused_field + 1, fields.len()) + } else { + " Tab/Arrows: navigate │ Enter: continue │ Esc: cancel".to_string() + }; + let help_line = Line::styled(help, Style::default().fg(theme.muted())); + let help_para = Paragraph::new(help_line); + help_para.render(chunks[2], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Tab => { + focused_field = (focused_field + 1) % total_fields; + } + KeyCode::BackTab => { + focused_field = if focused_field == 0 { + total_fields - 1 + } else { + focused_field - 1 + }; + } + KeyCode::Down => { + focused_field = (focused_field + 1) % total_fields; + } + KeyCode::Up => { + focused_field = if focused_field == 0 { + total_fields - 1 + } else { + focused_field - 1 + }; + } + KeyCode::Enter => { + let all_required_filled = fields.iter() + .filter(|f| f.required) + .all(|f| !f.input.value().is_empty()); + + if all_required_filled { + for field in &fields { + let value = field.input.value().to_string(); + match field.env_key.as_str() { + "GITHUB_PERSONAL_ACCESS_TOKEN" => { + config.github_pat = value.clone(); + // Also set this as the github_token_env for all active agents + // when using the fallback single PAT + for agent in config.agents.iter_mut().filter(|a| a.active) { + agent.github_token_env = Some("GITHUB_PERSONAL_ACCESS_TOKEN".to_string()); + } + } + _ => { + if field.env_key.starts_with("AGENT_") { + config.agent_tokens.push((field.env_key.clone(), value)); + // Set the github_token_env on the corresponding agent + let agent_id = field.env_key + .strip_prefix("AGENT_") + .and_then(|s| s.strip_suffix("_GITHUB_TOKEN")) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + if let Some(agent) = config.agents.iter_mut().find(|a| a.id == agent_id) { + agent.github_token_env = Some(field.env_key.clone()); + } + } + } + } + } + break; + } + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => { + let event = crossterm::event::Event::Key(key); + if focused_field < fields.len() { + fields[focused_field].input.handle_event(&event); + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_mode.rs b/crates/agentflow-tui/src/setup/step_mode.rs new file mode 100644 index 0000000..5693348 --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_mode.rs @@ -0,0 +1,132 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; + +use crate::util::theme::Theme; +use crate::widgets::select::SelectableListState; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SetupMode { + QuickStart, + Advanced, +} + +pub struct ModeStep { + selected_mode: SetupMode, +} + +impl ModeStep { + pub fn new() -> Self { + Self { + selected_mode: SetupMode::QuickStart, + } + } + + pub fn selected_mode(&self) -> SetupMode { + self.selected_mode + } + + pub fn render( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + ) -> Result<()> { + let modes = vec![ + "QuickStart".to_string(), + "Advanced".to_string(), + ]; + let mut list_state = SelectableListState::new(modes); + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(5), + Constraint::Length(2), + ]) + .split(area); + + let title_line = Line::styled( + "┌ OpenFlow Setup", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let title_para = Paragraph::new(title_line); + title_para.render(chunks[0], f.buffer_mut()); + + let sep_line = Line::styled( + "│", + Style::default().fg(theme.border()), + ); + let sep_para = Paragraph::new(sep_line); + sep_para.render(chunks[1], f.buffer_mut()); + + let prompt_line = Line::styled( + "◇ Setup mode", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let prompt_para = Paragraph::new(prompt_line); + prompt_para.render(chunks[2], f.buffer_mut()); + + let mode_info = match list_state.selected { + 0 => "QuickStart: Configure essentials only (recommended)", + 1 => "Advanced: Full configuration including proxy settings", + _ => "", + }; + + let list_widget = crate::widgets::select::SelectableList::new( + &list_state.items, + list_state.selected, + ).title("Select setup mode"); + list_widget.render( + Rect::new(chunks[3].x, chunks[3].y, chunks[3].width, chunks[3].height - 2), + f.buffer_mut(), + ); + + let info_line = Line::styled(mode_info.to_string(), theme.muted_style()); + let info_para = Paragraph::new(info_line).alignment(Alignment::Center); + info_para.render(chunks[4], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => { + list_state.move_up(); + } + KeyCode::Down => { + list_state.move_down(); + } + KeyCode::Enter => { + self.selected_mode = match list_state.selected { + 0 => SetupMode::QuickStart, + 1 => SetupMode::Advanced, + _ => SetupMode::QuickStart, + }; + break; + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => {} + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_provider.rs b/crates/agentflow-tui/src/setup/step_provider.rs new file mode 100644 index 0000000..457ece2 --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_provider.rs @@ -0,0 +1,159 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::Terminal; +use std::io; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::select::SelectableListState; + +#[derive(Debug, Clone, PartialEq)] +pub struct Provider { + pub name: String, + pub env_key: String, + pub requires_key: bool, +} + +fn get_providers() -> Vec { + vec![ + Provider { name: "Anthropic (Claude)".into(), env_key: "ANTHROPIC_API_KEY".into(), requires_key: true }, + Provider { name: "OpenAI".into(), env_key: "OPENAI_API_KEY".into(), requires_key: true }, + Provider { name: "Google Gemini".into(), env_key: "GEMINI_API_KEY".into(), requires_key: true }, + Provider { name: "Fireworks AI".into(), env_key: "FIREWORKS_API_KEY".into(), requires_key: true }, + Provider { name: "LiteLLM Proxy".into(), env_key: "LITELLM_URL".into(), requires_key: false }, + Provider { name: "Ollama (Local)".into(), env_key: "OLLAMA_HOST".into(), requires_key: false }, + Provider { name: "Skip for now".into(), env_key: String::new(), requires_key: false }, + ] +} + +pub struct ProviderStep { + selected_provider: Option, +} + +impl ProviderStep { + pub fn new() -> Self { + Self { + selected_provider: None, + } + } + + pub fn selected_provider(&self) -> Option<&Provider> { + self.selected_provider.as_ref() + } + + pub async fn render( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let providers = get_providers(); + let provider_names: Vec = providers.iter().map(|p| p.name.clone()).collect(); + let mut list_state = SelectableListState::new(provider_names).with_search(); + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(3) + .constraints([ + Constraint::Length(4), + Constraint::Min(8), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ SELECT LLM PROVIDER", + Style::default() + .fg(theme.accent_alt()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Choose your AI backend", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render(inner_title, f.buffer_mut()); + + let list_widget = crate::widgets::select::SelectableList::new( + &list_state.items, + list_state.selected, + ) + .search_query(&list_state.search_input); + + list_widget.render(chunks[1], f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => { + list_state.move_up(); + } + KeyCode::Down => { + list_state.move_down(); + } + KeyCode::Enter => { + if let Some(idx) = list_state.visible_items().iter().find(|(i, _)| *i == list_state.selected).map(|(i, _)| *i) { + self.selected_provider = Some(providers[idx].clone()); + let provider = &providers[idx]; + + // Store selected provider name for ApiStep + config.selected_provider = Some(provider.name.clone()); + + match provider.name.as_str() { + "Anthropic (Claude)" => { + if config.anthropic_key.is_empty() { + config.anthropic_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(); + } + } + "Google Gemini" => { + if config.gemini_key.is_none() { + config.gemini_key = Some(std::env::var("GEMINI_API_KEY").unwrap_or_default()); + } + } + "OpenAI" => { + if config.openai_key.is_none() { + config.openai_key = Some(std::env::var("OPENAI_API_KEY").unwrap_or_default()); + } + } + "Fireworks AI" => { + if config.fireworks_key.is_none() { + config.fireworks_key = Some(std::env::var("FIREWORKS_API_KEY").unwrap_or_default()); + } + } + _ => {} + } + } + break; + } + KeyCode::Esc => { + if !list_state.search_input.value().is_empty() { + list_state.search_input = tui_input::Input::default(); + } else { + return Err(anyhow::anyhow!("Setup cancelled")); + } + } + _ => { + list_state.handle_key(key); + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_proxy.rs b/crates/agentflow-tui/src/setup/step_proxy.rs new file mode 100644 index 0000000..46a458f --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_proxy.rs @@ -0,0 +1,145 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::{Line, Modifier, Style, Widget}; +use ratatui::Terminal; +use std::io; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::input::InputWidget; + +pub struct ProxyStep; + +impl ProxyStep { + pub fn new() -> Self { + Self + } + + pub async fn render( + &self, + terminal: &mut Terminal>, + _theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let theme = Theme::default(); + let mut proxy_url_input = Input::new(config.proxy_url.clone().unwrap_or_default()); + let mut proxy_key_input = Input::new(config.proxy_api_key.clone().unwrap_or_default()); + let mut target_model_input = Input::new(config.proxy_target_model.clone().unwrap_or_default()); + let mut gateway_url_input = Input::new(config.gateway_url.clone().unwrap_or_default()); + let mut gateway_key_input = Input::new(config.gateway_api_key.clone().unwrap_or_default()); + let mut focused_field = 0; + + loop { + terminal.draw(|f| { + let area = f.area(); + let y_start = area.height / 2 - 10; + + let title = Line::styled( + "◇ PROXY CONFIGURATION", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Advanced: configure proxy and model mapping", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render( + ratatui::layout::Rect { x: 2, y: y_start, width: area.width - 4, height: 2 }, + f.buffer_mut(), + ); + + let fields = [ + (&proxy_url_input, "Proxy URL", true), + (&proxy_key_input, "Proxy API Key", true), + (&target_model_input, "Target Model (PROXY_TARGET_MODEL)", true), + (&gateway_url_input, "Gateway URL", true), + (&gateway_key_input, "Gateway API Key", true), + ]; + + for (i, (input, label, optional)) in fields.iter().enumerate() { + let y = y_start + 3 + (i as u16) * 4; + let widget_area = ratatui::layout::Rect { + x: 2, + y, + width: area.width - 4, + height: 3, + }; + let widget = InputWidget::new(input, label) + .masked(i == 1 || i == 4) + .focused(focused_field == i) + .optional(*optional); + widget.render(widget_area, f.buffer_mut()); + } + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Tab => { + focused_field = (focused_field + 1) % 5; + } + KeyCode::BackTab => { + focused_field = if focused_field == 0 { + 4 + } else { + focused_field - 1 + }; + } + KeyCode::Enter => { + config.proxy_enabled = !proxy_url_input.value().is_empty(); + config.proxy_url = if proxy_url_input.value().is_empty() { + None + } else { + Some(proxy_url_input.value().to_string()) + }; + config.proxy_api_key = if proxy_key_input.value().is_empty() { + None + } else { + Some(proxy_key_input.value().to_string()) + }; + config.proxy_target_model = if target_model_input.value().is_empty() { + None + } else { + Some(target_model_input.value().to_string()) + }; + config.gateway_url = if gateway_url_input.value().is_empty() { + None + } else { + Some(gateway_url_input.value().to_string()) + }; + config.gateway_api_key = if gateway_key_input.value().is_empty() { + None + } else { + Some(gateway_key_input.value().to_string()) + }; + break; + } + KeyCode::Esc => { + config.proxy_enabled = false; + break; + } + _ => { + let event = crossterm::event::Event::Key(key); + let input = match focused_field { + 0 => &mut proxy_url_input, + 1 => &mut proxy_key_input, + 2 => &mut target_model_input, + 3 => &mut gateway_url_input, + 4 => &mut gateway_key_input, + _ => unreachable!(), + }; + input.handle_event(&event); + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_repo.rs b/crates/agentflow-tui/src/setup/step_repo.rs new file mode 100644 index 0000000..8cb130e --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_repo.rs @@ -0,0 +1,150 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::{Line, Modifier, Style, Widget}; +use ratatui::Terminal; +use std::io; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use crate::setup::SetupConfig; +use crate::util::theme::Theme; +use crate::widgets::check::{CheckList, CheckState}; +use crate::widgets::input::InputWidget; + +pub struct RepoStep; + +impl RepoStep { + pub fn new() -> Self { + Self + } + + /// Get the workspace directory path anchored to ~/.agentflow + fn get_agentflow_workspace_dir(repo: &str) -> String { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + let dir_name = repo.replace('/', "-").replace('\\', "-"); + format!("{}/.agentflow/workspaces/{}", home, dir_name) + } + + pub async fn render( + &self, + terminal: &mut Terminal>, + _theme: &Theme, + config: &mut SetupConfig, + ) -> Result<()> { + let theme = Theme::default(); + let mut repo_input = Input::new(config.repo.clone()); + // Workspace is always anchored to ~/.agentflow/workspaces - derived from repo + // This ensures artifact directories are always in the hidden ~/.agentflow dir + let workspace_dir = Self::get_agentflow_workspace_dir(&config.repo); + let mut workspace_input = Input::new(workspace_dir.clone()); + let mut focused_field = 0; + + let repo_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$").unwrap(); + + loop { + // Auto-update workspace when repo changes (workspace is derived, not editable) + let current_repo = repo_input.value(); + let expected_workspace = Self::get_agentflow_workspace_dir(current_repo); + if workspace_input.value() != expected_workspace { + workspace_input = Input::new(expected_workspace); + } + + let repo_valid = repo_regex.is_match(repo_input.value()); + let workspace_valid = !workspace_input.value().is_empty(); + + terminal.draw(|f| { + let area = f.area(); + let y_start = area.height / 2 - 6; + + let title = Line::styled( + "◇ REPOSITORY CONFIGURATION", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let subtitle = Line::styled( + " Set target repository and workspace", + Style::default().fg(theme.muted()), + ); + let title_para = ratatui::widgets::Paragraph::new(vec![title, subtitle]); + title_para.render( + ratatui::layout::Rect { x: 2, y: y_start, width: area.width - 4, height: 2 }, + f.buffer_mut(), + ); + + let repo_widget_area = ratatui::layout::Rect { + x: 2, + y: y_start + 3, + width: area.width - 4, + height: 3, + }; + let repo_widget = InputWidget::new(&repo_input, "GitHub Repository") + .focused(focused_field == 0); + repo_widget.render(repo_widget_area, f.buffer_mut()); + + let ws_widget_area = ratatui::layout::Rect { + x: 2, + y: y_start + 7, + width: area.width - 4, + height: 3, + }; + let ws_widget = InputWidget::new(&workspace_input, "Workspace Directory (auto-derived)") + .focused(false); // Never focused - auto-derived from repo + ws_widget.render(ws_widget_area, f.buffer_mut()); + + let mut checks = Vec::new(); + if repo_valid { + checks.push(("Repository format valid".to_string(), CheckState::Pass)); + } else { + checks.push(( + "Invalid repository format (owner/repo)".to_string(), + CheckState::Fail, + )); + } + // Workspace is always valid since it's auto-derived to ~/.agentflow/workspaces/ + checks.push(("Workspace directory (auto-derived to ~/.agentflow)".to_string(), CheckState::Pass)); + let check_area = ratatui::layout::Rect { + x: 2, + y: y_start + 11, + width: area.width - 4, + height: 4, + }; + let check_list = CheckList::new(checks); + check_list.render(check_area, f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Tab => { + // Only one editable field (repo), workspace is auto-derived + focused_field = 0; + } + KeyCode::Enter => { + if repo_valid && workspace_valid { + config.repo = repo_input.value().to_string(); + config.workspace_dir = workspace_input.value().to_string(); + break; + } + } + KeyCode::Esc => { + return Err(anyhow::anyhow!("Setup cancelled")); + } + _ => { + let event = crossterm::event::Event::Key(key); + // Only handle events for repo field - workspace is auto-derived + if focused_field == 0 { + repo_input.handle_event(&event); + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_security.rs b/crates/agentflow-tui/src/setup/step_security.rs new file mode 100644 index 0000000..a98e59c --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_security.rs @@ -0,0 +1,115 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; + +use crate::util::theme::Theme; +use crate::widgets::confirm::{ConfirmDialog, ConfirmDialogState, ConfirmResult}; +use crate::widgets::infobox::InfoBox; + +const DISCLAIMER_CONTENT: &[&str] = &[ + "OpenFlow is an autonomous development orchestration system", + "", + "What this means:", + "- AI agents will read your codebase and execute git operations", + "- Agents use Claude Code CLI to implement changes automatically", + "- PRs are created, reviewed, and merged without manual intervention", + "", + "Security features built-in:", + "- Secret pattern detection (API keys, tokens are auto-redacted)", + "- Dangerous commands require approval gate", + "- Whole-worktree scanning before any push", + "", + "Requirements:", + "- Claude Code CLI must be installed and configured", + "- GitHub Personal Access Token with repo scope", + "- At least one LLM API key (Anthropic, OpenAI, Gemini, or Fireworks)", + "", + "Best practices:", + "- Use a dedicated GitHub account for the bot", + "- Start with a test repository to understand behavior", + "- Review agent registry before running on production code", +]; + +pub struct SecurityStep { + confirmed: bool, +} + +impl SecurityStep { + pub fn new() -> Self { + Self { confirmed: false } + } + + pub fn is_confirmed(&self) -> bool { + self.confirmed + } + + pub fn render( + &mut self, + terminal: &mut Terminal>, + theme: &Theme, + ) -> Result<()> { + let mut confirm_state = ConfirmDialogState::new(); + + loop { + terminal.draw(|f| { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Min(10), + Constraint::Length(4), + ]) + .split(area); + + let title_line = Line::styled( + "┌ OpenFlow Setup", + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD), + ); + let title_para = Paragraph::new(title_line); + title_para.render(chunks[0], f.buffer_mut()); + + let box_content: Vec = DISCLAIMER_CONTENT.iter().map(|s| s.to_string()).collect(); + let info_box = InfoBox::new("Getting Started", &box_content); + info_box.render(chunks[1], f.buffer_mut()); + + let confirm_prompt = " Ready to set up OpenFlow?"; + let confirm_lines = vec![ + Line::styled(confirm_prompt.to_string(), theme.text_style()), + ]; + let confirm_para = Paragraph::new(confirm_lines); + confirm_para.render(Rect::new(chunks[2].x, chunks[2].y, chunks[2].width, 1), f.buffer_mut()); + + let dialog = ConfirmDialog::new("") + .selected_yes(confirm_state.selected_yes); + let dialog_area = Rect::new(chunks[2].x, chunks[2].y + 2, chunks[2].width, 2); + dialog.render(dialog_area, f.buffer_mut()); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + match confirm_state.handle_key(key) { + ConfirmResult::Yes => { + self.confirmed = true; + break; + } + ConfirmResult::No | ConfirmResult::Cancel => { + return Err(anyhow::anyhow!("Setup cancelled by user")); + } + ConfirmResult::Continue => {} + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/setup/step_welcome.rs b/crates/agentflow-tui/src/setup/step_welcome.rs new file mode 100644 index 0000000..e5c8343 --- /dev/null +++ b/crates/agentflow-tui/src/setup/step_welcome.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use ratatui::Terminal; +use std::io; + +use crate::util::logo::{get_logo_lines, version_string}; +use crate::util::theme::Theme; + +pub struct WelcomeStep; + +impl WelcomeStep { + pub fn new() -> Self { + Self + } + + pub fn render( + &self, + terminal: &mut Terminal>, + _theme: &Theme, + ) -> Result<()> { + let theme = Theme::default(); + + terminal.draw(|f| { + let area = f.area(); + let logo_lines = get_logo_lines(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(38), + Constraint::Length(logo_lines.len() as u16), + Constraint::Length(2), + Constraint::Percentage(38), + ]) + .split(area); + + let mut lines: Vec = Vec::new(); + + for logo_line in &logo_lines { + lines.push(Line::styled( + logo_line.clone(), + Style::default().fg(theme.accent()), + )); + } + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + paragraph.render(chunks[1], f.buffer_mut()); + + let footer = Line::from(vec![ + Span::styled(version_string(), Style::default().fg(theme.muted())), + Span::raw(" · "), + Span::styled("Press Enter to begin", Style::default().fg(theme.fg())), + ]); + let footer_para = Paragraph::new(footer).alignment(Alignment::Center); + footer_para.render(chunks[2], f.buffer_mut()); + })?; + + loop { + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + if key.code == crossterm::event::KeyCode::Enter + || key.code == crossterm::event::KeyCode::Esc + || key.code == crossterm::event::KeyCode::Char(' ') + { + break; + } + } + } + } + + Ok(()) + } +} diff --git a/crates/agentflow-tui/src/util/env_check.rs b/crates/agentflow-tui/src/util/env_check.rs new file mode 100644 index 0000000..e5718ab --- /dev/null +++ b/crates/agentflow-tui/src/util/env_check.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::process::Command; + +pub fn check_command(cmd: &str) -> Option { + let output = Command::new(cmd).arg("--version").output().ok()?; + if output.status.success() { + let version = String::from_utf8_lossy(&output.stdout); + Some(version.lines().next()?.to_string()) + } else { + None + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Token Validation +// ───────────────────────────────────────────────────────────────────────────── + +/// Validate a GitHub Personal Access Token format. +/// Valid prefixes: ghp_, gho_, ghu_, ghs_, ghr_ +/// Invalid prefixes: AIzaSy (Firebase/Google), sk- (OpenAI), etc. +pub fn validate_github_token(token: &str) -> Result<(), String> { + let token = token.trim(); + + if token.is_empty() { + return Err("Token is empty".to_string()); + } + + // Valid GitHub token prefixes + let valid_prefixes = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"]; + + // Known invalid prefixes (other services) + let invalid_prefixes = [ + ("AIzaSy", "Firebase/Google API key"), + ("AIza", "Firebase/Google API key"), + ("sk-", "OpenAI API key"), + ("sk-ant-", "Anthropic API key"), + ("fw_", "Fireworks API key"), + ("ya29.", "Google OAuth token"), + ("xoxb-", "Slack bot token"), + ("xoxp-", "Slack app token"), + ]; + + // Check for invalid prefixes first + for (prefix, service) in invalid_prefixes { + if token.starts_with(prefix) { + return Err(format!( + "Token appears to be a {} (prefix: {}), not a GitHub PAT", + service, prefix + )); + } + } + + // Check for valid GitHub prefixes + if valid_prefixes.iter().any(|p| token.starts_with(p)) { + // Basic length check (GitHub PATs are typically 40+ chars after prefix) + if token.len() < 36 { + return Err("GitHub token appears truncated (too short)".to_string()); + } + return Ok(()); + } + + // OAuth tokens (hex only, 40 chars) + if token.len() == 40 && token.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(()); + } + + // Unknown format + Err(format!( + "Token has unknown format (expected ghp_*, gho_*, ghu_*, ghs_*, ghr_*, or 40-char hex OAuth token)" + )) +} + +/// Check for duplicate keys in a .env file. +/// Returns a list of keys that appear more than once. +pub fn check_env_duplicates(env_path: &Path) -> Vec<(String, usize)> { + if !env_path.exists() { + return Vec::new(); + } + + let content = match fs::read_to_string(env_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut key_counts: HashMap = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Extract key (before =) + if let Some(key) = line.split('=').next() { + let key = key.trim().to_string(); + *key_counts.entry(key).or_insert(0) += 1; + } + } + + key_counts + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(key, count)| (key, count)) + .collect() +} + +/// Validate all agent GitHub tokens from environment. +/// Returns a list of (agent_id, error) for invalid tokens. +pub fn validate_agent_tokens() -> Vec<(String, String)> { + let mut errors = Vec::new(); + + // Standard GitHub PAT + if let Ok(token) = std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN") { + if let Err(e) = validate_github_token(&token) { + errors.push(("GITHUB_PERSONAL_ACCESS_TOKEN".to_string(), e)); + } + } + + // Per-agent tokens (from registry pattern) + let agent_ids = ["nexus", "forge", "sentinel", "vessel", "lore"]; + + for agent_id in agent_ids { + let env_var = format!("AGENT_{}_GITHUB_TOKEN", agent_id.to_uppercase()); + if let Ok(token) = std::env::var(&env_var) { + if let Err(e) = validate_github_token(&token) { + errors.push((env_var, e)); + } + } + } + + errors +} + +/// Scan .env file for common issues: +/// - Duplicate keys +/// - Invalid token formats +/// - Missing required keys +pub fn scan_env_file(env_path: &Path) -> Vec { + let mut issues = Vec::new(); + + if !env_path.exists() { + issues.push(EnvIssue::MissingFile); + return issues; + } + + // Check for duplicates + let duplicates = check_env_duplicates(env_path); + for (key, count) in duplicates { + issues.push(EnvIssue::DuplicateKey { key, count }); + } + + // Validate tokens from already-loaded environment + // (dotenvy is called by the binary before this) + let token_errors = validate_agent_tokens(); + for (env_var, error) in token_errors { + issues.push(EnvIssue::InvalidToken { env_var, error }); + } + + issues +} + +#[derive(Debug, Clone)] +pub enum EnvIssue { + MissingFile, + DuplicateKey { key: String, count: usize }, + InvalidToken { env_var: String, error: String }, +} + +impl std::fmt::Display for EnvIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvIssue::MissingFile => write!(f, ".env file not found"), + EnvIssue::DuplicateKey { key, count } => { + write!(f, "Duplicate key '{}' appears {} times", key, count) + } + EnvIssue::InvalidToken { env_var, error } => { + write!(f, "{}: {}", env_var, error) + } + } + } +} + +pub fn detect_os() -> (&'static str, &'static str) { + let os = if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "unknown" + }; + + (os, arch) +} + +pub fn check_rustup() -> bool { + check_command("rustup").is_some() +} + +pub fn check_rustc() -> Option { + check_command("rustc") +} + +pub fn check_git() -> Option { + check_command("git") +} + +pub fn check_node() -> Option { + check_command("node") +} + +pub fn check_claude() -> Option { + check_command("claude") +} + +pub fn check_gh_cli() -> Option { + check_command("gh") +} + +pub fn check_cargo() -> Option { + check_command("cargo") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_os_returns_known_values() { + let (os, arch) = detect_os(); + assert!(matches!(os, "linux" | "macos" | "windows" | "unknown")); + assert!(matches!(arch, "x86_64" | "aarch64" | "unknown")); + } +} diff --git a/crates/agentflow-tui/src/util/logo.rs b/crates/agentflow-tui/src/util/logo.rs new file mode 100644 index 0000000..4975e40 --- /dev/null +++ b/crates/agentflow-tui/src/util/logo.rs @@ -0,0 +1,26 @@ +pub const HEADER: &str = "◇ OpenFlows 0.1.0"; +pub const TAGLINE: &str = "I'll orchestrate your AI agents while you focus on what matters."; + +pub const LOGO: &str = r#" +┌────────────────────────────────────────────────────────────────────────────────┐ +│ ██████╗ ██████╗ ███████╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ██╗███████╗ │ +│ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔═══██╗██║ ██║██╔════╝ │ +│ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║█████╗ ██║ ██║ ██║██║ █╗ ██║███████╗ │ +│ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══╝ ██║ ██║ ██║██║███╗██║╚════██║ │ +│ ╚██████╔╝██║ ███████╗██║ ╚████║██║ ███████╗╚██████╔╝╚███╔███╔╝███████║ │ +│ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚══════╝ │ +└────────────────────────────────────────────────────────────────────────────────┘ +"#; + +pub const SETUP_HEADER: &str = "OpenFlows setup"; +pub const SECURITY_HEADER: &str = "Security disclaimer"; + +pub fn version_string() -> String { + let git_hash = std::env::var("GIT_HASH") + .unwrap_or_else(|_| "dev".to_string()); + format!("{} ({})", HEADER, git_hash) +} + +pub fn get_logo_lines() -> Vec { + LOGO.lines().map(|s| s.to_string()).collect() +} diff --git a/crates/agentflow-tui/src/util/mod.rs b/crates/agentflow-tui/src/util/mod.rs new file mode 100644 index 0000000..306aa34 --- /dev/null +++ b/crates/agentflow-tui/src/util/mod.rs @@ -0,0 +1,3 @@ +pub mod env_check; +pub mod logo; +pub mod theme; diff --git a/crates/agentflow-tui/src/util/theme.rs b/crates/agentflow-tui/src/util/theme.rs new file mode 100644 index 0000000..1a4f10b --- /dev/null +++ b/crates/agentflow-tui/src/util/theme.rs @@ -0,0 +1,85 @@ +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Theme { + Dark, + Light, +} + +impl Default for Theme { + fn default() -> Self { + Theme::Dark + } +} + +impl Theme { + pub fn bg(&self) -> Color { + Color::Rgb(10, 10, 15) + } + + pub fn surface(&self) -> Color { + Color::Rgb(18, 18, 26) + } + + pub fn fg(&self) -> Color { + Color::Rgb(230, 235, 245) + } + + pub fn border(&self) -> Color { + Color::Rgb(60, 70, 100) + } + + pub fn border_focus(&self) -> Color { + Color::Rgb(0, 255, 170) + } + + pub fn success(&self) -> Color { + Color::Rgb(0, 255, 170) + } + + pub fn error(&self) -> Color { + Color::Rgb(255, 85, 120) + } + + pub fn warning(&self) -> Color { + Color::Rgb(255, 200, 100) + } + + pub fn accent(&self) -> Color { + Color::Rgb(0, 200, 255) + } + + pub fn accent_alt(&self) -> Color { + Color::Rgb(180, 100, 255) + } + + pub fn muted(&self) -> Color { + Color::Rgb(80, 85, 110) + } + + pub fn title_style(&self) -> Style { + Style::default() + .fg(self.accent()) + .add_modifier(Modifier::BOLD) + } + + pub fn success_style(&self) -> Style { + Style::default().fg(self.success()) + } + + pub fn error_style(&self) -> Style { + Style::default().fg(self.error()) + } + + pub fn warning_style(&self) -> Style { + Style::default().fg(self.warning()) + } + + pub fn text_style(&self) -> Style { + Style::default().fg(self.fg()) + } + + pub fn muted_style(&self) -> Style { + Style::default().fg(self.muted()) + } +} diff --git a/crates/agentflow-tui/src/widgets/check.rs b/crates/agentflow-tui/src/widgets/check.rs new file mode 100644 index 0000000..00d6a32 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/check.rs @@ -0,0 +1,43 @@ +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; + +pub struct CheckList { + items: Vec<(String, CheckState)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CheckState { + Pass, + Fail, + Warn, + Pending, +} + +impl CheckList { + pub fn new(items: Vec<(String, CheckState)>) -> Self { + Self { items } + } +} + +impl Widget for CheckList { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + let mut lines = Vec::new(); + for (label, state) in &self.items { + let (icon, style) = match state { + CheckState::Pass => ("✓", theme.success_style()), + CheckState::Fail => ("✗", theme.error_style()), + CheckState::Warn => ("⚠", theme.warning_style()), + CheckState::Pending => ("○", theme.muted_style()), + }; + lines.push(Line::from(vec![ + Span::styled(format!("{} ", icon), style), + Span::styled(label, theme.text_style()), + ])); + } + + let paragraph = Paragraph::new(lines); + paragraph.render(area, buf); + } +} diff --git a/crates/agentflow-tui/src/widgets/confirm.rs b/crates/agentflow-tui/src/widgets/confirm.rs new file mode 100644 index 0000000..dba650c --- /dev/null +++ b/crates/agentflow-tui/src/widgets/confirm.rs @@ -0,0 +1,129 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; + +pub struct ConfirmDialog<'a> { + prompt: &'a str, + selected_yes: bool, +} + +impl<'a> ConfirmDialog<'a> { + pub fn new(prompt: &'a str) -> Self { + Self { + prompt, + selected_yes: true, + } + } + + pub fn selected_yes(mut self, yes: bool) -> Self { + self.selected_yes = yes; + self + } +} + +impl Widget for ConfirmDialog<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + + let yes_style = if self.selected_yes { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + theme.muted_style() + }; + + let no_style = if !self.selected_yes { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + theme.muted_style() + }; + + let lines = vec![ + Line::raw(""), + Line::styled(self.prompt.to_string(), theme.text_style()), + Line::raw(""), + Line::from(vec![ + Span::styled(" [Yes] ", yes_style), + Span::styled(" [No] ", no_style), + ]), + ]; + + let paragraph = Paragraph::new(lines); + paragraph.render(area, buf); + } +} + +pub struct ConfirmDialogState { + pub selected_yes: bool, +} + +impl ConfirmDialogState { + pub fn new() -> Self { + Self { selected_yes: true } + } + + pub fn toggle(&mut self) { + self.selected_yes = !self.selected_yes; + } + + pub fn move_left(&mut self) { + self.selected_yes = true; + } + + pub fn move_right(&mut self) { + self.selected_yes = false; + } + + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> ConfirmResult { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Left | KeyCode::Char('h') => { + self.move_left(); + ConfirmResult::Continue + } + KeyCode::Right | KeyCode::Char('l') => { + self.move_right(); + ConfirmResult::Continue + } + KeyCode::Tab => { + self.toggle(); + ConfirmResult::Continue + } + KeyCode::Enter => { + if self.selected_yes { + ConfirmResult::Yes + } else { + ConfirmResult::No + } + } + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.selected_yes = true; + ConfirmResult::Yes + } + KeyCode::Char('n') | KeyCode::Char('N') => { + self.selected_yes = false; + ConfirmResult::No + } + KeyCode::Esc => ConfirmResult::Cancel, + _ => ConfirmResult::Continue, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ConfirmResult { + Yes, + No, + Cancel, + Continue, +} + +impl Default for ConfirmDialogState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/agentflow-tui/src/widgets/infobox.rs b/crates/agentflow-tui/src/widgets/infobox.rs new file mode 100644 index 0000000..623924f --- /dev/null +++ b/crates/agentflow-tui/src/widgets/infobox.rs @@ -0,0 +1,144 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +pub struct InfoBox<'a> { + title: &'a str, + content: &'a [String], + box_type: InfoBoxType, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum InfoBoxType { + Info, + Warning, + Success, + Error, +} + +impl<'a> InfoBox<'a> { + pub fn new(title: &'a str, content: &'a [String]) -> Self { + Self { + title, + content, + box_type: InfoBoxType::Info, + } + } + + pub fn box_type(mut self, box_type: InfoBoxType) -> Self { + self.box_type = box_type; + self + } + + pub fn warning(mut self) -> Self { + self.box_type = InfoBoxType::Warning; + self + } + + pub fn success(mut self) -> Self { + self.box_type = InfoBoxType::Success; + self + } + + pub fn error(mut self) -> Self { + self.box_type = InfoBoxType::Error; + self + } +} + +impl Widget for InfoBox<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + + let (icon, accent_color) = match self.box_type { + InfoBoxType::Info => ("ℹ", theme.accent()), + InfoBoxType::Warning => ("⚠", theme.warning()), + InfoBoxType::Success => ("✓", theme.success()), + InfoBoxType::Error => ("✗", theme.error()), + }; + + let mut lines = Vec::new(); + + for line in self.content { + if line.is_empty() { + lines.push(Line::raw("")); + } else if line.starts_with("- ") || line.starts_with("• ") { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(line, theme.text_style()), + ])); + } else { + lines.push(Line::styled(line.clone(), theme.text_style())); + } + } + + let title_with_icon = format!(" {} {}", icon, self.title); + + let block = Block::default() + .borders(Borders::ALL) + .title(title_with_icon) + .border_style(Style::default().fg(accent_color)) + .title_style(Style::default().fg(accent_color).add_modifier(Modifier::BOLD)); + + let inner = block.inner(area); + block.render(area, buf); + + let paragraph = Paragraph::new(lines); + paragraph.render(inner, buf); + } +} + +pub struct KeyValueBox<'a> { + title: &'a str, + items: Vec<(&'a str, &'a str)>, +} + +impl<'a> KeyValueBox<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + items: Vec::new(), + } + } + + pub fn item(mut self, key: &'a str, value: &'a str) -> Self { + self.items.push((key, value)); + self + } + + pub fn items(mut self, items: Vec<(&'a str, &'a str)>) -> Self { + self.items = items; + self + } +} + +impl Widget for KeyValueBox<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + + let mut lines = Vec::new(); + lines.push(Line::raw("")); + + for (key, value) in &self.items { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{}: ", key), theme.muted_style()), + Span::styled(value.to_string(), theme.text_style()), + ])); + } + lines.push(Line::raw("")); + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", self.title)) + .border_style(Style::default().fg(theme.border())) + .title_style(theme.title_style()); + + let inner = block.inner(area); + block.render(area, buf); + + let paragraph = Paragraph::new(lines); + paragraph.render(inner, buf); + } +} diff --git a/crates/agentflow-tui/src/widgets/input.rs b/crates/agentflow-tui/src/widgets/input.rs new file mode 100644 index 0000000..fb07e39 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/input.rs @@ -0,0 +1,110 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::{Block, Borders, Widget}; +use tui_input::Input; + +pub struct InputWidget<'a> { + input: &'a Input, + label: &'a str, + masked: bool, + focused: bool, + optional: bool, +} + +impl<'a> InputWidget<'a> { + pub fn new(input: &'a Input, label: &'a str) -> Self { + Self { + input, + label, + masked: false, + focused: false, + optional: false, + } + } + + pub fn masked(mut self, masked: bool) -> Self { + self.masked = masked; + self + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn optional(mut self, optional: bool) -> Self { + self.optional = optional; + self + } +} + +impl Widget for InputWidget<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + let display_value = if self.masked { + "•".repeat(self.input.value().chars().count()) + } else { + self.input.value().to_string() + }; + + let label_suffix = if self.optional { "" } else { "" }; + let full_label = if self.label.ends_with(':') { + format!(" {}{}", self.label.trim_end_matches(':'), label_suffix) + } else { + format!(" {}{}", self.label, label_suffix) + }; + + let border_color = if self.focused { + theme.border_focus() + } else { + theme.border() + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(full_label) + .title_style(if self.focused { + Style::default() + .fg(theme.border_focus()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.muted()) + }) + .border_style(Style::default().fg(border_color)); + + let inner = block.inner(area); + block.render(area, buf); + + let display_with_cursor = if self.focused { + let cursor_pos = self.input.cursor(); + let chars: Vec = display_value.chars().collect(); + let mut result = String::new(); + for (i, c) in chars.iter().enumerate() { + if i == cursor_pos { + result.push_str(&format!("│{}", c)); + } else { + result.push(*c); + } + } + if cursor_pos >= chars.len() { + result.push('│'); + } + result + } else if display_value.is_empty() { + "···".to_string() + } else { + display_value.clone() + }; + + let style = if !self.focused && display_value.is_empty() { + Style::default().fg(theme.muted()) + } else if self.focused { + Style::default().fg(theme.fg()) + } else { + Style::default().fg(theme.muted()) + }; + + let paragraph = ratatui::widgets::Paragraph::new(display_with_cursor).style(style); + paragraph.render(inner, buf); + } +} diff --git a/crates/agentflow-tui/src/widgets/mod.rs b/crates/agentflow-tui/src/widgets/mod.rs new file mode 100644 index 0000000..42134d8 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/mod.rs @@ -0,0 +1,7 @@ +pub mod check; +pub mod confirm; +pub mod infobox; +pub mod input; +pub mod progress; +pub mod select; +pub mod table; diff --git a/crates/agentflow-tui/src/widgets/progress.rs b/crates/agentflow-tui/src/widgets/progress.rs new file mode 100644 index 0000000..da8ac82 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/progress.rs @@ -0,0 +1,42 @@ +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +pub struct ProgressBar { + current: usize, + total: usize, + width: u16, +} + +impl ProgressBar { + pub fn new(current: usize, total: usize) -> Self { + Self { + current, + total, + width: 40, + } + } + + pub fn width(mut self, width: u16) -> Self { + self.width = width; + self + } +} + +impl Widget for ProgressBar { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + let filled = if self.total > 0 { + (self.current as f64 / self.total as f64 * self.width as f64).ceil() as u16 + } else { + 0 + }; + + let bar_str: String = (0..self.width) + .map(|i| if i < filled { '█' } else { '░' }) + .collect(); + + let content = format!("Step {}/{}: {}", self.current, self.total, bar_str); + let paragraph = ratatui::widgets::Paragraph::new(content).style(theme.text_style()); + paragraph.render(area, buf); + } +} diff --git a/crates/agentflow-tui/src/widgets/select.rs b/crates/agentflow-tui/src/widgets/select.rs new file mode 100644 index 0000000..2e06f20 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/select.rs @@ -0,0 +1,246 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; +use tui_input::Input; + +pub struct SelectableList<'a> { + items: &'a [String], + selected: usize, + title: Option<&'a str>, + search_query: Option<&'a Input>, + show_search: bool, + filtered_indices: &'a [usize], +} + +impl<'a> SelectableList<'a> { + pub fn new(items: &'a [String], selected: usize) -> Self { + Self { + items, + selected, + title: None, + search_query: None, + show_search: false, + filtered_indices: &[], + } + } + + pub fn title(mut self, title: &'a str) -> Self { + self.title = Some(title); + self + } + + pub fn search_query(mut self, query: &'a Input) -> Self { + self.search_query = Some(query); + self.show_search = true; + self + } + + pub fn filtered_indices(mut self, indices: &'a [usize]) -> Self { + self.filtered_indices = indices; + self + } + + fn visible_items(&self) -> Vec<(usize, &String)> { + if self.filtered_indices.is_empty() && self.show_search { + let query = self.search_query.map(|i| i.value().to_lowercase()).unwrap_or_default(); + if query.is_empty() { + self.items.iter().enumerate().collect() + } else { + self.items + .iter() + .enumerate() + .filter(|(_, item)| item.to_lowercase().contains(&query)) + .collect() + } + } else if !self.filtered_indices.is_empty() { + self.filtered_indices + .iter() + .map(|&i| (i, &self.items[i])) + .collect() + } else { + self.items.iter().enumerate().collect() + } + } +} + +impl Widget for SelectableList<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + let visible = self.visible_items(); + + let content_height = if self.show_search { area.height.saturating_sub(3) } else { area.height.saturating_sub(2) }; + + let scroll_offset = if self.selected >= content_height as usize { + self.selected - content_height as usize + 1 + } else { + 0 + }; + + let mut lines = Vec::new(); + + if self.show_search { + let search_val = self.search_query.map(|i| i.value()).unwrap_or(""); + let search_line = Line::from(vec![ + Span::styled(" ◄ ", Style::default().fg(theme.muted())), + Span::styled(search_val, Style::default().fg(theme.fg())), + Span::styled("│", Style::default().fg(theme.accent())), + ]); + lines.push(search_line); + lines.push(Line::raw("")); + } + + for (idx, item) in visible.iter().skip(scroll_offset).take(content_height as usize) { + let is_selected = *idx == self.selected; + let icon = if is_selected { "▸" } else { " " }; + let style = if is_selected { + Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.muted()) + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", icon), style), + Span::styled(item.as_str(), style), + ])); + } + + while lines.len() < area.height as usize { + lines.push(Line::raw("")); + } + + let footer = Line::from(vec![ + Span::styled(" ↑/↓", Style::default().fg(theme.muted())), + Span::styled(" navigate ", Style::default().fg(theme.fg())), + Span::styled("Enter", Style::default().fg(theme.muted())), + Span::styled(" select ", Style::default().fg(theme.fg())), + Span::styled("Esc", Style::default().fg(theme.muted())), + Span::styled(" back", Style::default().fg(theme.fg())), + ]); + + let block = Block::default() + .borders(Borders::NONE); + + let inner = block.inner(area); + block.render(area, buf); + + let mut all_lines = lines.clone(); + all_lines.push(footer); + + let paragraph = Paragraph::new(all_lines); + paragraph.render(inner, buf); + } +} + +pub struct SelectableListState { + pub items: Vec, + pub selected: usize, + pub search_input: Input, + pub show_search: bool, +} + +impl SelectableListState { + pub fn new(items: Vec) -> Self { + Self { + items, + selected: 0, + search_input: Input::default(), + show_search: false, + } + } + + pub fn with_search(mut self) -> Self { + self.show_search = true; + self + } + + pub fn filtered_indices(&self) -> Vec { + let query = self.search_input.value().to_lowercase(); + if query.is_empty() { + return vec![]; + } + self.items + .iter() + .enumerate() + .filter(|(_, item)| item.to_lowercase().contains(&query)) + .map(|(i, _)| i) + .collect() + } + + pub fn visible_items(&self) -> Vec<(usize, &String)> { + let query = self.search_input.value().to_lowercase(); + if query.is_empty() { + self.items.iter().enumerate().collect() + } else { + self.items + .iter() + .enumerate() + .filter(|(_, item)| item.to_lowercase().contains(&query)) + .collect() + } + } + + pub fn move_up(&mut self) { + let visible = self.visible_items(); + if visible.is_empty() { + return; + } + let current_pos = visible.iter().position(|(i, _)| *i == self.selected).unwrap_or(0); + if current_pos > 0 { + self.selected = visible[current_pos - 1].0; + } else { + self.selected = visible.last().map(|(i, _)| *i).unwrap_or(0); + } + } + + pub fn move_down(&mut self) { + let visible = self.visible_items(); + if visible.is_empty() { + return; + } + let current_pos = visible.iter().position(|(i, _)| *i == self.selected).unwrap_or(0); + if current_pos < visible.len() - 1 { + self.selected = visible[current_pos + 1].0; + } else { + self.selected = visible.first().map(|(i, _)| *i).unwrap_or(0); + } + } + + pub fn selected_item(&self) -> Option<&String> { + self.items.get(self.selected) + } + + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Up => { + self.move_up(); + false + } + KeyCode::Down => { + self.move_down(); + false + } + KeyCode::Enter => true, + KeyCode::Esc => { + if self.show_search && !self.search_input.value().is_empty() { + self.search_input = Input::default(); + } + false + } + _ => { + if self.show_search { + use tui_input::backend::crossterm::EventHandler; + let event = crossterm::event::Event::Key(key); + self.search_input.handle_event(&event); + let visible = self.visible_items(); + if !visible.is_empty() && !visible.iter().any(|(i, _)| *i == self.selected) { + self.selected = visible[0].0; + } + } + false + } + } + } +} diff --git a/crates/agentflow-tui/src/widgets/table.rs b/crates/agentflow-tui/src/widgets/table.rs new file mode 100644 index 0000000..36e1389 --- /dev/null +++ b/crates/agentflow-tui/src/widgets/table.rs @@ -0,0 +1,60 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::{Block, Borders, Row, Table, Widget}; + +pub struct WorkerTable { + workers: Vec<(String, String, String)>, + selected_row: Option, +} + +impl WorkerTable { + pub fn new(workers: Vec<(String, String, String)>) -> Self { + Self { + workers, + selected_row: None, + } + } + + pub fn selected(mut self, idx: usize) -> Self { + self.selected_row = Some(idx); + self + } +} + +impl Widget for WorkerTable { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let theme = crate::util::theme::Theme::default(); + let header_style = Style::default() + .fg(theme.accent()) + .add_modifier(Modifier::BOLD); + + let rows: Vec = self + .workers + .iter() + .map(|(id, status, detail)| { + let status_color = match status.as_str() { + "IDLE" | "Done" => theme.success(), + "WORKING" | "Assigned" | "Building" => theme.warning(), + "Suspended" | "Failed" => theme.error(), + _ => theme.fg(), + }; + + Row::new(vec![id.clone(), status.clone(), detail.clone()]) + .style(Style::default().fg(status_color)) + }) + .collect(); + + let widths = [15, 15, 40]; + let table = Table::new(rows, widths) + .header(Row::new(vec!["Worker", "Status", "Detail"]).style(header_style)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Workers") + .border_style(theme.border()), + ) + .column_spacing(2); + + table.render(area, buf); + } +} diff --git a/crates/anthropic-mock/Cargo.toml b/crates/anthropic-mock/Cargo.toml index 2f1d397..f430142 100644 --- a/crates/anthropic-mock/Cargo.toml +++ b/crates/anthropic-mock/Cargo.toml @@ -7,10 +7,11 @@ license = "MIT" [dependencies] axum = "0.7.5" dotenvy = "0.15" +regex = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.36", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } futures = "0.3" diff --git a/crates/anthropic-mock/src/main.rs b/crates/anthropic-mock/src/main.rs index 6b5e0a9..b0a46ba 100644 --- a/crates/anthropic-mock/src/main.rs +++ b/crates/anthropic-mock/src/main.rs @@ -4,6 +4,7 @@ use axum::{ Json, Router, }; use futures::StreamExt; +use regex::Regex; use reqwest::Client; use serde::Deserialize; use serde_json::{json, Value}; @@ -13,6 +14,19 @@ use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; +/// Strip ANSI escape codes and terminal formatting artifacts from a string. +/// This handles cases where model names may have terminal formatting codes embedded. +/// Handles both real ANSI codes (\x1b[1m) and literal bracket patterns like [1m] +fn strip_ansi_codes(s: &str) -> String { + // Match ANSI escape sequences: ESC [ followed by parameters and command character + static ANSI_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = ANSI_RE.get_or_init(|| { + // Match real ANSI codes and also literal bracket patterns like [1m], [0m, [32m, etc. + Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][\x30-\x3f]*|\[[0-9;]+[a-zA-Z]\]").unwrap() + }); + re.replace_all(s, "").to_string() +} + #[derive(Debug, Deserialize)] struct AnthropicRequest { model: String, @@ -61,6 +75,17 @@ fn parse_model_map() -> HashMap { .collect() } +/// Check if a model name is a Claude model variant or alias. +/// This includes: claude-* models and common aliases (opus, sonnet, haiku) +fn is_claude_model(model: &str) -> bool { + let model_lower = model.to_lowercase(); + model_lower.starts_with("claude") + || model_lower.starts_with("claude-") + || model == "opus" + || model == "sonnet" + || model == "haiku" +} + #[tokio::main] async fn main() { match dotenvy::dotenv() { @@ -73,8 +98,18 @@ async fn main() { let backend_url = resolve_backend_url(); let backend_key = resolve_backend_key().expect("API key configuration error"); + // Check for PROXY_TARGET_MODEL (simpler config) or fall back to MODEL_MAP + let target_model = std::env::var("PROXY_TARGET_MODEL").ok(); let model_map = parse_model_map(); - if !model_map.is_empty() { + + if let Some(ref target) = target_model { + if !target.is_empty() { + info!( + target_model = %target, + "Model mapping: ALL Claude models → target (PROXY_TARGET_MODEL)" + ); + } + } else if !model_map.is_empty() { info!( mappings = model_map.len(), "Model name mapping loaded from MODEL_MAP" @@ -83,9 +118,10 @@ async fn main() { info!(from = %from, to = %to, "Model map entry"); } } else { - info!("No MODEL_MAP configured - forwarding model names unchanged"); + info!("No model mapping configured - forwarding model names unchanged"); } let model_map = Arc::new(RwLock::new(model_map)); + let target_model = Arc::new(target_model); info!( backend_url = %backend_url, @@ -101,9 +137,11 @@ async fn main() { let url = backend_url_clone.clone(); let key = backend_key_clone.clone(); let map = model_map.clone(); - handle_messages(headers, payload, url, key, map) + let target = target_model.clone(); + handle_messages(headers, payload, url, key, map, target) }), ) + .route("/v1/models", axum::routing::get(handle_models)) .route("/health", axum::routing::get(|| async { "ok" })); let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); @@ -202,21 +240,102 @@ fn convert_anthropic_to_openai_messages(payload: &AnthropicRequest) -> Vec Json { + // All Claude model names that Claude Code may resolve aliases to. + // The proxy maps any Claude model to PROXY_TARGET_MODEL, so all of + // these are "available" from the proxy's perspective. + let model_ids = [ + // Sonnet family — the `sonnet` alias resolves here + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250514", + "claude-sonnet-4-6", + "claude-sonnet-4-6-20251022", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-latest", + // Haiku family + "claude-haiku-4-5", + "claude-haiku-4-5-20251022", + "claude-3-5-haiku-20241022", + "claude-3-5-haiku-latest", + // Opus family + "claude-opus-4", + "claude-opus-4-20250514", + "claude-3-opus-20240229", + "claude-3-opus-latest", + ]; + + let data: Vec = model_ids + .iter() + .map(|id| { + json!({ + "id": id, + "type": "model", + "display_name": id.replace('-', " "), + "created_at": "2024-01-01T00:00:00Z", + "max_input_tokens": 200_000, + "max_tokens": 8192 + }) + }) + .collect(); + + let first = model_ids.first().unwrap_or(&""); + let last = model_ids.last().unwrap_or(&""); + + debug!("Returning /v1/models list ({} models)", model_ids.len()); + + Json(json!({ + "data": data, + "has_more": false, + "first_id": first, + "last_id": last, + })) +} + async fn handle_messages( _headers: HeaderMap, Json(payload): Json, backend_url: String, backend_key: String, model_map: Arc>>, + target_model: Arc>, ) -> (StatusCode, Json) { + // Strip any ANSI escape codes that may have leaked into the model name + let clean_model = strip_ansi_codes(&payload.model); + if clean_model != payload.model { + warn!(raw = %payload.model, clean = %clean_model, "Stripped ANSI codes from model name"); + } + + // Resolve model name using PROXY_TARGET_MODEL or MODEL_MAP let resolved_model = { - let map = model_map.read().await; - map.get(&payload.model) - .cloned() - .unwrap_or_else(|| payload.model.clone()) + // Check PROXY_TARGET_MODEL first + if let Some(ref target) = *target_model { + if !target.is_empty() && is_claude_model(&clean_model) { + target.clone() + } else { + // Not a Claude model or target is empty, try MODEL_MAP + let map = model_map.read().await; + map.get(&clean_model) + .cloned() + .unwrap_or_else(|| clean_model.clone()) + } + } else { + // No PROXY_TARGET_MODEL, use MODEL_MAP + let map = model_map.read().await; + map.get(&clean_model) + .cloned() + .unwrap_or_else(|| clean_model.clone()) + } }; - if resolved_model != payload.model { - info!(requested = %payload.model, resolved = %resolved_model, "Model name mapped"); + + if resolved_model != clean_model { + info!(requested = %clean_model, resolved = %resolved_model, "Model name mapped"); } info!( turns = payload.messages.len(), diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 487aa80..33711b8 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "config" -version = "0.2.0" -edition = "2021" -license = "MIT" +name = "config" +version = "0.2.0" +edition = "2021" +license = "MIT" +description = "Configuration management for OpenFlows agent orchestration" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["config", "agent", "orchestration"] +categories = ["config"] [dependencies] anyhow = { workspace = true } diff --git a/crates/config/src/registry.rs b/crates/config/src/registry.rs index b4f864c..9e349b7 100644 --- a/crates/config/src/registry.rs +++ b/crates/config/src/registry.rs @@ -102,15 +102,23 @@ impl Registry { } /// Resolve GitHub token for a given agent. - /// If the agent has `github_token_env` set, reads from that env var. - /// Falls back to `GITHUB_PERSONAL_ACCESS_TOKEN` for backward compatibility. + /// If the agent has `github_token_env` set, tries that env var first. + /// Falls back to `GITHUB_PERSONAL_ACCESS_TOKEN` if the agent-specific var is not set. /// Handles instance IDs (e.g., "forge-1") by stripping suffix to find base agent. pub fn resolve_github_token(&self, agent_id: &str) -> Result { let base_id = self.normalize_agent_id(agent_id); let token = match self.get(base_id) { Some(entry) => match &entry.github_token_env { - Some(env_var) => std::env::var(env_var) - .with_context(|| format!("{} not set for agent {}", env_var, agent_id))?, + Some(env_var) => { + // Try agent-specific token first, fall back to generic token + std::env::var(env_var).or_else(|_| { + std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN") + .with_context(|| format!( + "Neither {} nor GITHUB_PERSONAL_ACCESS_TOKEN is set for agent {}", + env_var, agent_id + )) + })? + } None => std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN") .context("GITHUB_PERSONAL_ACCESS_TOKEN not set (fallback for agent without github_token_env)")?, }, diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 2740afc..454d8b0 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "github" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "github" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "GitHub API client for OpenFlows orchestration" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["github", "api", "git"] +categories = ["api-bindings", "web-programming"] [dependencies] anyhow = { workspace = true } diff --git a/crates/pair-harness/Cargo.toml b/crates/pair-harness/Cargo.toml index f21d1b6..5ea86c6 100644 --- a/crates/pair-harness/Cargo.toml +++ b/crates/pair-harness/Cargo.toml @@ -1,9 +1,12 @@ [package] -name = "pair-harness" -version = "0.2.0" -edition = "2021" -license = "MIT" +name = "pair-harness" +version = "0.2.0" +edition = "2021" +license = "MIT" description = "Event-driven harness for FORGE-SENTINEL pair lifecycle management" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agents", "forge", "sentinel", "orchestration"] +categories = ["asynchronous", "development-tools"] [dependencies] anyhow = { workspace = true } diff --git a/crates/pair-harness/src/pair.rs b/crates/pair-harness/src/pair.rs index 94ddb56..3537958 100644 --- a/crates/pair-harness/src/pair.rs +++ b/crates/pair-harness/src/pair.rs @@ -17,7 +17,7 @@ use crate::process::{ProcessManager, SentinelMode}; use crate::provision::Provisioner; use crate::reset::ResetManager; use crate::types::{ - Complexity, ErrorHistory, ErrorHistoryEntry, FsEvent, PairConfig, PairOutcome, StatusJson, + Blocker, Complexity, ErrorHistory, ErrorHistoryEntry, FsEvent, PairConfig, PairOutcome, StatusJson, Ticket, TimeoutProfile, VerificationResult, VerificationState, }; use crate::watchdog::Watchdog; @@ -357,23 +357,57 @@ impl ForgeSentinelPair { // Use the project_root from config (contains .git) let project_root = config.project_root.clone(); + // Build ProcessManager with appropriate configuration + let process = match (&config.redis_url, &config.proxy_url, &config.model) { + // All three: redis + proxy + model + (Some(redis_url), Some(proxy_url), Some(model)) => ProcessManager::with_model( + &config.github_token, + Some(redis_url.clone()), + Some(proxy_url.clone()), + model, + ), + // Redis + proxy (no model) + (Some(redis_url), Some(proxy_url), None) => ProcessManager::with_proxy( + &config.github_token, + Some(redis_url.clone()), + proxy_url, + ), + // Redis only (no proxy, no model) + (Some(redis_url), None, None) => { + ProcessManager::with_redis(&config.github_token, redis_url) + } + // Proxy only (no redis) + (None, Some(proxy_url), Some(model)) => ProcessManager::with_model( + &config.github_token, + None, + Some(proxy_url.clone()), + model, + ), + (None, Some(proxy_url), None) => { + ProcessManager::with_proxy(&config.github_token, None, proxy_url) + } + // Model only (no redis, no proxy) - use model with direct API + (None, None, Some(model)) => ProcessManager::with_model( + &config.github_token, + None, + None, + model, + ), + // Nothing special - basic config + (None, None, None) => ProcessManager::new(&config.github_token), + // Redis + model (no proxy) - not a common case, use with_model + (Some(redis_url), None, Some(model)) => ProcessManager::with_model( + &config.github_token, + Some(redis_url.clone()), + None, + model, + ), + }; + Self { worktree: WorktreeManager::new(&project_root), locks: FileLockManager::new(&project_root), - process: match (&config.redis_url, &config.proxy_url) { - (Some(redis_url), Some(proxy_url)) => ProcessManager::with_proxy( - &config.github_token, - Some(redis_url.clone()), - proxy_url, - ), - (Some(redis_url), None) => { - ProcessManager::with_redis(&config.github_token, redis_url) - } - (None, Some(proxy_url)) => { - ProcessManager::with_proxy(&config.github_token, None, proxy_url) - } - (None, None) => ProcessManager::new(&config.github_token), - }, + process, reset: ResetManager::new(config.shared.clone(), config.max_resets), watchdog: Watchdog::new(config.shared.clone(), config.watchdog_timeout_secs), verification_state: VerificationState::new(config.max_verify_attempts), @@ -943,14 +977,85 @@ impl ForgeSentinelPair { // Written segments have evals, but PLAN has more segments to implement. // This is expected with --print mode: FORGE exits after each segment, // and we just respawn to continue the next one. - info!("FORGE exited after segment work - respawning to continue implementation"); + // However, check for API errors first — if FORGE is failing on every + // respawn due to credit/model issues, we must stop rather than loop. + if let Some(api_error) = self.check_forge_api_error().await { + error!(error = %api_error, "FORGE failed with API error during segment respawn - stopping pair"); + self.write_error_feedback( + "api_error", + &api_error, + Some("Check your API key, credits, and model availability. Update .env with valid credentials."), + ).await?; + return Ok(PairOutcome::Blocked { + reason: format!("API error: {}", api_error), + blockers: vec![Blocker { + blocker_type: "api_error".to_string(), + description: api_error.clone(), + nexus_action: "Check API credentials and credits".to_string(), + }], + }); + } + let new_count = self.reset.increment_reset(); + info!( + reset_count = new_count, + max_resets = self.config.max_resets, + "FORGE exited after segment work - respawning to continue implementation" + ); self.sentinel_retries.reset_all(); *forge = self.spawn_forge_resume().await?; - } else { - info!("FORGE exited with partial worklog - respawning to continue implementation"); + } else if !self.has_meaningful_worklog().await { + // WORKLOG.md exists but is empty/meaningless - likely an API error + if let Some(api_error) = self.check_forge_api_error().await { + error!(error = %api_error, "FORGE failed with API error - stopping pair"); + self.write_error_feedback( + "api_error", + &api_error, + Some("Check your API key, credits, and model availability. Update .env with valid credentials."), + ).await?; + return Ok(PairOutcome::Blocked { + reason: format!("API error: {}", api_error), + blockers: vec![Blocker { + blocker_type: "api_error".to_string(), + description: api_error.clone(), + nexus_action: "Check API credentials and credits".to_string(), + }], + }); + } + // No API error detected but no progress either - check for quick exit + let forge_uptime = self.forge_spawn_time.elapsed().as_secs(); + if forge_uptime < 10 { + warn!( + "FORGE exited quickly ({}s) with empty worklog - likely startup error", + forge_uptime + ); + // Write error feedback and stop + self.write_error_feedback( + "startup_error", + &format!("FORGE exited after {}s without making progress", forge_uptime), + Some("Check the FORGE stdout log for errors. Ensure your API key has credits and the model is available."), + ).await?; + return Ok(PairOutcome::Blocked { + reason: "FORGE failed to start properly".to_string(), + blockers: vec![Blocker { + blocker_type: "startup_error".to_string(), + description: "FORGE exited without making progress".to_string(), + nexus_action: "Check FORGE logs for details".to_string(), + }], + }); + } + info!("FORGE exited with empty worklog - respawning to try again"); self.sentinel_retries.reset_all(); *forge = self.spawn_forge_resume().await?; self.reset.increment_reset(); + } else { + let new_count = self.reset.increment_reset(); + info!( + reset_count = new_count, + max_resets = self.config.max_resets, + "FORGE exited with partial worklog - respawning to continue implementation" + ); + self.sentinel_retries.reset_all(); + *forge = self.spawn_forge_resume().await?; } } else { info!("FORGE exited after making progress - respawning to continue"); @@ -1816,6 +1921,103 @@ impl ForgeSentinelPair { .is_ok_and(|c| c.contains("AWAITING_SENTINEL_REVIEW")) } + /// Check FORGE stdout log for common API errors that would cause immediate exit. + /// Returns Some(error_message) if an API error is detected. + async fn check_forge_api_error(&self) -> Option { + let log_path = self.config.shared.join("logs").join("forge-stdout.log"); + if !log_path.exists() { + return None; + } + + let content = tokio::fs::read_to_string(&log_path).await.ok()?; + let lines: Vec<&str> = content.lines().collect(); + + // Check the last few lines for error patterns + for line in lines.iter().rev().take(10) { + let line_lower = line.to_lowercase(); + + // Claude CLI credit/model errors (direct Anthropic API without proxy) + if line_lower.contains("credit balance is too low") { + return Some("Claude CLI: Insufficient credits (check if proxy is configured correctly)".to_string()); + } + if line_lower.contains("rate limit") || line_lower.contains("rate_limit") { + return Some("API rate limit exceeded".to_string()); + } + if line_lower.contains("invalid api key") || line_lower.contains("authentication") { + return Some("API authentication error".to_string()); + } + if line_lower.contains("model not found") || line_lower.contains("model not available") { + return Some("Model not available".to_string()); + } + // Claude CLI model access errors: "There's an issue with the selected model" + if line_lower.contains("issue with the selected model") || line_lower.contains("may not exist or you may not have access") { + // Extract the model name from the error for better diagnostics + // Pattern: "issue with the selected model (model_name)" + if let Some(start) = line.find("model (") { + if let Some(end) = line[start..].find(")") { + let model_name = &line[start + 7..start + end]; + // Check for ANSI escape codes in model name (indicator of terminal capture bug) + if model_name.contains("\x1b") || model_name.contains("[1m") || model_name.contains("[0m") { + return Some(format!("Model name contains ANSI escape codes: '{}'. This is a bug in model configuration.", model_name)); + } + return Some(format!("Model '{}' not available or no access", model_name)); + } + } + return Some("Model not available or no access".to_string()); + } + if line_lower.contains("quota exceeded") || line_lower.contains("usage limit") { + return Some("API quota exceeded".to_string()); + } + + // Anthropic errors + if line_lower.contains("invalid anthropic api key") { + return Some("Anthropic API key invalid".to_string()); + } + if line_lower.contains("anthropic api error") { + return Some(format!("Anthropic API error: {}", line)); + } + + // Fireworks-specific errors (when using proxy) + if line_lower.contains("fireworks") && line_lower.contains("error") { + return Some(format!("Fireworks API error: {}", line)); + } + } + + None + } + + /// Check if WORKLOG.md has meaningful content (not just header). + /// An empty or header-only worklog indicates FORGE didn't make progress. + async fn has_meaningful_worklog(&self) -> bool { + let path = self.config.shared.join("WORKLOG.md"); + if !path.exists() { + return false; + } + + let content = match tokio::fs::read_to_string(&path).await { + Ok(c) => c, + Err(_) => return false, + }; + + // Check for actual content beyond just "# Worklog" header + let lines: Vec<&str> = content.lines().collect(); + + // Filter out empty lines and the header + let content_lines: Vec<&str> = lines + .iter() + .filter(|l| { + let trimmed = l.trim(); + !trimmed.is_empty() + && !trimmed.starts_with("# Worklog") + && !trimmed.starts_with("#") + }) + .map(|l| *l) + .collect(); + + // If we have at least one line of actual content, it's meaningful + !content_lines.is_empty() + } + /// Read STATUS.json and convert to PairOutcome. /// Returns `Ok(None)` if the file exists but is empty (race: inotify fires before flush). /// Handles deserialization errors gracefully by logging a warning and returning None, diff --git a/crates/pair-harness/src/process.rs b/crates/pair-harness/src/process.rs index 9f4c68a..6a31c6b 100644 --- a/crates/pair-harness/src/process.rs +++ b/crates/pair-harness/src/process.rs @@ -44,6 +44,10 @@ impl SentinelMode { } } +/// Default model to use when spawning Claude CLI processes. +/// Use 'sonnet' alias which is valid for Claude CLI - the proxy will map it to the target model. +const DEFAULT_CLI_MODEL: &str = "sonnet"; + /// Manages FORGE and SENTINEL processes. pub struct ProcessManager { claude_path: PathBuf, @@ -51,6 +55,10 @@ pub struct ProcessManager { redis_url: Option, proxy_url: Option, proxy_api_key: Option, + /// Model to pass to Claude CLI via --model flag. + /// Should be a proxy-compatible alias (e.g., "claude-sonnet-4-5") that the proxy + /// will map to PROXY_TARGET_MODEL. + model: String, } impl ProcessManager { @@ -62,6 +70,16 @@ impl ProcessManager { let proxy_url = std::env::var("PROXY_URL").ok(); let proxy_api_key = std::env::var("PROXY_API_KEY").ok(); + let model = std::env::var("CLAUDE_MODEL") + .or_else(|_| std::env::var("ANTHROPIC_MODEL")) + .unwrap_or_else(|_| DEFAULT_CLI_MODEL.to_string()); + + tracing::info!( + proxy_url = ?proxy_url, + proxy_api_key = ?proxy_api_key, + model = %model, + "ProcessManager::new - environment check" + ); Self { claude_path, @@ -69,6 +87,7 @@ impl ProcessManager { redis_url: None, proxy_url, proxy_api_key, + model, } } @@ -80,6 +99,9 @@ impl ProcessManager { let proxy_url = std::env::var("PROXY_URL").ok(); let proxy_api_key = std::env::var("PROXY_API_KEY").ok(); + let model = std::env::var("CLAUDE_MODEL") + .or_else(|_| std::env::var("ANTHROPIC_MODEL")) + .unwrap_or_else(|_| DEFAULT_CLI_MODEL.to_string()); Self { claude_path, @@ -87,6 +109,7 @@ impl ProcessManager { redis_url: Some(redis_url.into()), proxy_url, proxy_api_key, + model, } } @@ -101,6 +124,9 @@ impl ProcessManager { Self::validate_claude_binary(&claude_path); let proxy_api_key = std::env::var("PROXY_API_KEY").ok(); + let model = std::env::var("CLAUDE_MODEL") + .or_else(|_| std::env::var("ANTHROPIC_MODEL")) + .unwrap_or_else(|_| DEFAULT_CLI_MODEL.to_string()); Self { claude_path, @@ -108,6 +134,31 @@ impl ProcessManager { redis_url, proxy_url: Some(proxy_url.into()), proxy_api_key, + model, + } + } + + /// Create a ProcessManager with an explicit model override. + pub fn with_model( + github_token: impl Into, + redis_url: Option, + proxy_url: Option, + model: impl Into, + ) -> Self { + let claude_path = std::env::var("CLAUDE_PATH").unwrap_or_else(|_| "claude".to_string()); + let claude_path = PathBuf::from(claude_path); + + Self::validate_claude_binary(&claude_path); + + let proxy_api_key = std::env::var("PROXY_API_KEY").ok(); + + Self { + claude_path, + github_token: github_token.into(), + redis_url, + proxy_url, + proxy_api_key, + model: model.into(), } } @@ -147,11 +198,40 @@ impl ProcessManager { proxy_api_key: Option<&str>, ) { let base_url = proxy_url.trim_end_matches("/v1").trim_end_matches('/'); + info!("Setting ANTHROPIC_BASE_URL={} for proxy", base_url); + cmd.env("ANTHROPIC_BASE_URL", base_url); + + // Set API key for proxy authentication + let api_key_value = proxy_api_key.unwrap_or(routing_key); + info!("Setting ANTHROPIC_API_KEY for proxy (key length: {})", api_key_value.len()); + cmd.env("ANTHROPIC_API_KEY", api_key_value); + + // Also set the gateway URL and key for the proxy itself + if let Ok(gateway_url) = std::env::var("GATEWAY_URL") { + cmd.env("GATEWAY_URL", gateway_url); + } + if let Ok(gateway_key) = std::env::var("GATEWAY_API_KEY") { + cmd.env("GATEWAY_API_KEY", gateway_key); + } + if let Ok(model_map) = std::env::var("MODEL_MAP") { + cmd.env("MODEL_MAP", model_map); + } + } + + /// Inject Fireworks configuration for CLI agents. + /// NOTE: Fireworks doesn't have a native Anthropic endpoint, so Claude CLI + /// requires a proxy. This function is kept for potential future use if + /// Fireworks adds Anthropic support, but currently should not be called. + #[allow(dead_code)] + fn inject_fireworks_anthropic_env(cmd: &mut Command) { + let base_url = std::env::var("FIREWORKS_API_URL") + .unwrap_or_else(|_| "https://api.fireworks.ai/inference/v1".to_string()); + let base_url = base_url.trim_end_matches('/').trim_end_matches("/chat/completions").trim_end_matches('/').to_string(); + cmd.env("ANTHROPIC_BASE_URL", base_url); - if let Some(api_key) = proxy_api_key { + + if let Ok(api_key) = std::env::var("FIREWORKS_API_KEY") { cmd.env("ANTHROPIC_API_KEY", api_key); - } else { - cmd.env("ANTHROPIC_API_KEY", routing_key); } } @@ -198,6 +278,11 @@ impl ProcessManager { self.proxy_api_key.as_deref() } + /// Get the model that will be passed to Claude CLI via --model flag. + pub fn model(&self) -> &str { + &self.model + } + fn plugin_dir(target: &Path) -> PathBuf { target.join(".claude").join("plugins").join("orchestration") } @@ -225,6 +310,8 @@ impl ProcessManager { let mut cmd = Command::new(&self.claude_path); cmd.arg("--print") .arg("--dangerously-skip-permissions") + .arg("--model") + .arg(&self.model) .arg("--settings") .arg(&settings_path) .arg("--plugin-dir") @@ -241,7 +328,13 @@ impl ProcessManager { .env("SPRINTLESS_SHARED", shared.to_string_lossy().to_string()) .env("SPRINTLESS_GITHUB_TOKEN", &self.github_token); + info!( + model = %self.model, + "FORGE process will use model" + ); + if let Some(proxy_url) = &self.proxy_url { + info!(proxy_url = %proxy_url, proxy_api_key = ?self.proxy_api_key, "Injecting proxy environment for FORGE"); Self::inject_proxy_env( &mut cmd, "forge-key", @@ -249,6 +342,7 @@ impl ProcessManager { self.proxy_api_key.as_deref(), ); } else { + info!("No proxy configured - using direct Anthropic API"); cmd.env( "ANTHROPIC_API_KEY", std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), @@ -344,6 +438,8 @@ impl ProcessManager { let mut cmd = Command::new(&self.claude_path); cmd.arg("--print") .arg("--dangerously-skip-permissions") + .arg("--model") + .arg(&self.model) .arg("--settings") .arg(&settings_path) .arg("--plugin-dir") @@ -360,6 +456,11 @@ impl ProcessManager { .env("SPRINTLESS_SHARED", shared.to_string_lossy().to_string()) .env("SPRINTLESS_GITHUB_TOKEN", &self.github_token); + info!( + model = %self.model, + "FORGE PR process will use model" + ); + if let Some(proxy_url) = &self.proxy_url { Self::inject_proxy_env( &mut cmd, @@ -368,10 +469,24 @@ impl ProcessManager { self.proxy_api_key.as_deref(), ); } else { - cmd.env( - "ANTHROPIC_API_KEY", - std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), - ); + // For Claude CLI agents, we need either a proxy or direct Anthropic API + // Fireworks doesn't support native Anthropic format, so proxy is required + if std::env::var("FIREWORKS_API_KEY").is_ok() && std::env::var("ANTHROPIC_API_KEY").is_err() { + tracing::warn!("Fireworks API key set but no proxy configured. Claude CLI agents require proxy for Fireworks. Attempting auto-detection..."); + // Try to use local proxy if running + if let Ok(port) = std::env::var("PORT") { + let proxy_url = format!("http://localhost:{}/v1", port); + Self::inject_proxy_env(&mut cmd, "forge-key", &proxy_url, std::env::var("FIREWORKS_API_KEY").ok().as_deref()); + } else { + // Fall back to trying default proxy port + Self::inject_proxy_env(&mut cmd, "forge-key", "http://localhost:8765/v1", std::env::var("FIREWORKS_API_KEY").ok().as_deref()); + } + } else { + cmd.env( + "ANTHROPIC_API_KEY", + std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + ); + } Self::inject_llm_env(&mut cmd); } @@ -471,6 +586,8 @@ impl ProcessManager { .arg("--output-format") .arg("json") .arg("--dangerously-skip-permissions") + .arg("--model") + .arg(&self.model) .arg("--settings") .arg(&settings_path) .arg("--plugin-dir") @@ -489,18 +606,37 @@ impl ProcessManager { .env("SPRINTLESS_GITHUB_TOKEN", &self.github_token) .env("SPRINTLESS_SENTINEL_TIMEOUT_SECS", timeout_secs.to_string()); + info!( + model = %self.model, + "SENTINEL process will use model" + ); + if let Some(proxy_url) = &self.proxy_url { Self::inject_proxy_env( &mut cmd, - "sentinel-key", + "forge-key", proxy_url, self.proxy_api_key.as_deref(), ); } else { - cmd.env( - "ANTHROPIC_API_KEY", - std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), - ); + // For Claude CLI agents, we need either a proxy or direct Anthropic API + // Fireworks doesn't support native Anthropic format, so proxy is required + if std::env::var("FIREWORKS_API_KEY").is_ok() && std::env::var("ANTHROPIC_API_KEY").is_err() { + tracing::warn!("Fireworks API key set but no proxy configured. Claude CLI agents require proxy for Fireworks. Attempting auto-detection..."); + // Try to use local proxy if running + if let Ok(port) = std::env::var("PORT") { + let proxy_url = format!("http://localhost:{}/v1", port); + Self::inject_proxy_env(&mut cmd, "forge-key", &proxy_url, std::env::var("FIREWORKS_API_KEY").ok().as_deref()); + } else { + // Fall back to trying default proxy port + Self::inject_proxy_env(&mut cmd, "forge-key", "http://localhost:8765/v1", std::env::var("FIREWORKS_API_KEY").ok().as_deref()); + } + } else { + cmd.env( + "ANTHROPIC_API_KEY", + std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + ); + } Self::inject_llm_env(&mut cmd); } diff --git a/crates/pair-harness/src/types.rs b/crates/pair-harness/src/types.rs index 3155a05..d927bf6 100644 --- a/crates/pair-harness/src/types.rs +++ b/crates/pair-harness/src/types.rs @@ -53,6 +53,9 @@ pub struct PairConfig { pub shared: PathBuf, pub redis_url: Option, pub proxy_url: Option, + /// Model override from registry (e.g., "accounts/fireworks/models/glm-5"). + /// When set, ProcessManager will use this model instead of CLAUDE_MODEL/ANTHROPIC_MODEL. + pub model: Option, pub github_token: String, pub max_resets: u32, pub watchdog_timeout_secs: u64, @@ -87,6 +90,7 @@ impl PairConfig { ticket_id, redis_url: None, proxy_url: None, + model: None, github_token: github_token.into(), max_resets: 10, watchdog_timeout_secs: 1200, @@ -113,6 +117,7 @@ impl PairConfig { ticket_id, redis_url: Some(redis_url.into()), proxy_url: None, + model: None, github_token: github_token.into(), max_resets: 10, watchdog_timeout_secs: 1200, @@ -139,6 +144,7 @@ impl PairConfig { ticket_id, redis_url, proxy_url: Some(proxy_url.into()), + model: None, github_token: github_token.into(), max_resets: 10, watchdog_timeout_secs: 1200, @@ -146,6 +152,12 @@ impl PairConfig { max_verify_attempts: 3, } } + + /// Set the model override from registry. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } } /// Outcome of a pair's work on a ticket. diff --git a/crates/pocketflow-core/Cargo.toml b/crates/pocketflow-core/Cargo.toml index 1b5322d..0670f79 100644 --- a/crates/pocketflow-core/Cargo.toml +++ b/crates/pocketflow-core/Cargo.toml @@ -1,8 +1,12 @@ [package] -name = "pocketflow-core" -version = "0.1.0" -edition = "2021" -license = "MIT" +name = "pocketflow-core" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Core library for building multi-agent AI orchestration flows" +repository = "https://github.com/The-AgenticFlow/OpenFlows" +keywords = ["ai", "agents", "orchestration", "flow", "workflow"] +categories = ["asynchronous", "concurrency"] [dependencies] tokio = { workspace = true } diff --git a/crates/pocketflow-core/src/store.rs b/crates/pocketflow-core/src/store.rs index 79a7d2e..3a2f1be 100644 --- a/crates/pocketflow-core/src/store.rs +++ b/crates/pocketflow-core/src/store.rs @@ -8,7 +8,6 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; -use tracing::debug; // ── Event ring buffer ───────────────────────────────────────────────────── @@ -124,17 +123,17 @@ impl SharedStore { pub async fn get(&self, key: &str) -> Option { let v = self.backend.get(key).await; - debug!(key, found = v.is_some(), "store.get"); + tracing::trace!(key, found = v.is_some(), "store.get"); v } pub async fn set(&self, key: &str, value: Value) { - debug!(key, "store.set"); + tracing::trace!(key, "store.set"); self.backend.set(key, value).await; } pub async fn del(&self, key: &str) { - debug!(key, "store.del"); + tracing::trace!(key, "store.del"); self.backend.del(key).await; } diff --git a/docker-compose.yml b/docker-compose.yml index 89a6694..f061e29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,10 +24,11 @@ services: command: redis-server --appendonly yes restart: unless-stopped - agent-team: + openflows: build: context: . dockerfile: Dockerfile + container_name: openflows ports: - "3000:3000" volumes: diff --git a/docs/license-asset-flow.md b/docs/license-asset-flow.md new file mode 100644 index 0000000..bd071fe --- /dev/null +++ b/docs/license-asset-flow.md @@ -0,0 +1,441 @@ +# License and Asset Distribution Flow + +This document describes how the MIT license, documentation, and configuration assets are distributed across OpenFlows' packaging formats. + +## Overview + +OpenFlows uses a multi-format distribution strategy: +- **GitHub Releases**: Platform-specific tarballs with binaries + assets +- **npm Package**: `@the-agenticflow/openflows` with post-install binary downloader +- **Docker Images**: `ghcr.io/the-agenticflow/openflows` with embedded assets +- **crates.io**: Rust crate publication (library only, no assets) + +## License + +OpenFlows is released under the **MIT License**. + +### License File Location +- **Repository Root**: `/LICENSE` +- **Copyright**: Christian Yemele (2026) +- **Permissions**: Free to use, modify, distribute, sublicense, and sell + +### License Distribution Path + +``` +Repository Root (LICENSE) + │ + ├─→ GitHub Release Tarball + │ └─ Included in every platform archive + │ + ├─→ npm Package + │ └─ Declared in package.json "license": "MIT" + │ + ├─→ Docker Image + │ └─ Copied during build from repository root + │ + └─→ crates.io + └─ Specified in each crate's Cargo.toml +``` + +## Asset Distribution Matrix + +| Asset | GitHub Release | npm Package | Docker Image | crates.io | +|-------|---------------|-------------|--------------|-----------| +| LICENSE | ✅ Included | ✅ Declared | ✅ Included | ✅ Specified | +| README.md | ✅ Included | ❌ Not needed | ✅ Included | ❌ N/A | +| orchestration/ | ✅ Included | ✅ Downloaded | ✅ Included | ❌ N/A | +| Binaries | ✅ Main content | ✅ Downloaded | ✅ Built in | ❌ N/A | + +## Distribution Workflows + +### 1. GitHub Releases (Primary Distribution) + +**Trigger**: Git tag push (`v*`) or manual workflow dispatch + +**Build Process** (`.github/workflows/release.yml`): +```yaml +# Step 1: Build binaries for all platforms +cargo build --release --bin agentflow --bin agentflow-setup --bin agentflow-dashboard --bin agentflow-doctor + +# Step 2: Create distribution archive +mkdir -p dist/openflows-{version}-{platform} +cp target/release/agentflow* dist/openflows-{version}-{platform}/ +cp -r orchestration dist/openflows-{version}-{platform}/ +cp README.md LICENSE dist/openflows-{version}-{platform}/ + +# Step 3: Package and checksum +tar -czf openflows-{version}-{platform}.tar.gz +sha256sum openflows-{version}-{platform}.tar.gz > openflows-{version}-{platform}.tar.gz.sha256 +``` + +**Platforms Built**: +- `x86_64-apple-darwin` (macOS Intel) +- `aarch64-apple-darwin` (macOS Apple Silicon) +- `x86_64-unknown-linux-musl` (Linux x86_64 static) +- `aarch64-unknown-linux-gnu` (Linux ARM64) + +**Archive Contents**: +``` +openflows-v0.1.3-x86_64-unknown-linux-musl/ +├── agentflow # Main orchestration binary +├── agentflow-setup # Interactive TUI setup wizard +├── agentflow-dashboard # Real-time monitoring TUI +├── agentflow-doctor # System diagnostics tool +├── orchestration/ # Agent configurations +│ ├── agent/ +│ │ ├── agents/ # Agent persona definitions +│ │ │ ├── nexus.agent.md +│ │ │ ├── forge.agent.md +│ │ │ ├── sentinel.agent.md +│ │ │ └── vessel.agent.md +│ │ ├── registry.json # Team membership configuration +│ │ └── standards/ +│ └── plugin/ +│ └── hooks/ # Per-agent lifecycle hooks +├── README.md # Documentation +└── LICENSE # MIT License +``` + +### 2. npm Package Distribution + +**Package**: `@the-agenticflow/openflows` + +**package.json Configuration**: +```json +{ + "name": "@the-agenticflow/openflows", + "version": "0.1.2", + "license": "MIT", + "bin": { + "openflows": "./bin/openflows.js", + "openflows-setup": "./bin/openflows-setup.js", + "openflows-dashboard": "./bin/openflows-dashboard.js", + "openflows-doctor": "./bin/openflows-doctor.js" + }, + "scripts": { + "postinstall": "node scripts/install.js" + } +} +``` + +**Post-Install Flow** (`packaging/npm/scripts/install.js`): + +1. **Platform Detection**: + - Detects OS: `darwin`, `linux` + - Detects Arch: `x64`, `arm64` + - Checks libc: `gnu` vs `musl` + +2. **Binary Download**: + ```javascript + // Fetch latest release metadata + GET https://api.github.com/repos/The-AgenticFlow/AgentFlow/releases/latest + + // Download platform-specific tarball + GET https://github.com/The-AgenticFlow/AgentFlow/releases/download/{tag}/openflows-{tag}-{platform}.tar.gz + + // Extract to package bin/ directory + tar -xzf openflows-{tag}-{platform}.tar.gz -C bin/ + ``` + +3. **Fallback Logic**: + - If `x86_64-unknown-linux-gnu` fails, tries `x86_64-unknown-linux-musl` + - Ensures compatibility across different Linux distributions + +4. **Binary Renaming**: + ```javascript + // Rename for Node.js wrapper + agentflow → agentflow-bin + agentflow-setup → agentflow-setup-bin + // etc. + ``` + +**What Gets Distributed**: +- ✅ LICENSE: Declared in `package.json` +- ✅ Binaries: Downloaded from GitHub Releases (includes LICENSE + orchestration) +- ✅ orchestration/: Included in downloaded tarball +- ❌ README.md: Not included (users view on GitHub/npm registry) + +**Installation Command**: +```bash +npm install -g @the-agenticflow/openflows +# or +npx @the-agenticflow/openflows setup +``` + +### 3. Docker Image Distribution + +**Registry**: `ghcr.io/the-agenticflow/openflows` + +**Build Process** (`.github/workflows/release.yml`): +```yaml +# Uses Docker Buildx for multi-arch builds +docker buildx build --push \ + --tag ghcr.io/the-agenticflow/openflows:latest \ + --tag ghcr.io/the-agenticflow/openflows:{version} \ + --platform linux/amd64,linux/arm64 \ + . +``` + +**Dockerfile** (`Dockerfile`): +```dockerfile +FROM rust:1.75 as builder +# Build all binaries +COPY . . +RUN cargo build --release --bin agentflow --bin agentflow-setup + +FROM debian:bookworm-slim +# Copy binaries and assets +COPY --from=builder /app/target/release/agentflow /usr/local/bin/ +COPY --from=builder /app/target/release/agentflow-setup /usr/local/bin/ +COPY --from=builder /app/orchestration /app/orchestration/ +COPY --from=builder /app/README.md /app/ +COPY --from=builder /app/LICENSE /app/ + +# Runtime configuration +ENV RUST_LOG=info +CMD ["agentflow"] +``` + +**What Gets Distributed**: +- ✅ LICENSE: Copied to `/app/LICENSE` +- ✅ README.md: Copied to `/app/README.md` +- ✅ orchestration/: Copied to `/app/orchestration/` +- ✅ Binaries: Built into `/usr/local/bin/` + +**Usage**: +```bash +# Pull and run +docker pull ghcr.io/the-agenticflow/openflows:latest +docker run -v $(pwd):/workspace ghcr.io/the-agenticflow/openflows + +# With environment +docker run -e ANTHROPIC_API_KEY=... \ + -e GITHUB_PERSONAL_ACCESS_TOKEN=... \ + -v $(pwd):/workspace \ + ghcr.io/the-agenticflow/openflows +``` + +### 4. crates.io Distribution (Libraries Only) + +**Published Crates**: +```yaml +# Build order (dependencies first) +- pocketflow-core +- config +- agent-client +- github +- agent-nexus +- agent-forge +- agent-sentinel +- agent-vessel +- agent-lore +- pair-harness +- agentflow-tui +- openflows (workspace root) +``` + +**License Declaration** (each `Cargo.toml`): +```toml +[package] +name = "pocketflow-core" +version = "0.1.0" +license = "MIT" +``` + +**What Gets Distributed**: +- ✅ LICENSE: Declared in each crate's metadata +- ❌ orchestration/: Not included (application-level config) +- ❌ README.md: Not included (crate-level docs are separate) +- ❌ Binaries: Not included (crates are libraries only) + +**Publication Command**: +```bash +cargo publish -p pocketflow-core --token $CARGO_REGISTRY_TOKEN +cargo publish -p config --token $CARGO_REGISTRY_TOKEN +# ... for each crate +``` + +## Asset Lifecycle + +### Development Phase +``` +Repository +├── LICENSE (MIT) +├── README.md +├── orchestration/ +│ ├── agent/registry.json +│ └── plugin/hooks/ +└── crates/*/Cargo.toml (license = "MIT") +``` + +### Release Phase +``` +Git Tag (v0.1.3) + │ + ├──→ GitHub Actions: Build + │ ├── Compile binaries (4 platforms) + │ ├── Copy LICENSE + README.md + orchestration/ + │ ├── Create tarballs + checksums + │ └── Upload to GitHub Releases + │ + ├──→ GitHub Actions: Docker + │ ├── Build multi-arch images + │ ├── Embed LICENSE + README.md + orchestration/ + │ └── Push to ghcr.io + │ + └──→ GitHub Actions: crates.io + ├── Publish libraries + └── Metadata includes MIT license +``` + +### Installation Phase (User) +``` +npm install -g @the-agenticflow/openflows + │ + ├── Download package.json (license: "MIT") + ├── Run postinstall script + │ ├── Detect platform + │ ├── Download tarball from GitHub Releases + │ │ └── Contains: binaries + LICENSE + orchestration/ + │ └── Extract to ~/.npm-global/lib/node_modules/@the-agenticflow/openflows/bin/ + │ + └── User runs: + ├── openflows-setup → Uses orchestration/ from tarball + └── openflows → Reads LICENSE embedded in binaries +``` + +## License Compliance + +### For End Users +- **npm Package**: License declared in `package.json`, visible on npmjs.com +- **Docker Image**: LICENSE file at `/app/LICENSE` +- **GitHub Release**: LICENSE included in every tarball +- **Source Code**: LICENSE at repository root + +### For Contributors +- **MIT License**: Permissive, allows commercial use +- **No CLA Required**: Inbound = Outbound licensing +- **Attribution**: Keep copyright notice + license text + +### For Distributors +- **Requirement**: Include LICENSE with substantial portions +- **Allowed**: Sub-license, sell, modify, merge +- **Not Required**: Disclose source (MIT is not copyleft) + +## Verification + +### Verify License in Tarball +```bash +# Download tarball +curl -LO https://github.com/The-AgenticFlow/AgentFlow/releases/download/v0.1.3/openflows-v0.1.3-x86_64-unknown-linux-musl.tar.gz + +# Extract and verify +tar -tzf openflows-v0.1.3-x86_64-unknown-linux-musl.tar.gz | grep LICENSE +# Expected: openflows-v0.1.3-x86_64-unknown-linux-musl/LICENSE + +# View license +tar -xzf openflows-v0.1.3-x86_64-unknown-linux-musl.tar.gz +cat openflows-v0.1.3-x86_64-unknown-linux-musl/LICENSE +``` + +### Verify License in npm Package +```bash +# Install package +npm install -g @the-agenticflow/openflows + +# Check package metadata +npm info @the-agenticflow/openflows license +# Expected: MIT + +# View installed LICENSE (from downloaded tarball) +cat $(npm root -g)/@the-agenticflow/openflows/bin/LICENSE +``` + +### Verify License in Docker Image +```bash +# Pull image +docker pull ghcr.io/the-agenticflow/openflows:latest + +# Inspect LICENSE file +docker run --rm ghcr.io/the-agenticflow/openflows:latest cat /app/LICENSE +``` + +### Verify License on crates.io +```bash +# Check crate metadata +cargo search pocketflow-core --limit 1 +# Shows: pocketflow-core = "0.1.0" # ... + +# View on crates.io +open https://crates.io/crates/pocketflow-core +# License field shows: MIT +``` + +## Version Updates + +When creating a new release: + +1. **Update LICENSE copyright year** (if needed): + ``` + Copyright (c) 2026 Christian Yemele + ``` + +2. **Ensure package.json version matches**: + ```json + { + "version": "0.1.3", + "license": "MIT" + } + ``` + +3. **Tag and release**: + ```bash + git tag -a v0.1.3 -m "Release v0.1.3" + git push origin v0.1.3 + ``` + +4. **GitHub Actions automatically**: + - Builds all platforms + - Includes LICENSE in tarballs + - Pushes Docker images with LICENSE + - Publishes crates with MIT metadata + +## Troubleshooting + +### Missing LICENSE in tarball +- **Check**: `.github/workflows/release.yml` line 77 +- **Expected**: `cp README.md LICENSE "dist/${ARCHIVE}/" 2>/dev/null || true` +- **Fix**: Ensure LICENSE exists at repository root + +### npm install fails to download binary +- **Cause**: Network issue (like `EAI_AGAIN api.github.com`) +- **Solution 1**: Retry after network stabilizes +- **Solution 2**: Build from source instead + ```bash + git clone https://github.com/The-AgenticFlow/OpenFlows.git + cd OpenFlows + cargo build --release --bin agentflow-setup + ``` + +### Docker image missing orchestration config +- **Check**: `Dockerfile` COPY commands +- **Expected**: `COPY --from=builder /app/orchestration /app/orchestration/` +- **Fix**: Rebuild image with correct COPY paths + +## Related Documentation + +- **Build Process**: `CONTRIBUTING.md` - Development setup +- **Release Process**: `PACKAGING.md` - Detailed packaging guide +- **Installation Guide**: `README.md` - User installation instructions +- **Configuration**: `orchestration/agent/README.md` - Agent configuration + +## Summary + +The MIT license and associated assets follow a consistent distribution pattern: + +1. **Source of Truth**: Repository root LICENSE file +2. **Build-time Inclusion**: Assets copied into tarballs and Docker images +3. **Runtime Availability**: npm downloads tarballs containing LICENSE + orchestration +4. **Metadata Declaration**: All packages declare `license: "MIT"` in their manifests + +This ensures license compliance across all distribution channels while maintaining a single source of truth in the repository. diff --git a/orchestration/agent/registry.json b/orchestration/agent/registry.json index 0e3f73f..c198f95 100644 --- a/orchestration/agent/registry.json +++ b/orchestration/agent/registry.json @@ -1,10 +1,10 @@ { "team": [ - { "id": "nexus", "cli": "claude", "active": true, "instances": 1, "model_backend": "accounts/fireworks/models/glm-5", "routing_key": "nexus-key", "github_token_env": "AGENT_NEXUS_GITHUB_TOKEN" }, - { "id": "forge", "cli": "claude", "active": true, "instances": 2, "model_backend": "accounts/fireworks/models/glm-5", "routing_key": "forge-key", "github_token_env": "AGENT_FORGE_GITHUB_TOKEN" }, - { "id": "sentinel", "cli": "claude", "active": true, "instances": 1, "model_backend": "accounts/fireworks/models/glm-5", "routing_key": "sentinel-key", "github_token_env": "AGENT_SENTINEL_GITHUB_TOKEN" }, - { "id": "vessel", "cli": "claude", "active": true, "instances": 1, "model_backend": "accounts/fireworks/models/glm-5", "routing_key": "vessel-key", "github_token_env": "AGENT_VESSEL_GITHUB_TOKEN" }, - { "id": "lore", "cli": "claude", "active": true, "instances": 1, "model_backend": "accounts/fireworks/models/glm-5", "routing_key": "lore-key", "github_token_env": "AGENT_LORE_GITHUB_TOKEN" } + { "id": "nexus", "cli": "claude", "active": true, "instances": 1, "model_backend": "claude-sonnet-4-5-20250514", "routing_key": "nexus-key", "github_token_env": "AGENT_NEXUS_GITHUB_TOKEN" }, + { "id": "forge", "cli": "claude", "active": true, "instances": 2, "model_backend": "claude-sonnet-4-5-20250514", "routing_key": "forge-key", "github_token_env": "AGENT_FORGE_GITHUB_TOKEN" }, + { "id": "sentinel", "cli": "claude", "active": true, "instances": 1, "model_backend": "claude-sonnet-4-5-20250514", "routing_key": "sentinel-key", "github_token_env": "AGENT_SENTINEL_GITHUB_TOKEN" }, + { "id": "vessel", "cli": "claude", "active": true, "instances": 1, "model_backend": "claude-sonnet-4-5-20250514", "routing_key": "vessel-key", "github_token_env": "AGENT_VESSEL_GITHUB_TOKEN" }, + { "id": "lore", "cli": "claude", "active": true, "instances": 1, "model_backend": "claude-sonnet-4-5-20250514", "routing_key": "lore-key", "github_token_env": "AGENT_LORE_GITHUB_TOKEN" } ] } \ No newline at end of file diff --git a/packaging/homebrew/openflows.rb b/packaging/homebrew/openflows.rb new file mode 100644 index 0000000..f5a1cd8 --- /dev/null +++ b/packaging/homebrew/openflows.rb @@ -0,0 +1,19 @@ +class Openflows < Formula + desc "Autonomous AI development team — turns GitHub issues into working PRs" + homepage "https://openflows.dev" + url "https://github.com/The-AgenticFlow/AgentFlow.git", + tag: "v0.1.0", + revision: "PLACEHOLDER_SHA" + license "MIT" + + depends_on "rust" => :build + depends_on "node" + + def install + system "cargo", "install", *std_cargo_args(path: "binary") + end + + test do + assert_match "OpenFlows", shell_output("#{bin}/agentflow --version") + end +end diff --git a/packaging/npm/.env.example b/packaging/npm/.env.example new file mode 100644 index 0000000..1072ccd --- /dev/null +++ b/packaging/npm/.env.example @@ -0,0 +1,62 @@ +# OpenFlows Configuration +# Copy this file to .env and fill in your values +# +# Quick Start Options: +# =================== +# +# Option 1: Anthropic Direct (Recommended - no proxy needed) +# --------------------------------------------------------- +# Get your key from: https://console.anthropic.com +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Option 2: Fireworks AI (proxy auto-starts for Claude CLI agents) +# ---------------------------------------------------------------- +# Get your key from: https://fireworks.ai +# FIREWORKS_API_KEY=your-fireworks-api-key +# NOTE: Fireworks doesn't have a native Anthropic endpoint, +# so the proxy auto-starts to translate Anthropic→OpenAI for Claude CLI + +# Option 3: Custom Gateway (requires built-in proxy) +# -------------------------------------------------- +# The proxy will auto-start when you use this option +# GATEWAY_URL=https://your-gateway.com/v1 +# GATEWAY_API_KEY=your-gateway-api-key + +# =================== +# Required Settings +# =================== + +# GitHub Personal Access Token (required) +# Get from: https://github.com/settings/tokens +# Needs: repo, read:user, read:org +GITHUB_PERSONAL_ACCESS_TOKEN=your-github-pat + +# Target repository (owner/repo format) +GITHUB_REPOSITORY=owner/repo-name + +# =================== +# Optional Settings +# =================== + +# Logging level (trace, debug, info, warn, error) +RUST_LOG=info + +# Proxy port (if using built-in proxy) +# PROXY_PORT=8765 + +# GitHub MCP type: "hosted" (default) or "docker" +# GITHUB_MCP_TYPE=hosted + +# =================== +# Advanced +# =================== + +# Custom model backend (overrides registry) +# MODEL_BACKEND=accounts/fireworks/models/glm-5 + +# Model provider mapping (for proxy mode) +# Format: prefix=provider,prefix=provider +# MODEL_PROVIDER_MAP=glm=openai,gpt=openai,claude=anthropic + +# Fallback order for direct mode +# LLM_FALLBACK=anthropic,gemini,openai diff --git a/packaging/npm/.gitignore b/packaging/npm/.gitignore new file mode 100644 index 0000000..a478e0e --- /dev/null +++ b/packaging/npm/.gitignore @@ -0,0 +1,12 @@ +# Generated during postinstall - downloaded from GitHub releases +*-bin +.postinstall-done +.tmp/ + +# Extracted from release tarball +bin/orchestration/ +bin/LICENSE +bin/README.md + +# Local node_modules (if any) +node_modules/ diff --git a/packaging/npm/README.md b/packaging/npm/README.md new file mode 100644 index 0000000..efa3026 --- /dev/null +++ b/packaging/npm/README.md @@ -0,0 +1,280 @@ +# @the-agenticflow/openflows + +**Autonomous AI Development Team** — Install and run with zero configuration knowledge required. + +## Quick Start + +```bash +# 1. Install globally +npm install -g @the-agenticflow/openflows + +# 2. Run setup wizard (configures API keys) +openflows-setup + +# 3. Start the autonomous team +openflows +``` + +That's it! The package handles everything automatically. + +## How It Works + +### Zero-Configuration Philosophy + +You don't need to know about proxies, MCP servers, or backend routing. The package: + +1. **Installs all dependencies** - Including `anthropic-proxy` for LLM API translation +2. **Auto-detects your setup** - Fireworks key? Proxy starts automatically. Anthropic key? Direct mode. +3. **Manages the proxy lifecycle** - Starts/stops the built-in proxy as needed +4. **Provides helpful errors** - Clear messages when something needs attention + +### Configuration Modes + +| Mode | What You Need | Proxy Required? | +|------|---------------|-----------------| +| **Fireworks AI** | `FIREWORKS_API_KEY` + `PROXY_TARGET_MODEL` | Auto-started (built-in) | +| **Anthropic Direct** | `ANTHROPIC_API_KEY` | No | +| **Custom Gateway** | `GATEWAY_URL` + `GATEWAY_API_KEY` | Auto-started | + +### Fireworks AI Setup (Recommended) + +Fireworks AI provides cost-effective OpenAI-compatible endpoints. Since Claude Code CLI speaks the Anthropic Messages API, the package includes a built-in protocol translator that automatically starts. + +**Via the setup wizard:** + +```bash +openflows-setup +``` + +When you select **Fireworks AI** as your provider, the wizard will: +1. Ask for your `FIREWORKS_API_KEY` +2. Show the proxy configuration screen with these fields: + - **Proxy URL** — pre-filled as `http://localhost:8765/v1` + - **Proxy API Key** — your Fireworks key (auto-filled) + - **Target Model (PROXY_TARGET_MODEL)** — e.g., `accounts/fireworks/models/glm-5` + - **Gateway URL** — pre-filled as `https://api.fireworks.ai/inference/v1/` + - **Gateway API Key** — your Fireworks key (auto-filled) +3. Complete the remaining steps (agents, GitHub, repository) + +**What `PROXY_TARGET_MODEL` does:** + +Instead of manually mapping each Claude model name, set this once. The local proxy: +1. Strips ANSI escape codes from incoming model names +2. Detects Claude patterns (`claude-*`, `opus`, `sonnet`, `haiku`) +3. Routes all of them to your specified target model + +```env +PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 +``` + +**Manual setup (without wizard):** + +```bash +# Create .env in your project directory +cat > .env << 'EOF' +FIREWORKS_API_KEY=fw_your_key +PORT=8765 +PROXY_URL=http://localhost:8765/v1 +PROXY_API_KEY=fw_your_key +GATEWAY_URL=https://api.fireworks.ai/inference/v1/ +GATEWAY_API_KEY=fw_your_key +PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 +GITHUB_PERSONAL_ACCESS_TOKEN=ghp_your_token +GITHUB_REPOSITORY=owner/repo +EOF +``` + +### Commands + +```bash +openflows # Start orchestration +openflows-setup # Interactive setup wizard (TUI) +openflows-dashboard # Real-time monitoring (TUI) +openflows-doctor # System diagnostics +openflows --help # Show help +openflows --version # Show version +``` + +## What Gets Installed + +The post-install script downloads platform-specific binaries from GitHub Releases: + +| Binary | Purpose | +|--------|---------| +| `agentflow` | Main orchestration engine | +| `agentflow-setup` | Interactive TUI setup wizard | +| `agentflow-dashboard` | Real-time monitoring TUI | +| `agentflow-doctor` | System diagnostics tool | +| `anthropic-proxy` | Built-in Anthropic-to-OpenAI protocol translator (auto-starts for Fireworks) | + +Additionally: +- **mcp-proxy** is installed via npm for GitHub MCP connectivity +- **.env.example** is included for reference + +## Environment Configuration + +Create a `.env` file in your project directory: + +```bash +# Required +GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxx +GITHUB_REPOSITORY=owner/repo-name + +# Choose one API provider +FIREWORKS_API_KEY=fw_xxxxx # Recommended — proxy auto-starts +PROXY_TARGET_MODEL=accounts/fireworks/models/glm-5 # Target model for Fireworks +# ANTHROPIC_API_KEY=sk-ant-xxxx # Direct mode (no proxy needed) +# GATEWAY_URL=... # Custom gateway (proxy auto-starts) +# GATEWAY_API_KEY=... # For custom gateways +``` + +Run `openflows-setup` for a guided configuration experience. + +## Advanced Options + +### Disable Auto-Proxy + +```bash +openflows --no-proxy +``` + +### Proxy-Only Mode (Testing) + +```bash +openflows --proxy-only +# Starts only the built-in proxy on port 8765 +``` + +### Custom Proxy Port + +```bash +PROXY_PORT=9000 openflows +``` + +### Docker MCP (Alternative) + +If you prefer Docker for GitHub MCP: + +```bash +export GITHUB_MCP_TYPE=docker +openflows +``` + +## Troubleshooting + +### Fireworks Setup — Missing PROXY_TARGET_MODEL + +If you selected Fireworks AI during setup but didn't see the model configuration screen, ensure you're using version **0.1.15** or later. Earlier versions only showed proxy config in Advanced mode. + +```bash +openflows --version +# Should show 0.1.15 or later +``` + +If you're on an older version: +```bash +npm update -g @the-agenticflow/openflows +``` + +### `mcp-proxy` Installation Issues + +The post-install script attempts to install `mcp-proxy` (Python tool from PyPI) automatically. If you see errors: + +1. **Install manually:** + ```bash + # Recommended (fastest) + uv tool install mcp-proxy + + # Alternative + pipx install mcp-proxy + ``` + +2. **Use Docker mode instead:** + ```bash + export GITHUB_MCP_TYPE=docker + openflows + ``` + +The `mcp-proxy` tool bridges stdio to HTTP MCP servers like GitHub Copilot's MCP endpoint. + +### Permission Denied + +Ensure you're not using `sudo` for npm install: + +```bash +# Configure npm to use user-writable directory +mkdir -p ~/.npm-global +npm config set prefix '~/.npm-global' +echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc +source ~/.bashrc + +# Now install without sudo +npm install -g @the-agenticflow/openflows +``` + +### Network Issues + +If GitHub API is unreachable: + +1. The installer falls back to a known version +2. Or build from source: + ```bash + git clone https://github.com/The-AgenticFlow/AgentFlow.git + cd AgentFlow + cargo build --release + ``` + +### GitHub 401 Unauthorized + +Ensure your `GITHUB_PERSONAL_ACCESS_TOKEN` is valid with these scopes: +- `repo` (full repository access) +- `read:user` +- `read:org` + +## Platform Support + +| OS | Architecture | Status | +|----|--------------|--------| +| macOS | x86_64 (Intel) | ✅ | +| macOS | aarch64 (M1/M2) | ✅ | +| Linux | x86_64 (glibc) | ✅ | +| Linux | x86_64 (musl) | ✅ | +| Linux | aarch64 | ✅ | + +## Development + +### Testing Locally + +```bash +cd packaging/npm +npm pack +npm install -g the-agenticflow-openflows-0.1.4.tgz +openflows-setup --help +``` + +### Publishing + +1. Update version in `package.json` +2. Update fallback version in `scripts/install.js` +3. Test locally with `npm pack` +4. Publish: `npm publish --access public` + +## Files Included + +``` +@the-agenticflow/openflows/ +├── package.json # Package metadata +├── README.md # This file +├── .env.example # Configuration template +├── bin/ +│ ├── openflows.js # Main wrapper (handles proxy) +│ ├── openflows-setup.js # Setup wizard wrapper +│ ├── openflows-dashboard.js # Dashboard wrapper +│ └── openflows-doctor.js # Doctor wrapper +└── scripts/ + └── install.js # Post-install script +``` + +## License + +MIT - See [LICENSE](https://github.com/The-AgenticFlow/AgentFlow/blob/main/LICENSE) diff --git a/packaging/npm/bin/openflows-dashboard.js b/packaging/npm/bin/openflows-dashboard.js new file mode 100644 index 0000000..33579a2 --- /dev/null +++ b/packaging/npm/bin/openflows-dashboard.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { spawn } = require('child_process'); +const path = require('path'); +const binaryPath = path.join(__dirname, '..', 'bin', 'agentflow-dashboard-bin'); +const proc = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); +proc.on('exit', (code) => process.exit(code)); diff --git a/packaging/npm/bin/openflows-doctor.js b/packaging/npm/bin/openflows-doctor.js new file mode 100644 index 0000000..289f079 --- /dev/null +++ b/packaging/npm/bin/openflows-doctor.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { spawn } = require('child_process'); +const path = require('path'); +const binaryPath = path.join(__dirname, '..', 'bin', 'openflows-doctor-bin'); +const proc = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); +proc.on('exit', (code) => process.exit(code)); diff --git a/packaging/npm/bin/openflows-setup.js b/packaging/npm/bin/openflows-setup.js new file mode 100644 index 0000000..39c994f --- /dev/null +++ b/packaging/npm/bin/openflows-setup.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +const { spawn } = require('child_process'); +const path = require('path'); +const binaryPath = path.join(__dirname, '..', 'bin', 'agentflow-setup-bin'); +const proc = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); +proc.on('exit', (code) => process.exit(code)); diff --git a/packaging/npm/bin/openflows.js b/packaging/npm/bin/openflows.js new file mode 100644 index 0000000..b7516e8 --- /dev/null +++ b/packaging/npm/bin/openflows.js @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** + * OpenFlows - Autonomous AI Development Team + * + * This wrapper handles: + * 1. Automatic proxy management (for LLM API translation if needed) + * 2. Environment configuration + * 3. Graceful startup and shutdown + * + * Users don't need to know about proxies - everything is handled automatically. + */ +const { spawn, execSync, fork } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const http = require('http'); + +// Try to load .env file before checking environment +function loadEnvFile() { + const envPaths = [ + path.join(process.cwd(), '.env'), + path.join(os.homedir(), '.agentflow', '.env'), + ]; + + for (const envPath of envPaths) { + if (fs.existsSync(envPath)) { + try { + const content = fs.readFileSync(envPath, 'utf8'); + content.split('\n').forEach(line => { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) return; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + // Only set if not already defined + if (!process.env[key]) { + process.env[key] = value; + } + } + }); + console.log(`[openflows] Loaded environment from ${envPath}`); + return; + } catch (err) { + // Ignore errors + } + } + } +} + +// Load .env file early +loadEnvFile(); + +const binaryPath = path.join(__dirname, '..', 'bin', 'agentflow-bin'); +const PROXY_PORT = process.env.PROXY_PORT || 8765; +const PROXY_STARTUP_TIMEOUT = 5000; + +// Check if a port is in use +function isPortInUse(port) { + return new Promise((resolve) => { + const req = http.request({ + method: 'GET', + hostname: 'localhost', + port: port, + path: '/health', + timeout: 500 + }, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +// Check if user needs proxy (for LLM API translation if needed) +function needsProxy() { + // If user explicitly set PROXY_URL, respect it + if (process.env.PROXY_URL) { + return { needed: false, reason: 'PROXY_URL already set' }; + } + + // If user has Anthropic API key, they can use it directly + if (process.env.ANTHROPIC_API_KEY) { + return { needed: false, reason: 'Anthropic direct mode' }; + } + + // Fireworks users need proxy for Claude CLI agents (no native Anthropic endpoint) + if (process.env.FIREWORKS_API_KEY) { + return { needed: true, reason: 'Fireworks requires proxy for Claude CLI compatibility' }; + } + + // If user has Gateway config but no direct keys, they need proxy + if (process.env.GATEWAY_URL || process.env.GATEWAY_API_KEY) { + return { needed: true, reason: 'Gateway configured, no direct keys' }; + } + + // Check if there's a .env file that might have config + const envPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(envPath)) { + return { needed: false, reason: 'No direct API key found, .env will be loaded by binary' }; + } + + // No API config at all - let the binary handle the error + return { needed: false, reason: 'No API config - will error in binary' }; +} + +// Start the built-in proxy (anthropic-proxy binary) +async function startProxy() { + console.log('[openflows] Starting built-in API proxy...'); + + let proxyBinary = path.join(__dirname, '..', 'bin', 'anthropic-proxy-bin'); + + // Check if proxy binary exists + if (!fs.existsSync(proxyBinary)) { + // Try alternative location + const altProxy = path.join(__dirname, '..', 'bin', 'anthropic-proxy'); + if (!fs.existsSync(altProxy)) { + console.log('[openflows] No built-in proxy found, skipping proxy startup'); + return null; + } + proxyBinary = altProxy; + } + + // Build proxy environment with all necessary variables + const proxyEnv = { + ...process.env, + PORT: PROXY_PORT.toString(), + RUST_LOG: process.env.RUST_LOG || 'info' + }; + + // Ensure gateway config is passed (for Fireworks or custom gateways) + if (process.env.FIREWORKS_API_KEY && !process.env.GATEWAY_URL) { + proxyEnv.GATEWAY_URL = 'https://api.fireworks.ai/inference/v1/'; + proxyEnv.GATEWAY_API_KEY = process.env.FIREWORKS_API_KEY; + if (!process.env.PROXY_TARGET_MODEL) { + proxyEnv.PROXY_TARGET_MODEL = 'accounts/fireworks/models/glm-5'; + } + console.log('[openflows] Configured proxy for Fireworks gateway'); + } + + const proxy = spawn(proxyBinary, [], { + env: proxyEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let proxyReady = false; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!proxyReady) { + console.warn('[openflows] Proxy startup timeout, continuing without proxy'); + resolve(null); + } + }, PROXY_STARTUP_TIMEOUT); + + proxy.stdout.on('data', (data) => { + const line = data.toString(); + if (line.includes('listening') || line.includes('Proxy') || line.includes('started')) { + proxyReady = true; + clearTimeout(timeout); + console.log(`[openflows] ✓ Proxy started on port ${PROXY_PORT}`); + resolve(proxy); + } + }); + + proxy.stderr.on('data', (data) => { + const line = data.toString(); + // Log proxy errors but don't fail + if (line.includes('ERROR') || line.includes('error')) { + console.error('[openflows proxy]', line.trim()); + } + }); + + proxy.on('error', (err) => { + clearTimeout(timeout); + console.warn(`[openflows] Proxy failed to start: ${err.message}`); + resolve(null); + }); + + proxy.on('exit', (code) => { + if (!proxyReady) { + clearTimeout(timeout); + resolve(null); + } else { + console.log(`[openflows] Proxy exited with code ${code}`); + } + }); + }); +} + +// Clean up function +let cleanupCalled = false; +function cleanup(proxy, signal = 'SIGTERM') { + if (cleanupCalled) return; + cleanupCalled = true; + + if (proxy) { + console.log('[openflows] Stopping proxy...'); + try { + proxy.kill(signal); + } catch (err) { + // Ignore cleanup errors + } + } +} + +// Main entry point +async function main() { + const args = process.argv.slice(2); + + // Handle special commands + if (args[0] === '--help' || args[0] === '-h') { + console.log(` +OpenFlows - Autonomous AI Development Team + +Usage: + openflows [options] + +Options: + --help, -h Show this help + --version, -v Show version + --no-proxy Disable automatic proxy startup + --proxy-only Start only the proxy (for testing) + +Commands: + openflows-setup Guided setup wizard + openflows-dashboard Live monitoring TUI + openflows-doctor Diagnostic checks + + Environment Variables: + ANTHROPIC_API_KEY Use Anthropic directly (no proxy needed) + FIREWORKS_API_KEY Use Fireworks AI (proxy auto-starts for Claude CLI) + GATEWAY_URL Custom gateway URL (requires proxy) + GATEWAY_API_KEY Custom gateway API key + PROXY_PORT Port for built-in proxy (default: 8765) + +Examples: + # Quick start with Anthropic (no proxy needed) + ANTHROPIC_API_KEY=your-key openflows + + # Use Fireworks (proxy auto-starts for Claude CLI agents) + FIREWORKS_API_KEY=your-key openflows + + # Use custom gateway (proxy auto-starts) + GATEWAY_URL=https://your-gateway.com/v1 \\ + GATEWAY_API_KEY=your-key openflows + +Documentation: https://openflows.dev +`); + process.exit(0); + } + + if (args[0] === '--version' || args[0] === '-v') { + try { + const pkg = require('../package.json'); + console.log(`openflows v${pkg.version}`); + } catch { + console.log('openflows (version unknown)'); + } + process.exit(0); + } + + // Skip proxy if --no-proxy flag + const skipProxy = args.includes('--no-proxy'); + + // Start proxy only mode + if (args[0] === '--proxy-only') { + const proxy = await startProxy(); + if (proxy) { + console.log(`[openflows] Proxy running on http://localhost:${PROXY_PORT}`); + console.log('[openflows] Press Ctrl+C to stop'); + + // Keep running until killed + process.on('SIGINT', () => cleanup(proxy, 'SIGINT')); + process.on('SIGTERM', () => cleanup(proxy, 'SIGTERM')); + } else { + console.error('[openflows] Failed to start proxy'); + process.exit(1); + } + return; + } + + // Check if we need to start proxy + let proxy = null; + let env = { ...process.env }; + + if (!skipProxy) { + const { needed, reason } = needsProxy(); + + if (needed) { + console.log(`[openflows] ${reason} - starting proxy...`); + + // Check if proxy is already running + const proxyRunning = await isPortInUse(PROXY_PORT); + + if (proxyRunning) { + console.log(`[openflows] ✓ Proxy already running on port ${PROXY_PORT}`); + // Set PROXY_URL for the main binary + env.PROXY_URL = `http://localhost:${PROXY_PORT}/v1`; + if (process.env.FIREWORKS_API_KEY) { + env.PROXY_API_KEY = process.env.FIREWORKS_API_KEY; + } + } else { + proxy = await startProxy(); + if (proxy) { + // Set PROXY_URL for the main binary + env.PROXY_URL = `http://localhost:${PROXY_PORT}/v1`; + // Set PROXY_API_KEY for authentication + if (process.env.FIREWORKS_API_KEY) { + env.PROXY_API_KEY = process.env.FIREWORKS_API_KEY; + } else if (process.env.GATEWAY_API_KEY) { + env.PROXY_API_KEY = process.env.GATEWAY_API_KEY; + } + console.log(`[openflows] ✓ Proxy started, PROXY_URL set to ${env.PROXY_URL}`); + } + } + } else { + console.log(`[openflows] Mode: ${reason}`); + } + } + + // Spawn the main binary + const proc = spawn(binaryPath, args, { + env, + stdio: 'inherit' + }); + + // Handle signals + process.on('SIGINT', () => { + cleanup(proxy, 'SIGINT'); + proc.kill('SIGINT'); + }); + + process.on('SIGTERM', () => { + cleanup(proxy, 'SIGTERM'); + proc.kill('SIGTERM'); + }); + + // Handle exit + proc.on('exit', (code) => { + cleanup(proxy); + process.exit(code || 0); + }); + + proc.on('error', (err) => { + console.error('[openflows] Failed to start:', err.message); + cleanup(proxy); + process.exit(1); + }); +} + +main().catch(err => { + console.error('[openflows] Error:', err.message); + process.exit(1); +}); diff --git a/packaging/npm/package.json b/packaging/npm/package.json new file mode 100644 index 0000000..cf475a8 --- /dev/null +++ b/packaging/npm/package.json @@ -0,0 +1,60 @@ +{ + "name": "@the-agenticflow/openflows", + "version": "0.1.21", + "description": "Autonomous AI development team — turns GitHub issues into working PRs", + "main": "index.js", + "bin": { + "openflows": "./bin/openflows.js", + "openflows-setup": "./bin/openflows-setup.js", + "openflows-dashboard": "./bin/openflows-dashboard.js", + "openflows-doctor": "./bin/openflows-doctor.js" + }, + "scripts": { + "postinstall": "node scripts/install.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/The-AgenticFlow/OpenFlows.git" + }, + "keywords": [ + "ai", + "autonomous", + "development", + "agents", + "github", + "claude", + "fireworks", + "anthropic" + ], + "author": "The AgenticFlow Team", + "license": "MIT", + "bugs": { + "url": "https://github.com/The-AgenticFlow/OpenFlows/issues" + }, + "homepage": "https://openflows.dev", + "files": [ + "bin/", + "scripts/", + "README.md", + ".env.example" + ], + "engines": { + "node": ">=18.0.0" + }, + "os": [ + "darwin", + "linux" + ], + "cpu": [ + "x64", + "arm64" + ], + "dependencies": {}, + "optionalDependencies": { + "@openflows/linux-x64-gnu": "0.1.0", + "@openflows/linux-x64-musl": "0.1.0", + "@openflows/linux-arm64-gnu": "0.1.0", + "@openflows/darwin-x64": "0.1.0", + "@openflows/darwin-arm64": "0.1.0" + } +} diff --git a/packaging/npm/scripts/install.js b/packaging/npm/scripts/install.js new file mode 100644 index 0000000..27228d7 --- /dev/null +++ b/packaging/npm/scripts/install.js @@ -0,0 +1,356 @@ +#!/usr/bin/env node +/** + * Post-install script for @the-agenticflow/openflows + * + * This script: + * 1. Downloads the correct pre-built binary for the current platform + * 2. Installs mcp-proxy (required for GitHub MCP connectivity) + * 3. Verifies all dependencies are ready + */ +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync, spawn } = require('child_process'); + +const REPO = 'The-AgenticFlow/AgentFlow'; +const BIN_DIR = path.join(__dirname, '..', 'bin'); +const POSTINSTALL_LOG = path.join(__dirname, '..', '.postinstall-done'); + +function detectPlatform() { + const platform = os.platform(); + const arch = os.arch(); + + let osPart, archPart; + + switch (platform) { + case 'darwin': + osPart = 'apple-darwin'; + break; + case 'linux': + // Check for musl + try { + const ldd = execSync('ldd --version 2>&1', { encoding: 'utf8' }); + if (ldd.includes('musl')) { + osPart = 'unknown-linux-musl'; + } else { + osPart = 'unknown-linux-gnu'; + } + } catch { + osPart = 'unknown-linux-gnu'; + } + break; + default: + console.error(`Unsupported platform: ${platform}`); + process.exit(1); + } + + switch (arch) { + case 'x64': + archPart = 'x86_64'; + break; + case 'arm64': + archPart = 'aarch64'; + break; + default: + console.error(`Unsupported architecture: ${arch}`); + process.exit(1); + } + + return `${archPart}-${osPart}`; +} + +function download(url, dest) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const client = parsed.protocol === 'https:' ? https : http; + const file = fs.createWriteStream(dest); + + client.get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + download(response.headers.location, dest).then(resolve).catch(reject); + return; + } + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); + return; + } + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }).on('error', (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }); +} + +function extractTarGz(tarPath, destDir) { + return new Promise((resolve, reject) => { + const tar = require('child_process').spawn('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1']); + tar.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`tar exited with code ${code}`)); + }); + }); +} + +/** + * Install mcp-proxy - required for GitHub MCP connectivity + * + * mcp-proxy is a Python tool from PyPI (sparfenyuk/mcp-proxy) + * It bridges stdio to HTTP MCP servers like GitHub Copilot's MCP endpoint. + * + * Strategy: + * 1. Check if mcp-proxy is already available + * 2. Install via uv (fast) or pipx (alternative) + * 3. Fall back to Docker mode instructions if Python tools unavailable + */ +async function ensureMcpProxy() { + console.log(`[openflows] Checking mcp-proxy installation...`); + + // Check if already installed + try { + execSync('which mcp-proxy', { stdio: 'pipe' }); + console.log(`[openflows] ✓ mcp-proxy already installed`); + return true; + } catch { + // Not installed, proceed with installation + } + + console.log(`[openflows] Installing mcp-proxy (required for GitHub MCP)...`); + + // Try uv first (fastest) + try { + execSync('which uv', { stdio: 'pipe' }); + console.log(`[openflows] Installing via uv...`); + execSync('uv tool install mcp-proxy', { + stdio: 'inherit', + timeout: 120000 + }); + console.log(`[openflows] ✓ mcp-proxy installed via uv`); + return true; + } catch (err) { + // uv failed or not available + } + + // Try pipx as alternative + try { + execSync('which pipx', { stdio: 'pipe' }); + console.log(`[openflows] Installing via pipx...`); + execSync('pipx install mcp-proxy', { + stdio: 'inherit', + timeout: 120000 + }); + console.log(`[openflows] ✓ mcp-proxy installed via pipx`); + return true; + } catch (err) { + // pipx failed or not available + } + + // Try pip3 as last resort + try { + execSync('which pip3', { stdio: 'pipe' }); + console.log(`[openflows] Installing via pip3...`); + execSync('pip3 install --user mcp-proxy', { + stdio: 'inherit', + timeout: 120000 + }); + console.log(`[openflows] ✓ mcp-proxy installed via pip3`); + return true; + } catch (err) { + // pip3 failed or not available + } + + console.warn(`[openflows] ⚠ Could not install mcp-proxy automatically.`); + console.warn(`[openflows] Please install manually:`); + console.warn(`[openflows] uv tool install mcp-proxy`); + console.warn(`[openflows] Or use Docker mode:`); + console.warn(`[openflows] export GITHUB_MCP_TYPE=docker`); + return false; +} + +/** + * Check if essential tools are available + */ +function checkPrerequisites() { + const checks = [ + { name: 'git', cmd: 'git --version' }, + { name: 'node', cmd: 'node --version' }, + ]; + + console.log(`[openflows] Checking prerequisites...`); + + let allPassed = true; + for (const check of checks) { + try { + execSync(check.cmd, { stdio: 'pipe' }); + console.log(`[openflows] ✓ ${check.name} available`); + } catch { + console.warn(`[openflows] ✗ ${check.name} not found - please install it`); + allPassed = false; + } + } + + return allPassed; +} + +async function main() { + // Skip postinstall if already done (e.g., during npm link) + if (fs.existsSync(POSTINSTALL_LOG)) { + const age = Date.now() - fs.statSync(POSTINSTALL_LOG).mtimeMs; + if (age < 60000) { // Less than 1 minute old + console.log(`[openflows] Postinstall already completed, skipping...`); + return; + } + } + + const platform = detectPlatform(); + console.log(``); + console.log(`╔══════════════════════════════════════════════╗`); + console.log(`║ OpenFlows Installation ║`); + console.log(`║ Autonomous AI Development Team ║`); + console.log(`╚══════════════════════════════════════════════╝`); + console.log(``); + console.log(`[openflows] Platform: ${platform}`); + + // Ensure bin directory exists + if (!fs.existsSync(BIN_DIR)) { + fs.mkdirSync(BIN_DIR, { recursive: true }); + } + + // Get latest release tag with better error handling + let tag; + try { + tag = await new Promise((resolve, reject) => { + const req = https.get(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { + 'User-Agent': 'openflows-npm-installer', + 'Accept': 'application/vnd.github.v3+json' + } + }, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`GitHub API returned ${res.statusCode}`)); + return; + } + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (!json.tag_name) { + reject(new Error('No tag_name in release response')); + } else { + resolve(json.tag_name); + } + } catch (parseErr) { + reject(new Error(`Failed to parse release info: ${parseErr.message}`)); + } + }); + }); + req.on('error', reject); + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error('GitHub API request timeout')); + }); + }); + } catch (apiErr) { + console.error(`[openflows] GitHub API error: ${apiErr.message}`); + console.error('[openflows] Falling back to latest known version: v0.1.6'); + tag = 'v0.1.6'; + } + + const archiveName = `openflows-${tag}-${platform}.tar.gz`; + const downloadUrl = `https://github.com/${REPO}/releases/download/${tag}/${archiveName}`; + // Use package's temp directory instead of system /tmp to avoid permission issues + const tmpDir = path.join(__dirname, '..', '.tmp'); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + const tmpFile = path.join(tmpDir, archiveName); + + try { + console.log(`[openflows] Downloading binary for ${platform}...`); + await download(downloadUrl, tmpFile); + await extractTarGz(tmpFile, BIN_DIR); + } catch (err) { + // For x86_64 Linux, try musl fallback + if (platform === 'x86_64-unknown-linux-gnu') { + const muslArchiveName = `openflows-${tag}-x86_64-unknown-linux-musl.tar.gz`; + const muslDownloadUrl = `https://github.com/${REPO}/releases/download/${tag}/${muslArchiveName}`; + const muslTmpFile = path.join(tmpDir, muslArchiveName); + console.log(`[openflows] Trying musl fallback...`); + await download(muslDownloadUrl, muslTmpFile); + await extractTarGz(muslTmpFile, BIN_DIR); + fs.unlinkSync(muslTmpFile); + } else { + throw err; + } + } + + // Rename binaries to match expected names + const binaries = ['agentflow', 'agentflow-setup', 'agentflow-dashboard', 'agentflow-doctor', 'anthropic-proxy']; + for (const bin of binaries) { + const src = path.join(BIN_DIR, bin); + const dst = path.join(BIN_DIR, `${bin}-bin`); + if (fs.existsSync(src)) { + fs.renameSync(src, dst); + fs.chmodSync(dst, 0o755); + } + } + + // Also ensure anthropic-proxy is executable even if not renamed + const proxyBin = path.join(BIN_DIR, 'anthropic-proxy'); + if (fs.existsSync(proxyBin)) { + fs.chmodSync(proxyBin, 0o755); + } + + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + // Clean up temp directory if empty + try { + if (fs.existsSync(tmpDir) && fs.readdirSync(tmpDir).length === 0) { + fs.rmdirSync(tmpDir); + } + } catch (cleanupErr) { + // Ignore cleanup errors + } + + console.log(`[openflows] ✓ Binaries installed`); + + // Install mcp-proxy + await ensureMcpProxy(); + + // Check prerequisites + checkPrerequisites(); + + // Mark postinstall as done + fs.writeFileSync(POSTINSTALL_LOG, new Date().toISOString()); + + console.log(``); + console.log(`╔══════════════════════════════════════════════╗`); + console.log(`║ Installation Complete! ║`); + console.log(`╚══════════════════════════════════════════════╝`); + console.log(``); + console.log(` Available commands:`); + console.log(` openflows - Start orchestration`); + console.log(` openflows-setup - Guided setup wizard`); + console.log(` openflows-dashboard - Live monitoring TUI`); + console.log(` openflows-doctor - Diagnostic checks`); + console.log(``); + console.log(` Quick start:`); + console.log(` 1. openflows-setup # Configure API keys`); + console.log(` 2. openflows # Start the autonomous team`); + console.log(``); + console.log(` Docs: https://openflows.dev`); + console.log(``); +} + +main().catch(err => { + console.error('[openflows] Installation failed:', err.message); + process.exit(1); +}); diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..5564555 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# OpenFlows Installer +# Usage: curl -fsSL https://get.openflows.dev | bash +# or: curl -fsSL https://raw.githubusercontent.com/The-AgenticFlow/AgentFlow/main/scripts/install.sh | bash + +set -euo pipefail + +REPO="The-AgenticFlow/AgentFlow" +INSTALL_DIR="${AGENTFLOW_INSTALL_DIR:-$HOME/.local/bin}" +BINARIES=("agentflow" "agentflow-setup" "agentflow-dashboard" "agentflow-doctor") + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE} →${NC} $1"; } +success() { echo -e "${GREEN} ✓${NC} $1"; } +warn() { echo -e "${YELLOW} ⚠${NC} $1"; } +fail() { echo -e "${RED} ✗${NC} $1" >&2; } + +# Detect OS and architecture +detect_platform() { + local os arch + os=$(uname -s | tr '[:upper:]' '[:lower:]') + arch=$(uname -m) + case "$arch" in + x86_64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) fail "Unsupported architecture: $arch"; exit 1 ;; + esac + case "$os" in + darwin) os="apple-darwin" ;; + linux) + # Prefer musl for portability + if ldd --version 2>&1 | grep -qi musl; then + os="unknown-linux-musl" + else + os="unknown-linux-gnu" + fi + ;; + *) fail "Unsupported OS: $os"; exit 1 ;; + esac + echo "${arch}-${os}" +} + +# Check if a command exists +has_cmd() { command -v "$1" &>/dev/null; } + +# Check/install Rust toolchain +ensure_rust() { + if has_cmd rustc; then + success "Rust $(rustc --version)" + return + fi + info "Rust not found. Installing rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + if [ -f "$HOME/.cargo/env" ]; then + source "$HOME/.cargo/env" + fi + success "Rust $(rustc --version)" +} + +# Check Git +ensure_git() { + if has_cmd git; then + success "Git $(git --version)" + else + fail "Git is required. Please install git first." + exit 1 + fi +} + +# Check/install Node.js +ensure_node() { + if has_cmd node; then + local node_ver + node_ver=$(node --version) + local major + major=$(echo "$node_ver" | cut -d. -f1 | tr -d 'v') + if [ "$major" -ge 18 ]; then + success "Node.js $node_ver" + return + fi + warn "Node.js $node_ver is too old (need 18+). Installing via nvm..." + else + info "Node.js not found. Installing via nvm..." + fi + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + nvm install 20 + success "Node.js $(node --version)" +} + +# Install Claude Code CLI +ensure_claude() { + if has_cmd claude; then + success "Claude Code CLI $(claude --version 2>/dev/null || echo 'installed')" + return + fi + info "Installing Claude Code CLI..." + npm install -g @anthropic-ai/claude-code + success "Claude Code CLI installed" +} + +# Download pre-built binary from GitHub Releases +download_binary() { + local platform="$1" + local tag="$2" + local asset_name="openflows-${tag}-${platform}.tar.gz" + + info "Downloading OpenFlows ${tag} for ${platform}..." + + local download_url="https://github.com/${REPO}/releases/download/${tag}/${asset_name}" + + if has_cmd curl; then + curl -fsSL "$download_url" -o "/tmp/${asset_name}" || { + fail "Failed to download ${asset_name}" + info "Falling back to building from source..." + return 1 + } + elif has_cmd wget; then + wget -q "$download_url" -O "/tmp/${asset_name}" || { + fail "Failed to download ${asset_name}" + info "Falling back to building from source..." + return 1 + } + else + fail "Neither curl nor wget found" + return 1 + fi + + tar -xzf "/tmp/${asset_name}" -C /tmp/ + rm -f "/tmp/${asset_name}" + + local extract_dir="/tmp/openflows-${tag}-${platform}" + for bin in "${BINARIES[@]}"; do + if [ -f "${extract_dir}/${bin}" ]; then + cp "${extract_dir}/${bin}" "${INSTALL_DIR}/" + chmod +x "${INSTALL_DIR}/${bin}" + fi + done + rm -rf "${extract_dir}" + + success "Downloaded and extracted to ${INSTALL_DIR}/" + return 0 +} + +# Build from source as fallback +build_from_source() { + info "Building OpenFlows from source..." + local repo_dir + repo_dir=$(mktemp -d) + trap "rm -rf '$repo_dir'" EXIT + + git clone --depth 1 "https://github.com/${REPO}.git" "$repo_dir" + cd "$repo_dir" + + cargo build --release -p openflows + + for bin in "${BINARIES[@]}"; do + if [ -f "target/release/${bin}" ]; then + cp "target/release/${bin}" "${INSTALL_DIR}/" + chmod +x "${INSTALL_DIR}/${bin}" + success "Built and installed ${bin}" + fi + done +} + +# Add install dir to PATH if needed +ensure_path() { + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + warn "${INSTALL_DIR} is not in your PATH" + local shell_rc="" + case "$SHELL" in + */bash) shell_rc="$HOME/.bashrc" ;; + */zsh) shell_rc="$HOME/.zshrc" ;; + */fish) shell_rc="$HOME/.config/fish/config.fish" ;; + esac + if [ -n "$shell_rc" ]; then + if [[ "$SHELL" == */fish ]]; then + echo "fish_add_path $INSTALL_DIR" >> "$shell_rc" + else + echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$shell_rc" + fi + info "Added $INSTALL_DIR to PATH in $shell_rc" + info "Run 'source $shell_rc' or restart your terminal" + fi + fi +} + +# Offer to run setup wizard +run_setup() { + echo "" + echo "Would you like to run the setup wizard now? (Y/n)" + read -r response + if [[ "$response" =~ ^[Yy]$ ]] || [[ -z "$response" ]]; then + if has_cmd agentflow-setup; then + agentflow-setup + else + warn "agentflow-setup not found in PATH. Run it manually after adding $INSTALL_DIR to PATH." + fi + fi +} + +# Main +main() { + echo "" + echo "╔══════════════════════════════════════════════╗" + echo "║ OpenFlows Installer ║" + echo "║ Autonomous AI Development Team ║" + echo "╚══════════════════════════════════════════════╝" + echo "" + + local platform + platform=$(detect_platform) + info "Platform: $platform" + info "Install directory: $INSTALL_DIR" + echo "" + + # Check prerequisites + info "Checking prerequisites..." + ensure_rust + ensure_git + ensure_node + ensure_claude + echo "" + + # Try to download pre-built binary + mkdir -p "$INSTALL_DIR" + local installed=false + + if has_cmd gh; then + local latest + latest=$(gh release view --repo "$REPO" --json tagName -q .tagName 2>/dev/null || echo "") + if [ -n "$latest" ]; then + if download_binary "$platform" "$latest"; then + installed=true + fi + fi + fi + + if [ "$installed" = false ]; then + # Try direct download without gh CLI + local latest + latest=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": "//;s/".*//' || echo "") + if [ -n "$latest" ]; then + if download_binary "$platform" "$latest"; then + installed=true + fi + fi + fi + + if [ "$installed" = false ]; then + build_from_source + fi + + echo "" + ensure_path + + echo "" + echo "╔══════════════════════════════════════════════╗" + echo "║ Installation Complete! ║" + echo "╚══════════════════════════════════════════════╝" + echo "" + echo " Available commands:" + echo " agentflow - Start orchestration" + echo " agentflow-setup - Guided setup wizard" + echo " agentflow-dashboard - Live monitoring TUI" + echo " agentflow-doctor - Diagnostic checks" + echo "" + echo " Next steps:" + echo " 1. Run 'agentflow-setup' to configure API keys" + echo " 2. Run 'agentflow' to start the autonomous team" + echo "" + echo " Docs: https://openflows.dev" + echo "" + + run_setup +} + +main "$@"