From 6020db0243084b02299278929c908951303965e8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:59:21 -0700 Subject: [PATCH 1/8] feat: add inference.sh integration (infsh tool + skill) (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inference.sh CLI (infsh) as a tool integration, giving agents access to 150+ AI apps through a single CLI — image gen (FLUX, Reve, Seedream), video (Veo, Wan, Seedance), LLMs, search (Tavily, Exa), 3D, avatar/lipsync, and more. One API key manages all services. Tools: - infsh: run any infsh CLI command (app list, app run, etc.) - infsh_install: install the CLI if not present Registered as an 'inference' toolset (opt-in, not in core tools). Includes comprehensive skill docs with examples for all app categories. Changes from original PR: - NOT added to _HERMES_CORE_TOOLS (available via --toolsets inference) - Added 12 tests covering tool registration, command execution, error handling, timeout, JSON parsing, and install flow Inspired by PR #1021 by @okaris. Co-authored-by: okaris --- model_tools.py | 1 + skills/inference-sh/DESCRIPTION.md | 23 ++ skills/inference-sh/cli/SKILL.md | 372 ++++++++++++++++++ .../cli/references/app-discovery.md | 112 ++++++ .../cli/references/authentication.md | 59 +++ .../cli/references/cli-reference.md | 104 +++++ .../cli/references/running-apps.md | 171 ++++++++ tests/tools/test_infsh_tool.py | 114 ++++++ tools/infsh_tool.py | 302 ++++++++++++++ toolsets.py | 6 + 10 files changed, 1264 insertions(+) create mode 100644 skills/inference-sh/DESCRIPTION.md create mode 100644 skills/inference-sh/cli/SKILL.md create mode 100644 skills/inference-sh/cli/references/app-discovery.md create mode 100644 skills/inference-sh/cli/references/authentication.md create mode 100644 skills/inference-sh/cli/references/cli-reference.md create mode 100644 skills/inference-sh/cli/references/running-apps.md create mode 100644 tests/tools/test_infsh_tool.py create mode 100644 tools/infsh_tool.py diff --git a/model_tools.py b/model_tools.py index c8682dacbe..cc2cea9c92 100644 --- a/model_tools.py +++ b/model_tools.py @@ -95,6 +95,7 @@ def _discover_tools(): "tools.send_message_tool", "tools.honcho_tools", "tools.homeassistant_tool", + "tools.infsh_tool", ] import importlib for mod_name in _modules: diff --git a/skills/inference-sh/DESCRIPTION.md b/skills/inference-sh/DESCRIPTION.md new file mode 100644 index 0000000000..a4b2aa91ca --- /dev/null +++ b/skills/inference-sh/DESCRIPTION.md @@ -0,0 +1,23 @@ +# inference.sh + +Run 150+ AI applications in the cloud via the [inference.sh](https://inference.sh) platform. + +**One API key for everything** - Access all AI services with a single account. No need to manage separate API keys for FLUX, Veo, Claude, Tavily, Twitter, etc. You can also bring your own keys if you prefer. + +## Available Skills + +- **cli**: The inference.sh CLI (`infsh`) for running AI apps + +## What's Included + +- **Image Generation**: FLUX, Reve, Seedream, Grok Imagine, Gemini +- **Video Generation**: Veo, Wan, Seedance, OmniHuman, HunyuanVideo +- **LLMs**: Claude, Gemini, Kimi, GLM-4 (via OpenRouter) +- **Search**: Tavily, Exa +- **3D**: Rodin +- **Social**: Twitter/X automation +- **Audio**: TTS, voice cloning + +## Tools + +This category provides the `infsh` and `infsh_install` tools in the `inference` toolset. diff --git a/skills/inference-sh/cli/SKILL.md b/skills/inference-sh/cli/SKILL.md new file mode 100644 index 0000000000..67e33436e7 --- /dev/null +++ b/skills/inference-sh/cli/SKILL.md @@ -0,0 +1,372 @@ +--- +name: inference-sh-cli +description: "Run 150+ AI apps via inference.sh CLI (infsh) - image generation, video creation, LLMs, search, 3D, Twitter automation. Models: FLUX, Veo, Gemini, Grok, Claude, Seedance, OmniHuman, Tavily, Exa, OpenRouter. Triggers: inference.sh, infsh, ai apps, serverless ai, flux, veo, image generation, video generation" +version: 1.0.0 +author: inference.sh +license: MIT +metadata: + hermes: + tags: [AI, image-generation, video, LLM, search, inference, FLUX, Veo, Claude] + requires_tools: [infsh] +--- + +# inference.sh CLI + +Run 150+ AI apps in the cloud with a simple CLI. No GPU required. + +**One API key for everything** - Manage all AI services (FLUX, Veo, Claude, Tavily, X/Twitter, and more) with a single inference.sh account. No need to sign up for dozens of different providers. You can also bring your own API keys if you prefer. + +## Tools + +This skill is backed by the `infsh` and `infsh_install` tools: + +- **infsh**: Run any infsh command (app list, app run, etc.) +- **infsh_install**: Install the CLI if not already present + +## Quick Start + +```bash +# Install (if needed) +infsh_install + +# List available apps +infsh app list + +# Search for apps +infsh app list --search flux +infsh app list --search video + +# Run an app +infsh app run falai/flux-dev-lora --input '{"prompt": "a cat astronaut"}' --json +``` + +## Local File Uploads + +The CLI automatically uploads local files when you provide a file path instead of a URL: + +```bash +# Upscale a local image +infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' --json + +# Image-to-video from local file +infsh app run falai/wan-2-5-i2v --input '{"image": "/path/to/image.png", "prompt": "make it come alive"}' --json + +# Video generation with local first frame +infsh app run bytedance/seedance-1-5-pro --input '{"prompt": "dancing figure", "image": "./first-frame.png"}' --json +``` + +## Image Generation + +```bash +# Gemini 2.5 Flash Image (Google) - fast, high quality +infsh app run google/gemini-2-5-flash-image --input '{"prompt": "futuristic city", "num_images": 1}' --json + +# Gemini 3 Pro Image Preview (Google) - latest model +infsh app run google/gemini-3-pro-image-preview --input '{"prompt": "photorealistic landscape"}' --json + +# Gemini 3.1 Flash Image Preview (Google) +infsh app run google/gemini-3-1-flash-image-preview --input '{"prompt": "artistic portrait"}' --json + +# FLUX Dev with LoRA support +infsh app run falai/flux-dev-lora --input '{"prompt": "sunset over mountains", "num_images": 1}' --json + +# FLUX 2 Klein with LoRA +infsh app run falai/flux-2-klein-lora --input '{"prompt": "portrait photo"}' --json + +# Reve - stylized generation and editing +infsh app run falai/reve --input '{"prompt": "cyberpunk city"}' --json + +# Seedream 5 Lite - high-quality 2K-3K (ByteDance) +infsh app run bytedance/seedream-5-lite --input '{"prompt": "nature scene"}' --json + +# Seedream 4.5 - 2K-4K images +infsh app run bytedance/seedream-4-5 --input '{"prompt": "detailed illustration"}' --json + +# Seedream 3.0 - cinematic quality +infsh app run bytedance/seedream-3-0-t2i --input '{"prompt": "fantasy landscape"}' --json + +# Grok Imagine - xAI image generation +infsh app run xai/grok-imagine-image --input '{"prompt": "abstract art"}' --json + +# Grok Imagine Pro - higher quality +infsh app run xai/grok-imagine-image-pro --input '{"prompt": "photorealistic portrait"}' --json + +# Qwen Image 2 Pro (Alibaba) +infsh app run alibaba/qwen-image-2-pro --input '{"prompt": "anime character"}' --json +``` + +## Video Generation + +```bash +# Veo 3.1 Fast (Google) +infsh app run google/veo-3-1-fast --input '{"prompt": "drone shot of coastline"}' --json + +# Veo 3.1 (higher quality) +infsh app run google/veo-3-1 --input '{"prompt": "cinematic scene"}' --json + +# Veo 3 Fast +infsh app run google/veo-3-fast --input '{"prompt": "nature documentary shot"}' --json + +# Veo 2 +infsh app run google/veo-2 --input '{"prompt": "slow motion water splash"}' --json + +# Grok Imagine Video - xAI +infsh app run xai/grok-imagine-video --input '{"prompt": "timelapse of clouds"}' --json + +# Seedance 1.5 Pro - ByteDance +infsh app run bytedance/seedance-1-5-pro --input '{"prompt": "dancing figure", "resolution": "1080p"}' --json + +# Seedance 1.0 Pro +infsh app run bytedance/seedance-1-0-pro --input '{"prompt": "walking through forest"}' --json + +# Seedance 1.0 Pro Fast +infsh app run bytedance/seedance-1-0-pro-fast --input '{"prompt": "quick motion"}' --json + +# Seedance 1.0 Lite - 720p lightweight +infsh app run bytedance/seedance-1-0-lite --input '{"prompt": "simple animation"}' --json + +# Wan 2.5 - text-to-video +infsh app run falai/wan-2-5 --input '{"prompt": "person walking through city"}' --json + +# Wan 2.5 Image-to-Video +infsh app run falai/wan-2-5-i2v --input '{"image": "/path/to/image.png", "prompt": "make it move naturally"}' --json + +# LTX Video +infsh app run infsh/ltx-video --input '{"prompt": "realistic scene"}' --json + +# Magi 1 +infsh app run infsh/magi-1 --input '{"prompt": "creative video"}' --json +``` + +## Avatar & Lipsync + +```bash +# OmniHuman 1.5 - multi-character audio-driven avatars +infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/audio.mp3", "image": "/path/to/face.jpg"}' --json + +# OmniHuman 1.0 +infsh app run bytedance/omnihuman-1-0 --input '{"audio": "/path/to/speech.wav", "image": "/path/to/portrait.png"}' --json + +# Fabric 1.0 - image animation +infsh app run falai/fabric-1-0 --input '{"image": "/path/to/photo.jpg"}' --json + +# PixVerse Lipsync +infsh app run falai/pixverse-lipsync --input '{"audio": "/path/to/audio.mp3", "video": "/path/to/video.mp4"}' --json +``` + +## Upscaling + +```bash +# Topaz Image Upscaler - up to 4x +infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' --json + +# Topaz Video Upscaler +infsh app run falai/topaz-video-upscaler --input '{"video": "/path/to/video.mp4"}' --json + +# Real-ESRGAN - image enhancement +infsh app run infsh/real-esrgan --input '{"image": "/path/to/image.jpg"}' --json + +# Thera - upscale to any size +infsh app run infsh/thera --input '{"image": "/path/to/image.jpg"}' --json +``` + +## LLMs (via OpenRouter) + +```bash +# Claude Opus 4.6 +infsh app run openrouter/claude-opus-46 --input '{"prompt": "Explain quantum computing"}' --json + +# Claude Sonnet 4.5 +infsh app run openrouter/claude-sonnet-45 --input '{"prompt": "Write a poem"}' --json + +# Claude Haiku 4.5 +infsh app run openrouter/claude-haiku-45 --input '{"prompt": "Quick question"}' --json + +# Gemini 3 Pro Preview +infsh app run openrouter/gemini-3-pro-preview --input '{"prompt": "Analyze this"}' --json + +# Kimi K2 Thinking +infsh app run openrouter/kimi-k2-thinking --input '{"prompt": "Solve this step by step"}' --json + +# GLM 4.6 +infsh app run openrouter/glm-46 --input '{"prompt": "Help me with"}' --json + +# MiniMax M2.5 +infsh app run openrouter/minimax-m-25 --input '{"prompt": "Creative writing"}' --json + +# Intellect 3 +infsh app run openrouter/intellect-3 --input '{"prompt": "Research question"}' --json +``` + +## Web Search + +```bash +# Tavily Search Assistant - comprehensive results +infsh app run tavily/search-assistant --input '{"query": "latest AI news", "include_answer": true}' --json + +# Tavily Extract - get content from URLs +infsh app run tavily/extract --input '{"urls": ["https://example.com"]}' --json + +# Exa Search - neural search +infsh app run exa/search --input '{"query": "machine learning tutorials"}' --json + +# Exa Answer - LLM-powered answers +infsh app run exa/answer --input '{"query": "what is transformers architecture"}' --json + +# Exa Extract - extract web content +infsh app run exa/extract --input '{"url": "https://example.com"}' --json +``` + +## 3D Generation + +```bash +# Rodin 3D Generator +infsh app run infsh/rodin-3d-generator --input '{"prompt": "a wooden chair"}' --json + +# HunyuanImage to 3D +infsh app run infsh/hunyuan-image-to-3d-2 --input '{"image": "/path/to/object.png"}' --json +``` + +## Text-to-Speech + +```bash +# Kokoro TTS - lightweight +infsh app run falai/kokoro-tts --input '{"text": "Hello, this is a test."}' --json + +# Dia TTS - realistic dialogue +infsh app run falai/dia-tts --input '{"text": "Two characters talking"}' --json +``` + +## Twitter/X Automation + +```bash +# Post a tweet +infsh app run x/post-tweet --input '{"text": "Hello from AI!"}' --json + +# Create post with media +infsh app run x/post-create --input '{"text": "Check this out", "media": "/path/to/image.jpg"}' --json + +# Send DM +infsh app run x/dm-send --input '{"recipient_id": "123456", "text": "Hi there!"}' --json + +# Follow user +infsh app run x/user-follow --input '{"user_id": "123456"}' --json + +# Like a post +infsh app run x/post-like --input '{"post_id": "123456789"}' --json + +# Retweet +infsh app run x/post-retweet --input '{"post_id": "123456789"}' --json + +# Get user profile +infsh app run x/user-get --input '{"username": "elonmusk"}' --json + +# Get post +infsh app run x/post-get --input '{"post_id": "123456789"}' --json + +# Delete post +infsh app run x/post-delete --input '{"post_id": "123456789"}' --json +``` + +## Utilities + +```bash +# Browser automation +infsh app run infsh/agent-browser --function open --session new --input '{"url": "https://example.com"}' --json + +# Media merger - combine videos/images +infsh app run infsh/media-merger --input '{"files": ["/path/to/video1.mp4", "/path/to/video2.mp4"]}' --json + +# Video audio extractor +infsh app run infsh/video-audio-extractor --input '{"video": "/path/to/video.mp4"}' --json + +# Video audio merger +infsh app run infsh/video-audio-merger --input '{"video": "/path/to/video.mp4", "audio": "/path/to/audio.mp3"}' --json + +# Caption videos +infsh app run infsh/caption-videos --input '{"video": "/path/to/video.mp4"}' --json + +# Stitch images +infsh app run infsh/stitch-images --input '{"images": ["/path/to/1.jpg", "/path/to/2.jpg"]}' --json + +# Python executor +infsh app run infsh/python-executor --input '{"code": "print(2+2)"}' --json + +# HTML to image +infsh app run infsh/html-to-image --input '{"html": "

Hello

"}' --json + +# NSFW detection +infsh app run infsh/falconsai-nsfw-detection --input '{"image": "/path/to/image.jpg"}' --json + +# Media analyzer +infsh app run infsh/media-analyzer --input '{"file": "/path/to/media.jpg"}' --json +``` + +## Common Patterns + +### Generate + Upscale Pipeline + +```bash +# Generate image, capture URL, then upscale +infsh app run falai/flux-dev-lora --input '{"prompt": "portrait photo"}' --json --save result.json + +# Extract URL and upscale (using jq) +IMG=$(cat result.json | jq -r '.images[0].url') +infsh app run falai/topaz-image-upscaler --input "{\"image\": \"$IMG\", \"upscale_factor\": 2}" --json +``` + +### Get App Schema + +```bash +# See what inputs an app accepts +infsh app get falai/flux-dev-lora + +# Generate sample input +infsh app sample falai/flux-dev-lora + +# Save sample to file, edit, then run +infsh app sample falai/flux-dev-lora --save input.json +# edit input.json... +infsh app run falai/flux-dev-lora --input input.json --json +``` + +### Long-running Tasks + +```bash +# Start without waiting +infsh app run google/veo-3-1 --input '{"prompt": "..."}' --no-wait + +# Check status later +infsh task get + +# Save result when done +infsh task get --save result.json +``` + +## Available Categories + +| Category | Apps | +|----------|------| +| **Image** | google/nano-banana, google/nano-banana-pro, google/nano-banana-2, falai/flux-dev-lora, bytedance/seedream-5-lite, falai/reve, xai/grok-imagine-image | +| **Video** | google/veo-*, xai/grok-imagine-video, bytedance/seedance-*, falai/wan-2-5*, infsh/ltx-video, infsh/magi-1 | +| **Avatar** | bytedance/omnihuman-*, falai/fabric-1-0, falai/pixverse-lipsync | +| **Upscale** | falai/topaz-image-upscaler, falai/topaz-video-upscaler, infsh/real-esrgan, infsh/thera | +| **LLMs** | openrouter/claude-*, openrouter/gemini-*, openrouter/kimi-*, openrouter/glm-* | +| **Search** | tavily/search-assistant, tavily/extract, exa/search, exa/answer, exa/extract | +| **3D** | infsh/rodin-3d-generator, infsh/hunyuan-image-to-3d-2 | +| **TTS** | falai/kokoro-tts, falai/dia-tts | +| **Social** | x/post-tweet, x/post-create, x/dm-send, x/user-follow, x/post-like, x/post-retweet | +| **Utils** | infsh/agent-browser, infsh/media-merger, infsh/caption-videos, infsh/stitch-images | + +## Reference Files + +- [Authentication & Setup](references/authentication.md) +- [Discovering Apps](references/app-discovery.md) +- [Running Apps](references/running-apps.md) +- [CLI Reference](references/cli-reference.md) + +## Documentation + +- [inference.sh Docs](https://inference.sh/docs) +- [CLI Setup Guide](https://inference.sh/docs/extend/cli-setup) +- [Apps Overview](https://inference.sh/docs/apps/overview) diff --git a/skills/inference-sh/cli/references/app-discovery.md b/skills/inference-sh/cli/references/app-discovery.md new file mode 100644 index 0000000000..adcac8c5dc --- /dev/null +++ b/skills/inference-sh/cli/references/app-discovery.md @@ -0,0 +1,112 @@ +# Discovering Apps + +## List All Apps + +```bash +infsh app list +``` + +## Pagination + +```bash +infsh app list --page 2 +``` + +## Filter by Category + +```bash +infsh app list --category image +infsh app list --category video +infsh app list --category audio +infsh app list --category text +infsh app list --category other +``` + +## Search + +```bash +infsh app search "flux" +infsh app search "video generation" +infsh app search "tts" -l +infsh app search "image" --category image +``` + +Or use the flag form: + +```bash +infsh app list --search "flux" +infsh app list --search "video generation" +infsh app list --search "tts" +``` + +## Featured Apps + +```bash +infsh app list --featured +``` + +## Newest First + +```bash +infsh app list --new +``` + +## Detailed View + +```bash +infsh app list -l +``` + +Shows table with app name, category, description, and featured status. + +## Save to File + +```bash +infsh app list --save apps.json +``` + +## Your Apps + +List apps you've deployed: + +```bash +infsh app my +infsh app my -l # detailed +``` + +## Get App Details + +```bash +infsh app get falai/flux-dev-lora +infsh app get falai/flux-dev-lora --json +``` + +Shows full app info including input/output schema. + +## Popular Apps by Category + +### Image Generation +- `falai/flux-dev-lora` - FLUX.2 Dev (high quality) +- `falai/flux-2-klein-lora` - FLUX.2 Klein (fastest) +- `infsh/sdxl` - Stable Diffusion XL +- `google/gemini-3-pro-image-preview` - Gemini 3 Pro +- `xai/grok-imagine-image` - Grok image generation + +### Video Generation +- `google/veo-3-1-fast` - Veo 3.1 Fast +- `google/veo-3` - Veo 3 +- `bytedance/seedance-1-5-pro` - Seedance 1.5 Pro +- `infsh/ltx-video-2` - LTX Video 2 (with audio) +- `bytedance/omnihuman-1-5` - OmniHuman avatar + +### Audio +- `infsh/dia-tts` - Conversational TTS +- `infsh/kokoro-tts` - Kokoro TTS +- `infsh/fast-whisper-large-v3` - Fast transcription +- `infsh/diffrythm` - Music generation + +## Documentation + +- [Browsing the Grid](https://inference.sh/docs/apps/browsing-grid) - Visual app browsing +- [Apps Overview](https://inference.sh/docs/apps/overview) - Understanding apps +- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps diff --git a/skills/inference-sh/cli/references/authentication.md b/skills/inference-sh/cli/references/authentication.md new file mode 100644 index 0000000000..3b6519d3de --- /dev/null +++ b/skills/inference-sh/cli/references/authentication.md @@ -0,0 +1,59 @@ +# Authentication & Setup + +## Install the CLI + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Login + +```bash +infsh login +``` + +This opens a browser for authentication. After login, credentials are stored locally. + +## Check Authentication + +```bash +infsh me +``` + +Shows your user info if authenticated. + +## Environment Variable + +For CI/CD or scripts, set your API key: + +```bash +export INFSH_API_KEY=your-api-key +``` + +The environment variable overrides the config file. + +## Update CLI + +```bash +infsh update +``` + +Or reinstall: + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "not authenticated" | Run `infsh login` | +| "command not found" | Reinstall CLI or add to PATH | +| "API key invalid" | Check `INFSH_API_KEY` or re-login | + +## Documentation + +- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide +- [API Authentication](https://inference.sh/docs/api/authentication) - API key management +- [Secrets](https://inference.sh/docs/secrets/overview) - Managing credentials diff --git a/skills/inference-sh/cli/references/cli-reference.md b/skills/inference-sh/cli/references/cli-reference.md new file mode 100644 index 0000000000..50825825f7 --- /dev/null +++ b/skills/inference-sh/cli/references/cli-reference.md @@ -0,0 +1,104 @@ +# CLI Reference + +## Installation + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Global Commands + +| Command | Description | +|---------|-------------| +| `infsh help` | Show help | +| `infsh version` | Show CLI version | +| `infsh update` | Update CLI to latest | +| `infsh login` | Authenticate | +| `infsh me` | Show current user | + +## App Commands + +### Discovery + +| Command | Description | +|---------|-------------| +| `infsh app list` | List available apps | +| `infsh app list --category ` | Filter by category (image, video, audio, text, other) | +| `infsh app search ` | Search apps | +| `infsh app list --search ` | Search apps (flag form) | +| `infsh app list --featured` | Show featured apps | +| `infsh app list --new` | Sort by newest | +| `infsh app list --page ` | Pagination | +| `infsh app list -l` | Detailed table view | +| `infsh app list --save ` | Save to JSON file | +| `infsh app my` | List your deployed apps | +| `infsh app get ` | Get app details | +| `infsh app get --json` | Get app details as JSON | + +### Execution + +| Command | Description | +|---------|-------------| +| `infsh app run --input ` | Run app with input file | +| `infsh app run --input ''` | Run with inline JSON | +| `infsh app run --input --no-wait` | Run without waiting for completion | +| `infsh app sample ` | Show sample input | +| `infsh app sample --save ` | Save sample to file | + +## Task Commands + +| Command | Description | +|---------|-------------| +| `infsh task get ` | Get task status and result | +| `infsh task get --json` | Get task as JSON | +| `infsh task get --save ` | Save task result to file | + +### Development + +| Command | Description | +|---------|-------------| +| `infsh app init` | Create new app (interactive) | +| `infsh app init ` | Create new app with name | +| `infsh app test --input ` | Test app locally | +| `infsh app deploy` | Deploy app | +| `infsh app deploy --dry-run` | Validate without deploying | +| `infsh app pull ` | Pull app source | +| `infsh app pull --all` | Pull all your apps | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `INFSH_API_KEY` | API key (overrides config) | + +## Shell Completions + +```bash +# Bash +infsh completion bash > /etc/bash_completion.d/infsh + +# Zsh +infsh completion zsh > "${fpath[1]}/_infsh" + +# Fish +infsh completion fish > ~/.config/fish/completions/infsh.fish +``` + +## App Name Format + +Apps use the format `namespace/app-name`: + +- `falai/flux-dev-lora` - fal.ai's FLUX 2 Dev +- `google/veo-3` - Google's Veo 3 +- `infsh/sdxl` - inference.sh's SDXL +- `bytedance/seedance-1-5-pro` - ByteDance's Seedance +- `xai/grok-imagine-image` - xAI's Grok + +Version pinning: `namespace/app-name@version` + +## Documentation + +- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide +- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps via CLI +- [Creating an App](https://inference.sh/docs/extend/creating-app) - Build your own apps +- [Deploying](https://inference.sh/docs/extend/deploying) - Deploy apps to the cloud diff --git a/skills/inference-sh/cli/references/running-apps.md b/skills/inference-sh/cli/references/running-apps.md new file mode 100644 index 0000000000..e930d5cfbc --- /dev/null +++ b/skills/inference-sh/cli/references/running-apps.md @@ -0,0 +1,171 @@ +# Running Apps + +## Basic Run + +```bash +infsh app run user/app-name --input input.json +``` + +## Inline JSON + +```bash +infsh app run falai/flux-dev-lora --input '{"prompt": "a sunset over mountains"}' +``` + +## Version Pinning + +```bash +infsh app run user/app-name@1.0.0 --input input.json +``` + +## Local File Uploads + +The CLI automatically uploads local files when you provide a file path instead of a URL. Any field that accepts a URL also accepts a local path: + +```bash +# Upscale a local image +infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' + +# Image-to-video from local file +infsh app run falai/wan-2-5-i2v --input '{"image": "./my-image.png", "prompt": "make it move"}' + +# Avatar with local audio and image +infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/speech.mp3", "image": "/path/to/face.jpg"}' + +# Post tweet with local media +infsh app run x/post-create --input '{"text": "Check this out!", "media": "./screenshot.png"}' +``` + +Supported paths: +- Absolute paths: `/home/user/images/photo.jpg` +- Relative paths: `./image.png`, `../data/video.mp4` +- Home directory: `~/Pictures/photo.jpg` + +## Generate Sample Input + +Before running, generate a sample input file: + +```bash +infsh app sample falai/flux-dev-lora +``` + +Save to file: + +```bash +infsh app sample falai/flux-dev-lora --save input.json +``` + +Then edit `input.json` and run: + +```bash +infsh app run falai/flux-dev-lora --input input.json +``` + +## Workflow Example + +### Image Generation with FLUX + +```bash +# 1. Get app details +infsh app get falai/flux-dev-lora + +# 2. Generate sample input +infsh app sample falai/flux-dev-lora --save input.json + +# 3. Edit input.json +# { +# "prompt": "a cat astronaut floating in space", +# "num_images": 1, +# "image_size": "landscape_16_9" +# } + +# 4. Run +infsh app run falai/flux-dev-lora --input input.json +``` + +### Video Generation with Veo + +```bash +# 1. Generate sample +infsh app sample google/veo-3-1-fast --save input.json + +# 2. Edit prompt +# { +# "prompt": "A drone shot flying over a forest at sunset" +# } + +# 3. Run +infsh app run google/veo-3-1-fast --input input.json +``` + +### Text-to-Speech + +```bash +# Quick inline run +infsh app run falai/kokoro-tts --input '{"text": "Hello, this is a test."}' +``` + +## Task Tracking + +When you run an app, the CLI shows the task ID: + +``` +Running falai/flux-dev-lora +Task ID: abc123def456 +``` + +For long-running tasks, you can check status anytime: + +```bash +# Check task status +infsh task get abc123def456 + +# Get result as JSON +infsh task get abc123def456 --json + +# Save result to file +infsh task get abc123def456 --save result.json +``` + +### Run Without Waiting + +For very long tasks, run in background: + +```bash +# Submit and return immediately +infsh app run google/veo-3 --input input.json --no-wait + +# Check later +infsh task get +``` + +## Output + +The CLI returns the app output directly. For file outputs (images, videos, audio), you'll receive URLs to download. + +Example output: + +```json +{ + "images": [ + { + "url": "https://cloud.inference.sh/...", + "content_type": "image/png" + } + ] +} +``` + +## Error Handling + +| Error | Cause | Solution | +|-------|-------|----------| +| "invalid input" | Schema mismatch | Check `infsh app get` for required fields | +| "app not found" | Wrong app name | Check `infsh app list --search` | +| "quota exceeded" | Out of credits | Check account balance | + +## Documentation + +- [Running Apps](https://inference.sh/docs/apps/running) - Complete running apps guide +- [Streaming Results](https://inference.sh/docs/api/sdk/streaming) - Real-time progress updates +- [Setup Parameters](https://inference.sh/docs/apps/setup-parameters) - Configuring app inputs diff --git a/tests/tools/test_infsh_tool.py b/tests/tools/test_infsh_tool.py new file mode 100644 index 0000000000..f866df1e32 --- /dev/null +++ b/tests/tools/test_infsh_tool.py @@ -0,0 +1,114 @@ +"""Tests for tools/infsh_tool.py — inference.sh CLI integration.""" + +import json +import subprocess +from unittest.mock import patch, MagicMock + +import pytest + +from tools.infsh_tool import ( + check_infsh_requirements, + infsh_tool, + infsh_install, +) + + +class TestCheckRequirements: + def test_returns_bool(self): + result = check_infsh_requirements() + assert isinstance(result, bool) + + def test_returns_true_when_infsh_on_path(self, monkeypatch): + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/local/bin/infsh" if cmd == "infsh" else None) + assert check_infsh_requirements() is True + + def test_returns_false_when_missing(self, monkeypatch): + monkeypatch.setattr("shutil.which", lambda cmd: None) + assert check_infsh_requirements() is False + + +class TestInfshTool: + def test_not_installed_returns_error(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: False) + result = json.loads(infsh_tool("app list")) + assert result["success"] is False + assert "not installed" in result["error"].lower() + + def test_successful_command(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"apps": ["flux", "veo"]}' + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result) as mock_run: + result = json.loads(infsh_tool("app list --search flux")) + assert result["success"] is True + mock_run.assert_called_once() + call_cmd = mock_run.call_args[0][0] + assert "infsh app list --search flux" in call_cmd + + def test_failed_command(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "unknown command" + + with patch("subprocess.run", return_value=mock_result): + result = json.loads(infsh_tool("badcommand")) + assert result["success"] is False + assert result["exit_code"] == 1 + + def test_timeout_handled(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) + + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("infsh", 300)): + result = json.loads(infsh_tool("app run something", timeout=300)) + assert result["success"] is False + assert "timed out" in result["error"].lower() + + def test_json_output_parsed(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"url": "https://example.com/image.png"}' + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result): + result = json.loads(infsh_tool("app run flux --json")) + assert result["success"] is True + assert isinstance(result["output"], dict) + assert result["output"]["url"] == "https://example.com/image.png" + + +class TestInfshInstall: + def test_already_installed(self, monkeypatch): + monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) + monkeypatch.setattr("tools.infsh_tool._get_infsh_path", lambda: "/usr/local/bin/infsh") + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "infsh v1.2.3" + + with patch("subprocess.run", return_value=mock_result): + result = json.loads(infsh_install()) + assert result["success"] is True + assert result["already_installed"] is True + + +class TestToolRegistration: + def test_tools_registered(self): + from tools.registry import registry + assert "infsh" in registry._tools + assert "infsh_install" in registry._tools + + def test_infsh_in_inference_toolset(self): + from toolsets import TOOLSETS + assert "inference" in TOOLSETS + assert "infsh" in TOOLSETS["inference"]["tools"] + assert "infsh_install" in TOOLSETS["inference"]["tools"] + + def test_infsh_not_in_core_tools(self): + from toolsets import _HERMES_CORE_TOOLS + assert "infsh" not in _HERMES_CORE_TOOLS + assert "infsh_install" not in _HERMES_CORE_TOOLS diff --git a/tools/infsh_tool.py b/tools/infsh_tool.py new file mode 100644 index 0000000000..ede785dd1b --- /dev/null +++ b/tools/infsh_tool.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Inference.sh Tool Module + +A simple tool for running AI apps via the inference.sh CLI (infsh). +Provides two functions: + - infsh_install: Install the infsh CLI + - infsh: Run any infsh command + +This is a lightweight wrapper that gives agents direct access to 150+ AI apps +including image generation (FLUX, Reve), video (Veo, Wan), LLMs, search, and more. + +Usage: + from tools.infsh_tool import infsh_tool, infsh_install + + # Install the CLI + result = infsh_install() + + # Search for apps first (always do this!) + result = infsh_tool("app list --search flux") + + # Run an app + result = infsh_tool("app run falai/flux-dev-lora --input '{\"prompt\": \"a cat\"}' --json") +""" + +import json +import logging +import os +import shutil +import subprocess +from typing import Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_TIMEOUT = 300 # 5 minutes for long-running AI tasks +INSTALL_TIMEOUT = 60 + + +# --------------------------------------------------------------------------- +# Availability check +# --------------------------------------------------------------------------- + +def check_infsh_requirements() -> bool: + """Check if infsh is available in PATH.""" + return shutil.which("infsh") is not None + + +def _get_infsh_path() -> Optional[str]: + """Get the path to infsh binary.""" + return shutil.which("infsh") + + +# --------------------------------------------------------------------------- +# Install function +# --------------------------------------------------------------------------- + +def infsh_install() -> str: + """ + Install the inference.sh CLI. + + Downloads and installs the infsh binary using the official installer script. + The installer detects OS/arch, downloads the correct binary, verifies checksum, + and places it in PATH. + + Returns: + JSON string with success/error status + """ + try: + # Check if already installed + if check_infsh_requirements(): + infsh_path = _get_infsh_path() + # Get version + version_result = subprocess.run( + ["infsh", "--version"], + capture_output=True, + text=True, + timeout=10 + ) + version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown" + return json.dumps({ + "success": True, + "message": f"infsh is already installed at {infsh_path}", + "version": version, + "already_installed": True + }) + + # Run the installer + result = subprocess.run( + ["sh", "-c", "curl -fsSL https://cli.inference.sh | sh"], + capture_output=True, + text=True, + timeout=INSTALL_TIMEOUT, + env={**os.environ, "NONINTERACTIVE": "1"} + ) + + if result.returncode != 0: + return json.dumps({ + "success": False, + "error": f"Installation failed: {result.stderr}", + "stdout": result.stdout + }) + + # Verify installation + if not check_infsh_requirements(): + return json.dumps({ + "success": False, + "error": "Installation completed but infsh not found in PATH. You may need to restart your shell or add ~/.local/bin to PATH.", + "stdout": result.stdout + }) + + return json.dumps({ + "success": True, + "message": "infsh installed successfully", + "stdout": result.stdout, + "next_step": "Run 'infsh login' to authenticate, or set INFSH_API_KEY environment variable" + }) + + except subprocess.TimeoutExpired: + return json.dumps({ + "success": False, + "error": f"Installation timed out after {INSTALL_TIMEOUT}s" + }) + except Exception as e: + logger.exception("infsh_install error: %s", e) + return json.dumps({ + "success": False, + "error": f"Installation error: {type(e).__name__}: {e}" + }) + + +# --------------------------------------------------------------------------- +# Main tool function +# --------------------------------------------------------------------------- + +def infsh_tool( + command: str, + timeout: Optional[int] = None, +) -> str: + """ + Execute an infsh CLI command. + + Args: + command: The infsh command to run (without the 'infsh' prefix). + Examples: "app list", "app run falai/flux-schnell --input '{}'" + timeout: Command timeout in seconds (default: 300) + + Returns: + JSON string with output, exit_code, and error fields + """ + try: + effective_timeout = timeout or DEFAULT_TIMEOUT + + # Check if infsh is installed + if not check_infsh_requirements(): + return json.dumps({ + "success": False, + "error": "infsh CLI is not installed. Use infsh_install to install it first.", + "hint": "Call the infsh_install tool to set up the CLI" + }) + + # Build the full command + full_command = f"infsh {command}" + + # Execute + result = subprocess.run( + full_command, + shell=True, + capture_output=True, + text=True, + timeout=effective_timeout, + env=os.environ + ) + + output = result.stdout + error = result.stderr + + # Try to parse JSON output if present + parsed_output = None + if output.strip(): + try: + parsed_output = json.loads(output) + except json.JSONDecodeError: + pass # Not JSON, keep as string + + response = { + "success": result.returncode == 0, + "exit_code": result.returncode, + "output": parsed_output if parsed_output is not None else output, + } + + if error: + response["stderr"] = error + + return json.dumps(response, indent=2) + + except subprocess.TimeoutExpired: + return json.dumps({ + "success": False, + "error": f"Command timed out after {effective_timeout}s", + "hint": "For long-running tasks, consider using --no-wait flag" + }) + except Exception as e: + logger.exception("infsh_tool error: %s", e) + return json.dumps({ + "success": False, + "error": f"Execution error: {type(e).__name__}: {e}" + }) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +from tools.registry import registry + +INFSH_TOOL_DESCRIPTION = """Run AI apps via inference.sh CLI. Access 150+ apps for image generation, video, LLMs, search, 3D, and more. + +One API key for everything - manage all AI services (FLUX, Veo, Claude, Tavily, etc.) with a single inference.sh account. You can also bring your own API keys. + +IMPORTANT: Always use 'app list --search ' first to find the exact app ID before running. App names change frequently. + +Commands: +- app list --search : Find apps (ALWAYS DO THIS FIRST) +- app run --input '' --json: Run an app +- app get : Get app schema before running + +Verified app examples (use --search to confirm current names): +- Image: google/nano-banana, google/nano-banana-pro, google/nano-banana-2, falai/flux-dev-lora, bytedance/seedream-5-lite, falai/reve, xai/grok-imagine-image +- Video: google/veo-3-1-fast, bytedance/seedance-1-5-pro, falai/wan-2-5 +- Upscale: falai/topaz-image-upscaler +- Search: tavily/search-assistant, exa/search +- LLM: openrouter/claude-sonnet-45 + +Workflow: ALWAYS search first, then run: +1. app list --search image +2. app run falai/flux-dev-lora --input '{"prompt": "a sunset"}' --json""" + +INFSH_SCHEMA = { + "name": "infsh", + "description": INFSH_TOOL_DESCRIPTION, + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The infsh command (without 'infsh' prefix). ALWAYS use 'app list --search ' first to find correct app IDs, then 'app run --input --json'" + }, + "timeout": { + "type": "integer", + "description": "Max seconds to wait (default: 300). AI tasks like video generation may take 1-2 minutes.", + "minimum": 1 + } + }, + "required": ["command"] + } +} + +INFSH_INSTALL_SCHEMA = { + "name": "infsh_install", + "description": "Install the inference.sh CLI (infsh). Downloads and installs the binary. Run this first if infsh is not available.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } +} + + +def _handle_infsh(args, **kw): + return infsh_tool( + command=args.get("command", ""), + timeout=args.get("timeout"), + ) + + +def _handle_infsh_install(args, **kw): + return infsh_install() + + +# Register both tools under the "inference" toolset +registry.register( + name="infsh", + toolset="inference", + schema=INFSH_SCHEMA, + handler=_handle_infsh, + check_fn=check_infsh_requirements, + requires_env=[], +) + +registry.register( + name="infsh_install", + toolset="inference", + schema=INFSH_INSTALL_SCHEMA, + handler=_handle_infsh_install, + check_fn=lambda: True, # Always available - it's the installer + requires_env=[], +) diff --git a/toolsets.py b/toolsets.py index 1a73ff1b82..b94ea580df 100644 --- a/toolsets.py +++ b/toolsets.py @@ -183,6 +183,12 @@ TOOLSETS = { "tools": ["execute_code"], "includes": [] }, + + "inference": { + "description": "inference.sh CLI (infsh) — run 150+ AI apps: image gen (FLUX, Reve), video (Veo, Wan), LLMs, search (Tavily, Exa), 3D, and more", + "tools": ["infsh", "infsh_install"], + "includes": [] + }, "delegation": { "description": "Spawn subagents with isolated context for complex subtasks", From 30c417fe7092cf0f4b69fc2f1b029d056d9a5e7e Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 02:59:28 -0700 Subject: [PATCH 2/8] feat: add website blocklist enforcement for web/browser tools (#1064) Adds security.website_blocklist config for user-managed domain blocking across URL-capable tools. Enforced at the tool level (not monkey-patching) so it's safe and predictable. - tools/website_policy.py: shared policy loader with domain normalization, wildcard support (*.tracking.example), shared file imports, and structured block metadata - web_extract: pre-fetch URL check + post-redirect recheck - web_crawl: pre-crawl URL check + per-page URL recheck - browser_navigate: pre-navigation URL check - Blocked responses include blocked_by_policy metadata so the agent can explain exactly what was denied Config: security: website_blocklist: enabled: true domains: ["evil.com", "*.tracking.example"] shared_files: ["team-blocklist.txt"] Salvaged from PR #1086 by @kshitijk4poor. Browser post-redirect checks deferred (browser_tool was fully rewritten since the PR branched). Co-authored-by: kshitijk4poor --- hermes_cli/config.py | 5 + tests/tools/test_website_policy.py | 486 +++++++++++++++++++++++++++++ tools/browser_tool.py | 14 + tools/web_tools.py | 62 +++- tools/website_policy.py | 193 ++++++++++++ 5 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_website_policy.py create mode 100644 tools/website_policy.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 62d8a19a77..8f8d90c410 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -354,6 +354,11 @@ DEFAULT_CONFIG = { "tirith_path": "tirith", "tirith_timeout": 5, "tirith_fail_open": True, + "website_blocklist": { + "enabled": True, + "domains": [], + "shared_files": [], + }, }, # Config schema version - bump this when adding new required fields diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py new file mode 100644 index 0000000000..e66ce9d8c4 --- /dev/null +++ b/tests/tools/test_website_policy.py @@ -0,0 +1,486 @@ +import json +from pathlib import Path + +import pytest +import yaml + +from tools.website_policy import WebsitePolicyError, check_website_access, load_website_blocklist + + +def test_load_website_blocklist_merges_config_and_shared_file(tmp_path): + shared = tmp_path / "community-blocklist.txt" + shared.write_text("# comment\nexample.org\nsub.bad.net\n", encoding="utf-8") + + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": ["example.com", "https://www.evil.test/path"], + "shared_files": [str(shared)], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + policy = load_website_blocklist(config_path) + + assert policy["enabled"] is True + assert {rule["pattern"] for rule in policy["rules"]} == { + "example.com", + "evil.test", + "example.org", + "sub.bad.net", + } + + +def test_check_website_access_matches_parent_domain_subdomains(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": ["example.com"], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + blocked = check_website_access("https://docs.example.com/page", config_path=config_path) + + assert blocked is not None + assert blocked["host"] == "docs.example.com" + assert blocked["rule"] == "example.com" + + +def test_check_website_access_supports_wildcard_subdomains_only(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": ["*.tracking.example"], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + assert check_website_access("https://a.tracking.example", config_path=config_path) is not None + assert check_website_access("https://www.tracking.example", config_path=config_path) is not None + assert check_website_access("https://tracking.example", config_path=config_path) is None + + +def test_default_config_exposes_website_blocklist_shape(): + from hermes_cli.config import DEFAULT_CONFIG + + website_blocklist = DEFAULT_CONFIG["security"]["website_blocklist"] + assert website_blocklist["enabled"] is True + assert website_blocklist["domains"] == [] + assert website_blocklist["shared_files"] == [] + + +def test_load_website_blocklist_uses_enabled_default_when_section_missing(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump({"display": {"tool_progress": "all"}}, sort_keys=False), encoding="utf-8") + + policy = load_website_blocklist(config_path) + + assert policy == {"enabled": True, "rules": []} + + +def test_load_website_blocklist_raises_clean_error_for_invalid_domains_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": "example.com", + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + with pytest.raises(WebsitePolicyError, match="security.website_blocklist.domains must be a list"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_invalid_shared_files_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "shared_files": "community-blocklist.txt", + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + with pytest.raises(WebsitePolicyError, match="security.website_blocklist.shared_files must be a list"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_invalid_top_level_config_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(["not", "a", "mapping"], sort_keys=False), encoding="utf-8") + + with pytest.raises(WebsitePolicyError, match="config root must be a mapping"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_invalid_security_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump({"security": []}, sort_keys=False), encoding="utf-8") + + with pytest.raises(WebsitePolicyError, match="security must be a mapping"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_invalid_website_blocklist_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": "block everything", + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + with pytest.raises(WebsitePolicyError, match="security.website_blocklist must be a mapping"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_invalid_enabled_type(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": "false", + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + with pytest.raises(WebsitePolicyError, match="security.website_blocklist.enabled must be a boolean"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_raises_clean_error_for_malformed_yaml(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text("security: [oops\n", encoding="utf-8") + + with pytest.raises(WebsitePolicyError, match="Invalid config YAML"): + load_website_blocklist(config_path) + + +def test_load_website_blocklist_wraps_shared_file_read_errors(tmp_path, monkeypatch): + shared = tmp_path / "community-blocklist.txt" + shared.write_text("example.org\n", encoding="utf-8") + + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "shared_files": [str(shared)], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + def failing_read_text(self, *args, **kwargs): + raise PermissionError("no permission") + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + with pytest.raises(WebsitePolicyError, match="Failed to read shared blocklist file"): + load_website_blocklist(config_path) + + +def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes-home" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": ["dynamic.example"], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + blocked = check_website_access("https://dynamic.example/path") + + assert blocked is not None + assert blocked["rule"] == "dynamic.example" + + +def test_check_website_access_blocks_scheme_less_urls(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "domains": ["blocked.test"], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + blocked = check_website_access("www.blocked.test/path", config_path=config_path) + + assert blocked is not None + assert blocked["host"] == "www.blocked.test" + assert blocked["rule"] == "blocked.test" + + +def test_browser_navigate_returns_policy_block(monkeypatch): + from tools import browser_tool + + monkeypatch.setattr( + browser_tool, + "check_website_access", + lambda url: { + "host": "blocked.test", + "rule": "blocked.test", + "source": "config", + "message": "Blocked by website policy", + }, + ) + monkeypatch.setattr( + browser_tool, + "_run_browser_command", + lambda *args, **kwargs: pytest.fail("browser command should not run for blocked URL"), + ) + + result = json.loads(browser_tool.browser_navigate("https://blocked.test")) + + assert result["success"] is False + assert result["blocked_by_policy"]["rule"] == "blocked.test" + + +def test_browser_navigate_returns_clean_policy_error_for_missing_shared_file(monkeypatch, tmp_path): + from tools import browser_tool + + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "security": { + "website_blocklist": { + "enabled": True, + "shared_files": ["missing-blocklist.txt"], + } + } + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + monkeypatch.setattr(browser_tool, "check_website_access", lambda url: check_website_access(url, config_path=config_path)) + + result = json.loads(browser_tool.browser_navigate("https://allowed.test")) + + assert result["success"] is False + assert "Website policy error" in result["error"] + + +@pytest.mark.asyncio +async def test_web_extract_short_circuits_blocked_url(monkeypatch): + from tools import web_tools + + monkeypatch.setattr( + web_tools, + "check_website_access", + lambda url: { + "host": "blocked.test", + "rule": "blocked.test", + "source": "config", + "message": "Blocked by website policy", + }, + ) + monkeypatch.setattr( + web_tools, + "_get_firecrawl_client", + lambda: pytest.fail("firecrawl should not run for blocked URL"), + ) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = json.loads(await web_tools.web_extract_tool(["https://blocked.test"], use_llm_processing=False)) + + assert result["results"][0]["url"] == "https://blocked.test" + assert "Blocked by website policy" in result["results"][0]["error"] + + +@pytest.mark.asyncio +async def test_web_extract_returns_clean_policy_error_for_malformed_config(monkeypatch, tmp_path): + from tools import web_tools + + config_path = tmp_path / "config.yaml" + config_path.write_text("security: [oops\n", encoding="utf-8") + + monkeypatch.setattr(web_tools, "check_website_access", lambda url: check_website_access(url, config_path=config_path)) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = json.loads(await web_tools.web_extract_tool(["https://allowed.test"], use_llm_processing=False)) + + assert result["results"][0]["url"] == "https://allowed.test" + assert "Website policy error" in result["results"][0]["error"] + + +@pytest.mark.asyncio +async def test_web_extract_blocks_redirected_final_url(monkeypatch): + from tools import web_tools + + def fake_check(url): + if url == "https://allowed.test": + return None + if url == "https://blocked.test/final": + return { + "host": "blocked.test", + "rule": "blocked.test", + "source": "config", + "message": "Blocked by website policy", + } + pytest.fail(f"unexpected URL checked: {url}") + + class FakeFirecrawlClient: + def scrape(self, url, formats): + return { + "markdown": "secret content", + "metadata": { + "title": "Redirected", + "sourceURL": "https://blocked.test/final", + }, + } + + monkeypatch.setattr(web_tools, "check_website_access", fake_check) + monkeypatch.setattr(web_tools, "_get_firecrawl_client", lambda: FakeFirecrawlClient()) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = json.loads(await web_tools.web_extract_tool(["https://allowed.test"], use_llm_processing=False)) + + assert result["results"][0]["url"] == "https://blocked.test/final" + assert result["results"][0]["content"] == "" + assert result["results"][0]["blocked_by_policy"]["rule"] == "blocked.test" + + +@pytest.mark.asyncio +async def test_web_crawl_short_circuits_blocked_url(monkeypatch): + from tools import web_tools + + monkeypatch.setattr( + web_tools, + "check_website_access", + lambda url: { + "host": "blocked.test", + "rule": "blocked.test", + "source": "config", + "message": "Blocked by website policy", + }, + ) + monkeypatch.setattr( + web_tools, + "_get_firecrawl_client", + lambda: pytest.fail("firecrawl should not run for blocked crawl URL"), + ) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = json.loads(await web_tools.web_crawl_tool("https://blocked.test", use_llm_processing=False)) + + assert result["results"][0]["url"] == "https://blocked.test" + assert result["results"][0]["blocked_by_policy"]["rule"] == "blocked.test" + + +@pytest.mark.asyncio +async def test_web_crawl_blocks_redirected_final_url(monkeypatch): + from tools import web_tools + + def fake_check(url): + if url == "https://allowed.test": + return None + if url == "https://blocked.test/final": + return { + "host": "blocked.test", + "rule": "blocked.test", + "source": "config", + "message": "Blocked by website policy", + } + pytest.fail(f"unexpected URL checked: {url}") + + class FakeCrawlClient: + def crawl(self, url, **kwargs): + return { + "data": [ + { + "markdown": "secret crawl content", + "metadata": { + "title": "Redirected crawl page", + "sourceURL": "https://blocked.test/final", + }, + } + ] + } + + monkeypatch.setattr(web_tools, "check_website_access", fake_check) + monkeypatch.setattr(web_tools, "_get_firecrawl_client", lambda: FakeCrawlClient()) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = json.loads(await web_tools.web_crawl_tool("https://allowed.test", use_llm_processing=False)) + + assert result["results"][0]["content"] == "" + assert result["results"][0]["error"] == "Blocked by website policy" + assert result["results"][0]["blocked_by_policy"]["rule"] == "blocked.test" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index d57eedee89..c127f685f0 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -65,6 +65,7 @@ import requests from typing import Dict, Any, Optional, List from pathlib import Path from agent.auxiliary_client import call_llm +from tools.website_policy import check_website_access from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider @@ -901,6 +902,19 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: Returns: JSON string with navigation result (includes stealth features info on first nav) """ + # Website policy check — block before navigating + try: + blocked = check_website_access(url) + except Exception as _policy_err: + return json.dumps({"success": False, "error": f"Website policy error: {_policy_err}"}) + if blocked: + logger.info("Blocked browser_navigate to %s by rule %s", blocked["host"], blocked["rule"]) + return json.dumps({ + "success": False, + "error": blocked["message"], + "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}, + }) + effective_task_id = task_id or "default" # Get session info to check if this is a new session diff --git a/tools/web_tools.py b/tools/web_tools.py index ede1adb038..c478f25f93 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -49,6 +49,7 @@ from typing import List, Dict, Any, Optional from firecrawl import Firecrawl from agent.auxiliary_client import async_call_llm from tools.debug_helpers import DebugSession +from tools.website_policy import WebsitePolicyError, check_website_access logger = logging.getLogger(__name__) @@ -616,6 +617,21 @@ async def web_extract_tool( results.append({"url": url, "error": "Interrupted", "title": ""}) continue + # Website policy check — block before fetching + try: + blocked = check_website_access(url) + except WebsitePolicyError as policy_err: + results.append({"url": url, "title": "", "content": "", "error": f"Website policy error: {policy_err}"}) + continue + if blocked: + logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"]) + results.append({ + "url": url, "title": "", "content": "", + "error": blocked["message"], + "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}, + }) + continue + try: logger.info("Scraping: %s", url) scrape_result = _get_firecrawl_client().scrape( @@ -669,11 +685,26 @@ async def web_extract_tool( # Get title from metadata title = metadata.get("title", "") + # Re-check final URL after redirect + final_url = metadata.get("sourceURL", url) + try: + final_blocked = check_website_access(final_url) + except WebsitePolicyError: + final_blocked = None + if final_blocked: + logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"]) + results.append({ + "url": final_url, "title": title, "content": "", "raw_content": "", + "error": final_blocked["message"], + "blocked_by_policy": {"host": final_blocked["host"], "rule": final_blocked["rule"], "source": final_blocked["source"]}, + }) + continue + # Choose content based on requested format chosen_content = content_markdown if (format == "markdown" or (format is None and content_markdown)) else content_html or content_markdown or "" results.append({ - "url": metadata.get("sourceURL", url), + "url": final_url, "title": title, "content": chosen_content, "raw_content": chosen_content, @@ -778,6 +809,7 @@ async def web_extract_tool( "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"), + **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {}), } for r in response.get("results", []) ] @@ -870,6 +902,16 @@ async def web_crawl_tool( instructions_text = f" with instructions: '{instructions}'" if instructions else "" logger.info("Crawling %s%s", url, instructions_text) + # Website policy check — block before crawling + try: + blocked = check_website_access(url) + except WebsitePolicyError as policy_err: + return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": f"Website policy error: {policy_err}"}]}, ensure_ascii=False) + if blocked: + logger.info("Blocked web_crawl for %s by rule %s", blocked["host"], blocked["rule"]) + return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": blocked["message"], + "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}}]}, ensure_ascii=False) + # Use Firecrawl's v2 crawl functionality # Docs: https://docs.firecrawl.dev/features/crawl # The crawl() method automatically waits for completion and returns all data @@ -975,6 +1017,20 @@ async def web_crawl_tool( page_url = metadata.get("sourceURL", metadata.get("url", "Unknown URL")) title = metadata.get("title", "") + # Re-check crawled page URL against policy + try: + page_blocked = check_website_access(page_url) + except WebsitePolicyError: + page_blocked = None + if page_blocked: + logger.info("Blocked crawled page %s by rule %s", page_blocked["host"], page_blocked["rule"]) + pages.append({ + "url": page_url, "title": title, "content": "", "raw_content": "", + "error": page_blocked["message"], + "blocked_by_policy": {"host": page_blocked["host"], "rule": page_blocked["rule"], "source": page_blocked["source"]}, + }) + continue + # Choose content (prefer markdown) content = content_markdown or content_html or "" @@ -1070,9 +1126,11 @@ async def web_crawl_tool( # Trim output to minimal fields per entry: title, content, error trimmed_results = [ { + "url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), - "error": r.get("error") + "error": r.get("error"), + **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {}), } for r in response.get("results", []) ] diff --git a/tools/website_policy.py b/tools/website_policy.py new file mode 100644 index 0000000000..21e8dad762 --- /dev/null +++ b/tools/website_policy.py @@ -0,0 +1,193 @@ +"""Website access policy helpers for URL-capable tools. + +This module loads a user-managed website blocklist from ~/.hermes/config.yaml +and optional shared list files. It is intentionally lightweight so web/browser +tools can enforce URL policy without pulling in the heavier CLI config stack. +""" + +from __future__ import annotations + +import fnmatch +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import yaml + + +_DEFAULT_WEBSITE_BLOCKLIST = { + "enabled": True, + "domains": [], + "shared_files": [], +} + + +def _get_hermes_home() -> Path: + return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + + +def _get_default_config_path() -> Path: + return _get_hermes_home() / "config.yaml" + + +class WebsitePolicyError(Exception): + """Raised when a website policy file is malformed.""" + + +def _normalize_host(host: str) -> str: + return (host or "").strip().lower().rstrip(".") + + +def _normalize_rule(rule: Any) -> Optional[str]: + if not isinstance(rule, str): + return None + value = rule.strip().lower() + if not value or value.startswith("#"): + return None + if "://" in value: + parsed = urlparse(value) + value = parsed.netloc or parsed.path + value = value.split("/", 1)[0].strip().rstrip(".") + if value.startswith("www."): + value = value[4:] + return value or None + + +def _iter_blocklist_file_rules(path: Path) -> List[str]: + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise WebsitePolicyError(f"Shared blocklist file not found: {path}") from exc + except (OSError, UnicodeDecodeError) as exc: + raise WebsitePolicyError(f"Failed to read shared blocklist file {path}: {exc}") from exc + + rules: List[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + normalized = _normalize_rule(stripped) + if normalized: + rules.append(normalized) + return rules + + +def _load_policy_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + config_path = config_path or _get_default_config_path() + if not config_path.exists(): + return dict(_DEFAULT_WEBSITE_BLOCKLIST) + try: + with open(config_path, encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + except yaml.YAMLError as exc: + raise WebsitePolicyError(f"Invalid config YAML at {config_path}: {exc}") from exc + except OSError as exc: + raise WebsitePolicyError(f"Failed to read config file {config_path}: {exc}") from exc + if not isinstance(config, dict): + raise WebsitePolicyError("config root must be a mapping") + + security = config.get("security", {}) + if security is None: + security = {} + if not isinstance(security, dict): + raise WebsitePolicyError("security must be a mapping") + + website_blocklist = security.get("website_blocklist", {}) + if website_blocklist is None: + website_blocklist = {} + if not isinstance(website_blocklist, dict): + raise WebsitePolicyError("security.website_blocklist must be a mapping") + + policy = dict(_DEFAULT_WEBSITE_BLOCKLIST) + policy.update(website_blocklist) + return policy + + +def load_website_blocklist(config_path: Optional[Path] = None) -> Dict[str, Any]: + config_path = config_path or _get_default_config_path() + policy = _load_policy_config(config_path) + + raw_domains = policy.get("domains", []) or [] + if not isinstance(raw_domains, list): + raise WebsitePolicyError("security.website_blocklist.domains must be a list") + + raw_shared_files = policy.get("shared_files", []) or [] + if not isinstance(raw_shared_files, list): + raise WebsitePolicyError("security.website_blocklist.shared_files must be a list") + + enabled = policy.get("enabled", True) + if not isinstance(enabled, bool): + raise WebsitePolicyError("security.website_blocklist.enabled must be a boolean") + + rules: List[Dict[str, str]] = [] + seen: set[Tuple[str, str]] = set() + + for raw_rule in raw_domains: + normalized = _normalize_rule(raw_rule) + if normalized and ("config", normalized) not in seen: + rules.append({"pattern": normalized, "source": "config"}) + seen.add(("config", normalized)) + + for shared_file in raw_shared_files: + if not isinstance(shared_file, str) or not shared_file.strip(): + continue + path = Path(shared_file).expanduser() + if not path.is_absolute(): + path = (_get_hermes_home() / path).resolve() + for normalized in _iter_blocklist_file_rules(path): + key = (str(path), normalized) + if key in seen: + continue + rules.append({"pattern": normalized, "source": str(path)}) + seen.add(key) + + return {"enabled": enabled, "rules": rules} + + +def _match_host_against_rule(host: str, pattern: str) -> bool: + if not host or not pattern: + return False + if pattern.startswith("*."): + return fnmatch.fnmatch(host, pattern) + return host == pattern or host.endswith(f".{pattern}") + + +def _extract_host_from_urlish(url: str) -> str: + parsed = urlparse(url) + host = _normalize_host(parsed.hostname or parsed.netloc) + if host: + return host + + if "://" not in url: + schemeless = urlparse(f"//{url}") + host = _normalize_host(schemeless.hostname or schemeless.netloc) + if host: + return host + + return "" + + +def check_website_access(url: str, config_path: Optional[Path] = None) -> Optional[Dict[str, str]]: + host = _extract_host_from_urlish(url) + if not host: + return None + + policy = load_website_blocklist(config_path) + if not policy.get("enabled"): + return None + + for rule in policy.get("rules", []): + pattern = rule.get("pattern", "") + if _match_host_against_rule(host, pattern): + return { + "url": url, + "host": host, + "rule": pattern, + "source": rule.get("source", "config"), + "message": ( + f"Blocked by website policy: '{host}' matched rule '{pattern}'" + f" from {rule.get('source', 'config')}" + ), + } + return None From 6d1c5d44911a3be7551adc9e97b98c1a0c3ac001 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:00:17 -0700 Subject: [PATCH 3/8] refactor(tools): extract position calculation logic in fuzzy_match (#1681) Extract the repeated line-position calculation pattern into a _calculate_line_positions() helper. The same 4-line pattern was duplicated across _strategy_trimmed_boundary, _strategy_block_anchor, _strategy_context_aware, and _find_normalized_matches. Also standardizes the end_pos clamping (some sites used min(), some used an if-guard). Based on PR #1604 by aydnOktay. Co-authored-by: aydnOktay --- tools/fuzzy_match.py | 51 ++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index f53451c63d..ddcdf4274e 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -254,10 +254,9 @@ def _strategy_trimmed_boundary(content: str, pattern: str) -> List[Tuple[int, in if '\n'.join(check_lines) == modified_pattern: # Found match - calculate original positions - start_pos = sum(len(line) + 1 for line in content_lines[:i]) - end_pos = sum(len(line) + 1 for line in content_lines[:i + pattern_line_count]) - 1 - if end_pos >= len(content): - end_pos = len(content) + start_pos, end_pos = _calculate_line_positions( + content_lines, i, i + pattern_line_count, len(content) + ) matches.append((start_pos, end_pos)) return matches @@ -309,9 +308,10 @@ def _strategy_block_anchor(content: str, pattern: str) -> List[Tuple[int, int]]: if similarity >= threshold: # Calculate positions using ORIGINAL lines to ensure correct character offsets in the file - start_pos = sum(len(line) + 1 for line in orig_content_lines[:i]) - end_pos = sum(len(line) + 1 for line in orig_content_lines[:i + pattern_line_count]) - 1 - matches.append((start_pos, min(end_pos, len(content)))) + start_pos, end_pos = _calculate_line_positions( + orig_content_lines, i, i + pattern_line_count, len(content) + ) + matches.append((start_pos, end_pos)) return matches @@ -343,10 +343,9 @@ def _strategy_context_aware(content: str, pattern: str) -> List[Tuple[int, int]] # Need at least 50% of lines to have high similarity if high_similarity_count >= len(pattern_lines) * 0.5: - start_pos = sum(len(line) + 1 for line in content_lines[:i]) - end_pos = sum(len(line) + 1 for line in content_lines[:i + pattern_line_count]) - 1 - if end_pos >= len(content): - end_pos = len(content) + start_pos, end_pos = _calculate_line_positions( + content_lines, i, i + pattern_line_count, len(content) + ) matches.append((start_pos, end_pos)) return matches @@ -356,6 +355,26 @@ def _strategy_context_aware(content: str, pattern: str) -> List[Tuple[int, int]] # Helper Functions # ============================================================================= +def _calculate_line_positions(content_lines: List[str], start_line: int, + end_line: int, content_length: int) -> Tuple[int, int]: + """Calculate start and end character positions from line indices. + + Args: + content_lines: List of lines (without newlines) + start_line: Starting line index (0-based) + end_line: Ending line index (exclusive, 0-based) + content_length: Total length of the original content string + + Returns: + Tuple of (start_pos, end_pos) in the original content + """ + start_pos = sum(len(line) + 1 for line in content_lines[:start_line]) + end_pos = sum(len(line) + 1 for line in content_lines[:end_line]) - 1 + if end_pos >= content_length: + end_pos = content_length + return start_pos, end_pos + + def _find_normalized_matches(content: str, content_lines: List[str], content_normalized_lines: List[str], pattern: str, pattern_normalized: str) -> List[Tuple[int, int]]: @@ -383,13 +402,9 @@ def _find_normalized_matches(content: str, content_lines: List[str], if block == pattern_normalized: # Found a match - calculate original positions - start_pos = sum(len(line) + 1 for line in content_lines[:i]) - end_pos = sum(len(line) + 1 for line in content_lines[:i + num_pattern_lines]) - 1 - - # Handle case where end is past content - if end_pos >= len(content): - end_pos = len(content) - + start_pos, end_pos = _calculate_line_positions( + content_lines, i, i + num_pattern_lines, len(content) + ) matches.append((start_pos, end_pos)) return matches From c3d626eb07c1be7f83efb2aaad74cbffa6ddb21e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:01:30 -0700 Subject: [PATCH 4/8] Revert "feat: add inference.sh integration (infsh tool + skill) (#1682)" (#1684) This reverts commit 6020db0243084b02299278929c908951303965e8. --- model_tools.py | 1 - skills/inference-sh/DESCRIPTION.md | 23 -- skills/inference-sh/cli/SKILL.md | 372 ------------------ .../cli/references/app-discovery.md | 112 ------ .../cli/references/authentication.md | 59 --- .../cli/references/cli-reference.md | 104 ----- .../cli/references/running-apps.md | 171 -------- tests/tools/test_infsh_tool.py | 114 ------ tools/infsh_tool.py | 302 -------------- toolsets.py | 6 - 10 files changed, 1264 deletions(-) delete mode 100644 skills/inference-sh/DESCRIPTION.md delete mode 100644 skills/inference-sh/cli/SKILL.md delete mode 100644 skills/inference-sh/cli/references/app-discovery.md delete mode 100644 skills/inference-sh/cli/references/authentication.md delete mode 100644 skills/inference-sh/cli/references/cli-reference.md delete mode 100644 skills/inference-sh/cli/references/running-apps.md delete mode 100644 tests/tools/test_infsh_tool.py delete mode 100644 tools/infsh_tool.py diff --git a/model_tools.py b/model_tools.py index cc2cea9c92..c8682dacbe 100644 --- a/model_tools.py +++ b/model_tools.py @@ -95,7 +95,6 @@ def _discover_tools(): "tools.send_message_tool", "tools.honcho_tools", "tools.homeassistant_tool", - "tools.infsh_tool", ] import importlib for mod_name in _modules: diff --git a/skills/inference-sh/DESCRIPTION.md b/skills/inference-sh/DESCRIPTION.md deleted file mode 100644 index a4b2aa91ca..0000000000 --- a/skills/inference-sh/DESCRIPTION.md +++ /dev/null @@ -1,23 +0,0 @@ -# inference.sh - -Run 150+ AI applications in the cloud via the [inference.sh](https://inference.sh) platform. - -**One API key for everything** - Access all AI services with a single account. No need to manage separate API keys for FLUX, Veo, Claude, Tavily, Twitter, etc. You can also bring your own keys if you prefer. - -## Available Skills - -- **cli**: The inference.sh CLI (`infsh`) for running AI apps - -## What's Included - -- **Image Generation**: FLUX, Reve, Seedream, Grok Imagine, Gemini -- **Video Generation**: Veo, Wan, Seedance, OmniHuman, HunyuanVideo -- **LLMs**: Claude, Gemini, Kimi, GLM-4 (via OpenRouter) -- **Search**: Tavily, Exa -- **3D**: Rodin -- **Social**: Twitter/X automation -- **Audio**: TTS, voice cloning - -## Tools - -This category provides the `infsh` and `infsh_install` tools in the `inference` toolset. diff --git a/skills/inference-sh/cli/SKILL.md b/skills/inference-sh/cli/SKILL.md deleted file mode 100644 index 67e33436e7..0000000000 --- a/skills/inference-sh/cli/SKILL.md +++ /dev/null @@ -1,372 +0,0 @@ ---- -name: inference-sh-cli -description: "Run 150+ AI apps via inference.sh CLI (infsh) - image generation, video creation, LLMs, search, 3D, Twitter automation. Models: FLUX, Veo, Gemini, Grok, Claude, Seedance, OmniHuman, Tavily, Exa, OpenRouter. Triggers: inference.sh, infsh, ai apps, serverless ai, flux, veo, image generation, video generation" -version: 1.0.0 -author: inference.sh -license: MIT -metadata: - hermes: - tags: [AI, image-generation, video, LLM, search, inference, FLUX, Veo, Claude] - requires_tools: [infsh] ---- - -# inference.sh CLI - -Run 150+ AI apps in the cloud with a simple CLI. No GPU required. - -**One API key for everything** - Manage all AI services (FLUX, Veo, Claude, Tavily, X/Twitter, and more) with a single inference.sh account. No need to sign up for dozens of different providers. You can also bring your own API keys if you prefer. - -## Tools - -This skill is backed by the `infsh` and `infsh_install` tools: - -- **infsh**: Run any infsh command (app list, app run, etc.) -- **infsh_install**: Install the CLI if not already present - -## Quick Start - -```bash -# Install (if needed) -infsh_install - -# List available apps -infsh app list - -# Search for apps -infsh app list --search flux -infsh app list --search video - -# Run an app -infsh app run falai/flux-dev-lora --input '{"prompt": "a cat astronaut"}' --json -``` - -## Local File Uploads - -The CLI automatically uploads local files when you provide a file path instead of a URL: - -```bash -# Upscale a local image -infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' --json - -# Image-to-video from local file -infsh app run falai/wan-2-5-i2v --input '{"image": "/path/to/image.png", "prompt": "make it come alive"}' --json - -# Video generation with local first frame -infsh app run bytedance/seedance-1-5-pro --input '{"prompt": "dancing figure", "image": "./first-frame.png"}' --json -``` - -## Image Generation - -```bash -# Gemini 2.5 Flash Image (Google) - fast, high quality -infsh app run google/gemini-2-5-flash-image --input '{"prompt": "futuristic city", "num_images": 1}' --json - -# Gemini 3 Pro Image Preview (Google) - latest model -infsh app run google/gemini-3-pro-image-preview --input '{"prompt": "photorealistic landscape"}' --json - -# Gemini 3.1 Flash Image Preview (Google) -infsh app run google/gemini-3-1-flash-image-preview --input '{"prompt": "artistic portrait"}' --json - -# FLUX Dev with LoRA support -infsh app run falai/flux-dev-lora --input '{"prompt": "sunset over mountains", "num_images": 1}' --json - -# FLUX 2 Klein with LoRA -infsh app run falai/flux-2-klein-lora --input '{"prompt": "portrait photo"}' --json - -# Reve - stylized generation and editing -infsh app run falai/reve --input '{"prompt": "cyberpunk city"}' --json - -# Seedream 5 Lite - high-quality 2K-3K (ByteDance) -infsh app run bytedance/seedream-5-lite --input '{"prompt": "nature scene"}' --json - -# Seedream 4.5 - 2K-4K images -infsh app run bytedance/seedream-4-5 --input '{"prompt": "detailed illustration"}' --json - -# Seedream 3.0 - cinematic quality -infsh app run bytedance/seedream-3-0-t2i --input '{"prompt": "fantasy landscape"}' --json - -# Grok Imagine - xAI image generation -infsh app run xai/grok-imagine-image --input '{"prompt": "abstract art"}' --json - -# Grok Imagine Pro - higher quality -infsh app run xai/grok-imagine-image-pro --input '{"prompt": "photorealistic portrait"}' --json - -# Qwen Image 2 Pro (Alibaba) -infsh app run alibaba/qwen-image-2-pro --input '{"prompt": "anime character"}' --json -``` - -## Video Generation - -```bash -# Veo 3.1 Fast (Google) -infsh app run google/veo-3-1-fast --input '{"prompt": "drone shot of coastline"}' --json - -# Veo 3.1 (higher quality) -infsh app run google/veo-3-1 --input '{"prompt": "cinematic scene"}' --json - -# Veo 3 Fast -infsh app run google/veo-3-fast --input '{"prompt": "nature documentary shot"}' --json - -# Veo 2 -infsh app run google/veo-2 --input '{"prompt": "slow motion water splash"}' --json - -# Grok Imagine Video - xAI -infsh app run xai/grok-imagine-video --input '{"prompt": "timelapse of clouds"}' --json - -# Seedance 1.5 Pro - ByteDance -infsh app run bytedance/seedance-1-5-pro --input '{"prompt": "dancing figure", "resolution": "1080p"}' --json - -# Seedance 1.0 Pro -infsh app run bytedance/seedance-1-0-pro --input '{"prompt": "walking through forest"}' --json - -# Seedance 1.0 Pro Fast -infsh app run bytedance/seedance-1-0-pro-fast --input '{"prompt": "quick motion"}' --json - -# Seedance 1.0 Lite - 720p lightweight -infsh app run bytedance/seedance-1-0-lite --input '{"prompt": "simple animation"}' --json - -# Wan 2.5 - text-to-video -infsh app run falai/wan-2-5 --input '{"prompt": "person walking through city"}' --json - -# Wan 2.5 Image-to-Video -infsh app run falai/wan-2-5-i2v --input '{"image": "/path/to/image.png", "prompt": "make it move naturally"}' --json - -# LTX Video -infsh app run infsh/ltx-video --input '{"prompt": "realistic scene"}' --json - -# Magi 1 -infsh app run infsh/magi-1 --input '{"prompt": "creative video"}' --json -``` - -## Avatar & Lipsync - -```bash -# OmniHuman 1.5 - multi-character audio-driven avatars -infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/audio.mp3", "image": "/path/to/face.jpg"}' --json - -# OmniHuman 1.0 -infsh app run bytedance/omnihuman-1-0 --input '{"audio": "/path/to/speech.wav", "image": "/path/to/portrait.png"}' --json - -# Fabric 1.0 - image animation -infsh app run falai/fabric-1-0 --input '{"image": "/path/to/photo.jpg"}' --json - -# PixVerse Lipsync -infsh app run falai/pixverse-lipsync --input '{"audio": "/path/to/audio.mp3", "video": "/path/to/video.mp4"}' --json -``` - -## Upscaling - -```bash -# Topaz Image Upscaler - up to 4x -infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' --json - -# Topaz Video Upscaler -infsh app run falai/topaz-video-upscaler --input '{"video": "/path/to/video.mp4"}' --json - -# Real-ESRGAN - image enhancement -infsh app run infsh/real-esrgan --input '{"image": "/path/to/image.jpg"}' --json - -# Thera - upscale to any size -infsh app run infsh/thera --input '{"image": "/path/to/image.jpg"}' --json -``` - -## LLMs (via OpenRouter) - -```bash -# Claude Opus 4.6 -infsh app run openrouter/claude-opus-46 --input '{"prompt": "Explain quantum computing"}' --json - -# Claude Sonnet 4.5 -infsh app run openrouter/claude-sonnet-45 --input '{"prompt": "Write a poem"}' --json - -# Claude Haiku 4.5 -infsh app run openrouter/claude-haiku-45 --input '{"prompt": "Quick question"}' --json - -# Gemini 3 Pro Preview -infsh app run openrouter/gemini-3-pro-preview --input '{"prompt": "Analyze this"}' --json - -# Kimi K2 Thinking -infsh app run openrouter/kimi-k2-thinking --input '{"prompt": "Solve this step by step"}' --json - -# GLM 4.6 -infsh app run openrouter/glm-46 --input '{"prompt": "Help me with"}' --json - -# MiniMax M2.5 -infsh app run openrouter/minimax-m-25 --input '{"prompt": "Creative writing"}' --json - -# Intellect 3 -infsh app run openrouter/intellect-3 --input '{"prompt": "Research question"}' --json -``` - -## Web Search - -```bash -# Tavily Search Assistant - comprehensive results -infsh app run tavily/search-assistant --input '{"query": "latest AI news", "include_answer": true}' --json - -# Tavily Extract - get content from URLs -infsh app run tavily/extract --input '{"urls": ["https://example.com"]}' --json - -# Exa Search - neural search -infsh app run exa/search --input '{"query": "machine learning tutorials"}' --json - -# Exa Answer - LLM-powered answers -infsh app run exa/answer --input '{"query": "what is transformers architecture"}' --json - -# Exa Extract - extract web content -infsh app run exa/extract --input '{"url": "https://example.com"}' --json -``` - -## 3D Generation - -```bash -# Rodin 3D Generator -infsh app run infsh/rodin-3d-generator --input '{"prompt": "a wooden chair"}' --json - -# HunyuanImage to 3D -infsh app run infsh/hunyuan-image-to-3d-2 --input '{"image": "/path/to/object.png"}' --json -``` - -## Text-to-Speech - -```bash -# Kokoro TTS - lightweight -infsh app run falai/kokoro-tts --input '{"text": "Hello, this is a test."}' --json - -# Dia TTS - realistic dialogue -infsh app run falai/dia-tts --input '{"text": "Two characters talking"}' --json -``` - -## Twitter/X Automation - -```bash -# Post a tweet -infsh app run x/post-tweet --input '{"text": "Hello from AI!"}' --json - -# Create post with media -infsh app run x/post-create --input '{"text": "Check this out", "media": "/path/to/image.jpg"}' --json - -# Send DM -infsh app run x/dm-send --input '{"recipient_id": "123456", "text": "Hi there!"}' --json - -# Follow user -infsh app run x/user-follow --input '{"user_id": "123456"}' --json - -# Like a post -infsh app run x/post-like --input '{"post_id": "123456789"}' --json - -# Retweet -infsh app run x/post-retweet --input '{"post_id": "123456789"}' --json - -# Get user profile -infsh app run x/user-get --input '{"username": "elonmusk"}' --json - -# Get post -infsh app run x/post-get --input '{"post_id": "123456789"}' --json - -# Delete post -infsh app run x/post-delete --input '{"post_id": "123456789"}' --json -``` - -## Utilities - -```bash -# Browser automation -infsh app run infsh/agent-browser --function open --session new --input '{"url": "https://example.com"}' --json - -# Media merger - combine videos/images -infsh app run infsh/media-merger --input '{"files": ["/path/to/video1.mp4", "/path/to/video2.mp4"]}' --json - -# Video audio extractor -infsh app run infsh/video-audio-extractor --input '{"video": "/path/to/video.mp4"}' --json - -# Video audio merger -infsh app run infsh/video-audio-merger --input '{"video": "/path/to/video.mp4", "audio": "/path/to/audio.mp3"}' --json - -# Caption videos -infsh app run infsh/caption-videos --input '{"video": "/path/to/video.mp4"}' --json - -# Stitch images -infsh app run infsh/stitch-images --input '{"images": ["/path/to/1.jpg", "/path/to/2.jpg"]}' --json - -# Python executor -infsh app run infsh/python-executor --input '{"code": "print(2+2)"}' --json - -# HTML to image -infsh app run infsh/html-to-image --input '{"html": "

Hello

"}' --json - -# NSFW detection -infsh app run infsh/falconsai-nsfw-detection --input '{"image": "/path/to/image.jpg"}' --json - -# Media analyzer -infsh app run infsh/media-analyzer --input '{"file": "/path/to/media.jpg"}' --json -``` - -## Common Patterns - -### Generate + Upscale Pipeline - -```bash -# Generate image, capture URL, then upscale -infsh app run falai/flux-dev-lora --input '{"prompt": "portrait photo"}' --json --save result.json - -# Extract URL and upscale (using jq) -IMG=$(cat result.json | jq -r '.images[0].url') -infsh app run falai/topaz-image-upscaler --input "{\"image\": \"$IMG\", \"upscale_factor\": 2}" --json -``` - -### Get App Schema - -```bash -# See what inputs an app accepts -infsh app get falai/flux-dev-lora - -# Generate sample input -infsh app sample falai/flux-dev-lora - -# Save sample to file, edit, then run -infsh app sample falai/flux-dev-lora --save input.json -# edit input.json... -infsh app run falai/flux-dev-lora --input input.json --json -``` - -### Long-running Tasks - -```bash -# Start without waiting -infsh app run google/veo-3-1 --input '{"prompt": "..."}' --no-wait - -# Check status later -infsh task get - -# Save result when done -infsh task get --save result.json -``` - -## Available Categories - -| Category | Apps | -|----------|------| -| **Image** | google/nano-banana, google/nano-banana-pro, google/nano-banana-2, falai/flux-dev-lora, bytedance/seedream-5-lite, falai/reve, xai/grok-imagine-image | -| **Video** | google/veo-*, xai/grok-imagine-video, bytedance/seedance-*, falai/wan-2-5*, infsh/ltx-video, infsh/magi-1 | -| **Avatar** | bytedance/omnihuman-*, falai/fabric-1-0, falai/pixverse-lipsync | -| **Upscale** | falai/topaz-image-upscaler, falai/topaz-video-upscaler, infsh/real-esrgan, infsh/thera | -| **LLMs** | openrouter/claude-*, openrouter/gemini-*, openrouter/kimi-*, openrouter/glm-* | -| **Search** | tavily/search-assistant, tavily/extract, exa/search, exa/answer, exa/extract | -| **3D** | infsh/rodin-3d-generator, infsh/hunyuan-image-to-3d-2 | -| **TTS** | falai/kokoro-tts, falai/dia-tts | -| **Social** | x/post-tweet, x/post-create, x/dm-send, x/user-follow, x/post-like, x/post-retweet | -| **Utils** | infsh/agent-browser, infsh/media-merger, infsh/caption-videos, infsh/stitch-images | - -## Reference Files - -- [Authentication & Setup](references/authentication.md) -- [Discovering Apps](references/app-discovery.md) -- [Running Apps](references/running-apps.md) -- [CLI Reference](references/cli-reference.md) - -## Documentation - -- [inference.sh Docs](https://inference.sh/docs) -- [CLI Setup Guide](https://inference.sh/docs/extend/cli-setup) -- [Apps Overview](https://inference.sh/docs/apps/overview) diff --git a/skills/inference-sh/cli/references/app-discovery.md b/skills/inference-sh/cli/references/app-discovery.md deleted file mode 100644 index adcac8c5dc..0000000000 --- a/skills/inference-sh/cli/references/app-discovery.md +++ /dev/null @@ -1,112 +0,0 @@ -# Discovering Apps - -## List All Apps - -```bash -infsh app list -``` - -## Pagination - -```bash -infsh app list --page 2 -``` - -## Filter by Category - -```bash -infsh app list --category image -infsh app list --category video -infsh app list --category audio -infsh app list --category text -infsh app list --category other -``` - -## Search - -```bash -infsh app search "flux" -infsh app search "video generation" -infsh app search "tts" -l -infsh app search "image" --category image -``` - -Or use the flag form: - -```bash -infsh app list --search "flux" -infsh app list --search "video generation" -infsh app list --search "tts" -``` - -## Featured Apps - -```bash -infsh app list --featured -``` - -## Newest First - -```bash -infsh app list --new -``` - -## Detailed View - -```bash -infsh app list -l -``` - -Shows table with app name, category, description, and featured status. - -## Save to File - -```bash -infsh app list --save apps.json -``` - -## Your Apps - -List apps you've deployed: - -```bash -infsh app my -infsh app my -l # detailed -``` - -## Get App Details - -```bash -infsh app get falai/flux-dev-lora -infsh app get falai/flux-dev-lora --json -``` - -Shows full app info including input/output schema. - -## Popular Apps by Category - -### Image Generation -- `falai/flux-dev-lora` - FLUX.2 Dev (high quality) -- `falai/flux-2-klein-lora` - FLUX.2 Klein (fastest) -- `infsh/sdxl` - Stable Diffusion XL -- `google/gemini-3-pro-image-preview` - Gemini 3 Pro -- `xai/grok-imagine-image` - Grok image generation - -### Video Generation -- `google/veo-3-1-fast` - Veo 3.1 Fast -- `google/veo-3` - Veo 3 -- `bytedance/seedance-1-5-pro` - Seedance 1.5 Pro -- `infsh/ltx-video-2` - LTX Video 2 (with audio) -- `bytedance/omnihuman-1-5` - OmniHuman avatar - -### Audio -- `infsh/dia-tts` - Conversational TTS -- `infsh/kokoro-tts` - Kokoro TTS -- `infsh/fast-whisper-large-v3` - Fast transcription -- `infsh/diffrythm` - Music generation - -## Documentation - -- [Browsing the Grid](https://inference.sh/docs/apps/browsing-grid) - Visual app browsing -- [Apps Overview](https://inference.sh/docs/apps/overview) - Understanding apps -- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps diff --git a/skills/inference-sh/cli/references/authentication.md b/skills/inference-sh/cli/references/authentication.md deleted file mode 100644 index 3b6519d3de..0000000000 --- a/skills/inference-sh/cli/references/authentication.md +++ /dev/null @@ -1,59 +0,0 @@ -# Authentication & Setup - -## Install the CLI - -```bash -curl -fsSL https://cli.inference.sh | sh -``` - -## Login - -```bash -infsh login -``` - -This opens a browser for authentication. After login, credentials are stored locally. - -## Check Authentication - -```bash -infsh me -``` - -Shows your user info if authenticated. - -## Environment Variable - -For CI/CD or scripts, set your API key: - -```bash -export INFSH_API_KEY=your-api-key -``` - -The environment variable overrides the config file. - -## Update CLI - -```bash -infsh update -``` - -Or reinstall: - -```bash -curl -fsSL https://cli.inference.sh | sh -``` - -## Troubleshooting - -| Error | Solution | -|-------|----------| -| "not authenticated" | Run `infsh login` | -| "command not found" | Reinstall CLI or add to PATH | -| "API key invalid" | Check `INFSH_API_KEY` or re-login | - -## Documentation - -- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide -- [API Authentication](https://inference.sh/docs/api/authentication) - API key management -- [Secrets](https://inference.sh/docs/secrets/overview) - Managing credentials diff --git a/skills/inference-sh/cli/references/cli-reference.md b/skills/inference-sh/cli/references/cli-reference.md deleted file mode 100644 index 50825825f7..0000000000 --- a/skills/inference-sh/cli/references/cli-reference.md +++ /dev/null @@ -1,104 +0,0 @@ -# CLI Reference - -## Installation - -```bash -curl -fsSL https://cli.inference.sh | sh -``` - -## Global Commands - -| Command | Description | -|---------|-------------| -| `infsh help` | Show help | -| `infsh version` | Show CLI version | -| `infsh update` | Update CLI to latest | -| `infsh login` | Authenticate | -| `infsh me` | Show current user | - -## App Commands - -### Discovery - -| Command | Description | -|---------|-------------| -| `infsh app list` | List available apps | -| `infsh app list --category ` | Filter by category (image, video, audio, text, other) | -| `infsh app search ` | Search apps | -| `infsh app list --search ` | Search apps (flag form) | -| `infsh app list --featured` | Show featured apps | -| `infsh app list --new` | Sort by newest | -| `infsh app list --page ` | Pagination | -| `infsh app list -l` | Detailed table view | -| `infsh app list --save ` | Save to JSON file | -| `infsh app my` | List your deployed apps | -| `infsh app get ` | Get app details | -| `infsh app get --json` | Get app details as JSON | - -### Execution - -| Command | Description | -|---------|-------------| -| `infsh app run --input ` | Run app with input file | -| `infsh app run --input ''` | Run with inline JSON | -| `infsh app run --input --no-wait` | Run without waiting for completion | -| `infsh app sample ` | Show sample input | -| `infsh app sample --save ` | Save sample to file | - -## Task Commands - -| Command | Description | -|---------|-------------| -| `infsh task get ` | Get task status and result | -| `infsh task get --json` | Get task as JSON | -| `infsh task get --save ` | Save task result to file | - -### Development - -| Command | Description | -|---------|-------------| -| `infsh app init` | Create new app (interactive) | -| `infsh app init ` | Create new app with name | -| `infsh app test --input ` | Test app locally | -| `infsh app deploy` | Deploy app | -| `infsh app deploy --dry-run` | Validate without deploying | -| `infsh app pull ` | Pull app source | -| `infsh app pull --all` | Pull all your apps | - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `INFSH_API_KEY` | API key (overrides config) | - -## Shell Completions - -```bash -# Bash -infsh completion bash > /etc/bash_completion.d/infsh - -# Zsh -infsh completion zsh > "${fpath[1]}/_infsh" - -# Fish -infsh completion fish > ~/.config/fish/completions/infsh.fish -``` - -## App Name Format - -Apps use the format `namespace/app-name`: - -- `falai/flux-dev-lora` - fal.ai's FLUX 2 Dev -- `google/veo-3` - Google's Veo 3 -- `infsh/sdxl` - inference.sh's SDXL -- `bytedance/seedance-1-5-pro` - ByteDance's Seedance -- `xai/grok-imagine-image` - xAI's Grok - -Version pinning: `namespace/app-name@version` - -## Documentation - -- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide -- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps via CLI -- [Creating an App](https://inference.sh/docs/extend/creating-app) - Build your own apps -- [Deploying](https://inference.sh/docs/extend/deploying) - Deploy apps to the cloud diff --git a/skills/inference-sh/cli/references/running-apps.md b/skills/inference-sh/cli/references/running-apps.md deleted file mode 100644 index e930d5cfbc..0000000000 --- a/skills/inference-sh/cli/references/running-apps.md +++ /dev/null @@ -1,171 +0,0 @@ -# Running Apps - -## Basic Run - -```bash -infsh app run user/app-name --input input.json -``` - -## Inline JSON - -```bash -infsh app run falai/flux-dev-lora --input '{"prompt": "a sunset over mountains"}' -``` - -## Version Pinning - -```bash -infsh app run user/app-name@1.0.0 --input input.json -``` - -## Local File Uploads - -The CLI automatically uploads local files when you provide a file path instead of a URL. Any field that accepts a URL also accepts a local path: - -```bash -# Upscale a local image -infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' - -# Image-to-video from local file -infsh app run falai/wan-2-5-i2v --input '{"image": "./my-image.png", "prompt": "make it move"}' - -# Avatar with local audio and image -infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/speech.mp3", "image": "/path/to/face.jpg"}' - -# Post tweet with local media -infsh app run x/post-create --input '{"text": "Check this out!", "media": "./screenshot.png"}' -``` - -Supported paths: -- Absolute paths: `/home/user/images/photo.jpg` -- Relative paths: `./image.png`, `../data/video.mp4` -- Home directory: `~/Pictures/photo.jpg` - -## Generate Sample Input - -Before running, generate a sample input file: - -```bash -infsh app sample falai/flux-dev-lora -``` - -Save to file: - -```bash -infsh app sample falai/flux-dev-lora --save input.json -``` - -Then edit `input.json` and run: - -```bash -infsh app run falai/flux-dev-lora --input input.json -``` - -## Workflow Example - -### Image Generation with FLUX - -```bash -# 1. Get app details -infsh app get falai/flux-dev-lora - -# 2. Generate sample input -infsh app sample falai/flux-dev-lora --save input.json - -# 3. Edit input.json -# { -# "prompt": "a cat astronaut floating in space", -# "num_images": 1, -# "image_size": "landscape_16_9" -# } - -# 4. Run -infsh app run falai/flux-dev-lora --input input.json -``` - -### Video Generation with Veo - -```bash -# 1. Generate sample -infsh app sample google/veo-3-1-fast --save input.json - -# 2. Edit prompt -# { -# "prompt": "A drone shot flying over a forest at sunset" -# } - -# 3. Run -infsh app run google/veo-3-1-fast --input input.json -``` - -### Text-to-Speech - -```bash -# Quick inline run -infsh app run falai/kokoro-tts --input '{"text": "Hello, this is a test."}' -``` - -## Task Tracking - -When you run an app, the CLI shows the task ID: - -``` -Running falai/flux-dev-lora -Task ID: abc123def456 -``` - -For long-running tasks, you can check status anytime: - -```bash -# Check task status -infsh task get abc123def456 - -# Get result as JSON -infsh task get abc123def456 --json - -# Save result to file -infsh task get abc123def456 --save result.json -``` - -### Run Without Waiting - -For very long tasks, run in background: - -```bash -# Submit and return immediately -infsh app run google/veo-3 --input input.json --no-wait - -# Check later -infsh task get -``` - -## Output - -The CLI returns the app output directly. For file outputs (images, videos, audio), you'll receive URLs to download. - -Example output: - -```json -{ - "images": [ - { - "url": "https://cloud.inference.sh/...", - "content_type": "image/png" - } - ] -} -``` - -## Error Handling - -| Error | Cause | Solution | -|-------|-------|----------| -| "invalid input" | Schema mismatch | Check `infsh app get` for required fields | -| "app not found" | Wrong app name | Check `infsh app list --search` | -| "quota exceeded" | Out of credits | Check account balance | - -## Documentation - -- [Running Apps](https://inference.sh/docs/apps/running) - Complete running apps guide -- [Streaming Results](https://inference.sh/docs/api/sdk/streaming) - Real-time progress updates -- [Setup Parameters](https://inference.sh/docs/apps/setup-parameters) - Configuring app inputs diff --git a/tests/tools/test_infsh_tool.py b/tests/tools/test_infsh_tool.py deleted file mode 100644 index f866df1e32..0000000000 --- a/tests/tools/test_infsh_tool.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for tools/infsh_tool.py — inference.sh CLI integration.""" - -import json -import subprocess -from unittest.mock import patch, MagicMock - -import pytest - -from tools.infsh_tool import ( - check_infsh_requirements, - infsh_tool, - infsh_install, -) - - -class TestCheckRequirements: - def test_returns_bool(self): - result = check_infsh_requirements() - assert isinstance(result, bool) - - def test_returns_true_when_infsh_on_path(self, monkeypatch): - monkeypatch.setattr("shutil.which", lambda cmd: "/usr/local/bin/infsh" if cmd == "infsh" else None) - assert check_infsh_requirements() is True - - def test_returns_false_when_missing(self, monkeypatch): - monkeypatch.setattr("shutil.which", lambda cmd: None) - assert check_infsh_requirements() is False - - -class TestInfshTool: - def test_not_installed_returns_error(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: False) - result = json.loads(infsh_tool("app list")) - assert result["success"] is False - assert "not installed" in result["error"].lower() - - def test_successful_command(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = '{"apps": ["flux", "veo"]}' - mock_result.stderr = "" - - with patch("subprocess.run", return_value=mock_result) as mock_run: - result = json.loads(infsh_tool("app list --search flux")) - assert result["success"] is True - mock_run.assert_called_once() - call_cmd = mock_run.call_args[0][0] - assert "infsh app list --search flux" in call_cmd - - def test_failed_command(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - mock_result.stderr = "unknown command" - - with patch("subprocess.run", return_value=mock_result): - result = json.loads(infsh_tool("badcommand")) - assert result["success"] is False - assert result["exit_code"] == 1 - - def test_timeout_handled(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) - - with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("infsh", 300)): - result = json.loads(infsh_tool("app run something", timeout=300)) - assert result["success"] is False - assert "timed out" in result["error"].lower() - - def test_json_output_parsed(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = '{"url": "https://example.com/image.png"}' - mock_result.stderr = "" - - with patch("subprocess.run", return_value=mock_result): - result = json.loads(infsh_tool("app run flux --json")) - assert result["success"] is True - assert isinstance(result["output"], dict) - assert result["output"]["url"] == "https://example.com/image.png" - - -class TestInfshInstall: - def test_already_installed(self, monkeypatch): - monkeypatch.setattr("tools.infsh_tool.check_infsh_requirements", lambda: True) - monkeypatch.setattr("tools.infsh_tool._get_infsh_path", lambda: "/usr/local/bin/infsh") - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "infsh v1.2.3" - - with patch("subprocess.run", return_value=mock_result): - result = json.loads(infsh_install()) - assert result["success"] is True - assert result["already_installed"] is True - - -class TestToolRegistration: - def test_tools_registered(self): - from tools.registry import registry - assert "infsh" in registry._tools - assert "infsh_install" in registry._tools - - def test_infsh_in_inference_toolset(self): - from toolsets import TOOLSETS - assert "inference" in TOOLSETS - assert "infsh" in TOOLSETS["inference"]["tools"] - assert "infsh_install" in TOOLSETS["inference"]["tools"] - - def test_infsh_not_in_core_tools(self): - from toolsets import _HERMES_CORE_TOOLS - assert "infsh" not in _HERMES_CORE_TOOLS - assert "infsh_install" not in _HERMES_CORE_TOOLS diff --git a/tools/infsh_tool.py b/tools/infsh_tool.py deleted file mode 100644 index ede785dd1b..0000000000 --- a/tools/infsh_tool.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env python3 -""" -Inference.sh Tool Module - -A simple tool for running AI apps via the inference.sh CLI (infsh). -Provides two functions: - - infsh_install: Install the infsh CLI - - infsh: Run any infsh command - -This is a lightweight wrapper that gives agents direct access to 150+ AI apps -including image generation (FLUX, Reve), video (Veo, Wan), LLMs, search, and more. - -Usage: - from tools.infsh_tool import infsh_tool, infsh_install - - # Install the CLI - result = infsh_install() - - # Search for apps first (always do this!) - result = infsh_tool("app list --search flux") - - # Run an app - result = infsh_tool("app run falai/flux-dev-lora --input '{\"prompt\": \"a cat\"}' --json") -""" - -import json -import logging -import os -import shutil -import subprocess -from typing import Optional - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -DEFAULT_TIMEOUT = 300 # 5 minutes for long-running AI tasks -INSTALL_TIMEOUT = 60 - - -# --------------------------------------------------------------------------- -# Availability check -# --------------------------------------------------------------------------- - -def check_infsh_requirements() -> bool: - """Check if infsh is available in PATH.""" - return shutil.which("infsh") is not None - - -def _get_infsh_path() -> Optional[str]: - """Get the path to infsh binary.""" - return shutil.which("infsh") - - -# --------------------------------------------------------------------------- -# Install function -# --------------------------------------------------------------------------- - -def infsh_install() -> str: - """ - Install the inference.sh CLI. - - Downloads and installs the infsh binary using the official installer script. - The installer detects OS/arch, downloads the correct binary, verifies checksum, - and places it in PATH. - - Returns: - JSON string with success/error status - """ - try: - # Check if already installed - if check_infsh_requirements(): - infsh_path = _get_infsh_path() - # Get version - version_result = subprocess.run( - ["infsh", "--version"], - capture_output=True, - text=True, - timeout=10 - ) - version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown" - return json.dumps({ - "success": True, - "message": f"infsh is already installed at {infsh_path}", - "version": version, - "already_installed": True - }) - - # Run the installer - result = subprocess.run( - ["sh", "-c", "curl -fsSL https://cli.inference.sh | sh"], - capture_output=True, - text=True, - timeout=INSTALL_TIMEOUT, - env={**os.environ, "NONINTERACTIVE": "1"} - ) - - if result.returncode != 0: - return json.dumps({ - "success": False, - "error": f"Installation failed: {result.stderr}", - "stdout": result.stdout - }) - - # Verify installation - if not check_infsh_requirements(): - return json.dumps({ - "success": False, - "error": "Installation completed but infsh not found in PATH. You may need to restart your shell or add ~/.local/bin to PATH.", - "stdout": result.stdout - }) - - return json.dumps({ - "success": True, - "message": "infsh installed successfully", - "stdout": result.stdout, - "next_step": "Run 'infsh login' to authenticate, or set INFSH_API_KEY environment variable" - }) - - except subprocess.TimeoutExpired: - return json.dumps({ - "success": False, - "error": f"Installation timed out after {INSTALL_TIMEOUT}s" - }) - except Exception as e: - logger.exception("infsh_install error: %s", e) - return json.dumps({ - "success": False, - "error": f"Installation error: {type(e).__name__}: {e}" - }) - - -# --------------------------------------------------------------------------- -# Main tool function -# --------------------------------------------------------------------------- - -def infsh_tool( - command: str, - timeout: Optional[int] = None, -) -> str: - """ - Execute an infsh CLI command. - - Args: - command: The infsh command to run (without the 'infsh' prefix). - Examples: "app list", "app run falai/flux-schnell --input '{}'" - timeout: Command timeout in seconds (default: 300) - - Returns: - JSON string with output, exit_code, and error fields - """ - try: - effective_timeout = timeout or DEFAULT_TIMEOUT - - # Check if infsh is installed - if not check_infsh_requirements(): - return json.dumps({ - "success": False, - "error": "infsh CLI is not installed. Use infsh_install to install it first.", - "hint": "Call the infsh_install tool to set up the CLI" - }) - - # Build the full command - full_command = f"infsh {command}" - - # Execute - result = subprocess.run( - full_command, - shell=True, - capture_output=True, - text=True, - timeout=effective_timeout, - env=os.environ - ) - - output = result.stdout - error = result.stderr - - # Try to parse JSON output if present - parsed_output = None - if output.strip(): - try: - parsed_output = json.loads(output) - except json.JSONDecodeError: - pass # Not JSON, keep as string - - response = { - "success": result.returncode == 0, - "exit_code": result.returncode, - "output": parsed_output if parsed_output is not None else output, - } - - if error: - response["stderr"] = error - - return json.dumps(response, indent=2) - - except subprocess.TimeoutExpired: - return json.dumps({ - "success": False, - "error": f"Command timed out after {effective_timeout}s", - "hint": "For long-running tasks, consider using --no-wait flag" - }) - except Exception as e: - logger.exception("infsh_tool error: %s", e) - return json.dumps({ - "success": False, - "error": f"Execution error: {type(e).__name__}: {e}" - }) - - -# --------------------------------------------------------------------------- -# Registry -# --------------------------------------------------------------------------- - -from tools.registry import registry - -INFSH_TOOL_DESCRIPTION = """Run AI apps via inference.sh CLI. Access 150+ apps for image generation, video, LLMs, search, 3D, and more. - -One API key for everything - manage all AI services (FLUX, Veo, Claude, Tavily, etc.) with a single inference.sh account. You can also bring your own API keys. - -IMPORTANT: Always use 'app list --search ' first to find the exact app ID before running. App names change frequently. - -Commands: -- app list --search : Find apps (ALWAYS DO THIS FIRST) -- app run --input '' --json: Run an app -- app get : Get app schema before running - -Verified app examples (use --search to confirm current names): -- Image: google/nano-banana, google/nano-banana-pro, google/nano-banana-2, falai/flux-dev-lora, bytedance/seedream-5-lite, falai/reve, xai/grok-imagine-image -- Video: google/veo-3-1-fast, bytedance/seedance-1-5-pro, falai/wan-2-5 -- Upscale: falai/topaz-image-upscaler -- Search: tavily/search-assistant, exa/search -- LLM: openrouter/claude-sonnet-45 - -Workflow: ALWAYS search first, then run: -1. app list --search image -2. app run falai/flux-dev-lora --input '{"prompt": "a sunset"}' --json""" - -INFSH_SCHEMA = { - "name": "infsh", - "description": INFSH_TOOL_DESCRIPTION, - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The infsh command (without 'infsh' prefix). ALWAYS use 'app list --search ' first to find correct app IDs, then 'app run --input --json'" - }, - "timeout": { - "type": "integer", - "description": "Max seconds to wait (default: 300). AI tasks like video generation may take 1-2 minutes.", - "minimum": 1 - } - }, - "required": ["command"] - } -} - -INFSH_INSTALL_SCHEMA = { - "name": "infsh_install", - "description": "Install the inference.sh CLI (infsh). Downloads and installs the binary. Run this first if infsh is not available.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } -} - - -def _handle_infsh(args, **kw): - return infsh_tool( - command=args.get("command", ""), - timeout=args.get("timeout"), - ) - - -def _handle_infsh_install(args, **kw): - return infsh_install() - - -# Register both tools under the "inference" toolset -registry.register( - name="infsh", - toolset="inference", - schema=INFSH_SCHEMA, - handler=_handle_infsh, - check_fn=check_infsh_requirements, - requires_env=[], -) - -registry.register( - name="infsh_install", - toolset="inference", - schema=INFSH_INSTALL_SCHEMA, - handler=_handle_infsh_install, - check_fn=lambda: True, # Always available - it's the installer - requires_env=[], -) diff --git a/toolsets.py b/toolsets.py index b94ea580df..1a73ff1b82 100644 --- a/toolsets.py +++ b/toolsets.py @@ -183,12 +183,6 @@ TOOLSETS = { "tools": ["execute_code"], "includes": [] }, - - "inference": { - "description": "inference.sh CLI (infsh) — run 150+ AI apps: image gen (FLUX, Reve), video (Veo, Wan), LLMs, search (Tavily, Exa), 3D, and more", - "tools": ["infsh", "infsh_install"], - "includes": [] - }, "delegation": { "description": "Spawn subagents with isolated context for complex subtasks", From a6dcc231f849cb5561d791fc36241735914ee433 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:04:58 -0700 Subject: [PATCH 5/8] feat(gateway): add DingTalk platform adapter (#1685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DingTalk as a messaging platform using the dingtalk-stream SDK for real-time message reception via Stream Mode (no webhook needed). Replies are sent via session webhook using markdown format. Features: - Stream Mode connection (long-lived WebSocket, no public URL needed) - Text and rich text message support - DM and group chat support - Message deduplication with 5-minute window - Auto-reconnection with exponential backoff - Session webhook caching for reply routing Configuration: export DINGTALK_CLIENT_ID=your-app-key export DINGTALK_CLIENT_SECRET=your-app-secret # or in config.yaml: platforms: dingtalk: enabled: true extra: client_id: your-app-key client_secret: your-app-secret Files: - gateway/platforms/dingtalk.py (340 lines) — adapter implementation - gateway/config.py — add DINGTALK to Platform enum - gateway/run.py — add DingTalk to _create_adapter - hermes_cli/config.py — add env vars to _EXTRA_ENV_KEYS - hermes_cli/tools_config.py — add dingtalk to PLATFORMS - tests/gateway/test_dingtalk.py — 21 tests --- gateway/config.py | 1 + gateway/platforms/dingtalk.py | 340 +++++++++++++++++++++++++++++++++ gateway/run.py | 7 + hermes_cli/config.py | 1 + hermes_cli/tools_config.py | 1 + tests/gateway/test_dingtalk.py | 274 ++++++++++++++++++++++++++ 6 files changed, 624 insertions(+) create mode 100644 gateway/platforms/dingtalk.py create mode 100644 tests/gateway/test_dingtalk.py diff --git a/gateway/config.py b/gateway/config.py index 0b01ed26c9..e21f6ce0a6 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -42,6 +42,7 @@ class Platform(Enum): SIGNAL = "signal" HOMEASSISTANT = "homeassistant" EMAIL = "email" + DINGTALK = "dingtalk" @dataclass diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py new file mode 100644 index 0000000000..38a8026418 --- /dev/null +++ b/gateway/platforms/dingtalk.py @@ -0,0 +1,340 @@ +""" +DingTalk platform adapter using Stream Mode. + +Uses dingtalk-stream SDK for real-time message reception without webhooks. +Responses are sent via DingTalk's session webhook (markdown format). + +Requires: + pip install dingtalk-stream httpx + DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET env vars + +Configuration in config.yaml: + platforms: + dingtalk: + enabled: true + extra: + client_id: "your-app-key" # or DINGTALK_CLIENT_ID env var + client_secret: "your-secret" # or DINGTALK_CLIENT_SECRET env var +""" + +import asyncio +import logging +import os +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +try: + import dingtalk_stream + from dingtalk_stream import ChatbotHandler, ChatbotMessage + DINGTALK_STREAM_AVAILABLE = True +except ImportError: + DINGTALK_STREAM_AVAILABLE = False + dingtalk_stream = None # type: ignore[assignment] + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_LENGTH = 20000 +DEDUP_WINDOW_SECONDS = 300 +DEDUP_MAX_SIZE = 1000 +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] + + +def check_dingtalk_requirements() -> bool: + """Check if DingTalk dependencies are available and configured.""" + if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE: + return False + if not os.getenv("DINGTALK_CLIENT_ID") and not os.getenv("DINGTALK_CLIENT_SECRET"): + return False + return True + + +class DingTalkAdapter(BasePlatformAdapter): + """DingTalk chatbot adapter using Stream Mode. + + The dingtalk-stream SDK maintains a long-lived WebSocket connection. + Incoming messages arrive via a ChatbotHandler callback. Replies are + sent via the incoming message's session_webhook URL using httpx. + """ + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.DINGTALK) + + extra = config.extra or {} + self._client_id: str = extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", "") + self._client_secret: str = extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET", "") + + self._stream_client: Any = None + self._stream_task: Optional[asyncio.Task] = None + self._http_client: Optional["httpx.AsyncClient"] = None + + # Message deduplication: msg_id -> timestamp + self._seen_messages: Dict[str, float] = {} + # Map chat_id -> session_webhook for reply routing + self._session_webhooks: Dict[str, str] = {} + + # -- Connection lifecycle ----------------------------------------------- + + async def connect(self) -> bool: + """Connect to DingTalk via Stream Mode.""" + if not DINGTALK_STREAM_AVAILABLE: + logger.warning("[%s] dingtalk-stream not installed. Run: pip install dingtalk-stream", self.name) + return False + if not HTTPX_AVAILABLE: + logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name) + return False + if not self._client_id or not self._client_secret: + logger.warning("[%s] DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required", self.name) + return False + + try: + self._http_client = httpx.AsyncClient(timeout=30.0) + + credential = dingtalk_stream.Credential(self._client_id, self._client_secret) + self._stream_client = dingtalk_stream.DingTalkStreamClient(credential) + + # Capture the current event loop for cross-thread dispatch + loop = asyncio.get_running_loop() + handler = _IncomingHandler(self, loop) + self._stream_client.register_callback_handler( + dingtalk_stream.ChatbotMessage.TOPIC, handler + ) + + self._stream_task = asyncio.create_task(self._run_stream()) + self._mark_connected() + logger.info("[%s] Connected via Stream Mode", self.name) + return True + except Exception as e: + logger.error("[%s] Failed to connect: %s", self.name, e) + return False + + async def _run_stream(self) -> None: + """Run the blocking stream client with auto-reconnection.""" + backoff_idx = 0 + while self._running: + try: + logger.debug("[%s] Starting stream client...", self.name) + await asyncio.to_thread(self._stream_client.start) + except asyncio.CancelledError: + return + except Exception as e: + if not self._running: + return + logger.warning("[%s] Stream client error: %s", self.name, e) + + if not self._running: + return + + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + logger.info("[%s] Reconnecting in %ds...", self.name, delay) + await asyncio.sleep(delay) + backoff_idx += 1 + + async def disconnect(self) -> None: + """Disconnect from DingTalk.""" + self._running = False + self._mark_disconnected() + + if self._stream_task: + self._stream_task.cancel() + try: + await self._stream_task + except asyncio.CancelledError: + pass + self._stream_task = None + + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + self._stream_client = None + self._session_webhooks.clear() + self._seen_messages.clear() + logger.info("[%s] Disconnected", self.name) + + # -- Inbound message processing ----------------------------------------- + + async def _on_message(self, message: "ChatbotMessage") -> None: + """Process an incoming DingTalk chatbot message.""" + msg_id = getattr(message, "message_id", None) or uuid.uuid4().hex + if self._is_duplicate(msg_id): + logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id) + return + + text = self._extract_text(message) + if not text: + logger.debug("[%s] Empty message, skipping", self.name) + return + + # Chat context + conversation_id = getattr(message, "conversation_id", "") or "" + conversation_type = getattr(message, "conversation_type", "1") + is_group = str(conversation_type) == "2" + sender_id = getattr(message, "sender_id", "") or "" + sender_nick = getattr(message, "sender_nick", "") or sender_id + sender_staff_id = getattr(message, "sender_staff_id", "") or "" + + chat_id = conversation_id or sender_id + chat_type = "group" if is_group else "dm" + + # Store session webhook for reply routing + session_webhook = getattr(message, "session_webhook", None) or "" + if session_webhook and chat_id: + self._session_webhooks[chat_id] = session_webhook + + source = self.build_source( + chat_id=chat_id, + chat_name=getattr(message, "conversation_title", None), + chat_type=chat_type, + user_id=sender_id, + user_name=sender_nick, + user_id_alt=sender_staff_id if sender_staff_id else None, + ) + + # Parse timestamp + create_at = getattr(message, "create_at", None) + try: + timestamp = datetime.fromtimestamp(int(create_at) / 1000, tz=timezone.utc) if create_at else datetime.now(tz=timezone.utc) + except (ValueError, OSError, TypeError): + timestamp = datetime.now(tz=timezone.utc) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=msg_id, + raw_message=message, + timestamp=timestamp, + ) + + logger.debug("[%s] Message from %s in %s: %s", + self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:50]) + await self.handle_message(event) + + @staticmethod + def _extract_text(message: "ChatbotMessage") -> str: + """Extract plain text from a DingTalk chatbot message.""" + text = getattr(message, "text", None) or "" + if isinstance(text, dict): + content = text.get("content", "").strip() + else: + content = str(text).strip() + + # Fall back to rich text if present + if not content: + rich_text = getattr(message, "rich_text", None) + if rich_text and isinstance(rich_text, list): + parts = [item["text"] for item in rich_text + if isinstance(item, dict) and item.get("text")] + content = " ".join(parts).strip() + return content + + # -- Deduplication ------------------------------------------------------ + + def _is_duplicate(self, msg_id: str) -> bool: + """Check and record a message ID. Returns True if already seen.""" + now = time.time() + if len(self._seen_messages) > DEDUP_MAX_SIZE: + cutoff = now - DEDUP_WINDOW_SECONDS + self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff} + + if msg_id in self._seen_messages: + return True + self._seen_messages[msg_id] = now + return False + + # -- Outbound messaging ------------------------------------------------- + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a markdown reply via DingTalk session webhook.""" + metadata = metadata or {} + + session_webhook = metadata.get("session_webhook") or self._session_webhooks.get(chat_id) + if not session_webhook: + return SendResult(success=False, + error="No session_webhook available. Reply must follow an incoming message.") + + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + payload = { + "msgtype": "markdown", + "markdown": {"title": "Hermes", "text": content[:self.MAX_MESSAGE_LENGTH]}, + } + + try: + resp = await self._http_client.post(session_webhook, json=payload, timeout=15.0) + if resp.status_code < 300: + return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) + body = resp.text + logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200]) + return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}") + except httpx.TimeoutException: + return SendResult(success=False, error="Timeout sending message to DingTalk") + except Exception as e: + logger.error("[%s] Send error: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """DingTalk does not support typing indicators.""" + pass + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about a DingTalk conversation.""" + return {"name": chat_id, "type": "group" if "group" in chat_id.lower() else "dm"} + + +# --------------------------------------------------------------------------- +# Internal stream handler +# --------------------------------------------------------------------------- + +class _IncomingHandler(ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object): + """dingtalk-stream ChatbotHandler that forwards messages to the adapter.""" + + def __init__(self, adapter: DingTalkAdapter, loop: asyncio.AbstractEventLoop): + if DINGTALK_STREAM_AVAILABLE: + super().__init__() + self._adapter = adapter + self._loop = loop + + def process(self, message: "ChatbotMessage"): + """Called by dingtalk-stream in its thread when a message arrives. + + Schedules the async handler on the main event loop. + """ + loop = self._loop + if loop is None or loop.is_closed(): + logger.error("[DingTalk] Event loop unavailable, cannot dispatch message") + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + + future = asyncio.run_coroutine_threadsafe(self._adapter._on_message(message), loop) + try: + future.result(timeout=60) + except Exception: + logger.exception("[DingTalk] Error processing incoming message") + + return dingtalk_stream.AckMessage.STATUS_OK, "OK" diff --git a/gateway/run.py b/gateway/run.py index 7856e6a03b..c0501e27af 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1132,6 +1132,13 @@ class GatewayRunner: return None return EmailAdapter(config) + elif platform == Platform.DINGTALK: + from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements + if not check_dingtalk_requirements(): + logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set") + return None + return DingTalkAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8f8d90c410..d0b260d7d5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -34,6 +34,7 @@ _EXTRA_ENV_KEYS = frozenset({ "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL", "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", + "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", }) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index d106d0c472..c15c069e5d 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -110,6 +110,7 @@ PLATFORMS = { "whatsapp": {"label": "📱 WhatsApp", "default_toolset": "hermes-whatsapp"}, "signal": {"label": "📡 Signal", "default_toolset": "hermes-signal"}, "email": {"label": "📧 Email", "default_toolset": "hermes-email"}, + "dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"}, } diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py new file mode 100644 index 0000000000..5c73253fbf --- /dev/null +++ b/tests/gateway/test_dingtalk.py @@ -0,0 +1,274 @@ +"""Tests for DingTalk platform adapter.""" +import asyncio +import json +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock + +import pytest + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Requirements check +# --------------------------------------------------------------------------- + + +class TestDingTalkRequirements: + + def test_returns_false_when_sdk_missing(self, monkeypatch): + with patch.dict("sys.modules", {"dingtalk_stream": None}): + monkeypatch.setattr( + "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False + ) + from gateway.platforms.dingtalk import check_dingtalk_requirements + assert check_dingtalk_requirements() is False + + def test_returns_false_when_env_vars_missing(self, monkeypatch): + monkeypatch.setattr( + "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True + ) + monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True) + monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False) + monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False) + from gateway.platforms.dingtalk import check_dingtalk_requirements + assert check_dingtalk_requirements() is False + + def test_returns_true_when_all_available(self, monkeypatch): + monkeypatch.setattr( + "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True + ) + monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True) + monkeypatch.setenv("DINGTALK_CLIENT_ID", "test-id") + monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test-secret") + from gateway.platforms.dingtalk import check_dingtalk_requirements + assert check_dingtalk_requirements() is True + + +# --------------------------------------------------------------------------- +# Adapter construction +# --------------------------------------------------------------------------- + + +class TestDingTalkAdapterInit: + + def test_reads_config_from_extra(self): + from gateway.platforms.dingtalk import DingTalkAdapter + config = PlatformConfig( + enabled=True, + extra={"client_id": "cfg-id", "client_secret": "cfg-secret"}, + ) + adapter = DingTalkAdapter(config) + assert adapter._client_id == "cfg-id" + assert adapter._client_secret == "cfg-secret" + assert adapter.name == "Dingtalk" # base class uses .title() + + def test_falls_back_to_env_vars(self, monkeypatch): + monkeypatch.setenv("DINGTALK_CLIENT_ID", "env-id") + monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env-secret") + from gateway.platforms.dingtalk import DingTalkAdapter + config = PlatformConfig(enabled=True) + adapter = DingTalkAdapter(config) + assert adapter._client_id == "env-id" + assert adapter._client_secret == "env-secret" + + +# --------------------------------------------------------------------------- +# Message text extraction +# --------------------------------------------------------------------------- + + +class TestExtractText: + + def test_extracts_dict_text(self): + from gateway.platforms.dingtalk import DingTalkAdapter + msg = MagicMock() + msg.text = {"content": " hello world "} + msg.rich_text = None + assert DingTalkAdapter._extract_text(msg) == "hello world" + + def test_extracts_string_text(self): + from gateway.platforms.dingtalk import DingTalkAdapter + msg = MagicMock() + msg.text = "plain text" + msg.rich_text = None + assert DingTalkAdapter._extract_text(msg) == "plain text" + + def test_falls_back_to_rich_text(self): + from gateway.platforms.dingtalk import DingTalkAdapter + msg = MagicMock() + msg.text = "" + msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}] + assert DingTalkAdapter._extract_text(msg) == "part1 part2" + + def test_returns_empty_for_no_content(self): + from gateway.platforms.dingtalk import DingTalkAdapter + msg = MagicMock() + msg.text = "" + msg.rich_text = None + assert DingTalkAdapter._extract_text(msg) == "" + + +# --------------------------------------------------------------------------- +# Deduplication +# --------------------------------------------------------------------------- + + +class TestDeduplication: + + def test_first_message_not_duplicate(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + assert adapter._is_duplicate("msg-1") is False + + def test_second_same_message_is_duplicate(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + adapter._is_duplicate("msg-1") + assert adapter._is_duplicate("msg-1") is True + + def test_different_messages_not_duplicate(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + adapter._is_duplicate("msg-1") + assert adapter._is_duplicate("msg-2") is False + + def test_cache_cleanup_on_overflow(self): + from gateway.platforms.dingtalk import DingTalkAdapter, DEDUP_MAX_SIZE + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + # Fill beyond max + for i in range(DEDUP_MAX_SIZE + 10): + adapter._is_duplicate(f"msg-{i}") + # Cache should have been pruned + assert len(adapter._seen_messages) <= DEDUP_MAX_SIZE + 10 + + +# --------------------------------------------------------------------------- +# Send +# --------------------------------------------------------------------------- + + +class TestSend: + + @pytest.mark.asyncio + async def test_send_posts_to_webhook(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + adapter._http_client = mock_client + + result = await adapter.send( + "chat-123", "Hello!", + metadata={"session_webhook": "https://dingtalk.example/webhook"} + ) + assert result.success is True + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "https://dingtalk.example/webhook" + payload = call_args[1]["json"] + assert payload["msgtype"] == "markdown" + assert payload["markdown"]["title"] == "Hermes" + assert payload["markdown"]["text"] == "Hello!" + + @pytest.mark.asyncio + async def test_send_fails_without_webhook(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + adapter._http_client = AsyncMock() + + result = await adapter.send("chat-123", "Hello!") + assert result.success is False + assert "session_webhook" in result.error + + @pytest.mark.asyncio + async def test_send_uses_cached_webhook(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + adapter._http_client = mock_client + adapter._session_webhooks["chat-123"] = "https://cached.example/webhook" + + result = await adapter.send("chat-123", "Hello!") + assert result.success is True + assert mock_client.post.call_args[0][0] == "https://cached.example/webhook" + + @pytest.mark.asyncio + async def test_send_handles_http_error(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + adapter._http_client = mock_client + + result = await adapter.send( + "chat-123", "Hello!", + metadata={"session_webhook": "https://example/webhook"} + ) + assert result.success is False + assert "400" in result.error + + +# --------------------------------------------------------------------------- +# Connect / disconnect +# --------------------------------------------------------------------------- + + +class TestConnect: + + @pytest.mark.asyncio + async def test_connect_fails_without_sdk(self, monkeypatch): + monkeypatch.setattr( + "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False + ) + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + result = await adapter.connect() + assert result is False + + @pytest.mark.asyncio + async def test_connect_fails_without_credentials(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + adapter._client_id = "" + adapter._client_secret = "" + result = await adapter.connect() + assert result is False + + @pytest.mark.asyncio + async def test_disconnect_cleans_up(self): + from gateway.platforms.dingtalk import DingTalkAdapter + adapter = DingTalkAdapter(PlatformConfig(enabled=True)) + adapter._session_webhooks["a"] = "http://x" + adapter._seen_messages["b"] = 1.0 + adapter._http_client = AsyncMock() + adapter._stream_task = None + + await adapter.disconnect() + assert len(adapter._session_webhooks) == 0 + assert len(adapter._seen_messages) == 0 + assert adapter._http_client is None + + +# --------------------------------------------------------------------------- +# Platform enum +# --------------------------------------------------------------------------- + + +class TestPlatformEnum: + + def test_dingtalk_in_platform_enum(self): + assert Platform.DINGTALK.value == "dingtalk" From d132a3dfbb4e8982b0c209fe7a8b98cabeda55f9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:06:53 -0700 Subject: [PATCH 6/8] feat(skills): add inference.sh skill (terminal-based, no custom tools) (#1686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inference.sh as a built-in skill that uses the terminal tool to run infsh CLI commands. No custom tools or tool registration — the skill teaches the agent how to use the infsh binary via terminal. Covers 150+ AI apps: image gen (FLUX, Reve, Seedream), video (Veo, Wan, Seedance), LLMs, search (Tavily, Exa), 3D, avatars, and more. Includes reference docs for authentication, app discovery, running apps, and CLI command reference. Based on PR #1021 by @okaris, reworked as a skill-only integration. Co-authored-by: okaris --- skills/inference-sh/DESCRIPTION.md | 19 ++ skills/inference-sh/cli/SKILL.md | 155 ++++++++++++++++ .../cli/references/app-discovery.md | 112 ++++++++++++ .../cli/references/authentication.md | 59 ++++++ .../cli/references/cli-reference.md | 104 +++++++++++ .../cli/references/running-apps.md | 171 ++++++++++++++++++ 6 files changed, 620 insertions(+) create mode 100644 skills/inference-sh/DESCRIPTION.md create mode 100644 skills/inference-sh/cli/SKILL.md create mode 100644 skills/inference-sh/cli/references/app-discovery.md create mode 100644 skills/inference-sh/cli/references/authentication.md create mode 100644 skills/inference-sh/cli/references/cli-reference.md create mode 100644 skills/inference-sh/cli/references/running-apps.md diff --git a/skills/inference-sh/DESCRIPTION.md b/skills/inference-sh/DESCRIPTION.md new file mode 100644 index 0000000000..011ede4c16 --- /dev/null +++ b/skills/inference-sh/DESCRIPTION.md @@ -0,0 +1,19 @@ +# inference.sh + +Run 150+ AI applications in the cloud via the [inference.sh](https://inference.sh) platform. + +**One API key for everything** — access image generation, video creation, LLMs, search, 3D, and more through a single account. No need to manage separate API keys for each provider. + +## Available Skills + +- **cli**: Use the inference.sh CLI (`infsh`) via the terminal tool + +## What's Included + +- **Image Generation**: FLUX, Reve, Seedream, Grok Imagine, Gemini +- **Video Generation**: Veo, Wan, Seedance, OmniHuman, HunyuanVideo +- **LLMs**: Claude, Gemini, Kimi, GLM-4 (via OpenRouter) +- **Search**: Tavily, Exa +- **3D**: Rodin +- **Social**: Twitter/X automation +- **Audio**: TTS, voice cloning diff --git a/skills/inference-sh/cli/SKILL.md b/skills/inference-sh/cli/SKILL.md new file mode 100644 index 0000000000..79183f61c2 --- /dev/null +++ b/skills/inference-sh/cli/SKILL.md @@ -0,0 +1,155 @@ +--- +name: inference-sh-cli +description: "Run 150+ AI apps via inference.sh CLI (infsh) — image generation, video creation, LLMs, search, 3D, social automation. Uses the terminal tool. Triggers: inference.sh, infsh, ai apps, flux, veo, image generation, video generation, seedream, seedance, tavily" +version: 1.0.0 +author: okaris +license: MIT +metadata: + hermes: + tags: [AI, image-generation, video, LLM, search, inference, FLUX, Veo, Claude] + related_skills: [] +--- + +# inference.sh CLI + +Run 150+ AI apps in the cloud with a simple CLI. No GPU required. + +All commands use the **terminal tool** to run `infsh` commands. + +## When to Use + +- User asks to generate images (FLUX, Reve, Seedream, Grok, Gemini image) +- User asks to generate video (Veo, Wan, Seedance, OmniHuman) +- User asks about inference.sh or infsh +- User wants to run AI apps without managing individual provider APIs +- User asks for AI-powered search (Tavily, Exa) +- User needs avatar/lipsync generation + +## Prerequisites + +The `infsh` CLI must be installed and authenticated. Check with: + +```bash +infsh me +``` + +If not installed: + +```bash +curl -fsSL https://cli.inference.sh | sh +infsh login +``` + +See `references/authentication.md` for full setup details. + +## Workflow + +### 1. Always Search First + +Never guess app names — always search to find the correct app ID: + +```bash +infsh app list --search flux +infsh app list --search video +infsh app list --search image +``` + +### 2. Run an App + +Use the exact app ID from the search results. Always use `--json` for machine-readable output: + +```bash +infsh app run --input '{"prompt": "your prompt here"}' --json +``` + +### 3. Parse the Output + +The JSON output contains URLs to generated media. Present these to the user with `MEDIA:` for inline display. + +## Common Commands + +### Image Generation + +```bash +# Search for image apps +infsh app list --search image + +# FLUX Dev with LoRA +infsh app run falai/flux-dev-lora --input '{"prompt": "sunset over mountains", "num_images": 1}' --json + +# Gemini image generation +infsh app run google/gemini-2-5-flash-image --input '{"prompt": "futuristic city", "num_images": 1}' --json + +# Seedream (ByteDance) +infsh app run bytedance/seedream-5-lite --input '{"prompt": "nature scene"}' --json + +# Grok Imagine (xAI) +infsh app run xai/grok-imagine-image --input '{"prompt": "abstract art"}' --json +``` + +### Video Generation + +```bash +# Search for video apps +infsh app list --search video + +# Veo 3.1 (Google) +infsh app run google/veo-3-1-fast --input '{"prompt": "drone shot of coastline"}' --json + +# Seedance (ByteDance) +infsh app run bytedance/seedance-1-5-pro --input '{"prompt": "dancing figure", "resolution": "1080p"}' --json + +# Wan 2.5 +infsh app run falai/wan-2-5 --input '{"prompt": "person walking through city"}' --json +``` + +### Local File Uploads + +The CLI automatically uploads local files when you provide a path: + +```bash +# Upscale a local image +infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' --json + +# Image-to-video from local file +infsh app run falai/wan-2-5-i2v --input '{"image": "/path/to/image.png", "prompt": "make it move"}' --json + +# Avatar with audio +infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/audio.mp3", "image": "/path/to/face.jpg"}' --json +``` + +### Search & Research + +```bash +infsh app list --search search +infsh app run tavily/tavily-search --input '{"query": "latest AI news"}' --json +infsh app run exa/exa-search --input '{"query": "machine learning papers"}' --json +``` + +### Other Categories + +```bash +# 3D generation +infsh app list --search 3d + +# Audio / TTS +infsh app list --search tts + +# Twitter/X automation +infsh app list --search twitter +``` + +## Pitfalls + +1. **Never guess app IDs** — always run `infsh app list --search ` first. App IDs change and new apps are added frequently. +2. **Always use `--json`** — raw output is hard to parse. The `--json` flag gives structured output with URLs. +3. **Check authentication** — if commands fail with auth errors, run `infsh login` or verify `INFSH_API_KEY` is set. +4. **Long-running apps** — video generation can take 30-120 seconds. The terminal tool timeout should be sufficient, but warn the user it may take a moment. +5. **Input format** — the `--input` flag takes a JSON string. Make sure to properly escape quotes. + +## Reference Docs + +- `references/authentication.md` — Setup, login, API keys +- `references/app-discovery.md` — Searching and browsing the app catalog +- `references/running-apps.md` — Running apps, input formats, output handling +- `references/cli-reference.md` — Complete CLI command reference diff --git a/skills/inference-sh/cli/references/app-discovery.md b/skills/inference-sh/cli/references/app-discovery.md new file mode 100644 index 0000000000..adcac8c5dc --- /dev/null +++ b/skills/inference-sh/cli/references/app-discovery.md @@ -0,0 +1,112 @@ +# Discovering Apps + +## List All Apps + +```bash +infsh app list +``` + +## Pagination + +```bash +infsh app list --page 2 +``` + +## Filter by Category + +```bash +infsh app list --category image +infsh app list --category video +infsh app list --category audio +infsh app list --category text +infsh app list --category other +``` + +## Search + +```bash +infsh app search "flux" +infsh app search "video generation" +infsh app search "tts" -l +infsh app search "image" --category image +``` + +Or use the flag form: + +```bash +infsh app list --search "flux" +infsh app list --search "video generation" +infsh app list --search "tts" +``` + +## Featured Apps + +```bash +infsh app list --featured +``` + +## Newest First + +```bash +infsh app list --new +``` + +## Detailed View + +```bash +infsh app list -l +``` + +Shows table with app name, category, description, and featured status. + +## Save to File + +```bash +infsh app list --save apps.json +``` + +## Your Apps + +List apps you've deployed: + +```bash +infsh app my +infsh app my -l # detailed +``` + +## Get App Details + +```bash +infsh app get falai/flux-dev-lora +infsh app get falai/flux-dev-lora --json +``` + +Shows full app info including input/output schema. + +## Popular Apps by Category + +### Image Generation +- `falai/flux-dev-lora` - FLUX.2 Dev (high quality) +- `falai/flux-2-klein-lora` - FLUX.2 Klein (fastest) +- `infsh/sdxl` - Stable Diffusion XL +- `google/gemini-3-pro-image-preview` - Gemini 3 Pro +- `xai/grok-imagine-image` - Grok image generation + +### Video Generation +- `google/veo-3-1-fast` - Veo 3.1 Fast +- `google/veo-3` - Veo 3 +- `bytedance/seedance-1-5-pro` - Seedance 1.5 Pro +- `infsh/ltx-video-2` - LTX Video 2 (with audio) +- `bytedance/omnihuman-1-5` - OmniHuman avatar + +### Audio +- `infsh/dia-tts` - Conversational TTS +- `infsh/kokoro-tts` - Kokoro TTS +- `infsh/fast-whisper-large-v3` - Fast transcription +- `infsh/diffrythm` - Music generation + +## Documentation + +- [Browsing the Grid](https://inference.sh/docs/apps/browsing-grid) - Visual app browsing +- [Apps Overview](https://inference.sh/docs/apps/overview) - Understanding apps +- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps diff --git a/skills/inference-sh/cli/references/authentication.md b/skills/inference-sh/cli/references/authentication.md new file mode 100644 index 0000000000..3b6519d3de --- /dev/null +++ b/skills/inference-sh/cli/references/authentication.md @@ -0,0 +1,59 @@ +# Authentication & Setup + +## Install the CLI + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Login + +```bash +infsh login +``` + +This opens a browser for authentication. After login, credentials are stored locally. + +## Check Authentication + +```bash +infsh me +``` + +Shows your user info if authenticated. + +## Environment Variable + +For CI/CD or scripts, set your API key: + +```bash +export INFSH_API_KEY=your-api-key +``` + +The environment variable overrides the config file. + +## Update CLI + +```bash +infsh update +``` + +Or reinstall: + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "not authenticated" | Run `infsh login` | +| "command not found" | Reinstall CLI or add to PATH | +| "API key invalid" | Check `INFSH_API_KEY` or re-login | + +## Documentation + +- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide +- [API Authentication](https://inference.sh/docs/api/authentication) - API key management +- [Secrets](https://inference.sh/docs/secrets/overview) - Managing credentials diff --git a/skills/inference-sh/cli/references/cli-reference.md b/skills/inference-sh/cli/references/cli-reference.md new file mode 100644 index 0000000000..50825825f7 --- /dev/null +++ b/skills/inference-sh/cli/references/cli-reference.md @@ -0,0 +1,104 @@ +# CLI Reference + +## Installation + +```bash +curl -fsSL https://cli.inference.sh | sh +``` + +## Global Commands + +| Command | Description | +|---------|-------------| +| `infsh help` | Show help | +| `infsh version` | Show CLI version | +| `infsh update` | Update CLI to latest | +| `infsh login` | Authenticate | +| `infsh me` | Show current user | + +## App Commands + +### Discovery + +| Command | Description | +|---------|-------------| +| `infsh app list` | List available apps | +| `infsh app list --category ` | Filter by category (image, video, audio, text, other) | +| `infsh app search ` | Search apps | +| `infsh app list --search ` | Search apps (flag form) | +| `infsh app list --featured` | Show featured apps | +| `infsh app list --new` | Sort by newest | +| `infsh app list --page ` | Pagination | +| `infsh app list -l` | Detailed table view | +| `infsh app list --save ` | Save to JSON file | +| `infsh app my` | List your deployed apps | +| `infsh app get ` | Get app details | +| `infsh app get --json` | Get app details as JSON | + +### Execution + +| Command | Description | +|---------|-------------| +| `infsh app run --input ` | Run app with input file | +| `infsh app run --input ''` | Run with inline JSON | +| `infsh app run --input --no-wait` | Run without waiting for completion | +| `infsh app sample ` | Show sample input | +| `infsh app sample --save ` | Save sample to file | + +## Task Commands + +| Command | Description | +|---------|-------------| +| `infsh task get ` | Get task status and result | +| `infsh task get --json` | Get task as JSON | +| `infsh task get --save ` | Save task result to file | + +### Development + +| Command | Description | +|---------|-------------| +| `infsh app init` | Create new app (interactive) | +| `infsh app init ` | Create new app with name | +| `infsh app test --input ` | Test app locally | +| `infsh app deploy` | Deploy app | +| `infsh app deploy --dry-run` | Validate without deploying | +| `infsh app pull ` | Pull app source | +| `infsh app pull --all` | Pull all your apps | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `INFSH_API_KEY` | API key (overrides config) | + +## Shell Completions + +```bash +# Bash +infsh completion bash > /etc/bash_completion.d/infsh + +# Zsh +infsh completion zsh > "${fpath[1]}/_infsh" + +# Fish +infsh completion fish > ~/.config/fish/completions/infsh.fish +``` + +## App Name Format + +Apps use the format `namespace/app-name`: + +- `falai/flux-dev-lora` - fal.ai's FLUX 2 Dev +- `google/veo-3` - Google's Veo 3 +- `infsh/sdxl` - inference.sh's SDXL +- `bytedance/seedance-1-5-pro` - ByteDance's Seedance +- `xai/grok-imagine-image` - xAI's Grok + +Version pinning: `namespace/app-name@version` + +## Documentation + +- [CLI Setup](https://inference.sh/docs/extend/cli-setup) - Complete CLI installation guide +- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps via CLI +- [Creating an App](https://inference.sh/docs/extend/creating-app) - Build your own apps +- [Deploying](https://inference.sh/docs/extend/deploying) - Deploy apps to the cloud diff --git a/skills/inference-sh/cli/references/running-apps.md b/skills/inference-sh/cli/references/running-apps.md new file mode 100644 index 0000000000..e930d5cfbc --- /dev/null +++ b/skills/inference-sh/cli/references/running-apps.md @@ -0,0 +1,171 @@ +# Running Apps + +## Basic Run + +```bash +infsh app run user/app-name --input input.json +``` + +## Inline JSON + +```bash +infsh app run falai/flux-dev-lora --input '{"prompt": "a sunset over mountains"}' +``` + +## Version Pinning + +```bash +infsh app run user/app-name@1.0.0 --input input.json +``` + +## Local File Uploads + +The CLI automatically uploads local files when you provide a file path instead of a URL. Any field that accepts a URL also accepts a local path: + +```bash +# Upscale a local image +infsh app run falai/topaz-image-upscaler --input '{"image": "/path/to/photo.jpg", "upscale_factor": 2}' + +# Image-to-video from local file +infsh app run falai/wan-2-5-i2v --input '{"image": "./my-image.png", "prompt": "make it move"}' + +# Avatar with local audio and image +infsh app run bytedance/omnihuman-1-5 --input '{"audio": "/path/to/speech.mp3", "image": "/path/to/face.jpg"}' + +# Post tweet with local media +infsh app run x/post-create --input '{"text": "Check this out!", "media": "./screenshot.png"}' +``` + +Supported paths: +- Absolute paths: `/home/user/images/photo.jpg` +- Relative paths: `./image.png`, `../data/video.mp4` +- Home directory: `~/Pictures/photo.jpg` + +## Generate Sample Input + +Before running, generate a sample input file: + +```bash +infsh app sample falai/flux-dev-lora +``` + +Save to file: + +```bash +infsh app sample falai/flux-dev-lora --save input.json +``` + +Then edit `input.json` and run: + +```bash +infsh app run falai/flux-dev-lora --input input.json +``` + +## Workflow Example + +### Image Generation with FLUX + +```bash +# 1. Get app details +infsh app get falai/flux-dev-lora + +# 2. Generate sample input +infsh app sample falai/flux-dev-lora --save input.json + +# 3. Edit input.json +# { +# "prompt": "a cat astronaut floating in space", +# "num_images": 1, +# "image_size": "landscape_16_9" +# } + +# 4. Run +infsh app run falai/flux-dev-lora --input input.json +``` + +### Video Generation with Veo + +```bash +# 1. Generate sample +infsh app sample google/veo-3-1-fast --save input.json + +# 2. Edit prompt +# { +# "prompt": "A drone shot flying over a forest at sunset" +# } + +# 3. Run +infsh app run google/veo-3-1-fast --input input.json +``` + +### Text-to-Speech + +```bash +# Quick inline run +infsh app run falai/kokoro-tts --input '{"text": "Hello, this is a test."}' +``` + +## Task Tracking + +When you run an app, the CLI shows the task ID: + +``` +Running falai/flux-dev-lora +Task ID: abc123def456 +``` + +For long-running tasks, you can check status anytime: + +```bash +# Check task status +infsh task get abc123def456 + +# Get result as JSON +infsh task get abc123def456 --json + +# Save result to file +infsh task get abc123def456 --save result.json +``` + +### Run Without Waiting + +For very long tasks, run in background: + +```bash +# Submit and return immediately +infsh app run google/veo-3 --input input.json --no-wait + +# Check later +infsh task get +``` + +## Output + +The CLI returns the app output directly. For file outputs (images, videos, audio), you'll receive URLs to download. + +Example output: + +```json +{ + "images": [ + { + "url": "https://cloud.inference.sh/...", + "content_type": "image/png" + } + ] +} +``` + +## Error Handling + +| Error | Cause | Solution | +|-------|-------|----------| +| "invalid input" | Schema mismatch | Check `infsh app get` for required fields | +| "app not found" | Wrong app name | Check `infsh app list --search` | +| "quota exceeded" | Out of credits | Check account balance | + +## Documentation + +- [Running Apps](https://inference.sh/docs/apps/running) - Complete running apps guide +- [Streaming Results](https://inference.sh/docs/api/sdk/streaming) - Real-time progress updates +- [Setup Parameters](https://inference.sh/docs/apps/setup-parameters) - Configuring app inputs From 6fc76ef954a7f10d1464c1efa73e7d48f754fb8b Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 17 Mar 2026 03:11:21 -0700 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20harden=20website=20blocklist=20?= =?UTF-8?q?=E2=80=94=20default=20off,=20TTL=20cache,=20fail-open,=20guarde?= =?UTF-8?q?d=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default enabled: false (zero overhead when not configured) - Fast path: cached disabled state skips all work immediately - TTL cache (30s) for parsed policy — avoids re-reading config.yaml on every URL check - Missing shared files warn + skip instead of crashing all web tools - Lazy yaml import — missing PyYAML doesn't break browser toolset - Guarded browser_tool import — fail-open lambda fallback - check_website_access never raises for default path (fail-open with warning log); only raises with explicit config_path (test mode) - Simplified enforcement code in web_tools/browser_tool — no more try/except wrappers since errors are handled internally --- hermes_cli/config.py | 2 +- tests/tools/test_website_policy.py | 44 ++++++------ tools/browser_tool.py | 12 ++-- tools/web_tools.py | 23 ++---- tools/website_policy.py | 108 ++++++++++++++++++++++++++--- 5 files changed, 136 insertions(+), 53 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d0b260d7d5..a72c0ff638 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -356,7 +356,7 @@ DEFAULT_CONFIG = { "tirith_timeout": 5, "tirith_fail_open": True, "website_blocklist": { - "enabled": True, + "enabled": False, "domains": [], "shared_files": [], }, diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index e66ce9d8c4..13f7aedd6a 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -89,7 +89,7 @@ def test_default_config_exposes_website_blocklist_shape(): from hermes_cli.config import DEFAULT_CONFIG website_blocklist = DEFAULT_CONFIG["security"]["website_blocklist"] - assert website_blocklist["enabled"] is True + assert website_blocklist["enabled"] is False assert website_blocklist["domains"] == [] assert website_blocklist["shared_files"] == [] @@ -100,7 +100,7 @@ def test_load_website_blocklist_uses_enabled_default_when_section_missing(tmp_pa policy = load_website_blocklist(config_path) - assert policy == {"enabled": True, "rules": []} + assert policy == {"enabled": False, "rules": []} def test_load_website_blocklist_raises_clean_error_for_invalid_domains_type(tmp_path): @@ -232,8 +232,11 @@ def test_load_website_blocklist_wraps_shared_file_read_errors(tmp_path, monkeypa monkeypatch.setattr(Path, "read_text", failing_read_text) - with pytest.raises(WebsitePolicyError, match="Failed to read shared blocklist file"): - load_website_blocklist(config_path) + # Unreadable shared files are now warned and skipped (not raised), + # so the blocklist loads successfully but without those rules. + result = load_website_blocklist(config_path) + assert result["enabled"] is True + assert result["rules"] == [] # shared file rules skipped def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path): @@ -311,7 +314,8 @@ def test_browser_navigate_returns_policy_block(monkeypatch): assert result["blocked_by_policy"]["rule"] == "blocked.test" -def test_browser_navigate_returns_clean_policy_error_for_missing_shared_file(monkeypatch, tmp_path): +def test_browser_navigate_allows_when_shared_file_missing(monkeypatch, tmp_path): + """Missing shared blocklist files are warned and skipped, not fatal.""" from tools import browser_tool config_path = tmp_path / "config.yaml" @@ -330,12 +334,9 @@ def test_browser_navigate_returns_clean_policy_error_for_missing_shared_file(mon encoding="utf-8", ) - monkeypatch.setattr(browser_tool, "check_website_access", lambda url: check_website_access(url, config_path=config_path)) - - result = json.loads(browser_tool.browser_navigate("https://allowed.test")) - - assert result["success"] is False - assert "Website policy error" in result["error"] + # check_website_access should return None (allow) — missing file is skipped + result = check_website_access("https://allowed.test", config_path=config_path) + assert result is None @pytest.mark.asyncio @@ -365,20 +366,23 @@ async def test_web_extract_short_circuits_blocked_url(monkeypatch): assert "Blocked by website policy" in result["results"][0]["error"] -@pytest.mark.asyncio -async def test_web_extract_returns_clean_policy_error_for_malformed_config(monkeypatch, tmp_path): - from tools import web_tools - +def test_check_website_access_fails_open_on_malformed_config(tmp_path, monkeypatch): + """Malformed config with default path should fail open (return None), not crash.""" config_path = tmp_path / "config.yaml" config_path.write_text("security: [oops\n", encoding="utf-8") - monkeypatch.setattr(web_tools, "check_website_access", lambda url: check_website_access(url, config_path=config_path)) - monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + # With explicit config_path (test mode), errors propagate + with pytest.raises(WebsitePolicyError): + check_website_access("https://example.com", config_path=config_path) - result = json.loads(await web_tools.web_extract_tool(["https://allowed.test"], use_llm_processing=False)) + # Simulate default path by pointing HERMES_HOME to tmp_path + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from tools import website_policy + website_policy.invalidate_cache() - assert result["results"][0]["url"] == "https://allowed.test" - assert "Website policy error" in result["results"][0]["error"] + # With default path, errors are caught and fail open + result = check_website_access("https://example.com") + assert result is None # allowed, not crashed @pytest.mark.asyncio diff --git a/tools/browser_tool.py b/tools/browser_tool.py index c127f685f0..c396c6e506 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -65,7 +65,11 @@ import requests from typing import Dict, Any, Optional, List from pathlib import Path from agent.auxiliary_client import call_llm -from tools.website_policy import check_website_access + +try: + from tools.website_policy import check_website_access +except Exception: + check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider @@ -903,12 +907,8 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: JSON string with navigation result (includes stealth features info on first nav) """ # Website policy check — block before navigating - try: - blocked = check_website_access(url) - except Exception as _policy_err: - return json.dumps({"success": False, "error": f"Website policy error: {_policy_err}"}) + blocked = check_website_access(url) if blocked: - logger.info("Blocked browser_navigate to %s by rule %s", blocked["host"], blocked["rule"]) return json.dumps({ "success": False, "error": blocked["message"], diff --git a/tools/web_tools.py b/tools/web_tools.py index c478f25f93..92c0ae6011 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -49,7 +49,7 @@ from typing import List, Dict, Any, Optional from firecrawl import Firecrawl from agent.auxiliary_client import async_call_llm from tools.debug_helpers import DebugSession -from tools.website_policy import WebsitePolicyError, check_website_access +from tools.website_policy import check_website_access logger = logging.getLogger(__name__) @@ -618,11 +618,7 @@ async def web_extract_tool( continue # Website policy check — block before fetching - try: - blocked = check_website_access(url) - except WebsitePolicyError as policy_err: - results.append({"url": url, "title": "", "content": "", "error": f"Website policy error: {policy_err}"}) - continue + blocked = check_website_access(url) if blocked: logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"]) results.append({ @@ -687,10 +683,7 @@ async def web_extract_tool( # Re-check final URL after redirect final_url = metadata.get("sourceURL", url) - try: - final_blocked = check_website_access(final_url) - except WebsitePolicyError: - final_blocked = None + final_blocked = check_website_access(final_url) if final_blocked: logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"]) results.append({ @@ -903,10 +896,7 @@ async def web_crawl_tool( logger.info("Crawling %s%s", url, instructions_text) # Website policy check — block before crawling - try: - blocked = check_website_access(url) - except WebsitePolicyError as policy_err: - return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": f"Website policy error: {policy_err}"}]}, ensure_ascii=False) + blocked = check_website_access(url) if blocked: logger.info("Blocked web_crawl for %s by rule %s", blocked["host"], blocked["rule"]) return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": blocked["message"], @@ -1018,10 +1008,7 @@ async def web_crawl_tool( title = metadata.get("title", "") # Re-check crawled page URL against policy - try: - page_blocked = check_website_access(page_url) - except WebsitePolicyError: - page_blocked = None + page_blocked = check_website_access(page_url) if page_blocked: logger.info("Blocked crawled page %s by rule %s", page_blocked["host"], page_blocked["rule"]) pages.append({ diff --git a/tools/website_policy.py b/tools/website_policy.py index 21e8dad762..2a3d2470fb 100644 --- a/tools/website_policy.py +++ b/tools/website_policy.py @@ -3,25 +3,38 @@ This module loads a user-managed website blocklist from ~/.hermes/config.yaml and optional shared list files. It is intentionally lightweight so web/browser tools can enforce URL policy without pulling in the heavier CLI config stack. + +Policy is cached in memory with a short TTL so config changes take effect +quickly without re-reading the file on every URL check. """ from __future__ import annotations import fnmatch +import logging import os +import threading +import time from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse -import yaml - +logger = logging.getLogger(__name__) _DEFAULT_WEBSITE_BLOCKLIST = { - "enabled": True, + "enabled": False, "domains": [], "shared_files": [], } +# Cache: parsed policy + timestamp. Avoids re-reading config.yaml on every +# URL check (a web_crawl with 50 pages would otherwise mean 51 YAML parses). +_CACHE_TTL_SECONDS = 30.0 +_cache_lock = threading.Lock() +_cached_policy: Optional[Dict[str, Any]] = None +_cached_policy_path: Optional[str] = None +_cached_policy_time: float = 0.0 + def _get_hermes_home() -> Path: return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) @@ -55,12 +68,19 @@ def _normalize_rule(rule: Any) -> Optional[str]: def _iter_blocklist_file_rules(path: Path) -> List[str]: + """Load rules from a shared blocklist file. + + Missing or unreadable files log a warning and return an empty list + rather than raising — a bad file path should not disable all web tools. + """ try: raw = path.read_text(encoding="utf-8") - except FileNotFoundError as exc: - raise WebsitePolicyError(f"Shared blocklist file not found: {path}") from exc + except FileNotFoundError: + logger.warning("Shared blocklist file not found (skipping): %s", path) + return [] except (OSError, UnicodeDecodeError) as exc: - raise WebsitePolicyError(f"Failed to read shared blocklist file {path}: {exc}") from exc + logger.warning("Failed to read shared blocklist file %s (skipping): %s", path, exc) + return [] rules: List[str] = [] for line in raw.splitlines(): @@ -77,6 +97,13 @@ def _load_policy_config(config_path: Optional[Path] = None) -> Dict[str, Any]: config_path = config_path or _get_default_config_path() if not config_path.exists(): return dict(_DEFAULT_WEBSITE_BLOCKLIST) + + try: + import yaml + except ImportError: + logger.debug("PyYAML not installed — website blocklist disabled") + return dict(_DEFAULT_WEBSITE_BLOCKLIST) + try: with open(config_path, encoding="utf-8") as f: config = yaml.safe_load(f) or {} @@ -105,6 +132,27 @@ def _load_policy_config(config_path: Optional[Path] = None) -> Dict[str, Any]: def load_website_blocklist(config_path: Optional[Path] = None) -> Dict[str, Any]: + """Load and return the parsed website blocklist policy. + + Results are cached for ``_CACHE_TTL_SECONDS`` to avoid re-reading + config.yaml on every URL check. Pass an explicit ``config_path`` + to bypass the cache (used by tests). + """ + global _cached_policy, _cached_policy_path, _cached_policy_time + + resolved_path = str(config_path) if config_path else "__default__" + now = time.monotonic() + + # Return cached policy if still fresh and same path + if config_path is None: + with _cache_lock: + if ( + _cached_policy is not None + and _cached_policy_path == resolved_path + and (now - _cached_policy_time) < _CACHE_TTL_SECONDS + ): + return _cached_policy + config_path = config_path or _get_default_config_path() policy = _load_policy_config(config_path) @@ -142,7 +190,23 @@ def load_website_blocklist(config_path: Optional[Path] = None) -> Dict[str, Any] rules.append({"pattern": normalized, "source": str(path)}) seen.add(key) - return {"enabled": enabled, "rules": rules} + result = {"enabled": enabled, "rules": rules} + + # Cache the result (only for the default path — explicit paths are tests) + if config_path == _get_default_config_path(): + with _cache_lock: + _cached_policy = result + _cached_policy_path = "__default__" + _cached_policy_time = now + + return result + + +def invalidate_cache() -> None: + """Force the next ``check_website_access`` call to re-read config.""" + global _cached_policy + with _cache_lock: + _cached_policy = None def _match_host_against_rule(host: str, pattern: str) -> bool: @@ -169,17 +233,45 @@ def _extract_host_from_urlish(url: str) -> str: def check_website_access(url: str, config_path: Optional[Path] = None) -> Optional[Dict[str, str]]: + """Check whether a URL is allowed by the website blocklist policy. + + Returns ``None`` if access is allowed, or a dict with block metadata + (``host``, ``rule``, ``source``, ``message``) if blocked. + + Never raises on policy errors — logs a warning and returns ``None`` + (fail-open) so a config typo doesn't break all web tools. Pass + ``config_path`` explicitly (tests) to get strict error propagation. + """ + # Fast path: if no explicit config_path and the cached policy is disabled + # or empty, skip all work (no YAML read, no host extraction). + if config_path is None: + with _cache_lock: + if _cached_policy is not None and not _cached_policy.get("enabled"): + return None + host = _extract_host_from_urlish(url) if not host: return None - policy = load_website_blocklist(config_path) + try: + policy = load_website_blocklist(config_path) + except WebsitePolicyError as exc: + if config_path is not None: + raise # Tests pass explicit paths — let errors propagate + logger.warning("Website policy config error (failing open): %s", exc) + return None + except Exception as exc: + logger.warning("Unexpected error loading website policy (failing open): %s", exc) + return None + if not policy.get("enabled"): return None for rule in policy.get("rules", []): pattern = rule.get("pattern", "") if _match_host_against_rule(host, pattern): + logger.info("Blocked URL %s — matched rule '%s' from %s", + url, pattern, rule.get("source", "config")) return { "url": url, "host": host, From 3d38d852876abd3ac46f8edc607dda903dcfa259 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:13:58 -0700 Subject: [PATCH 8/8] docs: add Alibaba Cloud and DingTalk to setup wizard and docs (#1687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): add DingTalk platform adapter Add DingTalk as a messaging platform using the dingtalk-stream SDK for real-time message reception via Stream Mode (no webhook needed). Replies are sent via session webhook using markdown format. Features: - Stream Mode connection (long-lived WebSocket, no public URL needed) - Text and rich text message support - DM and group chat support - Message deduplication with 5-minute window - Auto-reconnection with exponential backoff - Session webhook caching for reply routing Configuration: export DINGTALK_CLIENT_ID=your-app-key export DINGTALK_CLIENT_SECRET=your-app-secret # or in config.yaml: platforms: dingtalk: enabled: true extra: client_id: your-app-key client_secret: your-app-secret Files: - gateway/platforms/dingtalk.py (340 lines) — adapter implementation - gateway/config.py — add DINGTALK to Platform enum - gateway/run.py — add DingTalk to _create_adapter - hermes_cli/config.py — add env vars to _EXTRA_ENV_KEYS - hermes_cli/tools_config.py — add dingtalk to PLATFORMS - tests/gateway/test_dingtalk.py — 21 tests * docs: add Alibaba Cloud and DingTalk to setup wizard and docs Wire Alibaba Cloud (DashScope) into hermes setup and hermes model provider selection flows. Add DingTalk env vars to documentation. Changes: - setup.py: Add Alibaba Cloud as provider choice (index 11) with DASHSCOPE_API_KEY prompt and model studio link - main.py: Add alibaba to provider_labels, providers list, and model flow dispatch - environment-variables.md: Add DASHSCOPE_API_KEY, DINGTALK_CLIENT_ID, DINGTALK_CLIENT_SECRET, and alibaba to HERMES_INFERENCE_PROVIDER --- hermes_cli/main.py | 4 +- hermes_cli/setup.py | 39 +++++++++++++++++-- .../docs/reference/environment-variables.md | 5 ++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 32d90ac6a9..e6cb01a2ba 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -784,6 +784,7 @@ def cmd_model(args): "opencode-go": "OpenCode Go", "ai-gateway": "AI Gateway", "kilocode": "Kilo Code", + "alibaba": "Alibaba Cloud (DashScope)", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) @@ -807,6 +808,7 @@ def cmd_model(args): ("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), ("opencode-go", "OpenCode Go (open models, $10/month subscription)"), ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"), + ("alibaba", "Alibaba Cloud / DashScope (Qwen models, Anthropic-compatible)"), ] # Add user-defined custom providers from config.yaml @@ -875,7 +877,7 @@ def cmd_model(args): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) - elif selected_provider in ("zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway"): + elif selected_provider in ("zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba"): _model_flow_api_key_provider(config, selected_provider, current_model) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index a79844def5..b635377f53 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -738,6 +738,7 @@ def setup_model_provider(config: dict): "Kilo Code (Kilo Gateway API)", "Anthropic (Claude models — API key or Claude Code subscription)", "AI Gateway (Vercel — 200+ models, pay-per-use)", + "Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)", "OpenCode Zen (35+ curated models, pay-as-you-go)", "OpenCode Go (open models, $10/month subscription)", ] @@ -1313,7 +1314,39 @@ def setup_model_provider(config: dict): _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6") _set_model_provider(config, "ai-gateway", pconfig.inference_base_url) - elif provider_idx == 11: # OpenCode Zen + elif provider_idx == 11: # Alibaba Cloud / DashScope + selected_provider = "alibaba" + print() + print_header("Alibaba Cloud / DashScope API Key") + pconfig = PROVIDER_REGISTRY["alibaba"] + print_info(f"Provider: {pconfig.name}") + print_info("Get your API key at: https://modelstudio.console.alibabacloud.com/") + print() + + existing_key = get_env_value("DASHSCOPE_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + new_key = prompt(" DashScope API key", password=True) + if new_key: + save_env_value("DASHSCOPE_API_KEY", new_key) + print_success("DashScope API key updated") + else: + new_key = prompt(" DashScope API key", password=True) + if new_key: + save_env_value("DASHSCOPE_API_KEY", new_key) + print_success("DashScope API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("alibaba", pconfig.inference_base_url, default_model="qwen3.5-plus") + _set_model_provider(config, "alibaba", pconfig.inference_base_url) + + elif provider_idx == 12: # OpenCode Zen selected_provider = "opencode-zen" print() print_header("OpenCode Zen API Key") @@ -1346,7 +1379,7 @@ def setup_model_provider(config: dict): _set_model_provider(config, "opencode-zen", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - elif provider_idx == 12: # OpenCode Go + elif provider_idx == 13: # OpenCode Go selected_provider = "opencode-go" print() print_header("OpenCode Go API Key") @@ -1379,7 +1412,7 @@ def setup_model_provider(config: dict): _set_model_provider(config, "opencode-go", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - # else: provider_idx == 13 (Keep current) — only shown when a provider already exists + # else: provider_idx == 14 (Keep current) — only shown when a provider already exists # Normalize "keep current" to an explicit provider so downstream logic # doesn't fall back to the generic OpenRouter/static-model path. if selected_provider is None: diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 0b5afa4b86..ed4b0f907f 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -32,6 +32,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `KILOCODE_BASE_URL` | Override Kilo Code base URL (default: `https://api.kilo.ai/api/gateway`) | | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | +| `DASHSCOPE_API_KEY` | Alibaba Cloud DashScope API key for Qwen models via Anthropic-compatible API ([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) | | `CLAUDE_CODE_OAUTH_TOKEN` | Explicit Claude Code token override if you export one manually | | `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) | | `LLM_MODEL` | Default model name (fallback when not set in config.yaml) | @@ -46,7 +47,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode`, `alibaba` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | @@ -162,6 +163,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `EMAIL_HOME_ADDRESS_NAME` | Display name for the email home target | | `EMAIL_POLL_INTERVAL` | Email polling interval in seconds | | `EMAIL_ALLOW_ALL_USERS` | Allow all inbound email senders | +| `DINGTALK_CLIENT_ID` | DingTalk bot AppKey from developer portal ([open.dingtalk.com](https://open.dingtalk.com)) | +| `DINGTALK_CLIENT_SECRET` | DingTalk bot AppSecret from developer portal | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | | `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |