Compare commits
5 Commits
e52d300cf1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 127c1b9fdb | |||
| 744e730672 | |||
| 61a44d004f | |||
| 2b8e637298 | |||
| 721a3642f3 |
@@ -169,6 +169,7 @@ COPILOT_PLAN.md
|
|||||||
ARCHITECTURAL_DEBT_REFACTOR.md
|
ARCHITECTURAL_DEBT_REFACTOR.md
|
||||||
COPILOT_UI_FEATURES.md
|
COPILOT_UI_FEATURES.md
|
||||||
MULTI_USER_IMPLEMENTATION_STEPS.md
|
MULTI_USER_IMPLEMENTATION_STEPS.md
|
||||||
|
readme_coverage_analysis.md
|
||||||
|
|
||||||
#themes
|
#themes
|
||||||
nord.yml
|
nord.yml
|
||||||
|
|||||||
@@ -3,192 +3,275 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
# Connpy
|
# Connpy (v6.0.3)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
[](https://pypi.org/pypi/connpy/)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
[](https://pypi.org/pypi/connpy/)
|
||||||
|
[](https://pypi.org/pypi/connpy/)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://modelcontextprotocol.io)
|
||||||
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
|
||||||
|
|
||||||
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
|
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
|
||||||
|
|
||||||
The v6 release introduces the **AI Copilot**, an interactive terminal assistant that understands your network context and helps you manage your infrastructure more intelligently.
|
The v6 release introduces a comprehensive **AI Copilot** and **AI Playbook Engine**, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.
|
||||||
|
|
||||||
|
|
||||||
## 🤖 AI Copilot (New in v6)
|
---
|
||||||
The AI Copilot is deeply integrated into your terminal workflow:
|
|
||||||
- **Terminal Context Awareness**: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
|
## 1. 🤖 AI System
|
||||||
- **Dynamic Context Selection**: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
|
|
||||||
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
|
### 1a. Terminal Copilot (Ctrl+Space)
|
||||||
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
|
Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
|
||||||
- **Flexible Auth & Keyless AI**: Support for advanced LiteLLM credentials (`--engineer-auth` / `--architect-auth`) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
|
* **Context Modes**: Cycles through `LINES` (sends raw scroll buffer), `SINGLE` (captures exactly one command + output block), and `RANGE` (logical group of recent commands) using **`Ctrl+Up/Down`**.
|
||||||
- **Enhanced Session Management**: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
|
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
|
||||||
- **Semantic Prompt Integration**: Emit standard OSC prompt sequences (`\x1b]133;B`) for real-time remote/web front-end command tracking.
|
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
|
||||||
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
|
* `/trust` / `/untrust`: Configures auto-run behavior for suggested non-destructive commands.
|
||||||
|
* `/os [system]`: Manually overrides target OS parsing rules (e.g. `/os cisco_ios`).
|
||||||
|
* `/prompt [regex]`: Overrides command prompt detection bounds.
|
||||||
|
* `/clear`: Clear context history.
|
||||||
|
|
||||||
|
### 1b. AI Chat (conn ai)
|
||||||
|
Start a standalone persistent session with the AI Copilot. Manage sessions using `--list`, `--resume`, `--session <id>` (to restore a specific history), `--delete <id>`, or send a quick single-shot question directly from the terminal prompt:
|
||||||
|
```bash
|
||||||
|
conn ai "how do i check bgp summary on cisco?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1c. MCP Integration
|
||||||
|
Connect to external data sources and tools dynamically via the Model Context Protocol (MCP). Use the interactive wizard or command actions to configure MCP servers:
|
||||||
|
```bash
|
||||||
|
conn ai --mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Core Features
|
---
|
||||||
- **Multi-Protocol**: Native support for SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
|
|
||||||
- **Context Management**: Set regex-based contexts to manage specific nodes across different environments (work, home, clients).
|
## 2. ⚙️ Automation & Playbooks
|
||||||
- **Advanced Inventory**:
|
|
||||||
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
|
### 2a. Quick Run (conn run)
|
||||||
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
|
Run commands in parallel directly on target nodes or folder structures:
|
||||||
- Bulk creation, copying, moving, and export/import of nodes.
|
```bash
|
||||||
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
|
conn run router1 "show interface"
|
||||||
- Fuzzy search integration with `fzf`.
|
```
|
||||||
- Advanced tab completion.
|
|
||||||
- Syntax highlighting and customizable themes.
|
### 2b. YAML Playbook Engine
|
||||||
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
|
Execute complex structured automation playbooks defined in YAML configuration files. Supports multi-task execution, variables (using global, per-node, or regex matching definitions), timeouts, and variable parallel execution bounds.
|
||||||
- **Plugin System**: Build and execute custom Python scripts locally or on a remote gRPC server.
|
|
||||||
- **gRPC Architecture**: Fully decoupled Client/Server model for distributed management.
|
```yaml
|
||||||
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
|
# example_playbook.yaml
|
||||||
|
- name: Verify Network Operations
|
||||||
|
hosts: "@office"
|
||||||
|
parallel: true
|
||||||
|
tasks:
|
||||||
|
- name: Get interface brief
|
||||||
|
run: "show ip interface brief"
|
||||||
|
- name: Check OSPF state
|
||||||
|
run: "show ip ospf neighbor"
|
||||||
|
test: "FULL"
|
||||||
|
```
|
||||||
|
Execute using the playbooks runner:
|
||||||
|
```bash
|
||||||
|
conn run example_playbook.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2c. AI-Assisted Automation
|
||||||
|
Leverage AI to generate playbook templates (`--generate-ai`), simulate command changes before execution (`--preflight-ai`), or analyze consolidated execution logs post-run (`--analyze`). Use `--test "expected text1" "expected text2"` to specify assert-style output validations.
|
||||||
|
* *To generate an empty template:* `conn run --generate`
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
---
|
||||||
|
|
||||||
|
## 3. 📂 Inventory Management
|
||||||
|
|
||||||
|
### 3a. Nodes
|
||||||
|
Manage connections using standard commands: add (`conn --add node1`), edit (`conn --mod node1`), delete (`conn --del node1`), show configuration (`conn --show node1`), or connect (`conn node1`).
|
||||||
|
|
||||||
|
### 3b. Profiles
|
||||||
|
Define credentials and templates globally and reference them inside node fields using the `@profile_name` placeholder. Manage profiles interactively or via commands:
|
||||||
|
```bash
|
||||||
|
conn profile -a profile_name
|
||||||
|
# Or equivalently:
|
||||||
|
conn -a profile profile_name
|
||||||
|
```
|
||||||
|
During the interactive `conn --add` prompt, you can input `@profile_name` in the **username** or **password** fields to reference it.
|
||||||
|
|
||||||
|
### 3c. Folders, Move, Copy, List
|
||||||
|
Organize nodes into logical folder hierarchies (`@office`, `@datacenter@office`). Move items (`conn move [src] [dst]`), copy (`conn copy [src] [dst]`), or list items with custom filters and formatting:
|
||||||
|
```bash
|
||||||
|
conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3d. Bulk, Export, Import
|
||||||
|
Bulk import connections from formatted text files (`conn bulk -f nodes.txt`), or export/import connection folders using YAML configurations (`conn export @folder > backup.yaml` / `conn import backup.yaml`).
|
||||||
|
|
||||||
|
### 3e. Tags System
|
||||||
|
Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (`os`), prompt regex rules (`prompt`), and page length triggers (`screen_length_command`).
|
||||||
|
```yaml
|
||||||
|
# Custom tags dictionary (YANG / VSR context)
|
||||||
|
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🔌 Protocols & Connection Features
|
||||||
|
|
||||||
|
### 4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM
|
||||||
|
Connect to various architectures using native protocols:
|
||||||
|
* **SSH / Telnet**: Standard CLI protocols.
|
||||||
|
* **SFTP**: Transfer files securely (`conn --sftp node`).
|
||||||
|
* **Docker**: Connect directly to local container names (host set to container name/ID).
|
||||||
|
* **Kubernetes (kubectl)**: Connect to pods (namespace customizable via options).
|
||||||
|
* **AWS SSM**: Connect to EC2 instances using Instance IDs as hosts.
|
||||||
|
|
||||||
|
### 4b. Jumphosts
|
||||||
|
Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.
|
||||||
|
|
||||||
|
### 4c. Debug Mode, Keepalive, Logging
|
||||||
|
Track connection steps (`conn --debug node`), set idle keepalive intervals (`conn config --keepalive <seconds>`), or define dynamic output log files using variables like `${unique}`, `${host}`, `${port}`, `${user}`, `${protocol}`, or `${date 'format'}`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 🖥️ Remote Capture (conn capture - Core Plugin)
|
||||||
|
Perform remote packet capture (`tcpdump`) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:
|
||||||
|
```bash
|
||||||
|
conn capture router1 eth0 -w -f "port 80"
|
||||||
|
```
|
||||||
|
* **Requirements**: Local installation of Wireshark or `tshark` is required for live piping (`-w`).
|
||||||
|
* **Advanced flags**: Specify network namespaces (`--ns <name>`), custom filters (`-f <filter>`), or configure the Wireshark local path (`--set-wireshark-path`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🛡️ Context Filtering
|
||||||
|
Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:
|
||||||
|
```bash
|
||||||
|
conn context production -a --regex ".*-prod"
|
||||||
|
conn context production --set
|
||||||
|
```
|
||||||
|
* **Manage Contexts**: List defined filters (`conn context --ls`), show context details (`conn context production -s`), or delete contexts (`conn context production -r`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 🔌 Plugin System
|
||||||
|
Extend `connpy` features and hook into core execution events (pre/post hooks) by writing Python scripts. Add, update, delete, or list plugins locally, or execute them on remote instances:
|
||||||
|
```bash
|
||||||
|
conn plugin --add my_plugin script.py
|
||||||
|
conn plugin --update my_plugin script.py
|
||||||
|
conn plugin --remote --sync
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ⚙️ gRPC Client-Server Architecture
|
||||||
|
|
||||||
|
### 8a. Server (start/stop/restart/debug)
|
||||||
|
Execute tasks on a centralized remote host. Start gRPC server (`conn api -s 50051`), stop (`conn api -x`), restart (`conn api -r`), or debug in the foreground (`conn api -d`).
|
||||||
|
|
||||||
|
### 8b. Client Config
|
||||||
|
Shift the local CLI to communicate with a remote server instance:
|
||||||
|
```bash
|
||||||
|
conn config --service-mode remote
|
||||||
|
conn config --remote localhost:50051
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8c. User Management
|
||||||
|
Manage server-side user credentials for distributed setups:
|
||||||
|
```bash
|
||||||
|
conn user --add username
|
||||||
|
conn user --list
|
||||||
|
conn user --regen-password username
|
||||||
|
```
|
||||||
|
Use `--path` to specify custom configuration folders in server Mode B.
|
||||||
|
|
||||||
|
### 8d. SSO / OIDC
|
||||||
|
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
|
||||||
|
```bash
|
||||||
|
conn sso --add provider_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8e. Login / Logout
|
||||||
|
Authenticate client sessions (`conn login [username]`), check connection status (`conn login --status`), or close sessions (`conn logout`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. ⚡ Installation & Configuration
|
||||||
|
|
||||||
|
### 9a. pip install
|
||||||
```bash
|
```bash
|
||||||
pip install connpy
|
pip install connpy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run it in Windows/Linux using Docker
|
### 9b. Shell Completion + FZF
|
||||||
|
Install autocompletions and fuzzy-search wrappers into your shell profile:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/fluzzi/connpy
|
eval "$(conn config --completion bash)"
|
||||||
cd connpy
|
eval "$(conn config --fzf-wrapper bash)"
|
||||||
docker compose build
|
|
||||||
|
|
||||||
# Run it like a native app (completely silent)
|
|
||||||
docker compose run --rm --remove-orphans connpy-app [command]
|
|
||||||
|
|
||||||
# Pro Tip: Add this alias for a 100% native experience from any folder
|
|
||||||
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 9c. conn config options
|
||||||
|
View configuration details (`conn config`) or customize variables like case sensitivity (`--allow-uppercase`), FZF list picker (`--fzf true`), configurations directory (`--configfolder`), or persistent AI API keys and models (`--engineer-model`).
|
||||||
## 🔒 Privacy & Integration
|
|
||||||
|
|
||||||
### Privacy Policy
|
|
||||||
Connpy is committed to protecting your privacy:
|
|
||||||
- **Local Storage**: All server addresses, usernames, and passwords are encrypted and stored **only** on your machine. No data is transmitted to our servers.
|
|
||||||
- **Data Access**: Data is used solely for managing and automating your connections.
|
|
||||||
|
|
||||||
### Google Integration
|
|
||||||
Used strictly for backup:
|
|
||||||
- **Backup**: Sync your encrypted configuration with your Google Drive account.
|
|
||||||
- **Scoped Access**: Connpy only accesses its own backup files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
|
### 9d. Theming
|
||||||
|
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
|
||||||
```bash
|
```bash
|
||||||
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
|
conn config --theme /path/to/theme.yaml
|
||||||
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic Examples:
|
|
||||||
```bash
|
|
||||||
# Add a folder and subfolder
|
|
||||||
conn --add @office
|
|
||||||
conn --add @datacenter@office
|
|
||||||
|
|
||||||
# Add a node with a profile
|
|
||||||
conn --add server1@datacenter@office --profile @myuser
|
|
||||||
|
|
||||||
# Connect to a node (fuzzy match)
|
|
||||||
conn server1
|
|
||||||
|
|
||||||
# Start the AI Copilot
|
|
||||||
conn ai
|
|
||||||
|
|
||||||
# Run a command on all nodes in a folder
|
|
||||||
conn run @office "uptime"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔌 Plugin System
|
## 10. 🔒 Privacy, Security & Synchronization (conn sync)
|
||||||
Connpy supports a robust plugin architecture where scripts can run transparently on a remote gRPC server.
|
Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (`conn sync --once`, `--list`, `--restore`) or schedule auto-sync. Segregate restores (`--nodes` / `--config`) or sync remote nodes with `--sync-remote`.
|
||||||
|
|
||||||
### Structure
|
|
||||||
Plugins must be Python files containing:
|
|
||||||
- **Class `Parser`**: Defines `argparse` arguments.
|
|
||||||
- **Class `Entrypoint`**: Execution logic.
|
|
||||||
- **Class `Preload`**: (Optional) Hooks and modifications to the core app.
|
|
||||||
|
|
||||||
See the [Plugin Requirements section](#plugin-requirements-for-connpy) for full technical details.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plugin Requirements for Connpy
|
## 11. 🐍 Python API
|
||||||
|
Embed connection and automation routines programmatically in Python:
|
||||||
|
|
||||||
### Remote Plugin Execution
|
|
||||||
When Connpy operates in remote mode, plugins are executed **transparently on the server**:
|
|
||||||
- The client automatically downloads the plugin source code (`Parser` class context) to generate the local `argparse` structure and provide autocompletion.
|
|
||||||
- The execution phase (`Entrypoint` class) is redirected via gRPC streams to execute in the server's memory.
|
|
||||||
- You can manage remote plugins using the `--remote` flag.
|
|
||||||
|
|
||||||
### General Structure
|
|
||||||
- The plugin script must define specific classes:
|
|
||||||
1. **Class `Parser`**: Handles `argparse.ArgumentParser` initialization.
|
|
||||||
2. **Class `Entrypoint`**: Main execution logic (receives `args`, `parser`, and `connapp`).
|
|
||||||
3. **Class `Preload`**: (Optional) For modifying core app behavior or registering hooks.
|
|
||||||
|
|
||||||
### Preload Modifications and Hooks
|
|
||||||
You can customize the behavior of core classes using hooks:
|
|
||||||
- **`modify(method)`**: Alter class instances (e.g., `connapp.config`, `connapp.ai`).
|
|
||||||
- **`register_pre_hook(method)`**: Logic to run before a method execution.
|
|
||||||
- **`register_post_hook(method)`**: Logic to run after a method execution.
|
|
||||||
|
|
||||||
### Command Completion Support
|
|
||||||
Plugins can provide intelligent tab completion:
|
|
||||||
1. **Tree-based Completion (Recommended)**: Define `_connpy_tree(info)` returning a navigation dictionary.
|
|
||||||
2. **Legacy Completion**: Define `_connpy_completion(wordsnumber, words, info)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ gRPC Service Architecture
|
|
||||||
Connpy can operate in a decoupled mode:
|
|
||||||
1. **Start the API (Server)**: `conn api -s 50051`
|
|
||||||
2. **Configure the Client**:
|
|
||||||
```bash
|
|
||||||
conn config --service-mode remote
|
|
||||||
conn config --remote-host localhost:50051
|
|
||||||
```
|
|
||||||
All inventory management and execution will now happen on the server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐍 Automation Module (API)
|
|
||||||
You can use `connpy` as a Python library for your own scripts.
|
|
||||||
|
|
||||||
### Basic Execution
|
|
||||||
```python
|
```python
|
||||||
import connpy
|
import connpy
|
||||||
router = connpy.node("uniqueName", "1.1.1.1", user="admin")
|
|
||||||
|
# 1. Direct single node interaction
|
||||||
|
router = connpy.node("router1", "1.1.1.1", user="admin")
|
||||||
router.run(["show ip int brief"])
|
router.run(["show ip int brief"])
|
||||||
print(router.output)
|
print(router.output)
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel Tasks with Variables
|
# 2. Parallel nodes execution with variables
|
||||||
```python
|
|
||||||
import connpy
|
|
||||||
config = connpy.configfile()
|
config = connpy.configfile()
|
||||||
nodes = config.getitem("@office", ["router1", "router2"])
|
nodes_info = config.getitem("@office", ["router1", "router2"])
|
||||||
routers = connpy.nodes(nodes, config=config)
|
routers = connpy.nodes(nodes_info, config=config)
|
||||||
|
|
||||||
variables = {
|
variables = {
|
||||||
"router1@office": {"id": "1"},
|
"router1@office": {"id": "1"},
|
||||||
"__global__": {"mask": "255.255.255.0"}
|
"__global__": {"mask": "255.255.255.0"}
|
||||||
}
|
}
|
||||||
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
||||||
```
|
|
||||||
|
|
||||||
### AI Programmatic Use
|
# 3. AI Copilot prompts
|
||||||
```python
|
|
||||||
import connpy
|
|
||||||
myai = connpy.ai(connpy.configfile())
|
myai = connpy.ai(connpy.configfile())
|
||||||
response = myai.ask("What is the status of the BGP neighbors in the office?")
|
response = myai.ask("Show BGP status.")
|
||||||
|
print(response)
|
||||||
```
|
```
|
||||||
|
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*For detailed developer notes and plugin hooks documentation, see the [Documentation](https://fluzzi.github.io/connpy/).*
|
|
||||||
|
## 12. 🐳 Docker Deployment
|
||||||
|
Run `connpy` containerized and silent:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm connpy-app [command]
|
||||||
|
```
|
||||||
|
Add `alias conn='docker compose run --rm connpy-app'` to your shell for a transparent container experience.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 📜 License
|
||||||
|
[PolyForm Noncommercial 1.0.0](LICENSE)
|
||||||
|
|||||||
+224
-128
@@ -5,182 +5,278 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
# Connpy
|
# Connpy (v6.0.3)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
[](https://pypi.org/pypi/connpy/)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
[](https://pypi.org/pypi/connpy/)
|
||||||
|
[](https://pypi.org/pypi/connpy/)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://github.com/fluzzi/connpy)
|
||||||
|
[](https://modelcontextprotocol.io)
|
||||||
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
||||||
[](https://pypi.org/pypi/connpy/)
|
|
||||||
|
|
||||||
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
|
**Connpy** is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for **SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM**.
|
||||||
|
|
||||||
The v6 release introduces the **AI Copilot**, an interactive terminal assistant that understands your network context and helps you manage your infrastructure more intelligently.
|
The v6 release introduces a comprehensive **AI Copilot** and **AI Playbook Engine**, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.
|
||||||
|
|
||||||
|
|
||||||
## 🤖 AI Copilot (New in v6)
|
---
|
||||||
The AI Copilot is deeply integrated into your terminal workflow:
|
|
||||||
- **Terminal Context Awareness**: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
|
## 1. 🤖 AI System
|
||||||
- **Dynamic Context Selection**: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
|
|
||||||
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
|
### 1a. Terminal Copilot (Ctrl+Space)
|
||||||
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
|
Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
|
||||||
- **Flexible Auth & Keyless AI**: Support for advanced LiteLLM credentials (`--engineer-auth` / `--architect-auth`) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
|
* **Context Modes**: Cycles through `LINES` (sends raw scroll buffer), `SINGLE` (captures exactly one command + output block), and `RANGE` (logical group of recent commands) using **`Ctrl+Up/Down`**.
|
||||||
- **Enhanced Session Management**: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
|
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
|
||||||
- **Semantic Prompt Integration**: Emit standard OSC prompt sequences (`\x1b]133;B`) for real-time remote/web front-end command tracking.
|
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
|
||||||
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
|
* `/trust` / `/untrust`: Configures auto-run behavior for suggested non-destructive commands.
|
||||||
|
* `/os [system]`: Manually overrides target OS parsing rules (e.g. `/os cisco_ios`).
|
||||||
|
* `/prompt [regex]`: Overrides command prompt detection bounds.
|
||||||
|
* `/clear`: Clear context history.
|
||||||
|
|
||||||
|
### 1b. AI Chat (conn ai)
|
||||||
|
Start a standalone persistent session with the AI Copilot. Manage sessions using `--list`, `--resume`, `--session <id>` (to restore a specific history), `--delete <id>`, or send a quick single-shot question directly from the terminal prompt:
|
||||||
|
```bash
|
||||||
|
conn ai "how do i check bgp summary on cisco?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1c. MCP Integration
|
||||||
|
Connect to external data sources and tools dynamically via the Model Context Protocol (MCP). Use the interactive wizard or command actions to configure MCP servers:
|
||||||
|
```bash
|
||||||
|
conn ai --mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Core Features
|
---
|
||||||
- **Multi-Protocol**: Native support for SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
|
|
||||||
- **Context Management**: Set regex-based contexts to manage specific nodes across different environments (work, home, clients).
|
## 2. ⚙️ Automation & Playbooks
|
||||||
- **Advanced Inventory**:
|
|
||||||
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
|
### 2a. Quick Run (conn run)
|
||||||
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
|
Run commands in parallel directly on target nodes or folder structures:
|
||||||
- Bulk creation, copying, moving, and export/import of nodes.
|
```bash
|
||||||
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
|
conn run router1 "show interface"
|
||||||
- Fuzzy search integration with `fzf`.
|
```
|
||||||
- Advanced tab completion.
|
|
||||||
- Syntax highlighting and customizable themes.
|
### 2b. YAML Playbook Engine
|
||||||
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
|
Execute complex structured automation playbooks defined in YAML configuration files. Supports multi-task execution, variables (using global, per-node, or regex matching definitions), timeouts, and variable parallel execution bounds.
|
||||||
- **Plugin System**: Build and execute custom Python scripts locally or on a remote gRPC server.
|
|
||||||
- **gRPC Architecture**: Fully decoupled Client/Server model for distributed management.
|
```yaml
|
||||||
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
|
# example_playbook.yaml
|
||||||
|
- name: Verify Network Operations
|
||||||
|
hosts: "@office"
|
||||||
|
parallel: true
|
||||||
|
tasks:
|
||||||
|
- name: Get interface brief
|
||||||
|
run: "show ip interface brief"
|
||||||
|
- name: Check OSPF state
|
||||||
|
run: "show ip ospf neighbor"
|
||||||
|
test: "FULL"
|
||||||
|
```
|
||||||
|
Execute using the playbooks runner:
|
||||||
|
```bash
|
||||||
|
conn run example_playbook.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2c. AI-Assisted Automation
|
||||||
|
Leverage AI to generate playbook templates (`--generate-ai`), simulate command changes before execution (`--preflight-ai`), or analyze consolidated execution logs post-run (`--analyze`). Use `--test "expected text1" "expected text2"` to specify assert-style output validations.
|
||||||
|
* *To generate an empty template:* `conn run --generate`
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
---
|
||||||
|
|
||||||
|
## 3. 📂 Inventory Management
|
||||||
|
|
||||||
|
### 3a. Nodes
|
||||||
|
Manage connections using standard commands: add (`conn --add node1`), edit (`conn --mod node1`), delete (`conn --del node1`), show configuration (`conn --show node1`), or connect (`conn node1`).
|
||||||
|
|
||||||
|
### 3b. Profiles
|
||||||
|
Define credentials and templates globally and reference them inside node fields using the `@profile_name` placeholder. Manage profiles interactively or via commands:
|
||||||
|
```bash
|
||||||
|
conn profile -a profile_name
|
||||||
|
# Or equivalently:
|
||||||
|
conn -a profile profile_name
|
||||||
|
```
|
||||||
|
During the interactive `conn --add` prompt, you can input `@profile_name` in the **username** or **password** fields to reference it.
|
||||||
|
|
||||||
|
### 3c. Folders, Move, Copy, List
|
||||||
|
Organize nodes into logical folder hierarchies (`@office`, `@datacenter@office`). Move items (`conn move [src] [dst]`), copy (`conn copy [src] [dst]`), or list items with custom filters and formatting:
|
||||||
|
```bash
|
||||||
|
conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3d. Bulk, Export, Import
|
||||||
|
Bulk import connections from formatted text files (`conn bulk -f nodes.txt`), or export/import connection folders using YAML configurations (`conn export @folder > backup.yaml` / `conn import backup.yaml`).
|
||||||
|
|
||||||
|
### 3e. Tags System
|
||||||
|
Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (`os`), prompt regex rules (`prompt`), and page length triggers (`screen_length_command`).
|
||||||
|
```yaml
|
||||||
|
# Custom tags dictionary (YANG / VSR context)
|
||||||
|
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🔌 Protocols & Connection Features
|
||||||
|
|
||||||
|
### 4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM
|
||||||
|
Connect to various architectures using native protocols:
|
||||||
|
* **SSH / Telnet**: Standard CLI protocols.
|
||||||
|
* **SFTP**: Transfer files securely (`conn --sftp node`).
|
||||||
|
* **Docker**: Connect directly to local container names (host set to container name/ID).
|
||||||
|
* **Kubernetes (kubectl)**: Connect to pods (namespace customizable via options).
|
||||||
|
* **AWS SSM**: Connect to EC2 instances using Instance IDs as hosts.
|
||||||
|
|
||||||
|
### 4b. Jumphosts
|
||||||
|
Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.
|
||||||
|
|
||||||
|
### 4c. Debug Mode, Keepalive, Logging
|
||||||
|
Track connection steps (`conn --debug node`), set idle keepalive intervals (`conn config --keepalive <seconds>`), or define dynamic output log files using variables like `${unique}`, `${host}`, `${port}`, `${user}`, `${protocol}`, or `${date 'format'}`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 🖥️ Remote Capture (conn capture - Core Plugin)
|
||||||
|
Perform remote packet capture (`tcpdump`) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:
|
||||||
|
```bash
|
||||||
|
conn capture router1 eth0 -w -f "port 80"
|
||||||
|
```
|
||||||
|
* **Requirements**: Local installation of Wireshark or `tshark` is required for live piping (`-w`).
|
||||||
|
* **Advanced flags**: Specify network namespaces (`--ns <name>`), custom filters (`-f <filter>`), or configure the Wireshark local path (`--set-wireshark-path`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🛡️ Context Filtering
|
||||||
|
Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:
|
||||||
|
```bash
|
||||||
|
conn context production -a --regex ".*-prod"
|
||||||
|
conn context production --set
|
||||||
|
```
|
||||||
|
* **Manage Contexts**: List defined filters (`conn context --ls`), show context details (`conn context production -s`), or delete contexts (`conn context production -r`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 🔌 Plugin System
|
||||||
|
Extend `connpy` features and hook into core execution events (pre/post hooks) by writing Python scripts. Add, update, delete, or list plugins locally, or execute them on remote instances:
|
||||||
|
```bash
|
||||||
|
conn plugin --add my_plugin script.py
|
||||||
|
conn plugin --update my_plugin script.py
|
||||||
|
conn plugin --remote --sync
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. ⚙️ gRPC Client-Server Architecture
|
||||||
|
|
||||||
|
### 8a. Server (start/stop/restart/debug)
|
||||||
|
Execute tasks on a centralized remote host. Start gRPC server (`conn api -s 50051`), stop (`conn api -x`), restart (`conn api -r`), or debug in the foreground (`conn api -d`).
|
||||||
|
|
||||||
|
### 8b. Client Config
|
||||||
|
Shift the local CLI to communicate with a remote server instance:
|
||||||
|
```bash
|
||||||
|
conn config --service-mode remote
|
||||||
|
conn config --remote localhost:50051
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8c. User Management
|
||||||
|
Manage server-side user credentials for distributed setups:
|
||||||
|
```bash
|
||||||
|
conn user --add username
|
||||||
|
conn user --list
|
||||||
|
conn user --regen-password username
|
||||||
|
```
|
||||||
|
Use `--path` to specify custom configuration folders in server Mode B.
|
||||||
|
|
||||||
|
### 8d. SSO / OIDC
|
||||||
|
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
|
||||||
|
```bash
|
||||||
|
conn sso --add provider_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8e. Login / Logout
|
||||||
|
Authenticate client sessions (`conn login [username]`), check connection status (`conn login --status`), or close sessions (`conn logout`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. ⚡ Installation & Configuration
|
||||||
|
|
||||||
|
### 9a. pip install
|
||||||
```bash
|
```bash
|
||||||
pip install connpy
|
pip install connpy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run it in Windows/Linux using Docker
|
### 9b. Shell Completion + FZF
|
||||||
|
Install autocompletions and fuzzy-search wrappers into your shell profile:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/fluzzi/connpy
|
eval "$(conn config --completion bash)"
|
||||||
cd connpy
|
eval "$(conn config --fzf-wrapper bash)"
|
||||||
docker compose build
|
|
||||||
|
|
||||||
# Run it like a native app (completely silent)
|
|
||||||
docker compose run --rm --remove-orphans connpy-app [command]
|
|
||||||
|
|
||||||
# Pro Tip: Add this alias for a 100% native experience from any folder
|
|
||||||
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 9c. conn config options
|
||||||
|
View configuration details (`conn config`) or customize variables like case sensitivity (`--allow-uppercase`), FZF list picker (`--fzf true`), configurations directory (`--configfolder`), or persistent AI API keys and models (`--engineer-model`).
|
||||||
## 🔒 Privacy & Integration
|
|
||||||
|
|
||||||
### Privacy Policy
|
|
||||||
Connpy is committed to protecting your privacy:
|
|
||||||
- **Local Storage**: All server addresses, usernames, and passwords are encrypted and stored **only** on your machine. No data is transmitted to our servers.
|
|
||||||
- **Data Access**: Data is used solely for managing and automating your connections.
|
|
||||||
|
|
||||||
### Google Integration
|
|
||||||
Used strictly for backup:
|
|
||||||
- **Backup**: Sync your encrypted configuration with your Google Drive account.
|
|
||||||
- **Scoped Access**: Connpy only accesses its own backup files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
|
### 9d. Theming
|
||||||
|
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
|
||||||
```bash
|
```bash
|
||||||
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
|
conn config --theme /path/to/theme.yaml
|
||||||
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic Examples:
|
|
||||||
```bash
|
|
||||||
# Add a folder and subfolder
|
|
||||||
conn --add @office
|
|
||||||
conn --add @datacenter@office
|
|
||||||
|
|
||||||
# Add a node with a profile
|
|
||||||
conn --add server1@datacenter@office --profile @myuser
|
|
||||||
|
|
||||||
# Connect to a node (fuzzy match)
|
|
||||||
conn server1
|
|
||||||
|
|
||||||
# Start the AI Copilot
|
|
||||||
conn ai
|
|
||||||
|
|
||||||
# Run a command on all nodes in a folder
|
|
||||||
conn run @office "uptime"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plugin Requirements for Connpy
|
## 10. 🔒 Privacy, Security & Synchronization (conn sync)
|
||||||
|
Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (`conn sync --once`, `--list`, `--restore`) or schedule auto-sync. Segregate restores (`--nodes` / `--config`) or sync remote nodes with `--sync-remote`.
|
||||||
|
|
||||||
### Remote Plugin Execution
|
|
||||||
When Connpy operates in remote mode, plugins are executed **transparently on the server**:
|
|
||||||
- The client automatically downloads the plugin source code (`Parser` class context) to generate the local `argparse` structure and provide autocompletion.
|
|
||||||
- The execution phase (`Entrypoint` class) is redirected via gRPC streams to execute in the server's memory.
|
|
||||||
- You can manage remote plugins using the `--remote` flag.
|
|
||||||
|
|
||||||
### General Structure
|
|
||||||
- The plugin script must define specific classes:
|
|
||||||
1. **Class `Parser`**: Handles `argparse.ArgumentParser` initialization.
|
|
||||||
2. **Class `Entrypoint`**: Main execution logic (receives `args`, `parser`, and `connapp`).
|
|
||||||
3. **Class `Preload`**: (Optional) For modifying core app behavior or registering hooks.
|
|
||||||
|
|
||||||
### Preload Modifications and Hooks
|
|
||||||
You can customize the behavior of core classes using hooks:
|
|
||||||
- **`modify(method)`**: Alter class instances (e.g., `connapp.config`, `connapp.ai`).
|
|
||||||
- **`register_pre_hook(method)`**: Logic to run before a method execution.
|
|
||||||
- **`register_post_hook(method)`**: Logic to run after a method execution.
|
|
||||||
|
|
||||||
### Command Completion Support
|
|
||||||
Plugins can provide intelligent tab completion:
|
|
||||||
1. **Tree-based Completion (Recommended)**: Define `_connpy_tree(info)` returning a navigation dictionary.
|
|
||||||
2. **Legacy Completion**: Define `_connpy_completion(wordsnumber, words, info)`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ gRPC Service Architecture
|
## 11. 🐍 Python API
|
||||||
Connpy can operate in a decoupled mode:
|
Embed connection and automation routines programmatically in Python:
|
||||||
1. **Start the API (Server)**: `conn api -s 50051`
|
|
||||||
2. **Configure the Client**:
|
|
||||||
```bash
|
|
||||||
conn config --service-mode remote
|
|
||||||
conn config --remote-host localhost:50051
|
|
||||||
```
|
|
||||||
All inventory management and execution will now happen on the server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐍 Automation Module (API)
|
|
||||||
You can use `connpy` as a Python library for your own scripts.
|
|
||||||
|
|
||||||
### Basic Execution
|
|
||||||
```python
|
```python
|
||||||
import connpy
|
import connpy
|
||||||
router = connpy.node("uniqueName", "1.1.1.1", user="admin")
|
|
||||||
|
# 1. Direct single node interaction
|
||||||
|
router = connpy.node("router1", "1.1.1.1", user="admin")
|
||||||
router.run(["show ip int brief"])
|
router.run(["show ip int brief"])
|
||||||
print(router.output)
|
print(router.output)
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel Tasks with Variables
|
# 2. Parallel nodes execution with variables
|
||||||
```python
|
|
||||||
import connpy
|
|
||||||
config = connpy.configfile()
|
config = connpy.configfile()
|
||||||
nodes = config.getitem("@office", ["router1", "router2"])
|
nodes_info = config.getitem("@office", ["router1", "router2"])
|
||||||
routers = connpy.nodes(nodes, config=config)
|
routers = connpy.nodes(nodes_info, config=config)
|
||||||
|
|
||||||
variables = {
|
variables = {
|
||||||
"router1@office": {"id": "1"},
|
"router1@office": {"id": "1"},
|
||||||
"__global__": {"mask": "255.255.255.0"}
|
"__global__": {"mask": "255.255.255.0"}
|
||||||
}
|
}
|
||||||
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
||||||
```
|
|
||||||
|
|
||||||
### AI Programmatic Use
|
# 3. AI Copilot prompts
|
||||||
```python
|
|
||||||
import connpy
|
|
||||||
myai = connpy.ai(connpy.configfile())
|
myai = connpy.ai(connpy.configfile())
|
||||||
response = myai.ask("What is the status of the BGP neighbors in the office?")
|
response = myai.ask("Show BGP status.")
|
||||||
|
print(response)
|
||||||
```
|
```
|
||||||
|
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*For detailed developer notes and plugin hooks documentation, see the [Documentation](https://fluzzi.github.io/connpy/).*
|
|
||||||
|
## 12. 🐳 Docker Deployment
|
||||||
|
Run `connpy` containerized and silent:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm connpy-app [command]
|
||||||
|
```
|
||||||
|
Add `alias conn='docker compose run --rm connpy-app'` for a transparent container experience.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 📜 License
|
||||||
|
[PolyForm Noncommercial 1.0.0](LICENSE)
|
||||||
'''
|
'''
|
||||||
from .core import node,nodes
|
from .core import node,nodes
|
||||||
from .configfile import configfile
|
from .configfile import configfile
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = "6.0.0b13"
|
__version__ = "6.0.3"
|
||||||
|
|||||||
+362
-41
@@ -17,7 +17,7 @@ def _init_litellm():
|
|||||||
global _litellm_initialized
|
global _litellm_initialized
|
||||||
if not _litellm_initialized:
|
if not _litellm_initialized:
|
||||||
import litellm
|
import litellm
|
||||||
# Silenciar feedback de litellm
|
# Silence litellm feedback
|
||||||
litellm.suppress_debug_info = True
|
litellm.suppress_debug_info = True
|
||||||
litellm.set_verbose = False
|
litellm.set_verbose = False
|
||||||
_litellm_initialized = True
|
_litellm_initialized = True
|
||||||
@@ -114,9 +114,10 @@ class ai:
|
|||||||
self.confirm_handler = confirm_handler or self._local_confirm_handler
|
self.confirm_handler = confirm_handler or self._local_confirm_handler
|
||||||
self.trusted_session = trust # Trust mode for the entire session
|
self.trusted_session = trust # Trust mode for the entire session
|
||||||
self.interrupted = False
|
self.interrupted = False
|
||||||
|
self.one_shot = kwargs.get("one_shot", False)
|
||||||
|
|
||||||
|
|
||||||
# 1. Cargar configuración genérica con herencia/merge global
|
# 1. Load generic configuration with global inheritance/merge
|
||||||
if hasattr(self.config, "get_effective_setting"):
|
if hasattr(self.config, "get_effective_setting"):
|
||||||
aiconfig = self.config.get_effective_setting("ai", {})
|
aiconfig = self.config.get_effective_setting("ai", {})
|
||||||
else:
|
else:
|
||||||
@@ -159,7 +160,7 @@ class ai:
|
|||||||
custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()]
|
custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()]
|
||||||
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
|
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
|
||||||
|
|
||||||
# Límites
|
# Limits
|
||||||
self.max_history = 30
|
self.max_history = 30
|
||||||
self.max_truncate = 50000
|
self.max_truncate = 50000
|
||||||
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
|
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
|
||||||
@@ -196,7 +197,7 @@ class ai:
|
|||||||
self.session_id = getattr(self.config, "session_id", None)
|
self.session_id = getattr(self.config, "session_id", None)
|
||||||
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
|
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
|
||||||
|
|
||||||
# Prompts base agnósticos
|
# Agnostic base prompts
|
||||||
architect_instructions = ""
|
architect_instructions = ""
|
||||||
if self.has_architect:
|
if self.has_architect:
|
||||||
architect_instructions = """
|
architect_instructions = """
|
||||||
@@ -285,10 +286,13 @@ class ai:
|
|||||||
@property
|
@property
|
||||||
def architect_system_prompt(self):
|
def architect_system_prompt(self):
|
||||||
"""Build architect system prompt with plugin extensions."""
|
"""Build architect system prompt with plugin extensions."""
|
||||||
|
prompt = self._architect_base_prompt
|
||||||
|
if getattr(self, "one_shot", False):
|
||||||
|
prompt += "\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer."
|
||||||
if self.architect_prompt_extensions:
|
if self.architect_prompt_extensions:
|
||||||
extensions = "\n".join(self.architect_prompt_extensions)
|
extensions = "\n".join(self.architect_prompt_extensions)
|
||||||
return self._architect_base_prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
return prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
||||||
return self._architect_base_prompt
|
return prompt
|
||||||
|
|
||||||
def register_ai_tool(self, tool_definition, handler, target="engineer", engineer_prompt=None, architect_prompt=None, status_formatter=None):
|
def register_ai_tool(self, tool_definition, handler, target="engineer", engineer_prompt=None, architect_prompt=None, status_formatter=None):
|
||||||
"""Register an external tool for the AI system.
|
"""Register an external tool for the AI system.
|
||||||
@@ -733,7 +737,7 @@ class ai:
|
|||||||
|
|
||||||
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
|
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
|
||||||
"""Internal loop where the Engineer executes technical tasks for the Architect."""
|
"""Internal loop where the Engineer executes technical tasks for the Architect."""
|
||||||
# Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
|
# Cache optimization for the Engineer (Only for direct Anthropic, Vertex has different rules)
|
||||||
if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
|
if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
|
||||||
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
||||||
else:
|
else:
|
||||||
@@ -765,13 +769,11 @@ class ai:
|
|||||||
if self.interrupted:
|
if self.interrupted:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
# Soft limit warning
|
if status and not chat_history:
|
||||||
if iteration == self.soft_limit_iterations and not soft_limit_warned:
|
status_text = f"[ai_status]Engineer: Analyzing mission... (step {iteration})"
|
||||||
self.console.print(f"[warning]⚠ Engineer has performed {iteration} steps. This is taking longer than expected.[/warning]")
|
if iteration >= self.soft_limit_iterations:
|
||||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary.[/warning]")
|
status_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
soft_limit_warned = True
|
status.update(status_text)
|
||||||
|
|
||||||
if status and not chat_history: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
safe_messages = self._sanitize_messages(messages)
|
safe_messages = self._sanitize_messages(messages)
|
||||||
@@ -794,19 +796,25 @@ class ai:
|
|||||||
for tc in resp_msg.tool_calls:
|
for tc in resp_msg.tool_calls:
|
||||||
fn, args = tc.function.name, json.loads(tc.function.arguments)
|
fn, args = tc.function.name, json.loads(tc.function.arguments)
|
||||||
|
|
||||||
# Notificación en tiempo real de la tarea técnica (Only if not in Architect loop)
|
# Real-time notification of the technical task (Only if not in Architect loop)
|
||||||
if status and not chat_history:
|
if status and not chat_history:
|
||||||
if fn == "list_nodes": status.update(f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}")
|
s_text = ""
|
||||||
|
if fn == "list_nodes": s_text = f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}"
|
||||||
elif fn == "run_commands":
|
elif fn == "run_commands":
|
||||||
cmds = args.get('commands', [])
|
cmds = args.get('commands', [])
|
||||||
cmd_str = cmds[0] if cmds else ""
|
cmd_str = cmds[0] if cmds else ""
|
||||||
status.update(f"[ai_status]Engineer: [CMD] {cmd_str}")
|
s_text = f"[ai_status]Engineer: [CMD] {cmd_str}"
|
||||||
elif fn == "get_node_info": status.update(f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}")
|
elif fn == "get_node_info": s_text = f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}"
|
||||||
elif fn.startswith("mcp_"):
|
elif fn.startswith("mcp_"):
|
||||||
server = fn.split("__")[0].replace("mcp_", "")
|
server = fn.split("__")[0].replace("mcp_", "")
|
||||||
tool = fn.split("__")[1] if "__" in fn else fn
|
tool = fn.split("__")[1] if "__" in fn else fn
|
||||||
status.update(f"[ai_status]Engineer: [MCP:{server}] {tool}")
|
s_text = f"[ai_status]Engineer: [MCP:{server}] {tool}"
|
||||||
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
|
elif fn in self.tool_status_formatters: s_text = self.tool_status_formatters[fn](args)
|
||||||
|
|
||||||
|
if s_text:
|
||||||
|
if iteration >= self.soft_limit_iterations:
|
||||||
|
s_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
|
status.update(s_text)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
||||||
@@ -876,6 +884,8 @@ class ai:
|
|||||||
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
|
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
|
||||||
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
|
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
|
||||||
]
|
]
|
||||||
|
if getattr(self, "one_shot", False):
|
||||||
|
base_tools = [t for t in base_tools if t["function"]["name"] not in ("delegate_to_engineer", "return_to_engineer")]
|
||||||
|
|
||||||
all_tools = base_tools + self.external_architect_tools
|
all_tools = base_tools + self.external_architect_tools
|
||||||
seen_names = set()
|
seen_names = set()
|
||||||
@@ -1011,11 +1021,18 @@ class ai:
|
|||||||
|
|
||||||
@MethodHook
|
@MethodHook
|
||||||
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
||||||
soft_limit_warned = False
|
|
||||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||||
|
|
||||||
|
def update_status(text):
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
if iteration >= self.soft_limit_iterations:
|
||||||
|
warning_suffix = " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
|
if warning_suffix not in text:
|
||||||
|
text += warning_suffix
|
||||||
|
status.update(text)
|
||||||
|
|
||||||
if chat_history is None: chat_history = []
|
if chat_history is None: chat_history = []
|
||||||
|
|
||||||
@@ -1034,7 +1051,7 @@ class ai:
|
|||||||
|
|
||||||
usage = {"input": 0, "output": 0, "total": 0}
|
usage = {"input": 0, "output": 0, "total": 0}
|
||||||
|
|
||||||
# 1. Selector de Rol inicial (Sticky Brain)
|
# 1. Initial Role Selector (Sticky Brain)
|
||||||
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
||||||
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
||||||
|
|
||||||
@@ -1043,7 +1060,7 @@ class ai:
|
|||||||
elif explicit_engineer:
|
elif explicit_engineer:
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
else:
|
else:
|
||||||
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
|
# Sticky Brain: Detect if the Architect was in control in recent history
|
||||||
is_architect_active = False
|
is_architect_active = False
|
||||||
for msg in reversed(chat_history[-5:]):
|
for msg in reversed(chat_history[-5:]):
|
||||||
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
||||||
@@ -1057,7 +1074,7 @@ class ai:
|
|||||||
if is_architect_active: break
|
if is_architect_active: break
|
||||||
current_brain = "architect" if is_architect_active else "engineer"
|
current_brain = "architect" if is_architect_active else "engineer"
|
||||||
|
|
||||||
# 2. Preparación de mensajes y limpieza
|
# 2. Message preparation and cleaning
|
||||||
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
||||||
@@ -1066,13 +1083,13 @@ class ai:
|
|||||||
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
||||||
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
||||||
|
|
||||||
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
|
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
|
||||||
if "claude" in model.lower() and "vertex" not in model.lower():
|
if "claude" in model.lower() and "vertex" not in model.lower():
|
||||||
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
||||||
else:
|
else:
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
# Interleaving de historial
|
# History interleaving
|
||||||
last_role = "system"
|
last_role = "system"
|
||||||
# Sanitize history if the current target model is not compatible with cache_control
|
# Sanitize history if the current target model is not compatible with cache_control
|
||||||
history_to_process = chat_history[-self.max_history:]
|
history_to_process = chat_history[-self.max_history:]
|
||||||
@@ -1092,7 +1109,7 @@ class ai:
|
|||||||
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
||||||
else: messages.append({"role": "user", "content": clean_input})
|
else: messages.append({"role": "user", "content": clean_input})
|
||||||
|
|
||||||
# 3. Bucle de ejecución
|
# 3. Execution loop
|
||||||
iteration = 0
|
iteration = 0
|
||||||
try:
|
try:
|
||||||
# Set up remote interrupt callback if bridge is provided
|
# Set up remote interrupt callback if bridge is provided
|
||||||
@@ -1106,18 +1123,14 @@ class ai:
|
|||||||
if self.interrupted:
|
if self.interrupted:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
# Soft limit warning
|
# Soft limit warning - handled inline within update_status
|
||||||
if iteration == self.soft_limit_iterations and not soft_limit_warned:
|
|
||||||
self.console.print(f"[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]")
|
|
||||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]")
|
|
||||||
soft_limit_warned = True
|
|
||||||
|
|
||||||
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
||||||
if status:
|
if status:
|
||||||
# Notify responder identity for web/remote clients
|
# Notify responder identity for web/remote clients
|
||||||
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
||||||
status.update(f"__RESPONDER__:{current_brain}")
|
status.update(f"__RESPONDER__:{current_brain}")
|
||||||
status.update(f"{label} is thinking... (step {iteration})")
|
update_status(f"{label} is thinking... (step {iteration})")
|
||||||
|
|
||||||
streamed_response = False
|
streamed_response = False
|
||||||
try:
|
try:
|
||||||
@@ -1132,7 +1145,7 @@ class ai:
|
|||||||
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if current_brain == "architect":
|
if current_brain == "architect":
|
||||||
if status: status.update("[unavailable]Architect unavailable! Falling back to Engineer...")
|
if status: update_status("[unavailable]Architect unavailable! Falling back to Engineer...")
|
||||||
# Preserve context when falling back - use clean_input directly
|
# Preserve context when falling back - use clean_input directly
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -1189,8 +1202,8 @@ class ai:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
if fn == "delegate_to_engineer": update_status(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
||||||
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
|
elif fn == "manage_memory_tool": update_status(f"[architect]Architect: [UPDATING MEMORY]")
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
||||||
@@ -1199,7 +1212,7 @@ class ai:
|
|||||||
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
||||||
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
||||||
elif fn == "consult_architect":
|
elif fn == "consult_architect":
|
||||||
if status: status.update("[architect]Engineer consulting Architect...")
|
if status: update_status("[architect]Engineer consulting Architect...")
|
||||||
try:
|
try:
|
||||||
# Consultation only - Engineer stays in control
|
# Consultation only - Engineer stays in control
|
||||||
claude_resp = completion(
|
claude_resp = completion(
|
||||||
@@ -1221,11 +1234,11 @@ class ai:
|
|||||||
try: status.start()
|
try: status.start()
|
||||||
except: pass
|
except: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status: status.update("[unavailable]Architect unavailable! Engineer continuing alone...")
|
if status: update_status("[unavailable]Architect unavailable! Engineer continuing alone...")
|
||||||
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
||||||
|
|
||||||
elif fn == "escalate_to_architect":
|
elif fn == "escalate_to_architect":
|
||||||
if status: status.update("[architect]Transferring control to Architect...")
|
if status: update_status("[architect]Transferring control to Architect...")
|
||||||
# Full escalation - Architect takes over
|
# Full escalation - Architect takes over
|
||||||
current_brain = "architect"
|
current_brain = "architect"
|
||||||
model = self.architect_model
|
model = self.architect_model
|
||||||
@@ -1247,7 +1260,7 @@ class ai:
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
elif fn == "return_to_engineer":
|
elif fn == "return_to_engineer":
|
||||||
if status: status.update("[engineer]Transferring control back to Engineer...")
|
if status: update_status("[engineer]Transferring control back to Engineer...")
|
||||||
# Architect returns control to Engineer
|
# Architect returns control to Engineer
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -1300,7 +1313,7 @@ class ai:
|
|||||||
messages.append(resp_msg.model_dump(exclude_none=True))
|
messages.append(resp_msg.model_dump(exclude_none=True))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status:
|
if status:
|
||||||
status.update(f"[error]Error fetching summary: {e}[/error]")
|
update_status(f"[error]Error fetching summary: {e}[/error]")
|
||||||
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
||||||
@@ -1617,3 +1630,311 @@ Node: {node_name}"""
|
|||||||
|
|
||||||
@MethodHook
|
@MethodHook
|
||||||
def confirm(self, user_input): return True
|
def confirm(self, user_input): return True
|
||||||
|
|
||||||
|
|
||||||
|
PLAYBOOK_BUILDER_SYSTEM_PROMPT = """
|
||||||
|
You are a Connpy Playbook Builder Agent, a specialist in creating structured Connpy automation playbooks in YAML format.
|
||||||
|
Your primary mission is to help the user build, refine, and validate playbooks.
|
||||||
|
|
||||||
|
You MUST follow the Connpy canonical playbook format strictly:
|
||||||
|
The playbook MUST always use the `tasks[]` array structure as the root key, where each task is sequential and independent.
|
||||||
|
|
||||||
|
Connpy YAML Playbook Canonical Schema:
|
||||||
|
---
|
||||||
|
tasks:
|
||||||
|
- name: "Task Description"
|
||||||
|
action: 'run' # Can be 'run' or 'test'. Mandatory.
|
||||||
|
nodes: # List of nodes filter or regular expressions to work on. Mandatory. Can be a string or array of strings. Supports regex (e.g. 'router.*@office' to match all routers in the 'office' folder).
|
||||||
|
- 'router1@office'
|
||||||
|
- 'router.*@office' # Regex filters are fully supported to match multiple nodes dynamically.
|
||||||
|
- '@aws'
|
||||||
|
commands: # List of CLI commands to execute. Mandatory.
|
||||||
|
- 'show version'
|
||||||
|
variables: # Key-value pairs for variables replacement in commands and expected. Optional.
|
||||||
|
__global__: # Global variables fallback. Optional.
|
||||||
|
key: value
|
||||||
|
node_name@folder: # Node-specific variables. Optional.
|
||||||
|
key: value
|
||||||
|
output: stdout # Mandatory. Output configuration. Choices: 'stdout', 'null', or a folder path like '/path/to/folder'.
|
||||||
|
options: # Execution options. Optional.
|
||||||
|
prompt: 'regex_prompt' # Optional prompt to expect.
|
||||||
|
parallel: 10 # Optional number of parallel threads. Default 10.
|
||||||
|
timeout: 20 # Optional execution timeout in seconds. Default 20.
|
||||||
|
|
||||||
|
- name: "Verification Task"
|
||||||
|
action: 'test'
|
||||||
|
nodes:
|
||||||
|
- 'router1@office'
|
||||||
|
commands:
|
||||||
|
- 'ping 10.100.100.1'
|
||||||
|
expected: '!' # Expected text pattern to search in output. Mandatory ONLY for 'test' action.
|
||||||
|
|
||||||
|
Connpy Variable Templating & Usage:
|
||||||
|
- Variables defined under the `variables` key (either globally under `__global__` or for specific nodes) are used in commands or expected output by surrounding the variable name with single curly braces: `{variable_name}`.
|
||||||
|
- Example: If you define a variable `ip` with a value of `10.100.100.1`, you use it in commands as `'ping {ip}'`.
|
||||||
|
- Recommendation (Important): Variables are not limited to simple words or values. You can define entire CLI commands as variables to abstract vendor-specific syntax! This is highly recommended when executing the same logical operation across different operating systems (OS) or vendors.
|
||||||
|
- Example: You can define `show_interface_cmd` under a specific node's variables to be `'show ip interface brief'` for Cisco, and `'show interfaces terse'` for Juniper, and then write a single generic command under `commands`:
|
||||||
|
`- '{show_interface_cmd}'`
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
1. When the user requests a playbook, you should guide them and output the YAML.
|
||||||
|
2. IMPORTANT: You have access to the `list_nodes` tool. Proactively use it to inspect the user's real inventory. This allows you to discover correct node names, folders, or device tags, and construct precise regex filters for the `nodes` field based on real assets.
|
||||||
|
3. IMPORTANT: Before presenting the playbook, you MUST call the `validate_playbook` tool with the YAML to let the backend check for syntax and schema correctness.
|
||||||
|
4. If `validate_playbook` returns errors, fix them in your YAML and validate again before responding to the user.
|
||||||
|
5. When the playbook is complete, validated, and the user approves it, you MUST call the `return_playbook` tool to return the final YAML.
|
||||||
|
6. All text responses must be in the same language the user uses in their prompt.
|
||||||
|
7. EFFICIENT TESTING: When the user asks to verify or check a condition (e.g. verify OS version, check port status), a single task with `action: 'test'` is completely self-sufficient. DO NOT generate an `action: 'run'` task followed by an `action: 'test'` task to perform the same check. The `test` action executes the commands, verifies the expectation, and displays the output if `output: stdout` is configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLAYBOOK_BUILDER_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_nodes",
|
||||||
|
"description": "[Universal Platform] Lists available nodes in the inventory. Use this to discover device names, folders, or operating systems to build proper regex filters.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "OBJECT",
|
||||||
|
"properties": {
|
||||||
|
"filter_pattern": {
|
||||||
|
"type": "STRING",
|
||||||
|
"description": "Regex or pattern to filter nodes (e.g. '.*', 'border.*', '@office')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "validate_playbook",
|
||||||
|
"description": "Validates the Connpy YAML playbook structure, syntax, and schema correctness with the backend.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "OBJECT",
|
||||||
|
"properties": {
|
||||||
|
"playbook_yaml": {
|
||||||
|
"type": "STRING",
|
||||||
|
"description": "The YAML content of the playbook to validate."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["playbook_yaml"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "return_playbook",
|
||||||
|
"description": "Returns the final validated YAML playbook to the calling application when the user is satisfied.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "OBJECT",
|
||||||
|
"properties": {
|
||||||
|
"playbook_yaml": {
|
||||||
|
"type": "STRING",
|
||||||
|
"description": "The final YAML content of the playbook."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["playbook_yaml"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class PlaybookBuilderAgent:
|
||||||
|
"""Specialized AI agent for building, validating, and generating Connpy YAML playbooks."""
|
||||||
|
|
||||||
|
def __init__(self, config, console=None, confirm_handler=None, trust=False, **kwargs):
|
||||||
|
self.config = config
|
||||||
|
self.console = console or printer.console
|
||||||
|
self.interrupted = False
|
||||||
|
|
||||||
|
# Load AI configuration
|
||||||
|
if hasattr(self.config, "get_effective_setting"):
|
||||||
|
aiconfig = self.config.get_effective_setting("ai", {})
|
||||||
|
else:
|
||||||
|
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||||
|
|
||||||
|
# Default model for technical tasks
|
||||||
|
self.model = kwargs.get("engineer_model") or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
|
||||||
|
self.key = kwargs.get("engineer_api_key") or aiconfig.get("engineer_api_key")
|
||||||
|
self.auth = kwargs.get("engineer_auth") or aiconfig.get("engineer_auth") or {}
|
||||||
|
if self.key and "api_key" not in self.auth:
|
||||||
|
self.auth = self.auth.copy()
|
||||||
|
self.auth["api_key"] = self.key
|
||||||
|
|
||||||
|
def validate_playbook(self, playbook_yaml: str) -> dict:
|
||||||
|
"""Sintactical and schema validation of Connpy Playbook YAML."""
|
||||||
|
import yaml
|
||||||
|
try:
|
||||||
|
# 1. Parse YAML
|
||||||
|
data = yaml.load(playbook_yaml, Loader=yaml.FullLoader)
|
||||||
|
except Exception as e:
|
||||||
|
return {"valid": False, "error": f"YAML Syntax Error: {e}"}
|
||||||
|
|
||||||
|
# 2. Check structure
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"valid": False, "error": "Playbook must be a YAML dictionary."}
|
||||||
|
|
||||||
|
if "tasks" not in data:
|
||||||
|
return {"valid": False, "error": "Playbook missing mandatory root 'tasks' key."}
|
||||||
|
|
||||||
|
tasks = data["tasks"]
|
||||||
|
if not isinstance(tasks, list):
|
||||||
|
return {"valid": False, "error": "'tasks' must be a list of tasks."}
|
||||||
|
|
||||||
|
# 3. Check individual tasks
|
||||||
|
for idx, task in enumerate(tasks):
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
return {"valid": False, "error": f"Task index {idx} must be a dictionary."}
|
||||||
|
|
||||||
|
name = task.get("name", f"Task {idx}")
|
||||||
|
|
||||||
|
# Mandatory fields
|
||||||
|
mandatory = ["name", "action", "nodes", "commands", "output"]
|
||||||
|
missing = [field for field in mandatory if field not in task]
|
||||||
|
if missing:
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) is missing mandatory fields: {missing}"}
|
||||||
|
|
||||||
|
# Validate nodes field type (supports string regexes or array of string regexes)
|
||||||
|
nodes = task["nodes"]
|
||||||
|
if not isinstance(nodes, (str, list)):
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) 'nodes' must be a string (regex) or a list of strings (regexes)."}
|
||||||
|
|
||||||
|
if isinstance(nodes, list):
|
||||||
|
for n_idx, node_item in enumerate(nodes):
|
||||||
|
if not isinstance(node_item, str):
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) 'nodes' list contains a non-string value at index {n_idx}: {node_item}"}
|
||||||
|
|
||||||
|
action = task["action"]
|
||||||
|
if action not in ["run", "test"]:
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) has invalid action '{action}'. Choices are: 'run', 'test'."}
|
||||||
|
|
||||||
|
if action == "test" and "expected" not in task:
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) has action 'test' but is missing the mandatory 'expected' key."}
|
||||||
|
|
||||||
|
output = task["output"]
|
||||||
|
if output not in [None, "stdout"] and not output.startswith("/"):
|
||||||
|
return {"valid": False, "error": f"Task '{name}' (index {idx}) output '{output}' is invalid. Must be 'stdout', 'null' or an absolute path."}
|
||||||
|
|
||||||
|
return {"valid": True, "message": "Playbook schema and syntax is valid."}
|
||||||
|
|
||||||
|
def ask(self, user_input, chat_history=None, status=None, debug=False, chunk_callback=None):
|
||||||
|
"""Standard conversation step with tool loop for PlaybookBuilderAgent."""
|
||||||
|
if chat_history is None:
|
||||||
|
chat_history = []
|
||||||
|
|
||||||
|
# System prompt and tool definition
|
||||||
|
system_prompt = PLAYBOOK_BUILDER_SYSTEM_PROMPT
|
||||||
|
tools = PLAYBOOK_BUILDER_TOOLS
|
||||||
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
for msg in chat_history:
|
||||||
|
m = msg if isinstance(msg, dict) else msg.copy()
|
||||||
|
if m.get('role') == 'assistant' and m.get('tool_calls') and m.get('content') == "":
|
||||||
|
m['content'] = None
|
||||||
|
messages.append(m)
|
||||||
|
|
||||||
|
messages.append({"role": "user", "content": user_input})
|
||||||
|
|
||||||
|
final_playbook_yaml = None
|
||||||
|
iteration = 0
|
||||||
|
max_iterations = 10
|
||||||
|
|
||||||
|
while iteration < max_iterations:
|
||||||
|
iteration += 1
|
||||||
|
|
||||||
|
if status:
|
||||||
|
status.update(f"Playbook Agent is thinking... (step {iteration})")
|
||||||
|
|
||||||
|
# Call LiteLLM completion
|
||||||
|
from connpy.ai import completion
|
||||||
|
try:
|
||||||
|
response = completion(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
tools=tools,
|
||||||
|
num_retries=3,
|
||||||
|
**self.auth
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"response": f"Playbook Agent failed: {str(e)}", "chat_history": messages[1:]}
|
||||||
|
|
||||||
|
resp_msg = response.choices[0].message
|
||||||
|
msg_dict = resp_msg.model_dump(exclude_none=True)
|
||||||
|
if msg_dict.get("tool_calls") and msg_dict.get("content") == "":
|
||||||
|
msg_dict["content"] = None
|
||||||
|
|
||||||
|
messages.append(msg_dict)
|
||||||
|
|
||||||
|
# If the model sends content, stream or yield it
|
||||||
|
if resp_msg.content:
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback(resp_msg.content)
|
||||||
|
elif not resp_msg.tool_calls:
|
||||||
|
# In direct non-streaming output, print markdown
|
||||||
|
self.console.print(Markdown(resp_msg.content))
|
||||||
|
|
||||||
|
if not resp_msg.tool_calls:
|
||||||
|
break
|
||||||
|
|
||||||
|
for tc in resp_msg.tool_calls:
|
||||||
|
fn = tc.function.name
|
||||||
|
args = json.loads(tc.function.arguments)
|
||||||
|
|
||||||
|
if fn == "list_nodes":
|
||||||
|
filter_pattern = args.get("filter_pattern", ".*")
|
||||||
|
try:
|
||||||
|
matched_names = self.config._getallnodes(filter_pattern)
|
||||||
|
if not matched_names:
|
||||||
|
obs = "No nodes found matching the filter."
|
||||||
|
else:
|
||||||
|
if len(matched_names) <= 5:
|
||||||
|
matched_data = self.config.getitems(matched_names, extract=True)
|
||||||
|
res = {}
|
||||||
|
for name, data in matched_data.items():
|
||||||
|
os_tag = "unknown"
|
||||||
|
if isinstance(data, dict):
|
||||||
|
ts = data.get("tags")
|
||||||
|
if isinstance(ts, dict): os_tag = ts.get("os", "unknown")
|
||||||
|
res[name] = {"os": os_tag}
|
||||||
|
obs = json.dumps(res)
|
||||||
|
else:
|
||||||
|
obs = json.dumps({
|
||||||
|
"matched_count": len(matched_names),
|
||||||
|
"message": "Too many nodes matched. Showing names only.",
|
||||||
|
"node_names": matched_names
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
obs = f"Error listing nodes: {e}"
|
||||||
|
messages.append({
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"role": "tool",
|
||||||
|
"name": fn,
|
||||||
|
"content": obs
|
||||||
|
})
|
||||||
|
elif fn == "validate_playbook":
|
||||||
|
playbook_yaml = args.get("playbook_yaml", "")
|
||||||
|
validation_res = self.validate_playbook(playbook_yaml)
|
||||||
|
messages.append({
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"role": "tool",
|
||||||
|
"name": fn,
|
||||||
|
"content": json.dumps(validation_res)
|
||||||
|
})
|
||||||
|
elif fn == "return_playbook":
|
||||||
|
final_playbook_yaml = args.get("playbook_yaml", "")
|
||||||
|
messages.append({
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"role": "tool",
|
||||||
|
"name": fn,
|
||||||
|
"content": json.dumps({"success": True, "message": "Playbook returned successfully."})
|
||||||
|
})
|
||||||
|
|
||||||
|
# If return_playbook was called, we can terminate early
|
||||||
|
if final_playbook_yaml is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": resp_msg.content or "",
|
||||||
|
"chat_history": messages[1:],
|
||||||
|
"playbook_yaml": final_playbook_yaml
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ from .api_handler import APIHandler
|
|||||||
from .plugin_handler import PluginHandler
|
from .plugin_handler import PluginHandler
|
||||||
from .import_export_handler import ImportExportHandler
|
from .import_export_handler import ImportExportHandler
|
||||||
from .context_handler import ContextHandler
|
from .context_handler import ContextHandler
|
||||||
|
from .sso_handler import SSOHandler
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AIHandler:
|
|||||||
if args.mcp is not None:
|
if args.mcp is not None:
|
||||||
return self.configure_mcp(args)
|
return self.configure_mcp(args)
|
||||||
|
|
||||||
# Determinar session_id para retomar
|
# Determine session_id to resume
|
||||||
session_id = None
|
session_id = None
|
||||||
if args.resume:
|
if args.resume:
|
||||||
sessions, _ = self.app.services.ai.list_sessions()
|
sessions, _ = self.app.services.ai.list_sessions()
|
||||||
@@ -54,8 +54,8 @@ class AIHandler:
|
|||||||
elif args.session:
|
elif args.session:
|
||||||
session_id = args.session[0]
|
session_id = args.session[0]
|
||||||
|
|
||||||
# Configurar argumentos adicionales para el servicio de AI
|
# Configure additional arguments for the AI service
|
||||||
# Prioridad: CLI Args > Configuración Local
|
# Priority: CLI Args > Local Config
|
||||||
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
||||||
arguments = {}
|
arguments = {}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class AIHandler:
|
|||||||
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
||||||
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
||||||
|
|
||||||
# El resto de la interacción el CLI la maneja con el agente subyacente
|
# The rest of the interaction is handled by the CLI with the underlying agent
|
||||||
self.app.myai = self.app.services.ai
|
self.app.myai = self.app.services.ai
|
||||||
self.ai_overrides = arguments
|
self.ai_overrides = arguments
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class AIHandler:
|
|||||||
|
|
||||||
def single_question(self, args, session_id):
|
def single_question(self, args, session_id):
|
||||||
query = " ".join(args.ask)
|
query = " ".join(args.ask)
|
||||||
with console.status("[ai_status]Agent is thinking and analyzing...") as status:
|
with console.status("[ai_status]Agent is thinking and analyzing...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
||||||
|
|
||||||
responder = result.get("responder", "engineer")
|
responder = result.get("responder", "engineer")
|
||||||
@@ -131,7 +131,7 @@ class AIHandler:
|
|||||||
if not user_query.strip(): continue
|
if not user_query.strip(): continue
|
||||||
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
||||||
|
|
||||||
with console.status("[ai_status]Agent is thinking...") as status:
|
with console.status("[ai_status]Agent is thinking...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
||||||
|
|
||||||
new_history = result.get("chat_history")
|
new_history = result.get("chat_history")
|
||||||
|
|||||||
+286
-2
@@ -15,7 +15,12 @@ class RunHandler:
|
|||||||
def dispatch(self, args):
|
def dispatch(self, args):
|
||||||
if len(args.data) > 1:
|
if len(args.data) > 1:
|
||||||
args.action = "noderun"
|
args.action = "noderun"
|
||||||
actions = {"noderun": self.node_run, "generate": self.yaml_generate, "run": self.yaml_run}
|
actions = {
|
||||||
|
"noderun": self.node_run,
|
||||||
|
"generate": self.yaml_generate,
|
||||||
|
"generate_ai": self.ai_generate,
|
||||||
|
"run": self.yaml_run
|
||||||
|
}
|
||||||
return actions.get(args.action)(args)
|
return actions.get(args.action)(args)
|
||||||
|
|
||||||
def node_run(self, args):
|
def node_run(self, args):
|
||||||
@@ -33,6 +38,41 @@ class RunHandler:
|
|||||||
|
|
||||||
commands = [" ".join(args.data[1:])]
|
commands = [" ".join(args.data[1:])]
|
||||||
|
|
||||||
|
# Check for Preflight AI simulation
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
matched_node_names = [n.get("name") if isinstance(n, dict) else n for n in matched_nodes]
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
matched_node_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
|
|
||||||
@@ -70,6 +110,40 @@ class RunHandler:
|
|||||||
)
|
)
|
||||||
printer.run_summary(results)
|
printer.run_summary(results)
|
||||||
|
|
||||||
|
# Analyze execution results if requested
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else " ".join(args.data[1:])
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -90,8 +164,105 @@ class RunHandler:
|
|||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
# Check preflight first before any task runs
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
preflight_failed = False
|
||||||
|
for task in playbook.get("tasks", []):
|
||||||
|
name = task.get("name", "Task")
|
||||||
|
nodelist = task.get("nodes", [])
|
||||||
|
commands = task.get("commands", [])
|
||||||
|
|
||||||
|
# Resolve nodes to names
|
||||||
|
try:
|
||||||
|
if isinstance(nodelist, str):
|
||||||
|
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||||
|
elif isinstance(nodelist, list):
|
||||||
|
resolved_nodes = []
|
||||||
|
for item in nodelist:
|
||||||
|
matches = self.app.services.nodes.list_nodes(item)
|
||||||
|
for m in matches:
|
||||||
|
if m not in resolved_nodes:
|
||||||
|
resolved_nodes.append(m)
|
||||||
|
else:
|
||||||
|
resolved_nodes = []
|
||||||
|
except Exception:
|
||||||
|
resolved_nodes = []
|
||||||
|
|
||||||
|
resolved_names = [n.get("name") if isinstance(n, dict) else n for n in resolved_nodes]
|
||||||
|
printer.console.print(f"\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)")
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
resolved_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed for task {name}: {e}")
|
||||||
|
preflight_failed = True
|
||||||
|
if preflight_failed:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Standard run
|
||||||
|
results_all = {}
|
||||||
for task in playbook.get("tasks", []):
|
for task in playbook.get("tasks", []):
|
||||||
self.cli_run(task)
|
task_res = self.cli_run(task)
|
||||||
|
if task_res:
|
||||||
|
results_all.update(task_res)
|
||||||
|
|
||||||
|
# If analyze is enabled, run analysis on accumulated results
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing playbook execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else f"Playbook: {path}"
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results_all,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
printer.error(f"Failed to run playbook {path}: {e}")
|
printer.error(f"Failed to run playbook {path}: {e}")
|
||||||
@@ -136,6 +307,7 @@ class RunHandler:
|
|||||||
|
|
||||||
nodelist = resolved_nodes
|
nodelist = resolved_nodes
|
||||||
|
|
||||||
|
results = {}
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
if action == "run":
|
if action == "run":
|
||||||
@@ -195,6 +367,118 @@ class RunHandler:
|
|||||||
)
|
)
|
||||||
# ALWAYS show the aggregate summary at the end
|
# ALWAYS show the aggregate summary at the end
|
||||||
printer.test_summary(results)
|
printer.test_summary(results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def ai_generate(self, args):
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.rule import Rule
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
dest_file = args.data[0]
|
||||||
|
if os.path.exists(dest_file):
|
||||||
|
printer.error(f"File '{dest_file}' already exists.")
|
||||||
|
sys.exit(14)
|
||||||
|
|
||||||
|
chat_history = []
|
||||||
|
|
||||||
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Operation cancelled by user.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_prompt.strip().lower() in ["exit", "quit"]:
|
||||||
|
printer.info("Exiting AI Assistant.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_prompt.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
res = self.app.services.ai.build_playbook_chat(
|
||||||
|
user_prompt,
|
||||||
|
chat_history=chat_history,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
renderer.flush()
|
||||||
|
if not first_chunk:
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
if res and "chat_history" in res:
|
||||||
|
chat_history = res["chat_history"]
|
||||||
|
|
||||||
|
# Check if the agent returned a validated playbook YAML
|
||||||
|
if res and "playbook_yaml" in res and res["playbook_yaml"]:
|
||||||
|
yaml_content = res["playbook_yaml"]
|
||||||
|
printer.console.print()
|
||||||
|
printer.success("Playbook YAML successfully generated and validated.")
|
||||||
|
|
||||||
|
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
|
||||||
|
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
|
||||||
|
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
|
||||||
|
printer.console.print(panel)
|
||||||
|
|
||||||
|
# Ask if the user wants to save it
|
||||||
|
try:
|
||||||
|
save_confirm = Prompt.ask(
|
||||||
|
f"\nDo you want to save this playbook to '{dest_file}'?",
|
||||||
|
choices=["y", "n", "run"],
|
||||||
|
default="y"
|
||||||
|
)
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Saving skipped.")
|
||||||
|
break
|
||||||
|
|
||||||
|
choice = save_confirm.strip().lower()
|
||||||
|
if choice in ["y", "yes", "run"]:
|
||||||
|
with open(dest_file, "w") as f:
|
||||||
|
f.write(yaml_content)
|
||||||
|
printer.success(f"Playbook saved successfully to '{dest_file}'")
|
||||||
|
if choice == "run":
|
||||||
|
printer.console.print()
|
||||||
|
printer.info("Executing the saved playbook...")
|
||||||
|
self.yaml_run(args)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
printer.warning("Playbook not saved. You can continue describing changes or exit.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Error in AI chat: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
import inquirer
|
||||||
|
from .. import printer
|
||||||
|
|
||||||
|
class SSOHandler:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def dispatch(self, args):
|
||||||
|
if self.app.services.mode == "remote":
|
||||||
|
printer.error("SSO management commands are only available in local/server-side mode.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Parse actions from argparse mutually exclusive options
|
||||||
|
if getattr(args, "add", None):
|
||||||
|
args.action = "add"
|
||||||
|
args.provider = args.add[0]
|
||||||
|
elif getattr(args, "delete", None):
|
||||||
|
args.action = "del"
|
||||||
|
args.provider = args.delete[0]
|
||||||
|
elif getattr(args, "list", False):
|
||||||
|
args.action = "list"
|
||||||
|
elif getattr(args, "show", None):
|
||||||
|
args.action = "show"
|
||||||
|
args.provider = args.show[0]
|
||||||
|
|
||||||
|
action = getattr(args, "action", None)
|
||||||
|
|
||||||
|
if action == "add":
|
||||||
|
return self.add_provider(args)
|
||||||
|
elif action == "del":
|
||||||
|
return self.delete_provider(args)
|
||||||
|
elif action == "list":
|
||||||
|
return self.list_providers(args)
|
||||||
|
elif action == "show":
|
||||||
|
return self.show_provider(args)
|
||||||
|
else:
|
||||||
|
printer.error(f"Unknown action: {action}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def add_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.setdefault("providers", {})
|
||||||
|
|
||||||
|
existing = providers.get(provider, {})
|
||||||
|
if existing:
|
||||||
|
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
|
||||||
|
|
||||||
|
# Interactive questionnaire
|
||||||
|
questions = [
|
||||||
|
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
|
||||||
|
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
|
||||||
|
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
|
||||||
|
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
|
||||||
|
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
|
||||||
|
]
|
||||||
|
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers:
|
||||||
|
printer.warning("Operation cancelled.")
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
jwks_url = answers["jwks_url"].strip()
|
||||||
|
secret = answers["secret"].strip()
|
||||||
|
username_claim = answers["username_claim"].strip()
|
||||||
|
algorithms_str = answers["algorithms"].strip()
|
||||||
|
allowed_domains_str = answers.get("allowed_domains", "").strip()
|
||||||
|
|
||||||
|
if not jwks_url and not secret:
|
||||||
|
printer.error("You must configure either a JWKS URL or a Secret.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not username_claim:
|
||||||
|
printer.error("Username claim cannot be empty.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
|
||||||
|
if not algorithms:
|
||||||
|
algorithms = ["RS256"]
|
||||||
|
|
||||||
|
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
|
||||||
|
|
||||||
|
provider_data = {
|
||||||
|
"username_claim": username_claim,
|
||||||
|
"algorithms": algorithms
|
||||||
|
}
|
||||||
|
if jwks_url:
|
||||||
|
provider_data["jwks_url"] = jwks_url
|
||||||
|
if secret:
|
||||||
|
provider_data["secret"] = secret
|
||||||
|
if allowed_domains:
|
||||||
|
provider_data["allowed_domains"] = allowed_domains
|
||||||
|
|
||||||
|
providers[provider] = provider_data
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' saved successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def delete_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Confirm delete
|
||||||
|
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers or not answers["confirm"]:
|
||||||
|
printer.info("Delete cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
del providers[provider]
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' deleted successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def list_providers(self, args):
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
if not providers:
|
||||||
|
printer.warning("No SSO providers configured.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print list in YAML format
|
||||||
|
providers_list = list(providers.keys())
|
||||||
|
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data("Configured SSO Providers", yaml_str)
|
||||||
|
|
||||||
|
def show_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data = providers[provider]
|
||||||
|
|
||||||
|
# Mask client secret for display if it's sensitive and not an env var starting with $
|
||||||
|
display_data = data.copy()
|
||||||
|
secret = display_data.get("secret")
|
||||||
|
if secret and not secret.startswith("$"):
|
||||||
|
display_data["secret"] = "********"
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data(f"SSO Provider: {provider}", yaml_str)
|
||||||
+11
-11
@@ -87,14 +87,14 @@ class CopilotInterface:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1. Visual Separation
|
# 1. Visual Separation
|
||||||
self.console.print("") # Salto de línea real
|
self.console.print("") # Real line break
|
||||||
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
||||||
self.console.print(Panel(
|
self.console.print(Panel(
|
||||||
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
||||||
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
||||||
border_style="cyan"
|
border_style="cyan"
|
||||||
))
|
))
|
||||||
self.console.print("\n") # Pequeño espacio antes del prompt del copilot
|
self.console.print("\n") # Small space before the copilot prompt
|
||||||
|
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
@bindings.add('c-up')
|
@bindings.add('c-up')
|
||||||
@@ -161,7 +161,7 @@ class CopilotInterface:
|
|||||||
|
|
||||||
if app and app.current_buffer:
|
if app and app.current_buffer:
|
||||||
text = app.current_buffer.text
|
text = app.current_buffer.text
|
||||||
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
|
# Only show command help if typing the first command and there are no spaces
|
||||||
if text.startswith('/') and ' ' not in text:
|
if text.startswith('/') and ' ' not in text:
|
||||||
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
||||||
matches = [c for c in commands if c.startswith(text.lower())]
|
matches = [c for c in commands if c.startswith(text.lower())]
|
||||||
@@ -176,19 +176,19 @@ class CopilotInterface:
|
|||||||
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
||||||
|
|
||||||
def clean_preview(text):
|
def clean_preview(text):
|
||||||
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
|
# Clean newlines and the initial prompt (all up to #, > or $) to leave only the command
|
||||||
original = text.strip().replace('\r', '').replace('\n', ' ')
|
original = text.strip().replace('\r', '').replace('\n', ' ')
|
||||||
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
||||||
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
|
# If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original
|
||||||
return cleaned if cleaned else original
|
return cleaned if cleaned else original
|
||||||
|
|
||||||
if state['context_mode'] == self.mode_range:
|
if state['context_mode'] == self.mode_range:
|
||||||
range_blocks = blocks[idx:]
|
range_blocks = blocks[idx:]
|
||||||
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
|
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
|
||||||
if len(range_blocks) > 1:
|
if len(range_blocks) > 1:
|
||||||
range_blocks = range_blocks[:-1]
|
range_blocks = range_blocks[:-1]
|
||||||
|
|
||||||
# Limpiar y truncar comandos muy largos para que no rompan la UI
|
# Clean and truncate very long commands so they don't break the UI
|
||||||
previews = []
|
previews = []
|
||||||
for b in range_blocks:
|
for b in range_blocks:
|
||||||
p = clean_preview(b[2])
|
p = clean_preview(b[2])
|
||||||
@@ -266,8 +266,8 @@ class CopilotInterface:
|
|||||||
style=ui_style
|
style=ui_style
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
|
# We use an internal try/finally to ensure that if something fails in prompt_async,
|
||||||
# no nos quedemos con la terminal en un estado extraño.
|
# we don't leave the terminal in a strange state.
|
||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
@@ -299,12 +299,12 @@ class CopilotInterface:
|
|||||||
except: pass
|
except: pass
|
||||||
asyncio.create_task(delayed_refresh())
|
asyncio.create_task(delayed_refresh())
|
||||||
|
|
||||||
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
|
# Move the cursor up and clean the line so the new prompt replaces the previous one
|
||||||
sys.stdout.write('\x1b[1A\x1b[2K')
|
sys.stdout.write('\x1b[1A\x1b[2K')
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Limpiar el mensaje de la barra cuando se hace una pregunta real
|
# Clean the toolbar message when a real question is asked
|
||||||
state['toolbar_msg'] = ''
|
state['toolbar_msg'] = ''
|
||||||
|
|
||||||
clean_question = directive.get("clean_prompt", question)
|
clean_question = directive.get("clean_prompt", question)
|
||||||
|
|||||||
@@ -120,6 +120,27 @@ def _get_users(configdir):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sso_providers(configdir):
|
||||||
|
import yaml
|
||||||
|
config_file = os.path.join(configdir, "config.yaml")
|
||||||
|
if not os.path.exists(config_file):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
config_data = data.get("config", {})
|
||||||
|
if isinstance(config_data, dict):
|
||||||
|
sso = config_data.get("sso", {})
|
||||||
|
if isinstance(sso, dict):
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
if isinstance(providers, dict):
|
||||||
|
return list(providers.keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tree(nodes, folders, profiles, plugins, configdir):
|
def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||||
"""Build the declarative CLI navigation tree.
|
"""Build the declarative CLI navigation tree.
|
||||||
|
|
||||||
@@ -169,12 +190,17 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
|||||||
run_after_node.update({
|
run_after_node.update({
|
||||||
"--test": {"*": run_after_node},
|
"--test": {"*": run_after_node},
|
||||||
"-t": {"*": run_after_node},
|
"-t": {"*": run_after_node},
|
||||||
|
"--analyze": {"*": run_after_node},
|
||||||
|
"--preflight-ai": run_after_node,
|
||||||
"*": run_after_node # Consume commands
|
"*": run_after_node # Consume commands
|
||||||
})
|
})
|
||||||
|
|
||||||
run_dict = {
|
run_dict = {
|
||||||
"--generate": {"__extra__": lambda w: get_cwd(w, "--generate")},
|
"--generate": {"__extra__": lambda w: get_cwd(w, "--generate")},
|
||||||
"-g": {"__extra__": lambda w: get_cwd(w, "-g")},
|
"-g": {"__extra__": lambda w: get_cwd(w, "-g")},
|
||||||
|
"--generate-ai": {"__extra__": lambda w: get_cwd(w, "--generate-ai")},
|
||||||
|
"--analyze": {"*": run_after_node},
|
||||||
|
"--preflight-ai": run_after_node,
|
||||||
"--test": {"*": None},
|
"--test": {"*": None},
|
||||||
"-t": {"*": None},
|
"-t": {"*": None},
|
||||||
"--help": None,
|
"--help": None,
|
||||||
@@ -231,6 +257,18 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
|||||||
"--help": None, "-h": None
|
"--help": None, "-h": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_sso_providers = lambda w=None: _get_sso_providers(configdir)
|
||||||
|
|
||||||
|
sso_dict = {
|
||||||
|
"--add": {"__extra__": _sso_providers, "*": None},
|
||||||
|
"--del": {"__extra__": _sso_providers},
|
||||||
|
"--rm": {"__extra__": _sso_providers},
|
||||||
|
"--show": {"__extra__": _sso_providers},
|
||||||
|
"--list": None,
|
||||||
|
"--ls": None,
|
||||||
|
"--help": None, "-h": None
|
||||||
|
}
|
||||||
|
|
||||||
mv_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
mv_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
||||||
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
||||||
ls_state = {
|
ls_state = {
|
||||||
@@ -326,6 +364,7 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
|||||||
"-h": None,
|
"-h": None,
|
||||||
},
|
},
|
||||||
"user": user_dict,
|
"user": user_dict,
|
||||||
|
"sso": sso_dict,
|
||||||
"login": {"--help": None, "-h": None, "*": None},
|
"login": {"--help": None, "-h": None, "*": None},
|
||||||
"logout": {"--help": None, "-h": None},
|
"logout": {"--help": None, "-h": None},
|
||||||
"config": config_dict,
|
"config": config_dict,
|
||||||
|
|||||||
+16
-1
@@ -37,7 +37,7 @@ RichHelpFormatter.group_name_formatter = str.upper
|
|||||||
from .cli import (
|
from .cli import (
|
||||||
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
|
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
|
||||||
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
|
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
|
||||||
ContextHandler
|
ContextHandler, SSOHandler
|
||||||
)
|
)
|
||||||
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
|
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
|
||||||
from .cli.help_text import get_help
|
from .cli.help_text import get_help
|
||||||
@@ -141,6 +141,7 @@ class connapp:
|
|||||||
from .cli.sync_handler import SyncHandler
|
from .cli.sync_handler import SyncHandler
|
||||||
from .cli.user_handler import UserHandler
|
from .cli.user_handler import UserHandler
|
||||||
from .cli.login_handler import LoginHandler
|
from .cli.login_handler import LoginHandler
|
||||||
|
from .cli.sso_handler import SSOHandler
|
||||||
|
|
||||||
# Instantiate Handlers
|
# Instantiate Handlers
|
||||||
self._node = NodeHandler(self)
|
self._node = NodeHandler(self)
|
||||||
@@ -155,6 +156,7 @@ class connapp:
|
|||||||
self._sync = SyncHandler(self)
|
self._sync = SyncHandler(self)
|
||||||
self._user = UserHandler(self)
|
self._user = UserHandler(self)
|
||||||
self._login = LoginHandler(self)
|
self._login = LoginHandler(self)
|
||||||
|
self._sso = SSOHandler(self)
|
||||||
|
|
||||||
# Register auto-sync hook to trigger after config saves
|
# Register auto-sync hook to trigger after config saves
|
||||||
from .configfile import configfile
|
from .configfile import configfile
|
||||||
@@ -303,6 +305,9 @@ class connapp:
|
|||||||
runparser.add_argument("run", nargs='+', action=self._store_type, help=get_help("run"), default="run").completer = nodes_completer
|
runparser.add_argument("run", nargs='+', action=self._store_type, help=get_help("run"), default="run").completer = nodes_completer
|
||||||
runparser.add_argument("-t", "--test", dest="test_expected", nargs='+', help="Expected text(s) to validate in output. Converts the action from 'run' to 'test'")
|
runparser.add_argument("-t", "--test", dest="test_expected", nargs='+', help="Expected text(s) to validate in output. Converts the action from 'run' to 'test'")
|
||||||
runparser.add_argument("-g","--generate", dest="action", action="store_const", help="Generate yaml file template", const="generate", default="run")
|
runparser.add_argument("-g","--generate", dest="action", action="store_const", help="Generate yaml file template", const="generate", default="run")
|
||||||
|
runparser.add_argument("--generate-ai", dest="action", action="store_const", help="Generate a playbook interactively with AI assistance", const="generate_ai")
|
||||||
|
runparser.add_argument("--analyze", nargs='?', const="", help="Analyze actual command execution results using AI")
|
||||||
|
runparser.add_argument("--preflight-ai", action="store_true", help="Simulate and predict command execution on devices using AI preventively")
|
||||||
runparser.set_defaults(func=self._run.dispatch)
|
runparser.set_defaults(func=self._run.dispatch)
|
||||||
#APIPARSER
|
#APIPARSER
|
||||||
apiparser = subparsers.add_parser("api", help="Start and stop connpy API", description="Start and stop connpy API", formatter_class=RichHelpFormatter)
|
apiparser = subparsers.add_parser("api", help="Start and stop connpy API", description="Start and stop connpy API", formatter_class=RichHelpFormatter)
|
||||||
@@ -375,6 +380,16 @@ class connapp:
|
|||||||
userparser.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)")
|
userparser.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)")
|
||||||
userparser.set_defaults(func=self._user.dispatch)
|
userparser.set_defaults(func=self._user.dispatch)
|
||||||
|
|
||||||
|
#SSOPARSER
|
||||||
|
ssoparser = subparsers.add_parser("sso", help="Manage SSO providers", description="Manage SSO providers", formatter_class=RichHelpFormatter)
|
||||||
|
ssoparser.error = self._custom_error
|
||||||
|
ssocrud = ssoparser.add_mutually_exclusive_group(required=True)
|
||||||
|
ssocrud.add_argument("--add", nargs=1, dest="add", help="Add or update SSO provider", metavar="PROVIDER_NAME")
|
||||||
|
ssocrud.add_argument("--del", "--rm", nargs=1, dest="delete", help="Delete SSO provider", metavar="PROVIDER_NAME")
|
||||||
|
ssocrud.add_argument("--list", "--ls", dest="list", action="store_true", help="List all configured SSO providers")
|
||||||
|
ssocrud.add_argument("--show", nargs=1, dest="show", help="Show SSO provider details", metavar="PROVIDER_NAME")
|
||||||
|
ssoparser.set_defaults(func=self._sso.dispatch)
|
||||||
|
|
||||||
#LOGINPARSER
|
#LOGINPARSER
|
||||||
loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter)
|
loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter)
|
||||||
loginparser.error = self._custom_error
|
loginparser.error = self._custom_error
|
||||||
|
|||||||
+47
-25
@@ -27,10 +27,10 @@ def copilot_terminal_mode():
|
|||||||
try:
|
try:
|
||||||
old_settings = termios.tcgetattr(fd)
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
|
||||||
# Primero pasamos a raw mode absoluto para matar ISIG, ICANON, ECHO, etc.
|
# First we switch to absolute raw mode to disable ISIG, ICANON, ECHO, etc.
|
||||||
tty.setraw(fd)
|
tty.setraw(fd)
|
||||||
|
|
||||||
# Luego rehabilitamos OPOST para que rich.Live se dibuje correctamente
|
# Then we re-enable OPOST so rich.Live renders correctly
|
||||||
new_settings = termios.tcgetattr(fd)
|
new_settings = termios.tcgetattr(fd)
|
||||||
new_settings[1] = new_settings[1] | termios.OPOST
|
new_settings[1] = new_settings[1] | termios.OPOST
|
||||||
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
|
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
|
||||||
@@ -686,12 +686,12 @@ class node:
|
|||||||
# Get raw bytes from BytesIO
|
# Get raw bytes from BytesIO
|
||||||
raw_bytes = self.mylog.getvalue()
|
raw_bytes = self.mylog.getvalue()
|
||||||
|
|
||||||
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
|
# Stop terminal reading so prompt_toolkit (in run_session)
|
||||||
# tenga control exclusivo del stdin sin interferencias de LocalStream.
|
# has exclusive control of stdin without LocalStream interference.
|
||||||
if hasattr(stream, 'stop_reading'):
|
if hasattr(stream, 'stop_reading'):
|
||||||
stream.stop_reading()
|
stream.stop_reading()
|
||||||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||||||
# Fallback si no tiene el método (en LocalStream)
|
# Fallback if the method is missing (in LocalStream)
|
||||||
stream._loop.remove_reader(stream.stdin_fd)
|
stream._loop.remove_reader(stream.stdin_fd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -708,7 +708,7 @@ class node:
|
|||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
print("\033[2m Returning to session...\033[0m", flush=True)
|
print("\033[2m Returning to session...\033[0m", flush=True)
|
||||||
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
|
# Restart terminal reading to return to interactive SSH/Telnet mode
|
||||||
if hasattr(stream, 'start_reading'):
|
if hasattr(stream, 'start_reading'):
|
||||||
stream.start_reading()
|
stream.start_reading()
|
||||||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||||||
@@ -776,14 +776,6 @@ class node:
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -804,6 +796,20 @@ class node:
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
# Only set terminal size on devices without a
|
||||||
|
# screen_length_command (e.g. Linux/bash servers).
|
||||||
|
# Routers already disable pagination via that command.
|
||||||
|
# After setwinsize, consume any SIGWINCH re-render
|
||||||
|
# prompt (~40ms on bash) with a short timeout.
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -886,14 +892,6 @@ class node:
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -915,6 +913,15 @@ class node:
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -940,13 +947,28 @@ class node:
|
|||||||
if vars is not None:
|
if vars is not None:
|
||||||
e = e.format(**vars)
|
e = e.format(**vars)
|
||||||
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
||||||
newpattern = f".*({updatedprompt}).*{e}.*"
|
|
||||||
cleaned_output = output
|
cleaned_output = output
|
||||||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
try:
|
||||||
|
newpattern = f".*({updatedprompt}).*{e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
try:
|
||||||
|
escaped_e = re.escape(e)
|
||||||
|
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
pass
|
||||||
|
|
||||||
if e in cleaned_output:
|
if e in cleaned_output:
|
||||||
self.result[e] = True
|
self.result[e] = True
|
||||||
else:
|
else:
|
||||||
self.result[e]= False
|
try:
|
||||||
|
if re.search(e, cleaned_output):
|
||||||
|
self.result[e] = True
|
||||||
|
else:
|
||||||
|
self.result[e] = False
|
||||||
|
except re.error:
|
||||||
|
self.result[e] = False
|
||||||
self.status = 0
|
self.status = 0
|
||||||
return self.result
|
return self.result
|
||||||
if result == 2:
|
if result == 2:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1542,11 +1542,6 @@ class ExecutionServiceStub(object):
|
|||||||
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
response_deserializer=connpy__pb2.StructResponse.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
self.run_yaml_playbook = channel.unary_unary(
|
|
||||||
'/connpy.ExecutionService/run_yaml_playbook',
|
|
||||||
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
|
||||||
_registered_method=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionServiceServicer(object):
|
class ExecutionServiceServicer(object):
|
||||||
@@ -1570,12 +1565,6 @@ class ExecutionServiceServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def run_yaml_playbook(self, request, context):
|
|
||||||
"""Missing associated documentation comment in .proto file."""
|
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
||||||
context.set_details('Method not implemented!')
|
|
||||||
raise NotImplementedError('Method not implemented!')
|
|
||||||
|
|
||||||
|
|
||||||
def add_ExecutionServiceServicer_to_server(servicer, server):
|
def add_ExecutionServiceServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -1594,11 +1583,6 @@ def add_ExecutionServiceServicer_to_server(servicer, server):
|
|||||||
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
'run_yaml_playbook': grpc.unary_unary_rpc_method_handler(
|
|
||||||
servicer.run_yaml_playbook,
|
|
||||||
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.ExecutionService', rpc_method_handlers)
|
'connpy.ExecutionService', rpc_method_handlers)
|
||||||
@@ -1691,33 +1675,6 @@ class ExecutionService(object):
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def run_yaml_playbook(request,
|
|
||||||
target,
|
|
||||||
options=(),
|
|
||||||
channel_credentials=None,
|
|
||||||
call_credentials=None,
|
|
||||||
insecure=False,
|
|
||||||
compression=None,
|
|
||||||
wait_for_ready=None,
|
|
||||||
timeout=None,
|
|
||||||
metadata=None):
|
|
||||||
return grpc.experimental.unary_unary(
|
|
||||||
request,
|
|
||||||
target,
|
|
||||||
'/connpy.ExecutionService/run_yaml_playbook',
|
|
||||||
connpy__pb2.ScriptRequest.SerializeToString,
|
|
||||||
connpy__pb2.StructResponse.FromString,
|
|
||||||
options,
|
|
||||||
channel_credentials,
|
|
||||||
insecure,
|
|
||||||
call_credentials,
|
|
||||||
compression,
|
|
||||||
wait_for_ready,
|
|
||||||
timeout,
|
|
||||||
metadata,
|
|
||||||
_registered_method=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ImportExportServiceStub(object):
|
class ImportExportServiceStub(object):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
@@ -1931,6 +1888,21 @@ class AIServiceStub(object):
|
|||||||
request_serializer=connpy__pb2.StringRequest.SerializeToString,
|
request_serializer=connpy__pb2.StringRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
response_deserializer=connpy__pb2.StructResponse.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
self.build_playbook_chat = channel.stream_stream(
|
||||||
|
'/connpy.AIService/build_playbook_chat',
|
||||||
|
request_serializer=connpy__pb2.AskRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.analyze_execution_results = channel.unary_stream(
|
||||||
|
'/connpy.AIService/analyze_execution_results',
|
||||||
|
request_serializer=connpy__pb2.AnalyzeRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.predict_execution_results = channel.unary_stream(
|
||||||
|
'/connpy.AIService/predict_execution_results',
|
||||||
|
request_serializer=connpy__pb2.PreflightRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
|
||||||
class AIServiceServicer(object):
|
class AIServiceServicer(object):
|
||||||
@@ -1990,6 +1962,24 @@ class AIServiceServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def build_playbook_chat(self, request_iterator, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def analyze_execution_results(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def predict_execution_results(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
|
||||||
def add_AIServiceServicer_to_server(servicer, server):
|
def add_AIServiceServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -2038,6 +2028,21 @@ def add_AIServiceServicer_to_server(servicer, server):
|
|||||||
request_deserializer=connpy__pb2.StringRequest.FromString,
|
request_deserializer=connpy__pb2.StringRequest.FromString,
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'build_playbook_chat': grpc.stream_stream_rpc_method_handler(
|
||||||
|
servicer.build_playbook_chat,
|
||||||
|
request_deserializer=connpy__pb2.AskRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
|
'analyze_execution_results': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.analyze_execution_results,
|
||||||
|
request_deserializer=connpy__pb2.AnalyzeRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
|
'predict_execution_results': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.predict_execution_results,
|
||||||
|
request_deserializer=connpy__pb2.PreflightRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AIService', rpc_method_handlers)
|
'connpy.AIService', rpc_method_handlers)
|
||||||
@@ -2292,6 +2297,87 @@ class AIService(object):
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_playbook_chat(request_iterator,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.stream_stream(
|
||||||
|
request_iterator,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/build_playbook_chat',
|
||||||
|
connpy__pb2.AskRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/analyze_execution_results',
|
||||||
|
connpy__pb2.AnalyzeRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def predict_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/predict_execution_results',
|
||||||
|
connpy__pb2.PreflightRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
|
||||||
class SystemServiceStub(object):
|
class SystemServiceStub(object):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
@@ -2551,11 +2637,21 @@ class AuthServiceStub(object):
|
|||||||
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
self.login_sso = channel.unary_unary(
|
||||||
|
'/connpy.AuthService/login_sso',
|
||||||
|
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
self.change_password = channel.unary_unary(
|
self.change_password = channel.unary_unary(
|
||||||
'/connpy.AuthService/change_password',
|
'/connpy.AuthService/change_password',
|
||||||
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
||||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
self.get_sso_providers = channel.unary_unary(
|
||||||
|
'/connpy.AuthService/get_sso_providers',
|
||||||
|
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
|
||||||
class AuthServiceServicer(object):
|
class AuthServiceServicer(object):
|
||||||
@@ -2567,12 +2663,24 @@ class AuthServiceServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def login_sso(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def get_sso_providers(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
|
||||||
def add_AuthServiceServicer_to_server(servicer, server):
|
def add_AuthServiceServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -2581,11 +2689,21 @@ def add_AuthServiceServicer_to_server(servicer, server):
|
|||||||
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
||||||
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'login_sso': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.login_sso,
|
||||||
|
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
||||||
|
),
|
||||||
'change_password': grpc.unary_unary_rpc_method_handler(
|
'change_password': grpc.unary_unary_rpc_method_handler(
|
||||||
servicer.change_password,
|
servicer.change_password,
|
||||||
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
||||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'get_sso_providers': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.get_sso_providers,
|
||||||
|
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||||
|
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AuthService', rpc_method_handlers)
|
'connpy.AuthService', rpc_method_handlers)
|
||||||
@@ -2624,6 +2742,33 @@ class AuthService(object):
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def login_sso(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/login_sso',
|
||||||
|
connpy__pb2.LoginSSORequest.SerializeToString,
|
||||||
|
connpy__pb2.LoginResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def change_password(request,
|
def change_password(request,
|
||||||
target,
|
target,
|
||||||
@@ -2650,3 +2795,30 @@ class AuthService(object):
|
|||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_sso_providers(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/get_sso_providers',
|
||||||
|
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
|
connpy__pb2.SSOProvidersResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|||||||
+244
-30
@@ -719,7 +719,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
|||||||
finally:
|
finally:
|
||||||
q.put(None)
|
q.put(None)
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
item = q.get()
|
item = q.get()
|
||||||
@@ -768,7 +770,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
|||||||
finally:
|
finally:
|
||||||
q.put(None)
|
q.put(None)
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
item = q.get()
|
item = q.get()
|
||||||
@@ -791,11 +795,6 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
|||||||
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
|
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
|
||||||
return connpy_pb2.StructResponse(data=to_struct(res))
|
return connpy_pb2.StructResponse(data=to_struct(res))
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
def run_yaml_playbook(self, request, context):
|
|
||||||
res = self.service.run_yaml_playbook(request.param1, request.parallel)
|
|
||||||
return connpy_pb2.StructResponse(data=to_struct(res))
|
|
||||||
|
|
||||||
class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
|
class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
|
||||||
def __init__(self, provider, registry=None):
|
def __init__(self, provider, registry=None):
|
||||||
if not hasattr(provider, "mode"):
|
if not hasattr(provider, "mode"):
|
||||||
@@ -955,12 +954,11 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
|||||||
def service(self):
|
def service(self):
|
||||||
return self._get_provider().ai
|
return self._get_provider().ai
|
||||||
|
|
||||||
@handle_errors
|
def _handle_chat_stream(self, request_iterator, context, service_method):
|
||||||
def ask(self, request_iterator, context):
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
import contextvars
|
||||||
|
|
||||||
ai_service = self.service
|
|
||||||
chunk_queue = queue.Queue()
|
chunk_queue = queue.Queue()
|
||||||
request_queue = queue.Queue()
|
request_queue = queue.Queue()
|
||||||
bridge = None
|
bridge = None
|
||||||
@@ -978,21 +976,29 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
|||||||
nonlocal history, bridge, agent_instance
|
nonlocal history, bridge, agent_instance
|
||||||
try:
|
try:
|
||||||
# Run the AI interaction (this blocks this specific thread)
|
# Run the AI interaction (this blocks this specific thread)
|
||||||
res = ai_service.ask(
|
if getattr(service_method, "__name__", None) == "build_playbook_chat":
|
||||||
input_text,
|
res = service_method(
|
||||||
chat_history=history if history else None,
|
input_text,
|
||||||
session_id=session_id,
|
chat_history=history if history else None,
|
||||||
debug=debug,
|
status=bridge,
|
||||||
status=bridge,
|
chunk_callback=callback
|
||||||
console=bridge,
|
)
|
||||||
confirm_handler=bridge.confirm,
|
else:
|
||||||
chunk_callback=callback,
|
res = service_method(
|
||||||
trust=trust,
|
input_text,
|
||||||
**overrides
|
chat_history=history if history else None,
|
||||||
)
|
session_id=session_id,
|
||||||
|
debug=debug,
|
||||||
|
status=bridge,
|
||||||
|
console=bridge,
|
||||||
|
confirm_handler=bridge.confirm,
|
||||||
|
chunk_callback=callback,
|
||||||
|
trust=trust,
|
||||||
|
**overrides
|
||||||
|
)
|
||||||
|
|
||||||
# Update history for next message
|
# Update history for next message
|
||||||
if "chat_history" in res:
|
if res and "chat_history" in res:
|
||||||
history = res["chat_history"]
|
history = res["chat_history"]
|
||||||
|
|
||||||
# Send final chunk marker
|
# Send final chunk marker
|
||||||
@@ -1046,10 +1052,10 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
|||||||
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
|
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
|
||||||
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
|
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
|
||||||
|
|
||||||
# Start AI in its own thread so we can keep listening for interrupts
|
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
|
||||||
|
ctx_ai = contextvars.copy_context()
|
||||||
ai_thread = threading.Thread(
|
ai_thread = threading.Thread(
|
||||||
target=run_ai_task,
|
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
|
||||||
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
|
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
ai_thread.start()
|
ai_thread.start()
|
||||||
@@ -1061,8 +1067,9 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
|||||||
# When client closes stream, send sentinel
|
# When client closes stream, send sentinel
|
||||||
chunk_queue.put((None, None))
|
chunk_queue.put((None, None))
|
||||||
|
|
||||||
# Start listening for client requests/signals
|
# Start listening for client requests/signals with a copied context
|
||||||
threading.Thread(target=request_listener, daemon=True).start()
|
ctx_listener = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
|
||||||
|
|
||||||
# Main response loop (yields to gRPC)
|
# Main response loop (yields to gRPC)
|
||||||
while True:
|
while True:
|
||||||
@@ -1086,6 +1093,73 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
|||||||
elif msg_type == "final_mark":
|
elif msg_type == "final_mark":
|
||||||
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
||||||
|
|
||||||
|
def _handle_unary_stream(self, service_method, *args, **kwargs):
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
|
||||||
|
chunk_queue = queue.Queue()
|
||||||
|
bridge = StatusBridge(chunk_queue, is_web=False)
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
chunk_queue.put(("text", chunk))
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
res = service_method(*args, chunk_callback=callback, status=bridge, **kwargs)
|
||||||
|
chunk_queue.put(("final_mark", res))
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"gRPC Unary Stream error: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
chunk_queue.put(("status", f"Error: {str(e)}"))
|
||||||
|
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "error": True}))
|
||||||
|
finally:
|
||||||
|
chunk_queue.put((None, None))
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
item = chunk_queue.get()
|
||||||
|
if item == (None, None):
|
||||||
|
break
|
||||||
|
|
||||||
|
msg_type, val = item
|
||||||
|
if msg_type == "text":
|
||||||
|
yield connpy_pb2.AIResponse(text_chunk=val, is_final=False)
|
||||||
|
elif msg_type == "status":
|
||||||
|
clean_val = val.replace("[ai_status]", "").replace("[/ai_status]", "")
|
||||||
|
yield connpy_pb2.AIResponse(status_update=clean_val, is_final=False)
|
||||||
|
elif msg_type == "debug":
|
||||||
|
yield connpy_pb2.AIResponse(debug_message=val, is_final=False)
|
||||||
|
elif msg_type == "important":
|
||||||
|
yield connpy_pb2.AIResponse(important_message=val, is_final=False)
|
||||||
|
elif msg_type == "confirm":
|
||||||
|
yield connpy_pb2.AIResponse(status_update=val, requires_confirmation=True, is_final=False)
|
||||||
|
elif msg_type == "final_mark":
|
||||||
|
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def ask(self, request_iterator, context):
|
||||||
|
yield from self._handle_chat_stream(request_iterator, context, self.service.ask)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def build_playbook_chat(self, request_iterator, context):
|
||||||
|
yield from self._handle_chat_stream(request_iterator, context, self.service.build_playbook_chat)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def analyze_execution_results(self, request, context):
|
||||||
|
results = from_struct(request.results)
|
||||||
|
query = request.query if request.query else None
|
||||||
|
yield from self._handle_unary_stream(self.service.analyze_execution_results, results, query=query)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def predict_execution_results(self, request, context):
|
||||||
|
target_nodes = list(request.target_nodes)
|
||||||
|
commands = list(request.commands)
|
||||||
|
yield from self._handle_unary_stream(self.service.predict_execution_results, target_nodes, commands)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def confirm(self, request, context):
|
def confirm(self, request, context):
|
||||||
res = self.service.confirm(request.value)
|
res = self.service.confirm(request.value)
|
||||||
@@ -1199,7 +1273,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
|||||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
||||||
|
|
||||||
token = self.registry.user_service.generate_jwt(username)
|
token = self.registry.user_service.generate_jwt(username)
|
||||||
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
|
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
|
||||||
|
|
||||||
return connpy_pb2.LoginResponse(
|
return connpy_pb2.LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -1207,6 +1281,137 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
|||||||
expires_at=expires_at
|
expires_at=expires_at
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def login_sso(self, request, context):
|
||||||
|
username = request.username
|
||||||
|
id_token = request.id_token
|
||||||
|
provider = request.provider
|
||||||
|
|
||||||
|
if not id_token or not provider:
|
||||||
|
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "id_token and provider are required")
|
||||||
|
|
||||||
|
# Load SSO configuration
|
||||||
|
sso_config = {}
|
||||||
|
if self.registry:
|
||||||
|
shared_config = self.registry.get_shared_config()
|
||||||
|
if shared_config:
|
||||||
|
sso_config = shared_config.config.get("sso", {})
|
||||||
|
|
||||||
|
providers = sso_config.get("providers", {})
|
||||||
|
if provider not in providers:
|
||||||
|
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"SSO Provider '{provider}' not configured in config.yaml")
|
||||||
|
|
||||||
|
p_config = providers[provider]
|
||||||
|
jwks_url = p_config.get("jwks_url")
|
||||||
|
secret = p_config.get("secret")
|
||||||
|
|
||||||
|
if secret and secret.startswith("$"):
|
||||||
|
import os
|
||||||
|
secret = os.getenv(secret[1:])
|
||||||
|
|
||||||
|
if not jwks_url and not secret:
|
||||||
|
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"Provider '{provider}' has no jwks_url or secret configured")
|
||||||
|
|
||||||
|
# Validate token
|
||||||
|
import jwt
|
||||||
|
try:
|
||||||
|
algorithms = p_config.get("algorithms", ["RS256"] if jwks_url else ["HS256"])
|
||||||
|
verify_aud = "audience" in p_config
|
||||||
|
audience = p_config.get("audience")
|
||||||
|
verify_iss = "issuer" in p_config
|
||||||
|
issuer = p_config.get("issuer")
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"verify_signature": True,
|
||||||
|
"verify_exp": True,
|
||||||
|
"verify_aud": verify_aud,
|
||||||
|
"verify_iss": verify_iss
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_kwargs = {
|
||||||
|
"algorithms": algorithms,
|
||||||
|
"options": options
|
||||||
|
}
|
||||||
|
if verify_aud:
|
||||||
|
decode_kwargs["audience"] = audience
|
||||||
|
if verify_iss:
|
||||||
|
decode_kwargs["issuer"] = issuer
|
||||||
|
|
||||||
|
if jwks_url:
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
jwks_client = PyJWKClient(jwks_url)
|
||||||
|
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
||||||
|
payload = jwt.decode(id_token, signing_key.key, **decode_kwargs)
|
||||||
|
else:
|
||||||
|
payload = jwt.decode(id_token, secret, **decode_kwargs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO Token validation failed: {str(e)}")
|
||||||
|
|
||||||
|
# Extract username from claim
|
||||||
|
username_claim = p_config.get("username_claim", "sub")
|
||||||
|
claim_username = payload.get(username_claim)
|
||||||
|
if not claim_username:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Username claim '{username_claim}' not found in SSO Token")
|
||||||
|
|
||||||
|
# Check domain restrictions (allowed_domains)
|
||||||
|
allowed_domains = p_config.get("allowed_domains", [])
|
||||||
|
if allowed_domains:
|
||||||
|
email = payload.get("email")
|
||||||
|
if not email and claim_username and "@" in claim_username:
|
||||||
|
email = claim_username
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Domain restriction enabled but no email claim found in SSO Token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_domain = email.split("@")[-1].strip().lower()
|
||||||
|
except Exception:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Invalid email format in SSO Token: '{email}'")
|
||||||
|
|
||||||
|
allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d]
|
||||||
|
if user_domain not in allowed_domains_lower:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO user domain '{user_domain}' not allowed")
|
||||||
|
|
||||||
|
# Normalize username to alphanumeric/dashes/underscores to match connpy's username regex
|
||||||
|
import re
|
||||||
|
normalized_username = re.sub(r'[^a-zA-Z0-9_-]', '_', claim_username.split('@')[0])
|
||||||
|
|
||||||
|
# If a requested username was sent, verify it matches
|
||||||
|
if username and username != normalized_username:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Mismatched username. Expected '{normalized_username}', got '{username}'")
|
||||||
|
|
||||||
|
# Check if user exists in connpy registry, otherwise auto-provision
|
||||||
|
try:
|
||||||
|
user_exists = any(u["username"] == normalized_username for u in self.registry.user_service.list_users())
|
||||||
|
if not user_exists:
|
||||||
|
import secrets
|
||||||
|
# Provision new user with random password (never used directly)
|
||||||
|
self.registry.user_service.create_user(normalized_username, secrets.token_hex(32))
|
||||||
|
except Exception as e:
|
||||||
|
context.abort(grpc.StatusCode.INTERNAL, f"Failed to auto-provision user: {str(e)}")
|
||||||
|
|
||||||
|
# Generate native connpy JWT token
|
||||||
|
token = self.registry.user_service.generate_jwt(normalized_username)
|
||||||
|
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
|
||||||
|
|
||||||
|
return connpy_pb2.LoginResponse(
|
||||||
|
token=token,
|
||||||
|
username=normalized_username,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def get_sso_providers(self, request, context):
|
||||||
|
sso_config = {}
|
||||||
|
if self.registry:
|
||||||
|
shared_config = self.registry.get_shared_config()
|
||||||
|
if shared_config:
|
||||||
|
sso_config = shared_config.config.get("sso", {})
|
||||||
|
providers = list(sso_config.get("providers", {}).keys())
|
||||||
|
external_providers = [p for p in providers if p != "trusted_gateway"]
|
||||||
|
return connpy_pb2.SSOProvidersResponse(providers=external_providers)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
username = _current_user.get()
|
username = _current_user.get()
|
||||||
@@ -1222,7 +1427,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
|||||||
return Empty()
|
return Empty()
|
||||||
|
|
||||||
class AuthInterceptor(grpc.ServerInterceptor):
|
class AuthInterceptor(grpc.ServerInterceptor):
|
||||||
OPEN_METHODS = ["/connpy.AuthService/login"]
|
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
|
||||||
|
|
||||||
def __init__(self, registry):
|
def __init__(self, registry):
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
@@ -1348,6 +1553,15 @@ def serve(config, port=8048, debug=False):
|
|||||||
fallback_provider = ServiceProvider(config, mode="local")
|
fallback_provider = ServiceProvider(config, mode="local")
|
||||||
registry = UserRegistry(config.defaultdir)
|
registry = UserRegistry(config.defaultdir)
|
||||||
|
|
||||||
|
# Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env
|
||||||
|
import os
|
||||||
|
if os.getenv("CONN_SSO_GATEWAY_SECRET") and registry._shared_config:
|
||||||
|
sso_config = registry._shared_config.config.get("sso", {})
|
||||||
|
providers = sso_config.get("providers", {})
|
||||||
|
if "trusted_gateway" not in providers:
|
||||||
|
from connpy import printer
|
||||||
|
printer.warning("CONN_SSO_GATEWAY_SECRET is defined in environment, but 'trusted_gateway' is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.")
|
||||||
|
|
||||||
interceptors = []
|
interceptors = []
|
||||||
if debug:
|
if debug:
|
||||||
interceptors.append(LoggingInterceptor())
|
interceptors.append(LoggingInterceptor())
|
||||||
|
|||||||
+122
-28
@@ -692,11 +692,6 @@ class ExecutionStub:
|
|||||||
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
|
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
|
||||||
return from_struct(self.stub.run_cli_script(req).data)
|
return from_struct(self.stub.run_cli_script(req).data)
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
def run_yaml_playbook(self, playbook_path, parallel=10):
|
|
||||||
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
|
|
||||||
return from_struct(self.stub.run_yaml_playbook(req).data)
|
|
||||||
|
|
||||||
class ImportExportStub:
|
class ImportExportStub:
|
||||||
def __init__(self, channel, remote_host):
|
def __init__(self, channel, remote_host):
|
||||||
self.stub = connpy_pb2_grpc.ImportExportServiceStub(channel)
|
self.stub = connpy_pb2_grpc.ImportExportServiceStub(channel)
|
||||||
@@ -724,8 +719,7 @@ class AIStub:
|
|||||||
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
|
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
|
||||||
self.remote_host = remote_host
|
self.remote_host = remote_host
|
||||||
|
|
||||||
@handle_errors
|
def _ai_chat_stream(self, stub_method, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, chunk_callback=None, **overrides):
|
||||||
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
|
||||||
import queue
|
import queue
|
||||||
from rich.prompt import Prompt
|
from rich.prompt import Prompt
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
@@ -760,7 +754,7 @@ class AIStub:
|
|||||||
if req is None: break
|
if req is None: break
|
||||||
yield req
|
yield req
|
||||||
|
|
||||||
responses = self.stub.ask(request_generator())
|
responses = stub_method(request_generator())
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
header_printed = False
|
header_printed = False
|
||||||
@@ -859,26 +853,32 @@ class AIStub:
|
|||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
if chunk_callback:
|
||||||
from rich.rule import Rule
|
header_printed = True
|
||||||
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
else:
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
# Print header on first chunk
|
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
alias = "architect" if current_responder == "architect" else "engineer"
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
|
|
||||||
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
|
# Print header on first chunk
|
||||||
header_printed = True
|
alias = "architect" if current_responder == "architect" else "engineer"
|
||||||
|
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
|
||||||
# Initialize parser
|
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
|
||||||
md_parser = IncrementalMarkdownParser(console=stable_console)
|
header_printed = True
|
||||||
|
|
||||||
|
# Initialize parser
|
||||||
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
|
|
||||||
full_content += response.text_chunk
|
full_content += response.text_chunk
|
||||||
md_parser.feed(response.text_chunk)
|
if chunk_callback:
|
||||||
|
chunk_callback(response.text_chunk)
|
||||||
|
elif md_parser:
|
||||||
|
md_parser.feed(response.text_chunk)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.is_final:
|
if response.is_final:
|
||||||
if header_printed:
|
if not chunk_callback and header_printed:
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
md_parser.flush()
|
md_parser.flush()
|
||||||
|
|
||||||
@@ -887,12 +887,8 @@ class AIStub:
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
final_result = from_struct(response.full_result)
|
final_result = from_struct(response.full_result)
|
||||||
responder = final_result.get("responder", "engineer")
|
|
||||||
alias = "architect" if responder == "architect" else "engineer"
|
|
||||||
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
|
|
||||||
title = f"[bold {alias}]{role_label}[/bold {alias}]"
|
|
||||||
|
|
||||||
if header_printed:
|
if not chunk_callback and header_printed:
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
from ..printer import connpy_theme, get_original_stdout
|
from ..printer import connpy_theme, get_original_stdout
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
@@ -911,6 +907,104 @@ class AIStub:
|
|||||||
|
|
||||||
return final_result
|
return final_result
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
||||||
|
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
|
||||||
|
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
def _process_unary_stream(self, responses, status=None, chunk_callback=None):
|
||||||
|
full_content = ""
|
||||||
|
header_printed = False
|
||||||
|
final_result = {"response": "", "chat_history": []}
|
||||||
|
md_parser = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for response in responses:
|
||||||
|
if response.status_update:
|
||||||
|
if status:
|
||||||
|
status.update(response.status_update)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.important_message:
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Text.from_ansi(response.important_message))
|
||||||
|
if status:
|
||||||
|
try: status.start()
|
||||||
|
except: pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not response.is_final:
|
||||||
|
if response.text_chunk:
|
||||||
|
if not header_printed:
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if chunk_callback:
|
||||||
|
header_printed = True
|
||||||
|
else:
|
||||||
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
|
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
|
||||||
|
# Print default header
|
||||||
|
stable_console.print(Rule("[bold engineer]AI Analysis[/bold engineer]", style="engineer"))
|
||||||
|
header_printed = True
|
||||||
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
|
|
||||||
|
full_content += response.text_chunk
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback(response.text_chunk)
|
||||||
|
elif md_parser:
|
||||||
|
md_parser.feed(response.text_chunk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.is_final:
|
||||||
|
if md_parser:
|
||||||
|
md_parser.flush()
|
||||||
|
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
final_result = from_struct(response.full_result)
|
||||||
|
|
||||||
|
if md_parser:
|
||||||
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
|
from ..printer import connpy_theme, get_original_stdout
|
||||||
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
stable_console.print(Rule(style="engineer"))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, grpc.RpcError):
|
||||||
|
raise
|
||||||
|
printer.warning(f"Stream interrupted: {e}")
|
||||||
|
|
||||||
|
if full_content:
|
||||||
|
final_result["streamed"] = True
|
||||||
|
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.AnalyzeRequest(query=query or "")
|
||||||
|
req.results.CopyFrom(to_struct(results))
|
||||||
|
responses = self.stub.analyze_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
|
||||||
|
responses = self.stub.predict_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def confirm(self, input_text, console=None):
|
def confirm(self, input_text, console=None):
|
||||||
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
|
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ class UserRegistry:
|
|||||||
def has_users(self) -> bool:
|
def has_users(self) -> bool:
|
||||||
"""Check if any users are registered (enables auth enforcement)."""
|
"""Check if any users are registered (enables auth enforcement)."""
|
||||||
return bool(self.user_service.list_users())
|
return bool(self.user_service.list_users())
|
||||||
|
|
||||||
|
def get_shared_config(self):
|
||||||
|
"""Thread-safe access to the hot-reloaded shared configuration."""
|
||||||
|
with self._lock:
|
||||||
|
self._refresh_shared()
|
||||||
|
return self._shared_config
|
||||||
|
|
||||||
def evict(self, username):
|
def evict(self, username):
|
||||||
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
||||||
|
|||||||
+1
-1
@@ -573,7 +573,7 @@ class BlockMarkdownRenderer:
|
|||||||
if not block_text:
|
if not block_text:
|
||||||
return
|
return
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
self._console.print(Markdown(block_text))
|
self._console.print(Markdown(block_text, code_theme="ansi_dark"))
|
||||||
|
|
||||||
# Alias for backward compatibility
|
# Alias for backward compatibility
|
||||||
IncrementalMarkdownParser = BlockMarkdownRenderer
|
IncrementalMarkdownParser = BlockMarkdownRenderer
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ service ExecutionService {
|
|||||||
rpc run_commands (RunRequest) returns (stream NodeRunResult) {}
|
rpc run_commands (RunRequest) returns (stream NodeRunResult) {}
|
||||||
rpc test_commands (TestRequest) returns (stream NodeRunResult) {}
|
rpc test_commands (TestRequest) returns (stream NodeRunResult) {}
|
||||||
rpc run_cli_script (ScriptRequest) returns (StructResponse) {}
|
rpc run_cli_script (ScriptRequest) returns (StructResponse) {}
|
||||||
rpc run_yaml_playbook (ScriptRequest) returns (StructResponse) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service ImportExportService {
|
service ImportExportService {
|
||||||
@@ -72,6 +71,9 @@ service AIService {
|
|||||||
rpc configure_mcp (MCPRequest) returns (google.protobuf.Empty) {}
|
rpc configure_mcp (MCPRequest) returns (google.protobuf.Empty) {}
|
||||||
rpc list_mcp_servers (google.protobuf.Empty) returns (ValueResponse) {}
|
rpc list_mcp_servers (google.protobuf.Empty) returns (ValueResponse) {}
|
||||||
rpc load_session_data (StringRequest) returns (StructResponse) {}
|
rpc load_session_data (StringRequest) returns (StructResponse) {}
|
||||||
|
rpc build_playbook_chat (stream AskRequest) returns (stream AIResponse) {}
|
||||||
|
rpc analyze_execution_results (AnalyzeRequest) returns (stream AIResponse) {}
|
||||||
|
rpc predict_execution_results (PreflightRequest) returns (stream AIResponse) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
service SystemService {
|
service SystemService {
|
||||||
@@ -299,7 +301,13 @@ message MCPRequest {
|
|||||||
|
|
||||||
service AuthService {
|
service AuthService {
|
||||||
rpc login (LoginRequest) returns (LoginResponse) {}
|
rpc login (LoginRequest) returns (LoginResponse) {}
|
||||||
|
rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
|
||||||
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
|
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message SSOProvidersResponse {
|
||||||
|
repeated string providers = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LoginRequest {
|
message LoginRequest {
|
||||||
@@ -307,6 +315,12 @@ message LoginRequest {
|
|||||||
string password = 2;
|
string password = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message LoginSSORequest {
|
||||||
|
string username = 1;
|
||||||
|
string id_token = 2;
|
||||||
|
string provider = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message LoginResponse {
|
message LoginResponse {
|
||||||
string token = 1;
|
string token = 1;
|
||||||
string username = 2;
|
string username = 2;
|
||||||
@@ -317,3 +331,13 @@ message ChangePasswordRequest {
|
|||||||
string old_password = 1;
|
string old_password = 1;
|
||||||
string new_password = 2;
|
string new_password = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AnalyzeRequest {
|
||||||
|
google.protobuf.Struct results = 1;
|
||||||
|
string query = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PreflightRequest {
|
||||||
|
repeated string target_nodes = 1;
|
||||||
|
repeated string commands = 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,3 +319,37 @@ class AIService(BaseService):
|
|||||||
agent = ai(self.config)
|
agent = ai(self.config)
|
||||||
return agent.load_session_data(session_id)
|
return agent.load_session_data(session_id)
|
||||||
|
|
||||||
|
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||||
|
"""Interact with the specialized Playbook Builder Agent."""
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
agent = PlaybookBuilderAgent(self.config)
|
||||||
|
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||||
|
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||||
|
import json
|
||||||
|
results_str = json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||||
|
if query:
|
||||||
|
prompt += f"\nSpecific user request: {query}"
|
||||||
|
prompt += f"\n\nResults Data:\n{results_str}"
|
||||||
|
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||||
|
|
||||||
|
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||||
|
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
|
||||||
|
|
||||||
|
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||||
|
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||||
|
nodes_str = ", ".join(target_nodes)
|
||||||
|
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||||
|
|
||||||
|
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||||
|
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||||
|
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||||
|
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||||
|
|
||||||
|
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from typing import List, Dict, Any, Callable, Optional
|
from typing import List, Dict, Any, Callable, Optional
|
||||||
import os
|
import os
|
||||||
import yaml
|
|
||||||
from .base import BaseService
|
from .base import BaseService
|
||||||
from connpy.core import nodes as Nodes
|
from connpy.core import nodes as Nodes
|
||||||
from .exceptions import ConnpyError
|
from .exceptions import ConnpyError
|
||||||
@@ -108,52 +107,3 @@ class ExecutionService(BaseService):
|
|||||||
|
|
||||||
return self.run_commands(nodes_filter, commands, parallel=parallel)
|
return self.run_commands(nodes_filter, commands, parallel=parallel)
|
||||||
|
|
||||||
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
|
|
||||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
|
||||||
playbook = None
|
|
||||||
if playbook_data.startswith("---YAML---\n"):
|
|
||||||
try:
|
|
||||||
content = playbook_data[len("---YAML---\n"):]
|
|
||||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(playbook_data):
|
|
||||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
|
||||||
try:
|
|
||||||
with open(playbook_data, "r") as f:
|
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
|
||||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
|
||||||
|
|
||||||
action = playbook.get("action", "run")
|
|
||||||
options = playbook.get("options", {})
|
|
||||||
|
|
||||||
# Extract all fields similar to RunHandler.cli_run
|
|
||||||
exec_args = {
|
|
||||||
"nodes_filter": playbook["nodes"],
|
|
||||||
"commands": playbook["commands"],
|
|
||||||
"variables": playbook.get("variables"),
|
|
||||||
"parallel": options.get("parallel", parallel),
|
|
||||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
|
||||||
"prompt": options.get("prompt"),
|
|
||||||
"name": playbook.get("name", "Task")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map 'output' field to folder path if it's not stdout/null
|
|
||||||
output_cfg = playbook.get("output")
|
|
||||||
if output_cfg not in [None, "stdout"]:
|
|
||||||
exec_args["folder"] = output_cfg
|
|
||||||
|
|
||||||
if action == "run":
|
|
||||||
return self.run_commands(**exec_args)
|
|
||||||
elif action == "test":
|
|
||||||
exec_args["expected"] = playbook.get("expected", [])
|
|
||||||
return self.test_commands(**exec_args)
|
|
||||||
else:
|
|
||||||
raise ConnpyError(f"Unsupported playbook action: {action}")
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,18 +210,19 @@ class UserService:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
||||||
|
|
||||||
def generate_jwt(self, username) -> str:
|
def generate_jwt(self, username) -> str:
|
||||||
"""Generates a secure JSON Web Token for the user expiring in 8 hours."""
|
"""Generates a secure JSON Web Token for the user expiring in 12 hours."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
raise ValueError(f"User '{username}' not found")
|
||||||
|
|
||||||
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
|
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"exp": expiration
|
"exp": expiration
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||||
if isinstance(token, bytes):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
@@ -231,7 +232,8 @@ class UserService:
|
|||||||
"""Decodes JWT and returns username if token is valid and unexpired."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -480,6 +480,15 @@ class TestToolDefinitions:
|
|||||||
names = [t["function"]["name"] for t in tools]
|
names = [t["function"]["name"] for t in tools]
|
||||||
assert "arch_tool" in names
|
assert "arch_tool" in names
|
||||||
|
|
||||||
|
def test_architect_tools_one_shot(self, ai_config):
|
||||||
|
from connpy.ai import ai
|
||||||
|
one_shot_ai = ai(ai_config, one_shot=True)
|
||||||
|
tools = one_shot_ai._get_architect_tools()
|
||||||
|
names = [t["function"]["name"] for t in tools]
|
||||||
|
assert "delegate_to_engineer" not in names
|
||||||
|
assert "return_to_engineer" not in names
|
||||||
|
assert "manage_memory_tool" in names
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# AI Session Management tests
|
# AI Session Management tests
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock, ANY
|
||||||
|
from connpy.connapp import connapp
|
||||||
|
import os
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(populated_config):
|
||||||
|
"""Returns an instance of connapp initialized with mock config."""
|
||||||
|
return connapp(populated_config)
|
||||||
|
|
||||||
|
def test_run_generate_ai_dispatch(app):
|
||||||
|
"""Test that connpy run --generate-ai parses and calls ai_generate."""
|
||||||
|
with patch("connpy.cli.run_handler.RunHandler.ai_generate") as mock_ai_gen:
|
||||||
|
app.start(["run", "--generate-ai", "new_playbook.yaml"])
|
||||||
|
mock_ai_gen.assert_called_once()
|
||||||
|
args = mock_ai_gen.call_args[0][0]
|
||||||
|
assert args.data == ["new_playbook.yaml"]
|
||||||
|
assert args.action == "generate_ai"
|
||||||
|
|
||||||
|
def test_run_preflight_ai_node(app):
|
||||||
|
"""Test that connpy run --preflight-ai calls predict_execution_results and exits."""
|
||||||
|
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||||
|
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
app.start(["run", "router1", "show version", "--preflight-ai"])
|
||||||
|
|
||||||
|
assert exc.value.code == 0
|
||||||
|
mock_predict.assert_called_once_with(["router1"], ["show version"], chunk_callback=ANY)
|
||||||
|
|
||||||
|
def test_run_analyze_node(app):
|
||||||
|
"""Test that connpy run --analyze calls analyze_execution_results after execution."""
|
||||||
|
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "success"}})
|
||||||
|
|
||||||
|
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||||
|
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
|
||||||
|
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
|
||||||
|
app.start(["run", "router1", "show version", "--analyze"])
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
mock_analyze.assert_called_once_with(
|
||||||
|
{"router1": {"status": 0, "output": "success"}},
|
||||||
|
query="show version",
|
||||||
|
chunk_callback=ANY
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_run_preflight_ai_playbook(app, tmp_path):
|
||||||
|
"""Test that running a playbook with --preflight-ai predicts results per task."""
|
||||||
|
playbook_path = tmp_path / "test_playbook.yaml"
|
||||||
|
playbook_content = """
|
||||||
|
tasks:
|
||||||
|
- name: test-task
|
||||||
|
action: run
|
||||||
|
nodes: "router1"
|
||||||
|
commands: ["show ip interface brief"]
|
||||||
|
output: stdout
|
||||||
|
"""
|
||||||
|
playbook_path.write_text(playbook_content)
|
||||||
|
|
||||||
|
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||||
|
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
app.start(["run", str(playbook_path), "--preflight-ai"])
|
||||||
|
|
||||||
|
assert exc.value.code == 0
|
||||||
|
mock_predict.assert_called_once_with(["router1"], ["show ip interface brief"], chunk_callback=ANY)
|
||||||
|
|
||||||
|
def test_run_analyze_playbook(app, tmp_path):
|
||||||
|
"""Test that running a playbook with --analyze triggers strategic analysis on all task outcomes."""
|
||||||
|
playbook_path = tmp_path / "test_playbook.yaml"
|
||||||
|
playbook_content = """
|
||||||
|
tasks:
|
||||||
|
- name: test-task
|
||||||
|
action: run
|
||||||
|
nodes: "router1"
|
||||||
|
commands: ["show ip interface brief"]
|
||||||
|
output: stdout
|
||||||
|
"""
|
||||||
|
playbook_path.write_text(playbook_content)
|
||||||
|
|
||||||
|
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "ok"}})
|
||||||
|
|
||||||
|
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||||
|
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
|
||||||
|
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
|
||||||
|
app.start(["run", str(playbook_path), "--analyze"])
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
mock_analyze.assert_called_once_with(
|
||||||
|
{"router1": {"status": 0, "output": "ok"}},
|
||||||
|
query=f"Playbook: {str(playbook_path)}",
|
||||||
|
chunk_callback=ANY
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ai_generate_wizard_save(app, tmp_path):
|
||||||
|
"""Test that ai_generate wizard runs interactive chat loop, asks for validation and saves YAML."""
|
||||||
|
dest_yaml = tmp_path / "playbook.yaml"
|
||||||
|
|
||||||
|
mock_chat = MagicMock(return_value={
|
||||||
|
"response": "Here is your playbook.",
|
||||||
|
"chat_history": [],
|
||||||
|
"playbook_yaml": "tasks:\n - name: mytask"
|
||||||
|
})
|
||||||
|
app.services.ai.build_playbook_chat = mock_chat
|
||||||
|
|
||||||
|
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save
|
||||||
|
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]):
|
||||||
|
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||||
|
|
||||||
|
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
|
||||||
|
assert os.path.exists(dest_yaml)
|
||||||
|
with open(dest_yaml) as f:
|
||||||
|
content = f.read()
|
||||||
|
assert "tasks:" in content
|
||||||
|
|
||||||
|
def test_ai_generate_wizard_run(app, tmp_path):
|
||||||
|
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
|
||||||
|
dest_yaml = tmp_path / "playbook_run.yaml"
|
||||||
|
|
||||||
|
mock_chat = MagicMock(return_value={
|
||||||
|
"response": "Here is your playbook.",
|
||||||
|
"chat_history": [],
|
||||||
|
"playbook_yaml": "tasks:\n - name: mytask\n action: run\n nodes: '*'\n commands: ['show version']\n output: stdout"
|
||||||
|
})
|
||||||
|
app.services.ai.build_playbook_chat = mock_chat
|
||||||
|
|
||||||
|
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]):
|
||||||
|
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
|
||||||
|
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||||
|
|
||||||
|
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
|
||||||
|
assert os.path.exists(dest_yaml)
|
||||||
|
with open(dest_yaml) as f:
|
||||||
|
content = f.read()
|
||||||
|
assert "tasks:" in content
|
||||||
|
|
||||||
|
mock_yaml_run.assert_called_once()
|
||||||
|
args = mock_yaml_run.call_args[0][0]
|
||||||
|
assert args.data == [str(dest_yaml)]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from connpy.cli.sso_handler import SSOHandler
|
||||||
|
|
||||||
|
def test_sso_handler_add_provider_with_allowed_domains():
|
||||||
|
# 1. Setup mock app structure
|
||||||
|
app_mock = MagicMock()
|
||||||
|
app_mock.services.mode = "local"
|
||||||
|
app_mock.config.config = {"sso": {"providers": {}}}
|
||||||
|
|
||||||
|
handler = SSOHandler(app_mock)
|
||||||
|
|
||||||
|
# Mock inquirer prompts
|
||||||
|
mock_answers = {
|
||||||
|
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
|
||||||
|
"secret": "my-secret-key",
|
||||||
|
"username_claim": "email",
|
||||||
|
"algorithms": "RS256, HS256",
|
||||||
|
"allowed_domains": "yyy.com, company.org"
|
||||||
|
}
|
||||||
|
|
||||||
|
args_mock = MagicMock()
|
||||||
|
args_mock.provider = "google"
|
||||||
|
|
||||||
|
with patch("inquirer.prompt", return_value=mock_answers):
|
||||||
|
handler.add_provider(args_mock)
|
||||||
|
|
||||||
|
# Verify update_setting was called with the correct data structure
|
||||||
|
app_mock.services.config_svc.update_setting.assert_called_once()
|
||||||
|
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
|
||||||
|
|
||||||
|
assert saved_key == "sso"
|
||||||
|
assert "providers" in saved_sso_config
|
||||||
|
assert "google" in saved_sso_config["providers"]
|
||||||
|
|
||||||
|
google_config = saved_sso_config["providers"]["google"]
|
||||||
|
assert google_config["jwks_url"] == "https://accounts.google.com/.well-known/jwks.json"
|
||||||
|
assert google_config["secret"] == "my-secret-key"
|
||||||
|
assert google_config["username_claim"] == "email"
|
||||||
|
assert google_config["algorithms"] == ["RS256", "HS256"]
|
||||||
|
assert google_config["allowed_domains"] == ["yyy.com", "company.org"]
|
||||||
|
|
||||||
|
def test_sso_handler_add_provider_allowed_domains_empty():
|
||||||
|
app_mock = MagicMock()
|
||||||
|
app_mock.services.mode = "local"
|
||||||
|
app_mock.config.config = {"sso": {"providers": {}}}
|
||||||
|
|
||||||
|
handler = SSOHandler(app_mock)
|
||||||
|
|
||||||
|
mock_answers = {
|
||||||
|
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
|
||||||
|
"secret": "",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": "RS256",
|
||||||
|
"allowed_domains": " " # empty input
|
||||||
|
}
|
||||||
|
|
||||||
|
args_mock = MagicMock()
|
||||||
|
args_mock.provider = "google"
|
||||||
|
|
||||||
|
with patch("inquirer.prompt", return_value=mock_answers):
|
||||||
|
handler.add_provider(args_mock)
|
||||||
|
|
||||||
|
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
|
||||||
|
google_config = saved_sso_config["providers"]["google"]
|
||||||
|
|
||||||
|
assert "allowed_domains" not in google_config
|
||||||
@@ -199,4 +199,47 @@ class TestUserCompletions:
|
|||||||
assert "--help" in logout_completions
|
assert "--help" in logout_completions
|
||||||
|
|
||||||
|
|
||||||
|
class TestSsoCompletions:
|
||||||
|
def test_sso_command_options(self):
|
||||||
|
from connpy.completion import _build_tree, resolve_completion
|
||||||
|
tree = _build_tree([], [], [], {}, "/tmp")
|
||||||
|
|
||||||
|
# Test options at the "sso" level
|
||||||
|
sso_completions = resolve_completion(["sso", ""], tree)
|
||||||
|
assert "--add" in sso_completions
|
||||||
|
assert "--del" in sso_completions
|
||||||
|
assert "--rm" in sso_completions
|
||||||
|
assert "--show" in sso_completions
|
||||||
|
assert "--list" in sso_completions
|
||||||
|
assert "--ls" in sso_completions
|
||||||
|
|
||||||
|
def test_sso_action_completed_providers(self, tmp_path):
|
||||||
|
from connpy.completion import _build_tree, resolve_completion
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Create mock config.yaml with SSO providers
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_data = {
|
||||||
|
"config": {
|
||||||
|
"sso": {
|
||||||
|
"providers": {
|
||||||
|
"google": {"username_claim": "email"},
|
||||||
|
"authelia": {"username_claim": "sub"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(config_data, f)
|
||||||
|
|
||||||
|
tree = _build_tree([], [], [], {}, str(tmp_path))
|
||||||
|
|
||||||
|
# Resolve after --del, --rm, --show, --add
|
||||||
|
for action in ["--del", "--rm", "--show", "--add"]:
|
||||||
|
completions = resolve_completion(["sso", action, ""], tree)
|
||||||
|
assert "google" in completions
|
||||||
|
assert "authelia" in completions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,58 @@ class TestNodeTest:
|
|||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("1.1.1.1") == False
|
assert result.get("1.1.1.1") == False
|
||||||
|
|
||||||
|
def test_test_expected_regex(self, mock_pexpect):
|
||||||
|
"""Regex in expected matches correctly."""
|
||||||
|
child = mock_pexpect["child"]
|
||||||
|
child.expect.return_value = 0
|
||||||
|
|
||||||
|
from connpy.core import node
|
||||||
|
n = node("router1", "10.0.0.1", user="admin", password="")
|
||||||
|
|
||||||
|
with patch.object(n, '_connect', return_value=True):
|
||||||
|
n.child = child
|
||||||
|
n.mylog = io.BytesIO(b"Debian version 12.5")
|
||||||
|
with patch.object(n, '_logclean', return_value="Debian version 12.5"):
|
||||||
|
result = n.test(["cat /etc/debian_version"], "version \\d+\\.\\d+")
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result.get("version \\d+\\.\\d+") == True
|
||||||
|
|
||||||
|
def test_test_expected_invalid_regex(self, mock_pexpect):
|
||||||
|
"""Malformed regex defaults to literal matching safely."""
|
||||||
|
child = mock_pexpect["child"]
|
||||||
|
child.expect.return_value = 0
|
||||||
|
|
||||||
|
from connpy.core import node
|
||||||
|
n = node("router1", "10.0.0.1", user="admin", password="")
|
||||||
|
|
||||||
|
with patch.object(n, '_connect', return_value=True):
|
||||||
|
n.child = child
|
||||||
|
# (invalid is a malformed regex (missing closing paren), but matches literally
|
||||||
|
n.mylog = io.BytesIO(b"some (invalid text")
|
||||||
|
with patch.object(n, '_logclean', return_value="some (invalid text"):
|
||||||
|
result = n.test(["echo"], "(invalid")
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result.get("(invalid") == True
|
||||||
|
|
||||||
|
def test_test_expected_with_vars(self, mock_pexpect):
|
||||||
|
"""Expected output formats variables properly."""
|
||||||
|
child = mock_pexpect["child"]
|
||||||
|
child.expect.return_value = 0
|
||||||
|
|
||||||
|
from connpy.core import node
|
||||||
|
n = node("router1", "10.0.0.1", user="admin", password="")
|
||||||
|
|
||||||
|
with patch.object(n, '_connect', return_value=True):
|
||||||
|
n.child = child
|
||||||
|
n.mylog = io.BytesIO(b"Debian version 12")
|
||||||
|
with patch.object(n, '_logclean', return_value="Debian version 12"):
|
||||||
|
result = n.test(["echo"], "version {version_num}", vars={"version_num": "12"})
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result.get("version 12") == True
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# nodes (parallel) tests
|
# nodes (parallel) tests
|
||||||
|
|||||||
@@ -129,3 +129,232 @@ class TestGRPCAuthentication:
|
|||||||
# 4. Logging in with new password must succeed
|
# 4. Logging in with new password must succeed
|
||||||
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
|
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
|
||||||
assert login_res_new.token is not None
|
assert login_res_new.token is not None
|
||||||
|
|
||||||
|
def test_sso_login_success_and_auto_provision(self, channel, registry):
|
||||||
|
"""Tests that a valid SSO token successfully logs the user in and auto-provisions their account."""
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
# 1. Setup SSO configuration in the registry's shared config
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"authelia": {
|
||||||
|
"secret": "sso-shared-secret",
|
||||||
|
"username_claim": "preferred_username",
|
||||||
|
"algorithms": ["HS256"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Check that the user 'ssoalice' does not exist yet
|
||||||
|
assert not any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
|
||||||
|
|
||||||
|
# 3. Generate a valid SSO token signed with Authelia's secret
|
||||||
|
sso_token = jwt.encode(
|
||||||
|
{"preferred_username": "ssoalice"},
|
||||||
|
"sso-shared-secret",
|
||||||
|
algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Call login_sso
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="ssoalice",
|
||||||
|
id_token=sso_token,
|
||||||
|
provider="authelia"
|
||||||
|
)
|
||||||
|
login_res = auth_stub.login_sso(login_req)
|
||||||
|
|
||||||
|
assert login_res.username == "ssoalice"
|
||||||
|
assert isinstance(login_res.token, str)
|
||||||
|
assert login_res.expires_at > 0
|
||||||
|
|
||||||
|
# 5. Verify user 'ssoalice' was auto-created/provisioned
|
||||||
|
assert any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
|
||||||
|
|
||||||
|
# 6. Make an authenticated call to NodeService list_nodes with the returned token
|
||||||
|
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
|
||||||
|
req = connpy_pb2.FilterRequest()
|
||||||
|
metadata = [("authorization", f"Bearer {login_res.token}")]
|
||||||
|
res = node_stub.list_nodes(req, metadata=metadata)
|
||||||
|
assert res is not None
|
||||||
|
|
||||||
|
def test_sso_login_invalid_signature(self, channel, registry):
|
||||||
|
"""Verifies that an SSO token with an invalid signature fails with UNAUTHENTICATED."""
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"authelia": {
|
||||||
|
"secret": "sso-shared-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Token signed with a WRONG key
|
||||||
|
wrong_token = jwt.encode({"sub": "bob"}, "wrong-secret", algorithm="HS256")
|
||||||
|
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="bob",
|
||||||
|
id_token=wrong_token,
|
||||||
|
provider="authelia"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(grpc.RpcError) as exc:
|
||||||
|
auth_stub.login_sso(login_req)
|
||||||
|
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
|
||||||
|
assert "SSO Token validation failed" in exc.value.details()
|
||||||
|
|
||||||
|
def test_sso_login_mismatched_username(self, channel, registry):
|
||||||
|
"""Verifies that if the requested username doesn't match the token claim, it fails."""
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"authelia": {
|
||||||
|
"secret": "sso-shared-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode({"sub": "charlie"}, "sso-shared-secret", algorithm="HS256")
|
||||||
|
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="different_user",
|
||||||
|
id_token=token,
|
||||||
|
provider="authelia"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(grpc.RpcError) as exc:
|
||||||
|
auth_stub.login_sso(login_req)
|
||||||
|
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
|
||||||
|
assert "Mismatched username" in exc.value.details()
|
||||||
|
|
||||||
|
def test_sso_login_allowed_domains_success(self, channel, registry):
|
||||||
|
"""Verifies that SSO login succeeds if email matches allowed_domains."""
|
||||||
|
import jwt
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"google": {
|
||||||
|
"secret": "google-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"],
|
||||||
|
"allowed_domains": ["yyy.com", "other.org"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "john", "email": "john@yyy.com"},
|
||||||
|
"google-secret",
|
||||||
|
algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="john",
|
||||||
|
id_token=token,
|
||||||
|
provider="google"
|
||||||
|
)
|
||||||
|
login_res = auth_stub.login_sso(login_req)
|
||||||
|
assert login_res.username == "john"
|
||||||
|
|
||||||
|
def test_sso_login_allowed_domains_failed(self, channel, registry):
|
||||||
|
"""Verifies that SSO login fails if email does not match allowed_domains."""
|
||||||
|
import jwt
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"google": {
|
||||||
|
"secret": "google-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"],
|
||||||
|
"allowed_domains": ["yyy.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "john", "email": "john@attacker.com"},
|
||||||
|
"google-secret",
|
||||||
|
algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="john",
|
||||||
|
id_token=token,
|
||||||
|
provider="google"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(grpc.RpcError) as exc:
|
||||||
|
auth_stub.login_sso(login_req)
|
||||||
|
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
|
||||||
|
assert "SSO user domain 'attacker.com' not allowed" in exc.value.details()
|
||||||
|
|
||||||
|
def test_sso_login_allowed_domains_fallback_to_username(self, channel, registry):
|
||||||
|
"""Verifies allowed_domains validation falls back to username claim if email is not present."""
|
||||||
|
import jwt
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"google": {
|
||||||
|
"secret": "google-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"],
|
||||||
|
"allowed_domains": ["yyy.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{"sub": "john@yyy.com"},
|
||||||
|
"google-secret",
|
||||||
|
algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_req = connpy_pb2.LoginSSORequest(
|
||||||
|
username="john",
|
||||||
|
id_token=token,
|
||||||
|
provider="google"
|
||||||
|
)
|
||||||
|
login_res = auth_stub.login_sso(login_req)
|
||||||
|
assert login_res.username == "john"
|
||||||
|
|
||||||
|
def test_login_and_login_sso_expiration_time(self, channel, registry):
|
||||||
|
"""Verifies expires_at is set to 12 hours in both login and login_sso."""
|
||||||
|
import jwt
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
# 1. Test standard login expiration
|
||||||
|
registry.user_service.create_user("exp_user", "password123")
|
||||||
|
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
|
||||||
|
login_res = auth_stub.login(connpy_pb2.LoginRequest(username="exp_user", password="password123"))
|
||||||
|
|
||||||
|
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
expected_expires_12h = now + 12 * 3600
|
||||||
|
# Allow a 10s buffer for execution lag
|
||||||
|
assert abs(login_res.expires_at - expected_expires_12h) < 10
|
||||||
|
|
||||||
|
# 2. Test SSO login expiration
|
||||||
|
registry._shared_config.config["sso"] = {
|
||||||
|
"providers": {
|
||||||
|
"authelia": {
|
||||||
|
"secret": "sso-secret",
|
||||||
|
"username_claim": "sub",
|
||||||
|
"algorithms": ["HS256"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token = jwt.encode({"sub": "sso_exp_user"}, "sso-secret", algorithm="HS256")
|
||||||
|
login_sso_res = auth_stub.login_sso(connpy_pb2.LoginSSORequest(
|
||||||
|
username="sso_exp_user",
|
||||||
|
id_token=token,
|
||||||
|
provider="authelia"
|
||||||
|
))
|
||||||
|
|
||||||
|
assert abs(login_sso_res.expires_at - expected_expires_12h) < 10
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
from connpy.services.ai_service import AIService
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PlaybookBuilderAgent validation tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_validate_playbook_valid(ai_config):
|
||||||
|
"""Verifies that a valid canonical tasks[] playbook passes validation."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
valid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply standard config"
|
||||||
|
action: "run"
|
||||||
|
nodes: "router1"
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
- "end"
|
||||||
|
output: "stdout"
|
||||||
|
- name: "Verify connectivity"
|
||||||
|
action: "test"
|
||||||
|
nodes: "router1"
|
||||||
|
commands:
|
||||||
|
- "ping 10.0.0.1"
|
||||||
|
expected: "!"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = agent.validate_playbook(valid_yaml)
|
||||||
|
assert res["valid"] is True
|
||||||
|
assert "valid" in res["message"].lower()
|
||||||
|
|
||||||
|
def test_validate_playbook_invalid_yaml(ai_config):
|
||||||
|
"""Verifies that syntax errors in YAML are caught and reported."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Broken task"
|
||||||
|
action: "run
|
||||||
|
nodes: "router1"
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "syntax error" in res["error"].lower()
|
||||||
|
|
||||||
|
def test_validate_playbook_missing_tasks_key(ai_config):
|
||||||
|
"""Verifies that a playbook without tasks root key is invalid."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
not_tasks:
|
||||||
|
- name: "Apply standard config"
|
||||||
|
action: "run"
|
||||||
|
nodes: "router1"
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "missing mandatory root 'tasks' key" in res["error"].lower()
|
||||||
|
|
||||||
|
def test_validate_playbook_missing_mandatory_fields(ai_config):
|
||||||
|
"""Verifies that missing name, action, nodes, commands, or output triggers a validation failure."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
# Missing nodes
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply standard config"
|
||||||
|
action: "run"
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "missing mandatory fields" in res["error"].lower()
|
||||||
|
assert "nodes" in res["error"]
|
||||||
|
|
||||||
|
def test_validate_playbook_invalid_action(ai_config):
|
||||||
|
"""Verifies that an unsupported action type is caught."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply standard config"
|
||||||
|
action: "delete_everything"
|
||||||
|
nodes: "router1"
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "invalid action" in res["error"].lower()
|
||||||
|
|
||||||
|
def test_validate_playbook_missing_expected_in_test(ai_config):
|
||||||
|
"""Verifies that action 'test' requires the expected field."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply standard config"
|
||||||
|
action: "test"
|
||||||
|
nodes: "router1"
|
||||||
|
commands:
|
||||||
|
- "ping 10.0.0.1"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "missing the mandatory 'expected' key" in res["error"].lower()
|
||||||
|
|
||||||
|
def test_validate_playbook_invalid_nodes_type(ai_config):
|
||||||
|
"""Verifies that nodes of invalid type (e.g. integer) is caught."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply config"
|
||||||
|
action: "run"
|
||||||
|
nodes: 12345
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "nodes' must be a string (regex) or a list of strings (regexes)" in res["error"]
|
||||||
|
|
||||||
|
def test_validate_playbook_invalid_nodes_list_item(ai_config):
|
||||||
|
"""Verifies that nodes list containing non-string items is caught."""
|
||||||
|
agent = PlaybookBuilderAgent(ai_config)
|
||||||
|
|
||||||
|
invalid_yaml = """
|
||||||
|
tasks:
|
||||||
|
- name: "Apply config"
|
||||||
|
action: "run"
|
||||||
|
nodes:
|
||||||
|
- "router1"
|
||||||
|
- 9999
|
||||||
|
commands:
|
||||||
|
- "conf t"
|
||||||
|
output: "stdout"
|
||||||
|
"""
|
||||||
|
res = agent.validate_playbook(invalid_yaml)
|
||||||
|
assert res["valid"] is False
|
||||||
|
assert "list contains a non-string value" in res["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# AIService new methods delegation tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def test_build_playbook_chat_delegation(ai_config):
|
||||||
|
"""Verifies that build_playbook_chat instantiates PlaybookBuilderAgent and delegates ask."""
|
||||||
|
service = AIService(ai_config)
|
||||||
|
|
||||||
|
with patch("connpy.ai.PlaybookBuilderAgent") as MockAgentClass:
|
||||||
|
mock_agent = MockAgentClass.return_value
|
||||||
|
mock_agent.ask.return_value = {"response": "Mock response", "chat_history": []}
|
||||||
|
|
||||||
|
history = [{"role": "user", "content": "build playbook"}]
|
||||||
|
res = service.build_playbook_chat("help me", chat_history=history)
|
||||||
|
|
||||||
|
MockAgentClass.assert_called_once_with(ai_config)
|
||||||
|
mock_agent.ask.assert_called_once_with("help me", chat_history=history, status=None, chunk_callback=None)
|
||||||
|
assert res["response"] == "Mock response"
|
||||||
|
|
||||||
|
def test_analyze_execution_results_delegation(ai_config):
|
||||||
|
"""Verifies that analyze_execution_results formats prompt with @architect and delegates to self.ask."""
|
||||||
|
service = AIService(ai_config)
|
||||||
|
service.ask = MagicMock()
|
||||||
|
|
||||||
|
results = {"router1": {"output": "success", "status": 0}}
|
||||||
|
service.analyze_execution_results(results, query="diagnose border")
|
||||||
|
|
||||||
|
service.ask.assert_called_once()
|
||||||
|
args, kwargs = service.ask.call_args
|
||||||
|
prompt = args[0]
|
||||||
|
|
||||||
|
assert prompt.startswith("@architect:")
|
||||||
|
assert "diagnose border" in prompt
|
||||||
|
assert "Results Data:" in prompt
|
||||||
|
assert "router1" in prompt
|
||||||
|
assert kwargs.get("one_shot") is True
|
||||||
|
|
||||||
|
def test_predict_execution_results_delegation(ai_config):
|
||||||
|
"""Verifies that predict_execution_results formats prompt with @engineer and delegates to self.ask."""
|
||||||
|
service = AIService(ai_config)
|
||||||
|
service.ask = MagicMock()
|
||||||
|
|
||||||
|
nodes = ["router1", "router2"]
|
||||||
|
commands = ["conf t", "interface lo0"]
|
||||||
|
service.predict_execution_results(nodes, commands)
|
||||||
|
|
||||||
|
service.ask.assert_called_once()
|
||||||
|
args, kwargs = service.ask.call_args
|
||||||
|
prompt = args[0]
|
||||||
|
|
||||||
|
assert prompt.startswith("@engineer:")
|
||||||
|
assert "Preflight Simulation Agent" in prompt
|
||||||
|
assert "router1, router2" in prompt
|
||||||
|
assert "conf t" in prompt
|
||||||
|
assert "interface lo0" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# gRPC Integration Tests for AIService
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
from concurrent import futures
|
||||||
|
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
|
||||||
|
|
||||||
|
class TestGRPCAIIntegration:
|
||||||
|
@pytest.fixture
|
||||||
|
def grpc_server(self, populated_config):
|
||||||
|
"""Starts a local gRPC server for IA integration testing."""
|
||||||
|
srv = grpc.server(futures.ThreadPoolExecutor(max_workers=5))
|
||||||
|
connpy_pb2_grpc.add_AIServiceServicer_to_server(server.ServerServicer(populated_config).ai if hasattr(server, 'ServerServicer') else server.AIServicer(populated_config), srv)
|
||||||
|
port = srv.add_insecure_port('127.0.0.1:0')
|
||||||
|
srv.start()
|
||||||
|
yield f"127.0.0.1:{port}"
|
||||||
|
srv.stop(0)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def channel(self, grpc_server):
|
||||||
|
with grpc.insecure_channel(grpc_server) as channel:
|
||||||
|
yield channel
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ai_stub(self, channel):
|
||||||
|
return stubs.AIStub(channel, "localhost")
|
||||||
|
|
||||||
|
def test_build_playbook_chat_grpc(self, ai_stub, populated_config):
|
||||||
|
"""Verifies that build_playbook_chat gRPC stream functions correctly."""
|
||||||
|
# Mock PlaybookBuilderAgent.ask to simulate agent response stream
|
||||||
|
def mock_ask(user_input, chat_history=None, status=None, debug=False, chunk_callback=None):
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback("Generated Tasks:\n- name: config")
|
||||||
|
return {"response": "Done", "playbook_yaml": "tasks:\n- name: config"}
|
||||||
|
|
||||||
|
with patch("connpy.ai.PlaybookBuilderAgent.ask", side_effect=mock_ask):
|
||||||
|
chunks = []
|
||||||
|
def callback(chunk):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
res = ai_stub.build_playbook_chat("make playbook", chunk_callback=callback)
|
||||||
|
assert "tasks:" in res["playbook_yaml"]
|
||||||
|
assert len(chunks) > 0
|
||||||
|
assert "Generated Tasks:" in chunks[0]
|
||||||
|
|
||||||
|
def test_analyze_execution_results_grpc(self, ai_stub, populated_config):
|
||||||
|
"""Verifies that analyze_execution_results gRPC stream functions correctly."""
|
||||||
|
# Mock AIService.ask to simulate response stream
|
||||||
|
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback("Results are optimal.")
|
||||||
|
return {"response": "Done"}
|
||||||
|
|
||||||
|
with patch.object(AIService, "ask", side_effect=mock_ask):
|
||||||
|
chunks = []
|
||||||
|
def callback(chunk):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
res = ai_stub.analyze_execution_results({"r1": "ok"}, query="test query", chunk_callback=callback)
|
||||||
|
assert res is not None
|
||||||
|
assert len(chunks) > 0
|
||||||
|
assert "optimal" in chunks[0]
|
||||||
|
|
||||||
|
def test_predict_execution_results_grpc(self, ai_stub, populated_config):
|
||||||
|
"""Verifies that predict_execution_results gRPC stream functions correctly."""
|
||||||
|
# Mock AIService.ask to simulate response stream
|
||||||
|
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback("Commands are safe.")
|
||||||
|
return {"response": "Done"}
|
||||||
|
|
||||||
|
with patch.object(AIService, "ask", side_effect=mock_ask):
|
||||||
|
chunks = []
|
||||||
|
def callback(chunk):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
res = ai_stub.predict_execution_results(["r1"], ["show version"], chunk_callback=callback)
|
||||||
|
assert res is not None
|
||||||
|
assert len(chunks) > 0
|
||||||
|
assert "safe" in chunks[0]
|
||||||
@@ -90,7 +90,7 @@ el.replaceWith(d);
|
|||||||
if args.mcp is not None:
|
if args.mcp is not None:
|
||||||
return self.configure_mcp(args)
|
return self.configure_mcp(args)
|
||||||
|
|
||||||
# Determinar session_id para retomar
|
# Determine session_id to resume
|
||||||
session_id = None
|
session_id = None
|
||||||
if args.resume:
|
if args.resume:
|
||||||
sessions, _ = self.app.services.ai.list_sessions()
|
sessions, _ = self.app.services.ai.list_sessions()
|
||||||
@@ -100,8 +100,8 @@ el.replaceWith(d);
|
|||||||
elif args.session:
|
elif args.session:
|
||||||
session_id = args.session[0]
|
session_id = args.session[0]
|
||||||
|
|
||||||
# Configurar argumentos adicionales para el servicio de AI
|
# Configure additional arguments for the AI service
|
||||||
# Prioridad: CLI Args > Configuración Local
|
# Priority: CLI Args > Local Config
|
||||||
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
||||||
arguments = {}
|
arguments = {}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ el.replaceWith(d);
|
|||||||
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
||||||
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
||||||
|
|
||||||
# El resto de la interacción el CLI la maneja con el agente subyacente
|
# The rest of the interaction is handled by the CLI with the underlying agent
|
||||||
self.app.myai = self.app.services.ai
|
self.app.myai = self.app.services.ai
|
||||||
self.ai_overrides = arguments
|
self.ai_overrides = arguments
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
def single_question(self, args, session_id):
|
def single_question(self, args, session_id):
|
||||||
query = " ".join(args.ask)
|
query = " ".join(args.ask)
|
||||||
with console.status("[ai_status]Agent is thinking and analyzing...") as status:
|
with console.status("[ai_status]Agent is thinking and analyzing...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
||||||
|
|
||||||
responder = result.get("responder", "engineer")
|
responder = result.get("responder", "engineer")
|
||||||
@@ -177,7 +177,7 @@ el.replaceWith(d);
|
|||||||
if not user_query.strip(): continue
|
if not user_query.strip(): continue
|
||||||
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
||||||
|
|
||||||
with console.status("[ai_status]Agent is thinking...") as status:
|
with console.status("[ai_status]Agent is thinking...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
||||||
|
|
||||||
new_history = result.get("chat_history")
|
new_history = result.get("chat_history")
|
||||||
@@ -502,7 +502,7 @@ el.replaceWith(d);
|
|||||||
if args.mcp is not None:
|
if args.mcp is not None:
|
||||||
return self.configure_mcp(args)
|
return self.configure_mcp(args)
|
||||||
|
|
||||||
# Determinar session_id para retomar
|
# Determine session_id to resume
|
||||||
session_id = None
|
session_id = None
|
||||||
if args.resume:
|
if args.resume:
|
||||||
sessions, _ = self.app.services.ai.list_sessions()
|
sessions, _ = self.app.services.ai.list_sessions()
|
||||||
@@ -512,8 +512,8 @@ el.replaceWith(d);
|
|||||||
elif args.session:
|
elif args.session:
|
||||||
session_id = args.session[0]
|
session_id = args.session[0]
|
||||||
|
|
||||||
# Configurar argumentos adicionales para el servicio de AI
|
# Configure additional arguments for the AI service
|
||||||
# Prioridad: CLI Args > Configuración Local
|
# Priority: CLI Args > Local Config
|
||||||
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
settings = self.app.services.config_svc.get_settings().get("ai", {})
|
||||||
arguments = {}
|
arguments = {}
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ el.replaceWith(d);
|
|||||||
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
|
||||||
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
|
||||||
|
|
||||||
# El resto de la interacción el CLI la maneja con el agente subyacente
|
# The rest of the interaction is handled by the CLI with the underlying agent
|
||||||
self.app.myai = self.app.services.ai
|
self.app.myai = self.app.services.ai
|
||||||
self.ai_overrides = arguments
|
self.ai_overrides = arguments
|
||||||
|
|
||||||
@@ -583,7 +583,7 @@ el.replaceWith(d);
|
|||||||
if not user_query.strip(): continue
|
if not user_query.strip(): continue
|
||||||
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
|
||||||
|
|
||||||
with console.status("[ai_status]Agent is thinking...") as status:
|
with console.status("[ai_status]Agent is thinking...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
|
||||||
|
|
||||||
new_history = result.get("chat_history")
|
new_history = result.get("chat_history")
|
||||||
@@ -618,7 +618,7 @@ el.replaceWith(d);
|
|||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">def single_question(self, args, session_id):
|
<pre><code class="python">def single_question(self, args, session_id):
|
||||||
query = " ".join(args.ask)
|
query = " ".join(args.ask)
|
||||||
with console.status("[ai_status]Agent is thinking and analyzing...") as status:
|
with console.status("[ai_status]Agent is thinking and analyzing...[/ai_status]") as status:
|
||||||
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
|
||||||
|
|
||||||
responder = result.get("responder", "engineer")
|
responder = result.get("responder", "engineer")
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ el.replaceWith(d);
|
|||||||
<dd>
|
<dd>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt>
|
||||||
|
<dd>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt><code class="name"><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></dt>
|
<dt><code class="name"><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></dt>
|
||||||
<dd>
|
<dd>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
@@ -142,6 +146,7 @@ el.replaceWith(d);
|
|||||||
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
|
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
|
||||||
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
|
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
|
||||||
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
|
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
|
||||||
|
<li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li>
|
||||||
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
|
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
|
||||||
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
|
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
|
||||||
<li><code><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></li>
|
<li><code><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></li>
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ el.replaceWith(d);
|
|||||||
def dispatch(self, args):
|
def dispatch(self, args):
|
||||||
if len(args.data) > 1:
|
if len(args.data) > 1:
|
||||||
args.action = "noderun"
|
args.action = "noderun"
|
||||||
actions = {"noderun": self.node_run, "generate": self.yaml_generate, "run": self.yaml_run}
|
actions = {
|
||||||
|
"noderun": self.node_run,
|
||||||
|
"generate": self.yaml_generate,
|
||||||
|
"generate_ai": self.ai_generate,
|
||||||
|
"run": self.yaml_run
|
||||||
|
}
|
||||||
return actions.get(args.action)(args)
|
return actions.get(args.action)(args)
|
||||||
|
|
||||||
def node_run(self, args):
|
def node_run(self, args):
|
||||||
@@ -81,6 +86,41 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
commands = [" ".join(args.data[1:])]
|
commands = [" ".join(args.data[1:])]
|
||||||
|
|
||||||
|
# Check for Preflight AI simulation
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
matched_node_names = [n.get("name") if isinstance(n, dict) else n for n in matched_nodes]
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
matched_node_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
|
|
||||||
@@ -118,6 +158,40 @@ el.replaceWith(d);
|
|||||||
)
|
)
|
||||||
printer.run_summary(results)
|
printer.run_summary(results)
|
||||||
|
|
||||||
|
# Analyze execution results if requested
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else " ".join(args.data[1:])
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -138,8 +212,105 @@ el.replaceWith(d);
|
|||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
# Check preflight first before any task runs
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
preflight_failed = False
|
||||||
|
for task in playbook.get("tasks", []):
|
||||||
|
name = task.get("name", "Task")
|
||||||
|
nodelist = task.get("nodes", [])
|
||||||
|
commands = task.get("commands", [])
|
||||||
|
|
||||||
|
# Resolve nodes to names
|
||||||
|
try:
|
||||||
|
if isinstance(nodelist, str):
|
||||||
|
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||||
|
elif isinstance(nodelist, list):
|
||||||
|
resolved_nodes = []
|
||||||
|
for item in nodelist:
|
||||||
|
matches = self.app.services.nodes.list_nodes(item)
|
||||||
|
for m in matches:
|
||||||
|
if m not in resolved_nodes:
|
||||||
|
resolved_nodes.append(m)
|
||||||
|
else:
|
||||||
|
resolved_nodes = []
|
||||||
|
except Exception:
|
||||||
|
resolved_nodes = []
|
||||||
|
|
||||||
|
resolved_names = [n.get("name") if isinstance(n, dict) else n for n in resolved_nodes]
|
||||||
|
printer.console.print(f"\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)")
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
resolved_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed for task {name}: {e}")
|
||||||
|
preflight_failed = True
|
||||||
|
if preflight_failed:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Standard run
|
||||||
|
results_all = {}
|
||||||
for task in playbook.get("tasks", []):
|
for task in playbook.get("tasks", []):
|
||||||
self.cli_run(task)
|
task_res = self.cli_run(task)
|
||||||
|
if task_res:
|
||||||
|
results_all.update(task_res)
|
||||||
|
|
||||||
|
# If analyze is enabled, run analysis on accumulated results
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing playbook execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else f"Playbook: {path}"
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results_all,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
printer.error(f"Failed to run playbook {path}: {e}")
|
printer.error(f"Failed to run playbook {path}: {e}")
|
||||||
@@ -184,6 +355,7 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
nodelist = resolved_nodes
|
nodelist = resolved_nodes
|
||||||
|
|
||||||
|
results = {}
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
if action == "run":
|
if action == "run":
|
||||||
@@ -243,13 +415,244 @@ el.replaceWith(d);
|
|||||||
)
|
)
|
||||||
# ALWAYS show the aggregate summary at the end
|
# ALWAYS show the aggregate summary at the end
|
||||||
printer.test_summary(results)
|
printer.test_summary(results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))</code></pre>
|
printer.error(str(e))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def ai_generate(self, args):
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.rule import Rule
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
dest_file = args.data[0]
|
||||||
|
if os.path.exists(dest_file):
|
||||||
|
printer.error(f"File '{dest_file}' already exists.")
|
||||||
|
sys.exit(14)
|
||||||
|
|
||||||
|
chat_history = []
|
||||||
|
|
||||||
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Operation cancelled by user.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_prompt.strip().lower() in ["exit", "quit"]:
|
||||||
|
printer.info("Exiting AI Assistant.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_prompt.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
res = self.app.services.ai.build_playbook_chat(
|
||||||
|
user_prompt,
|
||||||
|
chat_history=chat_history,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
renderer.flush()
|
||||||
|
if not first_chunk:
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
if res and "chat_history" in res:
|
||||||
|
chat_history = res["chat_history"]
|
||||||
|
|
||||||
|
# Check if the agent returned a validated playbook YAML
|
||||||
|
if res and "playbook_yaml" in res and res["playbook_yaml"]:
|
||||||
|
yaml_content = res["playbook_yaml"]
|
||||||
|
printer.console.print()
|
||||||
|
printer.success("Playbook YAML successfully generated and validated.")
|
||||||
|
|
||||||
|
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
|
||||||
|
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
|
||||||
|
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
|
||||||
|
printer.console.print(panel)
|
||||||
|
|
||||||
|
# Ask if the user wants to save it
|
||||||
|
try:
|
||||||
|
save_confirm = Prompt.ask(
|
||||||
|
f"\nDo you want to save this playbook to '{dest_file}'?",
|
||||||
|
choices=["y", "n", "run"],
|
||||||
|
default="y"
|
||||||
|
)
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Saving skipped.")
|
||||||
|
break
|
||||||
|
|
||||||
|
choice = save_confirm.strip().lower()
|
||||||
|
if choice in ["y", "yes", "run"]:
|
||||||
|
with open(dest_file, "w") as f:
|
||||||
|
f.write(yaml_content)
|
||||||
|
printer.success(f"Playbook saved successfully to '{dest_file}'")
|
||||||
|
if choice == "run":
|
||||||
|
printer.console.print()
|
||||||
|
printer.info("Executing the saved playbook...")
|
||||||
|
self.yaml_run(args)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
printer.warning("Playbook not saved. You can continue describing changes or exit.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Error in AI chat: {e}")</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
<h3>Methods</h3>
|
<h3>Methods</h3>
|
||||||
<dl>
|
<dl>
|
||||||
|
<dt id="connpy.cli.run_handler.RunHandler.ai_generate"><code class="name flex">
|
||||||
|
<span>def <span class="ident">ai_generate</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def ai_generate(self, args):
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.rule import Rule
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
dest_file = args.data[0]
|
||||||
|
if os.path.exists(dest_file):
|
||||||
|
printer.error(f"File '{dest_file}' already exists.")
|
||||||
|
sys.exit(14)
|
||||||
|
|
||||||
|
chat_history = []
|
||||||
|
|
||||||
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Operation cancelled by user.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if user_prompt.strip().lower() in ["exit", "quit"]:
|
||||||
|
printer.info("Exiting AI Assistant.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_prompt.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
res = self.app.services.ai.build_playbook_chat(
|
||||||
|
user_prompt,
|
||||||
|
chat_history=chat_history,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try:
|
||||||
|
status_context.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
renderer.flush()
|
||||||
|
if not first_chunk:
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
if res and "chat_history" in res:
|
||||||
|
chat_history = res["chat_history"]
|
||||||
|
|
||||||
|
# Check if the agent returned a validated playbook YAML
|
||||||
|
if res and "playbook_yaml" in res and res["playbook_yaml"]:
|
||||||
|
yaml_content = res["playbook_yaml"]
|
||||||
|
printer.console.print()
|
||||||
|
printer.success("Playbook YAML successfully generated and validated.")
|
||||||
|
|
||||||
|
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
|
||||||
|
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
|
||||||
|
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
|
||||||
|
printer.console.print(panel)
|
||||||
|
|
||||||
|
# Ask if the user wants to save it
|
||||||
|
try:
|
||||||
|
save_confirm = Prompt.ask(
|
||||||
|
f"\nDo you want to save this playbook to '{dest_file}'?",
|
||||||
|
choices=["y", "n", "run"],
|
||||||
|
default="y"
|
||||||
|
)
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
printer.console.print()
|
||||||
|
printer.warning("Saving skipped.")
|
||||||
|
break
|
||||||
|
|
||||||
|
choice = save_confirm.strip().lower()
|
||||||
|
if choice in ["y", "yes", "run"]:
|
||||||
|
with open(dest_file, "w") as f:
|
||||||
|
f.write(yaml_content)
|
||||||
|
printer.success(f"Playbook saved successfully to '{dest_file}'")
|
||||||
|
if choice == "run":
|
||||||
|
printer.console.print()
|
||||||
|
printer.info("Executing the saved playbook...")
|
||||||
|
self.yaml_run(args)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
printer.warning("Playbook not saved. You can continue describing changes or exit.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Error in AI chat: {e}")</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.cli.run_handler.RunHandler.cli_run"><code class="name flex">
|
<dt id="connpy.cli.run_handler.RunHandler.cli_run"><code class="name flex">
|
||||||
<span>def <span class="ident">cli_run</span></span>(<span>self, script)</span>
|
<span>def <span class="ident">cli_run</span></span>(<span>self, script)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -297,6 +700,7 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
nodelist = resolved_nodes
|
nodelist = resolved_nodes
|
||||||
|
|
||||||
|
results = {}
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
if action == "run":
|
if action == "run":
|
||||||
@@ -356,9 +760,12 @@ el.replaceWith(d);
|
|||||||
)
|
)
|
||||||
# ALWAYS show the aggregate summary at the end
|
# ALWAYS show the aggregate summary at the end
|
||||||
printer.test_summary(results)
|
printer.test_summary(results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))</code></pre>
|
printer.error(str(e))
|
||||||
|
return {}</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -373,7 +780,12 @@ el.replaceWith(d);
|
|||||||
<pre><code class="python">def dispatch(self, args):
|
<pre><code class="python">def dispatch(self, args):
|
||||||
if len(args.data) > 1:
|
if len(args.data) > 1:
|
||||||
args.action = "noderun"
|
args.action = "noderun"
|
||||||
actions = {"noderun": self.node_run, "generate": self.yaml_generate, "run": self.yaml_run}
|
actions = {
|
||||||
|
"noderun": self.node_run,
|
||||||
|
"generate": self.yaml_generate,
|
||||||
|
"generate_ai": self.ai_generate,
|
||||||
|
"run": self.yaml_run
|
||||||
|
}
|
||||||
return actions.get(args.action)(args)</code></pre>
|
return actions.get(args.action)(args)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
@@ -401,6 +813,41 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
commands = [" ".join(args.data[1:])]
|
commands = [" ".join(args.data[1:])]
|
||||||
|
|
||||||
|
# Check for Preflight AI simulation
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
matched_node_names = [n.get("name") if isinstance(n, dict) else n for n in matched_nodes]
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
matched_node_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[engineer][bold]Preflight AI Simulation[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
header_printed = False
|
header_printed = False
|
||||||
|
|
||||||
@@ -438,6 +885,40 @@ el.replaceWith(d);
|
|||||||
)
|
)
|
||||||
printer.run_summary(results)
|
printer.run_summary(results)
|
||||||
|
|
||||||
|
# Analyze execution results if requested
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else " ".join(args.data[1:])
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except ConnpyError as e:
|
except ConnpyError as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
sys.exit(1)</code></pre>
|
sys.exit(1)</code></pre>
|
||||||
@@ -478,8 +959,105 @@ el.replaceWith(d);
|
|||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
||||||
|
|
||||||
|
# Check preflight first before any task runs
|
||||||
|
if getattr(args, "preflight_ai", False):
|
||||||
|
preflight_failed = False
|
||||||
|
for task in playbook.get("tasks", []):
|
||||||
|
name = task.get("name", "Task")
|
||||||
|
nodelist = task.get("nodes", [])
|
||||||
|
commands = task.get("commands", [])
|
||||||
|
|
||||||
|
# Resolve nodes to names
|
||||||
|
try:
|
||||||
|
if isinstance(nodelist, str):
|
||||||
|
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||||
|
elif isinstance(nodelist, list):
|
||||||
|
resolved_nodes = []
|
||||||
|
for item in nodelist:
|
||||||
|
matches = self.app.services.nodes.list_nodes(item)
|
||||||
|
for m in matches:
|
||||||
|
if m not in resolved_nodes:
|
||||||
|
resolved_nodes.append(m)
|
||||||
|
else:
|
||||||
|
resolved_nodes = []
|
||||||
|
except Exception:
|
||||||
|
resolved_nodes = []
|
||||||
|
|
||||||
|
resolved_names = [n.get("name") if isinstance(n, dict) else n for n in resolved_nodes]
|
||||||
|
printer.console.print(f"\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)")
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Simulating execution...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.predict_execution_results(
|
||||||
|
resolved_names,
|
||||||
|
commands,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title=f"[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]", style="engineer"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Preflight AI simulation failed for task {name}: {e}")
|
||||||
|
preflight_failed = True
|
||||||
|
if preflight_failed:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Standard run
|
||||||
|
results_all = {}
|
||||||
for task in playbook.get("tasks", []):
|
for task in playbook.get("tasks", []):
|
||||||
self.cli_run(task)
|
task_res = self.cli_run(task)
|
||||||
|
if task_res:
|
||||||
|
results_all.update(task_res)
|
||||||
|
|
||||||
|
# If analyze is enabled, run analysis on accumulated results
|
||||||
|
if getattr(args, "analyze", None) is not None:
|
||||||
|
printer.console.print()
|
||||||
|
|
||||||
|
renderer = printer.BlockMarkdownRenderer()
|
||||||
|
first_chunk = True
|
||||||
|
status_context = printer.console.status("[ai_status]Analyzing playbook execution results...[/ai_status]")
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
nonlocal first_chunk
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
first_chunk = False
|
||||||
|
renderer.feed(chunk)
|
||||||
|
|
||||||
|
query = args.analyze if args.analyze else f"Playbook: {path}"
|
||||||
|
try:
|
||||||
|
status_context.start()
|
||||||
|
self.app.services.ai.analyze_execution_results(
|
||||||
|
results_all,
|
||||||
|
query=query,
|
||||||
|
chunk_callback=callback
|
||||||
|
)
|
||||||
|
if first_chunk:
|
||||||
|
try: status_context.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
|
||||||
|
renderer.flush()
|
||||||
|
printer.console.print(Rule(style="architect"))
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"AI Analysis failed: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
printer.error(f"Failed to run playbook {path}: {e}")
|
printer.error(f"Failed to run playbook {path}: {e}")
|
||||||
@@ -506,7 +1084,8 @@ el.replaceWith(d);
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<h4><code><a title="connpy.cli.run_handler.RunHandler" href="#connpy.cli.run_handler.RunHandler">RunHandler</a></code></h4>
|
<h4><code><a title="connpy.cli.run_handler.RunHandler" href="#connpy.cli.run_handler.RunHandler">RunHandler</a></code></h4>
|
||||||
<ul class="">
|
<ul class="two-column">
|
||||||
|
<li><code><a title="connpy.cli.run_handler.RunHandler.ai_generate" href="#connpy.cli.run_handler.RunHandler.ai_generate">ai_generate</a></code></li>
|
||||||
<li><code><a title="connpy.cli.run_handler.RunHandler.cli_run" href="#connpy.cli.run_handler.RunHandler.cli_run">cli_run</a></code></li>
|
<li><code><a title="connpy.cli.run_handler.RunHandler.cli_run" href="#connpy.cli.run_handler.RunHandler.cli_run">cli_run</a></code></li>
|
||||||
<li><code><a title="connpy.cli.run_handler.RunHandler.dispatch" href="#connpy.cli.run_handler.RunHandler.dispatch">dispatch</a></code></li>
|
<li><code><a title="connpy.cli.run_handler.RunHandler.dispatch" href="#connpy.cli.run_handler.RunHandler.dispatch">dispatch</a></code></li>
|
||||||
<li><code><a title="connpy.cli.run_handler.RunHandler.node_run" href="#connpy.cli.run_handler.RunHandler.node_run">node_run</a></code></li>
|
<li><code><a title="connpy.cli.run_handler.RunHandler.node_run" href="#connpy.cli.run_handler.RunHandler.node_run">node_run</a></code></li>
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||||
|
<meta name="generator" content="pdoc3 0.11.5">
|
||||||
|
<title>connpy.cli.sso_handler API documentation</title>
|
||||||
|
<meta name="description" content="">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
|
||||||
|
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
|
||||||
|
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
|
||||||
|
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
|
||||||
|
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
|
||||||
|
<script>window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
|
||||||
|
hljs.highlightAll();
|
||||||
|
/* Collapse source docstrings */
|
||||||
|
setTimeout(() => {
|
||||||
|
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
|
||||||
|
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
|
||||||
|
.forEach(el => {
|
||||||
|
let d = document.createElement('details');
|
||||||
|
d.classList.add('hljs-string');
|
||||||
|
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
|
||||||
|
el.replaceWith(d);
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
})</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<article id="content">
|
||||||
|
<header>
|
||||||
|
<h1 class="title">Module <code>connpy.cli.sso_handler</code></h1>
|
||||||
|
</header>
|
||||||
|
<section id="section-intro">
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 class="section-title" id="header-classes">Classes</h2>
|
||||||
|
<dl>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler"><code class="flex name class">
|
||||||
|
<span>class <span class="ident">SSOHandler</span></span>
|
||||||
|
<span>(</span><span>app)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">class SSOHandler:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def dispatch(self, args):
|
||||||
|
if self.app.services.mode == "remote":
|
||||||
|
printer.error("SSO management commands are only available in local/server-side mode.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Parse actions from argparse mutually exclusive options
|
||||||
|
if getattr(args, "add", None):
|
||||||
|
args.action = "add"
|
||||||
|
args.provider = args.add[0]
|
||||||
|
elif getattr(args, "delete", None):
|
||||||
|
args.action = "del"
|
||||||
|
args.provider = args.delete[0]
|
||||||
|
elif getattr(args, "list", False):
|
||||||
|
args.action = "list"
|
||||||
|
elif getattr(args, "show", None):
|
||||||
|
args.action = "show"
|
||||||
|
args.provider = args.show[0]
|
||||||
|
|
||||||
|
action = getattr(args, "action", None)
|
||||||
|
|
||||||
|
if action == "add":
|
||||||
|
return self.add_provider(args)
|
||||||
|
elif action == "del":
|
||||||
|
return self.delete_provider(args)
|
||||||
|
elif action == "list":
|
||||||
|
return self.list_providers(args)
|
||||||
|
elif action == "show":
|
||||||
|
return self.show_provider(args)
|
||||||
|
else:
|
||||||
|
printer.error(f"Unknown action: {action}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def add_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.setdefault("providers", {})
|
||||||
|
|
||||||
|
existing = providers.get(provider, {})
|
||||||
|
if existing:
|
||||||
|
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
|
||||||
|
|
||||||
|
# Interactive questionnaire
|
||||||
|
questions = [
|
||||||
|
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
|
||||||
|
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
|
||||||
|
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
|
||||||
|
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
|
||||||
|
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
|
||||||
|
]
|
||||||
|
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers:
|
||||||
|
printer.warning("Operation cancelled.")
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
jwks_url = answers["jwks_url"].strip()
|
||||||
|
secret = answers["secret"].strip()
|
||||||
|
username_claim = answers["username_claim"].strip()
|
||||||
|
algorithms_str = answers["algorithms"].strip()
|
||||||
|
allowed_domains_str = answers.get("allowed_domains", "").strip()
|
||||||
|
|
||||||
|
if not jwks_url and not secret:
|
||||||
|
printer.error("You must configure either a JWKS URL or a Secret.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not username_claim:
|
||||||
|
printer.error("Username claim cannot be empty.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
|
||||||
|
if not algorithms:
|
||||||
|
algorithms = ["RS256"]
|
||||||
|
|
||||||
|
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
|
||||||
|
|
||||||
|
provider_data = {
|
||||||
|
"username_claim": username_claim,
|
||||||
|
"algorithms": algorithms
|
||||||
|
}
|
||||||
|
if jwks_url:
|
||||||
|
provider_data["jwks_url"] = jwks_url
|
||||||
|
if secret:
|
||||||
|
provider_data["secret"] = secret
|
||||||
|
if allowed_domains:
|
||||||
|
provider_data["allowed_domains"] = allowed_domains
|
||||||
|
|
||||||
|
providers[provider] = provider_data
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' saved successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def delete_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Confirm delete
|
||||||
|
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers or not answers["confirm"]:
|
||||||
|
printer.info("Delete cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
del providers[provider]
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' deleted successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def list_providers(self, args):
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
if not providers:
|
||||||
|
printer.warning("No SSO providers configured.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print list in YAML format
|
||||||
|
providers_list = list(providers.keys())
|
||||||
|
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data("Configured SSO Providers", yaml_str)
|
||||||
|
|
||||||
|
def show_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data = providers[provider]
|
||||||
|
|
||||||
|
# Mask client secret for display if it's sensitive and not an env var starting with $
|
||||||
|
display_data = data.copy()
|
||||||
|
secret = display_data.get("secret")
|
||||||
|
if secret and not secret.startswith("$"):
|
||||||
|
display_data["secret"] = "********"
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data(f"SSO Provider: {provider}", yaml_str)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
<h3>Methods</h3>
|
||||||
|
<dl>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler.add_provider"><code class="name flex">
|
||||||
|
<span>def <span class="ident">add_provider</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def add_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.setdefault("providers", {})
|
||||||
|
|
||||||
|
existing = providers.get(provider, {})
|
||||||
|
if existing:
|
||||||
|
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
|
||||||
|
|
||||||
|
# Interactive questionnaire
|
||||||
|
questions = [
|
||||||
|
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
|
||||||
|
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
|
||||||
|
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
|
||||||
|
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
|
||||||
|
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
|
||||||
|
]
|
||||||
|
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers:
|
||||||
|
printer.warning("Operation cancelled.")
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
jwks_url = answers["jwks_url"].strip()
|
||||||
|
secret = answers["secret"].strip()
|
||||||
|
username_claim = answers["username_claim"].strip()
|
||||||
|
algorithms_str = answers["algorithms"].strip()
|
||||||
|
allowed_domains_str = answers.get("allowed_domains", "").strip()
|
||||||
|
|
||||||
|
if not jwks_url and not secret:
|
||||||
|
printer.error("You must configure either a JWKS URL or a Secret.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not username_claim:
|
||||||
|
printer.error("Username claim cannot be empty.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
|
||||||
|
if not algorithms:
|
||||||
|
algorithms = ["RS256"]
|
||||||
|
|
||||||
|
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
|
||||||
|
|
||||||
|
provider_data = {
|
||||||
|
"username_claim": username_claim,
|
||||||
|
"algorithms": algorithms
|
||||||
|
}
|
||||||
|
if jwks_url:
|
||||||
|
provider_data["jwks_url"] = jwks_url
|
||||||
|
if secret:
|
||||||
|
provider_data["secret"] = secret
|
||||||
|
if allowed_domains:
|
||||||
|
provider_data["allowed_domains"] = allowed_domains
|
||||||
|
|
||||||
|
providers[provider] = provider_data
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' saved successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler.delete_provider"><code class="name flex">
|
||||||
|
<span>def <span class="ident">delete_provider</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def delete_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Confirm delete
|
||||||
|
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||||
|
answers = inquirer.prompt(questions)
|
||||||
|
if not answers or not answers["confirm"]:
|
||||||
|
printer.info("Delete cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
del providers[provider]
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
try:
|
||||||
|
self.app.services.config_svc.update_setting("sso", sso)
|
||||||
|
printer.success(f"SSO Provider '{provider}' deleted successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
printer.error(f"Failed to save SSO configuration: {e}")
|
||||||
|
sys.exit(1)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler.dispatch"><code class="name flex">
|
||||||
|
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def dispatch(self, args):
|
||||||
|
if self.app.services.mode == "remote":
|
||||||
|
printer.error("SSO management commands are only available in local/server-side mode.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Parse actions from argparse mutually exclusive options
|
||||||
|
if getattr(args, "add", None):
|
||||||
|
args.action = "add"
|
||||||
|
args.provider = args.add[0]
|
||||||
|
elif getattr(args, "delete", None):
|
||||||
|
args.action = "del"
|
||||||
|
args.provider = args.delete[0]
|
||||||
|
elif getattr(args, "list", False):
|
||||||
|
args.action = "list"
|
||||||
|
elif getattr(args, "show", None):
|
||||||
|
args.action = "show"
|
||||||
|
args.provider = args.show[0]
|
||||||
|
|
||||||
|
action = getattr(args, "action", None)
|
||||||
|
|
||||||
|
if action == "add":
|
||||||
|
return self.add_provider(args)
|
||||||
|
elif action == "del":
|
||||||
|
return self.delete_provider(args)
|
||||||
|
elif action == "list":
|
||||||
|
return self.list_providers(args)
|
||||||
|
elif action == "show":
|
||||||
|
return self.show_provider(args)
|
||||||
|
else:
|
||||||
|
printer.error(f"Unknown action: {action}")
|
||||||
|
sys.exit(1)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler.list_providers"><code class="name flex">
|
||||||
|
<span>def <span class="ident">list_providers</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def list_providers(self, args):
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
if not providers:
|
||||||
|
printer.warning("No SSO providers configured.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print list in YAML format
|
||||||
|
providers_list = list(providers.keys())
|
||||||
|
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data("Configured SSO Providers", yaml_str)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
|
<dt id="connpy.cli.sso_handler.SSOHandler.show_provider"><code class="name flex">
|
||||||
|
<span>def <span class="ident">show_provider</span></span>(<span>self, args)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def show_provider(self, args):
|
||||||
|
provider = args.provider
|
||||||
|
sso = self.app.config.config.get("sso", {})
|
||||||
|
providers = sso.get("providers", {})
|
||||||
|
|
||||||
|
if provider not in providers:
|
||||||
|
printer.error(f"SSO Provider '{provider}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
data = providers[provider]
|
||||||
|
|
||||||
|
# Mask client secret for display if it's sensitive and not an env var starting with $
|
||||||
|
display_data = data.copy()
|
||||||
|
secret = display_data.get("secret")
|
||||||
|
if secret and not secret.startswith("$"):
|
||||||
|
display_data["secret"] = "********"
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
|
||||||
|
printer.data(f"SSO Provider: {provider}", yaml_str)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
<nav id="sidebar">
|
||||||
|
<div class="toc">
|
||||||
|
<ul></ul>
|
||||||
|
</div>
|
||||||
|
<ul id="index">
|
||||||
|
<li><h3>Super-module</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><h3><a href="#header-classes">Classes</a></h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<h4><code><a title="connpy.cli.sso_handler.SSOHandler" href="#connpy.cli.sso_handler.SSOHandler">SSOHandler</a></code></h4>
|
||||||
|
<ul class="">
|
||||||
|
<li><code><a title="connpy.cli.sso_handler.SSOHandler.add_provider" href="#connpy.cli.sso_handler.SSOHandler.add_provider">add_provider</a></code></li>
|
||||||
|
<li><code><a title="connpy.cli.sso_handler.SSOHandler.delete_provider" href="#connpy.cli.sso_handler.SSOHandler.delete_provider">delete_provider</a></code></li>
|
||||||
|
<li><code><a title="connpy.cli.sso_handler.SSOHandler.dispatch" href="#connpy.cli.sso_handler.SSOHandler.dispatch">dispatch</a></code></li>
|
||||||
|
<li><code><a title="connpy.cli.sso_handler.SSOHandler.list_providers" href="#connpy.cli.sso_handler.SSOHandler.list_providers">list_providers</a></code></li>
|
||||||
|
<li><code><a title="connpy.cli.sso_handler.SSOHandler.show_provider" href="#connpy.cli.sso_handler.SSOHandler.show_provider">show_provider</a></code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
<footer id="footer">
|
||||||
|
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -121,14 +121,14 @@ el.replaceWith(d);
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1. Visual Separation
|
# 1. Visual Separation
|
||||||
self.console.print("") # Salto de línea real
|
self.console.print("") # Real line break
|
||||||
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
||||||
self.console.print(Panel(
|
self.console.print(Panel(
|
||||||
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
||||||
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
||||||
border_style="cyan"
|
border_style="cyan"
|
||||||
))
|
))
|
||||||
self.console.print("\n") # Pequeño espacio antes del prompt del copilot
|
self.console.print("\n") # Small space before the copilot prompt
|
||||||
|
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
@bindings.add('c-up')
|
@bindings.add('c-up')
|
||||||
@@ -195,7 +195,7 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
if app and app.current_buffer:
|
if app and app.current_buffer:
|
||||||
text = app.current_buffer.text
|
text = app.current_buffer.text
|
||||||
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
|
# Only show command help if typing the first command and there are no spaces
|
||||||
if text.startswith('/') and ' ' not in text:
|
if text.startswith('/') and ' ' not in text:
|
||||||
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
||||||
matches = [c for c in commands if c.startswith(text.lower())]
|
matches = [c for c in commands if c.startswith(text.lower())]
|
||||||
@@ -210,19 +210,19 @@ el.replaceWith(d);
|
|||||||
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
||||||
|
|
||||||
def clean_preview(text):
|
def clean_preview(text):
|
||||||
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
|
# Clean newlines and the initial prompt (all up to #, > or $) to leave only the command
|
||||||
original = text.strip().replace('\r', '').replace('\n', ' ')
|
original = text.strip().replace('\r', '').replace('\n', ' ')
|
||||||
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
||||||
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
|
# If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original
|
||||||
return cleaned if cleaned else original
|
return cleaned if cleaned else original
|
||||||
|
|
||||||
if state['context_mode'] == self.mode_range:
|
if state['context_mode'] == self.mode_range:
|
||||||
range_blocks = blocks[idx:]
|
range_blocks = blocks[idx:]
|
||||||
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
|
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
|
||||||
if len(range_blocks) > 1:
|
if len(range_blocks) > 1:
|
||||||
range_blocks = range_blocks[:-1]
|
range_blocks = range_blocks[:-1]
|
||||||
|
|
||||||
# Limpiar y truncar comandos muy largos para que no rompan la UI
|
# Clean and truncate very long commands so they don't break the UI
|
||||||
previews = []
|
previews = []
|
||||||
for b in range_blocks:
|
for b in range_blocks:
|
||||||
p = clean_preview(b[2])
|
p = clean_preview(b[2])
|
||||||
@@ -300,8 +300,8 @@ el.replaceWith(d);
|
|||||||
style=ui_style
|
style=ui_style
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
|
# We use an internal try/finally to ensure that if something fails in prompt_async,
|
||||||
# no nos quedemos con la terminal en un estado extraño.
|
# we don't leave the terminal in a strange state.
|
||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
@@ -333,12 +333,12 @@ el.replaceWith(d);
|
|||||||
except: pass
|
except: pass
|
||||||
asyncio.create_task(delayed_refresh())
|
asyncio.create_task(delayed_refresh())
|
||||||
|
|
||||||
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
|
# Move the cursor up and clean the line so the new prompt replaces the previous one
|
||||||
sys.stdout.write('\x1b[1A\x1b[2K')
|
sys.stdout.write('\x1b[1A\x1b[2K')
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Limpiar el mensaje de la barra cuando se hace una pregunta real
|
# Clean the toolbar message when a real question is asked
|
||||||
state['toolbar_msg'] = ''
|
state['toolbar_msg'] = ''
|
||||||
|
|
||||||
clean_question = directive.get("clean_prompt", question)
|
clean_question = directive.get("clean_prompt", question)
|
||||||
@@ -575,14 +575,14 @@ el.replaceWith(d);
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1. Visual Separation
|
# 1. Visual Separation
|
||||||
self.console.print("") # Salto de línea real
|
self.console.print("") # Real line break
|
||||||
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
||||||
self.console.print(Panel(
|
self.console.print(Panel(
|
||||||
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n"
|
||||||
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
"Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
|
||||||
border_style="cyan"
|
border_style="cyan"
|
||||||
))
|
))
|
||||||
self.console.print("\n") # Pequeño espacio antes del prompt del copilot
|
self.console.print("\n") # Small space before the copilot prompt
|
||||||
|
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
@bindings.add('c-up')
|
@bindings.add('c-up')
|
||||||
@@ -649,7 +649,7 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
if app and app.current_buffer:
|
if app and app.current_buffer:
|
||||||
text = app.current_buffer.text
|
text = app.current_buffer.text
|
||||||
# Solo mostrar ayuda de comandos si estamos escribiendo el primer comando y no hay espacios
|
# Only show command help if typing the first command and there are no spaces
|
||||||
if text.startswith('/') and ' ' not in text:
|
if text.startswith('/') and ' ' not in text:
|
||||||
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
||||||
matches = [c for c in commands if c.startswith(text.lower())]
|
matches = [c for c in commands if c.startswith(text.lower())]
|
||||||
@@ -664,19 +664,19 @@ el.replaceWith(d);
|
|||||||
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
||||||
|
|
||||||
def clean_preview(text):
|
def clean_preview(text):
|
||||||
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
|
# Clean newlines and the initial prompt (all up to #, > or $) to leave only the command
|
||||||
original = text.strip().replace('\r', '').replace('\n', ' ')
|
original = text.strip().replace('\r', '').replace('\n', ' ')
|
||||||
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
||||||
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
|
# If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original
|
||||||
return cleaned if cleaned else original
|
return cleaned if cleaned else original
|
||||||
|
|
||||||
if state['context_mode'] == self.mode_range:
|
if state['context_mode'] == self.mode_range:
|
||||||
range_blocks = blocks[idx:]
|
range_blocks = blocks[idx:]
|
||||||
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
|
# If there is more than one block, the last one is always the empty/current prompt. We omit it visually.
|
||||||
if len(range_blocks) > 1:
|
if len(range_blocks) > 1:
|
||||||
range_blocks = range_blocks[:-1]
|
range_blocks = range_blocks[:-1]
|
||||||
|
|
||||||
# Limpiar y truncar comandos muy largos para que no rompan la UI
|
# Clean and truncate very long commands so they don't break the UI
|
||||||
previews = []
|
previews = []
|
||||||
for b in range_blocks:
|
for b in range_blocks:
|
||||||
p = clean_preview(b[2])
|
p = clean_preview(b[2])
|
||||||
@@ -754,8 +754,8 @@ el.replaceWith(d);
|
|||||||
style=ui_style
|
style=ui_style
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
|
# We use an internal try/finally to ensure that if something fails in prompt_async,
|
||||||
# no nos quedemos con la terminal en un estado extraño.
|
# we don't leave the terminal in a strange state.
|
||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
@@ -787,12 +787,12 @@ el.replaceWith(d);
|
|||||||
except: pass
|
except: pass
|
||||||
asyncio.create_task(delayed_refresh())
|
asyncio.create_task(delayed_refresh())
|
||||||
|
|
||||||
# Mover el cursor arriba y limpiar la línea para que el nuevo prompt reemplace al anterior
|
# Move the cursor up and clean the line so the new prompt replaces the previous one
|
||||||
sys.stdout.write('\x1b[1A\x1b[2K')
|
sys.stdout.write('\x1b[1A\x1b[2K')
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Limpiar el mensaje de la barra cuando se hace una pregunta real
|
# Clean the toolbar message when a real question is asked
|
||||||
state['toolbar_msg'] = ''
|
state['toolbar_msg'] = ''
|
||||||
|
|
||||||
clean_question = directive.get("clean_prompt", question)
|
clean_question = directive.get("clean_prompt", question)
|
||||||
|
|||||||
@@ -100,6 +100,21 @@ el.replaceWith(d);
|
|||||||
request_deserializer=connpy__pb2.StringRequest.FromString,
|
request_deserializer=connpy__pb2.StringRequest.FromString,
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'build_playbook_chat': grpc.stream_stream_rpc_method_handler(
|
||||||
|
servicer.build_playbook_chat,
|
||||||
|
request_deserializer=connpy__pb2.AskRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
|
'analyze_execution_results': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.analyze_execution_results,
|
||||||
|
request_deserializer=connpy__pb2.AnalyzeRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
|
'predict_execution_results': grpc.unary_stream_rpc_method_handler(
|
||||||
|
servicer.predict_execution_results,
|
||||||
|
request_deserializer=connpy__pb2.PreflightRequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.AIResponse.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AIService', rpc_method_handlers)
|
'connpy.AIService', rpc_method_handlers)
|
||||||
@@ -123,11 +138,21 @@ el.replaceWith(d);
|
|||||||
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
||||||
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'login_sso': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.login_sso,
|
||||||
|
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
|
||||||
|
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
||||||
|
),
|
||||||
'change_password': grpc.unary_unary_rpc_method_handler(
|
'change_password': grpc.unary_unary_rpc_method_handler(
|
||||||
servicer.change_password,
|
servicer.change_password,
|
||||||
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
||||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'get_sso_providers': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.get_sso_providers,
|
||||||
|
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||||
|
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AuthService', rpc_method_handlers)
|
'connpy.AuthService', rpc_method_handlers)
|
||||||
@@ -209,11 +234,6 @@ el.replaceWith(d);
|
|||||||
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
'run_yaml_playbook': grpc.unary_unary_rpc_method_handler(
|
|
||||||
servicer.run_yaml_playbook,
|
|
||||||
request_deserializer=connpy__pb2.ScriptRequest.FromString,
|
|
||||||
response_serializer=connpy__pb2.StructResponse.SerializeToString,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.ExecutionService', rpc_method_handlers)
|
'connpy.ExecutionService', rpc_method_handlers)
|
||||||
@@ -739,11 +759,129 @@ el.replaceWith(d);
|
|||||||
wait_for_ready,
|
wait_for_ready,
|
||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_playbook_chat(request_iterator,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.stream_stream(
|
||||||
|
request_iterator,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/build_playbook_chat',
|
||||||
|
connpy__pb2.AskRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/analyze_execution_results',
|
||||||
|
connpy__pb2.AnalyzeRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def predict_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/predict_execution_results',
|
||||||
|
connpy__pb2.PreflightRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
<h3>Static methods</h3>
|
<h3>Static methods</h3>
|
||||||
<dl>
|
<dl>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">analyze_execution_results</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@staticmethod
|
||||||
|
def analyze_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/analyze_execution_results',
|
||||||
|
connpy__pb2.AnalyzeRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask"><code class="name flex">
|
||||||
<span>def <span class="ident">ask</span></span>(<span>request_iterator,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
<span>def <span class="ident">ask</span></span>(<span>request_iterator,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -818,6 +956,43 @@ def ask_copilot(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat"><code class="name flex">
|
||||||
|
<span>def <span class="ident">build_playbook_chat</span></span>(<span>request_iterator,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@staticmethod
|
||||||
|
def build_playbook_chat(request_iterator,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.stream_stream(
|
||||||
|
request_iterator,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/build_playbook_chat',
|
||||||
|
connpy__pb2.AskRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp"><code class="name flex">
|
||||||
<span>def <span class="ident">configure_mcp</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
<span>def <span class="ident">configure_mcp</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1077,6 +1252,43 @@ def load_session_data(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">predict_execution_results</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@staticmethod
|
||||||
|
def predict_execution_results(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_stream(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AIService/predict_execution_results',
|
||||||
|
connpy__pb2.PreflightRequest.SerializeToString,
|
||||||
|
connpy__pb2.AIResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer"><code class="flex name class">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer"><code class="flex name class">
|
||||||
@@ -1139,6 +1351,24 @@ def load_session_data(request,
|
|||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def load_session_data(self, request, context):
|
def load_session_data(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def build_playbook_chat(self, request_iterator, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def analyze_execution_results(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def predict_execution_results(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
@@ -1151,6 +1381,22 @@ def load_session_data(request,
|
|||||||
</ul>
|
</ul>
|
||||||
<h3>Methods</h3>
|
<h3>Methods</h3>
|
||||||
<dl>
|
<dl>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, request, context)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def analyze_execution_results(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask"><code class="name flex">
|
||||||
<span>def <span class="ident">ask</span></span>(<span>self, request_iterator, context)</span>
|
<span>def <span class="ident">ask</span></span>(<span>self, request_iterator, context)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1183,6 +1429,22 @@ def load_session_data(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat"><code class="name flex">
|
||||||
|
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, request_iterator, context)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def build_playbook_chat(self, request_iterator, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp"><code class="name flex">
|
||||||
<span>def <span class="ident">configure_mcp</span></span>(<span>self, request, context)</span>
|
<span>def <span class="ident">configure_mcp</span></span>(<span>self, request, context)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1295,6 +1557,22 @@ def load_session_data(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, request, context)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def predict_execution_results(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub"><code class="flex name class">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub"><code class="flex name class">
|
||||||
@@ -1359,6 +1637,21 @@ def load_session_data(request,
|
|||||||
'/connpy.AIService/load_session_data',
|
'/connpy.AIService/load_session_data',
|
||||||
request_serializer=connpy__pb2.StringRequest.SerializeToString,
|
request_serializer=connpy__pb2.StringRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
response_deserializer=connpy__pb2.StructResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.build_playbook_chat = channel.stream_stream(
|
||||||
|
'/connpy.AIService/build_playbook_chat',
|
||||||
|
request_serializer=connpy__pb2.AskRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.analyze_execution_results = channel.unary_stream(
|
||||||
|
'/connpy.AIService/analyze_execution_results',
|
||||||
|
request_serializer=connpy__pb2.AnalyzeRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.predict_execution_results = channel.unary_stream(
|
||||||
|
'/connpy.AIService/predict_execution_results',
|
||||||
|
request_serializer=connpy__pb2.PreflightRequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.AIResponse.FromString,
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
||||||
@@ -1407,6 +1700,33 @@ def load_session_data(request,
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def login_sso(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/login_sso',
|
||||||
|
connpy__pb2.LoginSSORequest.SerializeToString,
|
||||||
|
connpy__pb2.LoginResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def change_password(request,
|
def change_password(request,
|
||||||
target,
|
target,
|
||||||
@@ -1432,6 +1752,33 @@ def load_session_data(request,
|
|||||||
wait_for_ready,
|
wait_for_ready,
|
||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_sso_providers(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/get_sso_providers',
|
||||||
|
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
|
connpy__pb2.SSOProvidersResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
@@ -1474,6 +1821,43 @@ def change_password(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex">
|
||||||
|
<span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@staticmethod
|
||||||
|
def get_sso_providers(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/get_sso_providers',
|
||||||
|
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
|
connpy__pb2.SSOProvidersResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
|
||||||
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1511,6 +1895,43 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso"><code class="name flex">
|
||||||
|
<span>def <span class="ident">login_sso</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@staticmethod
|
||||||
|
def login_sso(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/connpy.AuthService/login_sso',
|
||||||
|
connpy__pb2.LoginSSORequest.SerializeToString,
|
||||||
|
connpy__pb2.LoginResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
|
||||||
@@ -1530,7 +1951,19 @@ def login(request,
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def login_sso(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def get_sso_providers(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
@@ -1559,6 +1992,22 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex">
|
||||||
|
<span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def get_sso_providers(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
|
||||||
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
|
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1575,6 +2024,22 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso"><code class="name flex">
|
||||||
|
<span>def <span class="ident">login_sso</span></span>(<span>self, request, context)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def login_sso(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
|
||||||
@@ -1600,10 +2065,20 @@ def login(request,
|
|||||||
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
self.login_sso = channel.unary_unary(
|
||||||
|
'/connpy.AuthService/login_sso',
|
||||||
|
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
|
_registered_method=True)
|
||||||
self.change_password = channel.unary_unary(
|
self.change_password = channel.unary_unary(
|
||||||
'/connpy.AuthService/change_password',
|
'/connpy.AuthService/change_password',
|
||||||
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
||||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
self.get_sso_providers = channel.unary_unary(
|
||||||
|
'/connpy.AuthService/get_sso_providers',
|
||||||
|
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||||
|
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
||||||
@@ -2313,33 +2788,6 @@ def update_setting(request,
|
|||||||
wait_for_ready,
|
wait_for_ready,
|
||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def run_yaml_playbook(request,
|
|
||||||
target,
|
|
||||||
options=(),
|
|
||||||
channel_credentials=None,
|
|
||||||
call_credentials=None,
|
|
||||||
insecure=False,
|
|
||||||
compression=None,
|
|
||||||
wait_for_ready=None,
|
|
||||||
timeout=None,
|
|
||||||
metadata=None):
|
|
||||||
return grpc.experimental.unary_unary(
|
|
||||||
request,
|
|
||||||
target,
|
|
||||||
'/connpy.ExecutionService/run_yaml_playbook',
|
|
||||||
connpy__pb2.ScriptRequest.SerializeToString,
|
|
||||||
connpy__pb2.StructResponse.FromString,
|
|
||||||
options,
|
|
||||||
channel_credentials,
|
|
||||||
insecure,
|
|
||||||
call_credentials,
|
|
||||||
compression,
|
|
||||||
wait_for_ready,
|
|
||||||
timeout,
|
|
||||||
metadata,
|
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
@@ -2419,43 +2867,6 @@ def run_commands(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook"><code class="name flex">
|
|
||||||
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
|
||||||
</code></dt>
|
|
||||||
<dd>
|
|
||||||
<details class="source">
|
|
||||||
<summary>
|
|
||||||
<span>Expand source code</span>
|
|
||||||
</summary>
|
|
||||||
<pre><code class="python">@staticmethod
|
|
||||||
def run_yaml_playbook(request,
|
|
||||||
target,
|
|
||||||
options=(),
|
|
||||||
channel_credentials=None,
|
|
||||||
call_credentials=None,
|
|
||||||
insecure=False,
|
|
||||||
compression=None,
|
|
||||||
wait_for_ready=None,
|
|
||||||
timeout=None,
|
|
||||||
metadata=None):
|
|
||||||
return grpc.experimental.unary_unary(
|
|
||||||
request,
|
|
||||||
target,
|
|
||||||
'/connpy.ExecutionService/run_yaml_playbook',
|
|
||||||
connpy__pb2.ScriptRequest.SerializeToString,
|
|
||||||
connpy__pb2.StructResponse.FromString,
|
|
||||||
options,
|
|
||||||
channel_credentials,
|
|
||||||
insecure,
|
|
||||||
call_credentials,
|
|
||||||
compression,
|
|
||||||
wait_for_ready,
|
|
||||||
timeout,
|
|
||||||
metadata,
|
|
||||||
_registered_method=True)</code></pre>
|
|
||||||
</details>
|
|
||||||
<div class="desc"></div>
|
|
||||||
</dd>
|
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands"><code class="name flex">
|
||||||
<span>def <span class="ident">test_commands</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
<span>def <span class="ident">test_commands</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -2519,12 +2930,6 @@ def test_commands(request,
|
|||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
def run_cli_script(self, request, context):
|
def run_cli_script(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
||||||
context.set_details('Method not implemented!')
|
|
||||||
raise NotImplementedError('Method not implemented!')
|
|
||||||
|
|
||||||
def run_yaml_playbook(self, request, context):
|
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
@@ -2569,22 +2974,6 @@ def test_commands(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook"><code class="name flex">
|
|
||||||
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, request, context)</span>
|
|
||||||
</code></dt>
|
|
||||||
<dd>
|
|
||||||
<details class="source">
|
|
||||||
<summary>
|
|
||||||
<span>Expand source code</span>
|
|
||||||
</summary>
|
|
||||||
<pre><code class="python">def run_yaml_playbook(self, request, context):
|
|
||||||
"""Missing associated documentation comment in .proto file."""
|
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
||||||
context.set_details('Method not implemented!')
|
|
||||||
raise NotImplementedError('Method not implemented!')</code></pre>
|
|
||||||
</details>
|
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
|
||||||
</dd>
|
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands"><code class="name flex">
|
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands"><code class="name flex">
|
||||||
<span>def <span class="ident">test_commands</span></span>(<span>self, request, context)</span>
|
<span>def <span class="ident">test_commands</span></span>(<span>self, request, context)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -2635,11 +3024,6 @@ def test_commands(request,
|
|||||||
'/connpy.ExecutionService/run_cli_script',
|
'/connpy.ExecutionService/run_cli_script',
|
||||||
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
response_deserializer=connpy__pb2.StructResponse.FromString,
|
||||||
_registered_method=True)
|
|
||||||
self.run_yaml_playbook = channel.unary_unary(
|
|
||||||
'/connpy.ExecutionService/run_yaml_playbook',
|
|
||||||
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
|
|
||||||
response_deserializer=connpy__pb2.StructResponse.FromString,
|
|
||||||
_registered_method=True)</code></pre>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
||||||
@@ -6089,9 +6473,11 @@ def stop_api(request,
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService">AIService</a></code></h4>
|
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService">AIService</a></code></h4>
|
||||||
<ul class="two-column">
|
<ul class="">
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask">ask</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask">ask</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot">ask_copilot</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.ask_copilot">ask_copilot</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.confirm">confirm</a></code></li>
|
||||||
@@ -6099,13 +6485,16 @@ def stop_api(request,
|
|||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIService.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></code></h4>
|
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></code></h4>
|
||||||
<ul class="two-column">
|
<ul class="">
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
|
||||||
@@ -6113,6 +6502,7 @@ def stop_api(request,
|
|||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -6122,14 +6512,18 @@ def stop_api(request,
|
|||||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
|
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
|
||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
|
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
|
||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -6165,7 +6559,6 @@ def stop_api(request,
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionService.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -6174,7 +6567,6 @@ def stop_api(request,
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ el.replaceWith(d);
|
|||||||
fallback_provider = ServiceProvider(config, mode="local")
|
fallback_provider = ServiceProvider(config, mode="local")
|
||||||
registry = UserRegistry(config.defaultdir)
|
registry = UserRegistry(config.defaultdir)
|
||||||
|
|
||||||
|
# Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env
|
||||||
|
import os
|
||||||
|
if os.getenv("CONN_SSO_GATEWAY_SECRET") and registry._shared_config:
|
||||||
|
sso_config = registry._shared_config.config.get("sso", {})
|
||||||
|
providers = sso_config.get("providers", {})
|
||||||
|
if "trusted_gateway" not in providers:
|
||||||
|
from connpy import printer
|
||||||
|
printer.warning("CONN_SSO_GATEWAY_SECRET is defined in environment, but 'trusted_gateway' is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.")
|
||||||
|
|
||||||
interceptors = []
|
interceptors = []
|
||||||
if debug:
|
if debug:
|
||||||
interceptors.append(LoggingInterceptor())
|
interceptors.append(LoggingInterceptor())
|
||||||
@@ -174,12 +183,11 @@ el.replaceWith(d);
|
|||||||
def service(self):
|
def service(self):
|
||||||
return self._get_provider().ai
|
return self._get_provider().ai
|
||||||
|
|
||||||
@handle_errors
|
def _handle_chat_stream(self, request_iterator, context, service_method):
|
||||||
def ask(self, request_iterator, context):
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
import contextvars
|
||||||
|
|
||||||
ai_service = self.service
|
|
||||||
chunk_queue = queue.Queue()
|
chunk_queue = queue.Queue()
|
||||||
request_queue = queue.Queue()
|
request_queue = queue.Queue()
|
||||||
bridge = None
|
bridge = None
|
||||||
@@ -197,21 +205,29 @@ el.replaceWith(d);
|
|||||||
nonlocal history, bridge, agent_instance
|
nonlocal history, bridge, agent_instance
|
||||||
try:
|
try:
|
||||||
# Run the AI interaction (this blocks this specific thread)
|
# Run the AI interaction (this blocks this specific thread)
|
||||||
res = ai_service.ask(
|
if getattr(service_method, "__name__", None) == "build_playbook_chat":
|
||||||
input_text,
|
res = service_method(
|
||||||
chat_history=history if history else None,
|
input_text,
|
||||||
session_id=session_id,
|
chat_history=history if history else None,
|
||||||
debug=debug,
|
status=bridge,
|
||||||
status=bridge,
|
chunk_callback=callback
|
||||||
console=bridge,
|
)
|
||||||
confirm_handler=bridge.confirm,
|
else:
|
||||||
chunk_callback=callback,
|
res = service_method(
|
||||||
trust=trust,
|
input_text,
|
||||||
**overrides
|
chat_history=history if history else None,
|
||||||
)
|
session_id=session_id,
|
||||||
|
debug=debug,
|
||||||
|
status=bridge,
|
||||||
|
console=bridge,
|
||||||
|
confirm_handler=bridge.confirm,
|
||||||
|
chunk_callback=callback,
|
||||||
|
trust=trust,
|
||||||
|
**overrides
|
||||||
|
)
|
||||||
|
|
||||||
# Update history for next message
|
# Update history for next message
|
||||||
if "chat_history" in res:
|
if res and "chat_history" in res:
|
||||||
history = res["chat_history"]
|
history = res["chat_history"]
|
||||||
|
|
||||||
# Send final chunk marker
|
# Send final chunk marker
|
||||||
@@ -265,10 +281,10 @@ el.replaceWith(d);
|
|||||||
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
|
if req.HasField("engineer_auth"): overrides["engineer_auth"] = from_struct(req.engineer_auth)
|
||||||
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
|
if req.HasField("architect_auth"): overrides["architect_auth"] = from_struct(req.architect_auth)
|
||||||
|
|
||||||
# Start AI in its own thread so we can keep listening for interrupts
|
# Start AI in its own thread with a fresh copy of context so we can keep listening for interrupts
|
||||||
|
ctx_ai = contextvars.copy_context()
|
||||||
ai_thread = threading.Thread(
|
ai_thread = threading.Thread(
|
||||||
target=run_ai_task,
|
target=lambda: ctx_ai.run(run_ai_task, req.input_text, req.session_id, req.debug, overrides, req.trust),
|
||||||
args=(req.input_text, req.session_id, req.debug, overrides, req.trust),
|
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
ai_thread.start()
|
ai_thread.start()
|
||||||
@@ -280,8 +296,9 @@ el.replaceWith(d);
|
|||||||
# When client closes stream, send sentinel
|
# When client closes stream, send sentinel
|
||||||
chunk_queue.put((None, None))
|
chunk_queue.put((None, None))
|
||||||
|
|
||||||
# Start listening for client requests/signals
|
# Start listening for client requests/signals with a copied context
|
||||||
threading.Thread(target=request_listener, daemon=True).start()
|
ctx_listener = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx_listener.run(request_listener), daemon=True).start()
|
||||||
|
|
||||||
# Main response loop (yields to gRPC)
|
# Main response loop (yields to gRPC)
|
||||||
while True:
|
while True:
|
||||||
@@ -305,6 +322,73 @@ el.replaceWith(d);
|
|||||||
elif msg_type == "final_mark":
|
elif msg_type == "final_mark":
|
||||||
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
||||||
|
|
||||||
|
def _handle_unary_stream(self, service_method, *args, **kwargs):
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
|
||||||
|
chunk_queue = queue.Queue()
|
||||||
|
bridge = StatusBridge(chunk_queue, is_web=False)
|
||||||
|
|
||||||
|
def callback(chunk):
|
||||||
|
chunk_queue.put(("text", chunk))
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
res = service_method(*args, chunk_callback=callback, status=bridge, **kwargs)
|
||||||
|
chunk_queue.put(("final_mark", res))
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"gRPC Unary Stream error: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
chunk_queue.put(("status", f"Error: {str(e)}"))
|
||||||
|
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "error": True}))
|
||||||
|
finally:
|
||||||
|
chunk_queue.put((None, None))
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
item = chunk_queue.get()
|
||||||
|
if item == (None, None):
|
||||||
|
break
|
||||||
|
|
||||||
|
msg_type, val = item
|
||||||
|
if msg_type == "text":
|
||||||
|
yield connpy_pb2.AIResponse(text_chunk=val, is_final=False)
|
||||||
|
elif msg_type == "status":
|
||||||
|
clean_val = val.replace("[ai_status]", "").replace("[/ai_status]", "")
|
||||||
|
yield connpy_pb2.AIResponse(status_update=clean_val, is_final=False)
|
||||||
|
elif msg_type == "debug":
|
||||||
|
yield connpy_pb2.AIResponse(debug_message=val, is_final=False)
|
||||||
|
elif msg_type == "important":
|
||||||
|
yield connpy_pb2.AIResponse(important_message=val, is_final=False)
|
||||||
|
elif msg_type == "confirm":
|
||||||
|
yield connpy_pb2.AIResponse(status_update=val, requires_confirmation=True, is_final=False)
|
||||||
|
elif msg_type == "final_mark":
|
||||||
|
yield connpy_pb2.AIResponse(is_final=True, full_result=to_struct(val))
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def ask(self, request_iterator, context):
|
||||||
|
yield from self._handle_chat_stream(request_iterator, context, self.service.ask)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def build_playbook_chat(self, request_iterator, context):
|
||||||
|
yield from self._handle_chat_stream(request_iterator, context, self.service.build_playbook_chat)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def analyze_execution_results(self, request, context):
|
||||||
|
results = from_struct(request.results)
|
||||||
|
query = request.query if request.query else None
|
||||||
|
yield from self._handle_unary_stream(self.service.analyze_execution_results, results, query=query)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def predict_execution_results(self, request, context):
|
||||||
|
target_nodes = list(request.target_nodes)
|
||||||
|
commands = list(request.commands)
|
||||||
|
yield from self._handle_unary_stream(self.service.predict_execution_results, target_nodes, commands)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def confirm(self, request, context):
|
def confirm(self, request, context):
|
||||||
res = self.service.confirm(request.value)
|
res = self.service.confirm(request.value)
|
||||||
@@ -386,8 +470,10 @@ def service(self):
|
|||||||
<ul class="hlist">
|
<ul class="hlist">
|
||||||
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></b></code>:
|
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer">AIServiceServicer</a></b></code>:
|
||||||
<ul class="hlist">
|
<ul class="hlist">
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask">ask</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.ask_copilot">ask_copilot</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.confirm">confirm</a></code></li>
|
||||||
@@ -395,6 +481,7 @@ def service(self):
|
|||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AIServiceServicer.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -409,7 +496,7 @@ def service(self):
|
|||||||
<span>Expand source code</span>
|
<span>Expand source code</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">class AuthInterceptor(grpc.ServerInterceptor):
|
<pre><code class="python">class AuthInterceptor(grpc.ServerInterceptor):
|
||||||
OPEN_METHODS = ["/connpy.AuthService/login"]
|
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
|
||||||
|
|
||||||
def __init__(self, registry):
|
def __init__(self, registry):
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
@@ -596,7 +683,7 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
|||||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
||||||
|
|
||||||
token = self.registry.user_service.generate_jwt(username)
|
token = self.registry.user_service.generate_jwt(username)
|
||||||
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
|
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
|
||||||
|
|
||||||
return connpy_pb2.LoginResponse(
|
return connpy_pb2.LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -604,6 +691,137 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
|||||||
expires_at=expires_at
|
expires_at=expires_at
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def login_sso(self, request, context):
|
||||||
|
username = request.username
|
||||||
|
id_token = request.id_token
|
||||||
|
provider = request.provider
|
||||||
|
|
||||||
|
if not id_token or not provider:
|
||||||
|
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "id_token and provider are required")
|
||||||
|
|
||||||
|
# Load SSO configuration
|
||||||
|
sso_config = {}
|
||||||
|
if self.registry:
|
||||||
|
shared_config = self.registry.get_shared_config()
|
||||||
|
if shared_config:
|
||||||
|
sso_config = shared_config.config.get("sso", {})
|
||||||
|
|
||||||
|
providers = sso_config.get("providers", {})
|
||||||
|
if provider not in providers:
|
||||||
|
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"SSO Provider '{provider}' not configured in config.yaml")
|
||||||
|
|
||||||
|
p_config = providers[provider]
|
||||||
|
jwks_url = p_config.get("jwks_url")
|
||||||
|
secret = p_config.get("secret")
|
||||||
|
|
||||||
|
if secret and secret.startswith("$"):
|
||||||
|
import os
|
||||||
|
secret = os.getenv(secret[1:])
|
||||||
|
|
||||||
|
if not jwks_url and not secret:
|
||||||
|
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"Provider '{provider}' has no jwks_url or secret configured")
|
||||||
|
|
||||||
|
# Validate token
|
||||||
|
import jwt
|
||||||
|
try:
|
||||||
|
algorithms = p_config.get("algorithms", ["RS256"] if jwks_url else ["HS256"])
|
||||||
|
verify_aud = "audience" in p_config
|
||||||
|
audience = p_config.get("audience")
|
||||||
|
verify_iss = "issuer" in p_config
|
||||||
|
issuer = p_config.get("issuer")
|
||||||
|
|
||||||
|
options = {
|
||||||
|
"verify_signature": True,
|
||||||
|
"verify_exp": True,
|
||||||
|
"verify_aud": verify_aud,
|
||||||
|
"verify_iss": verify_iss
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_kwargs = {
|
||||||
|
"algorithms": algorithms,
|
||||||
|
"options": options
|
||||||
|
}
|
||||||
|
if verify_aud:
|
||||||
|
decode_kwargs["audience"] = audience
|
||||||
|
if verify_iss:
|
||||||
|
decode_kwargs["issuer"] = issuer
|
||||||
|
|
||||||
|
if jwks_url:
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
jwks_client = PyJWKClient(jwks_url)
|
||||||
|
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
||||||
|
payload = jwt.decode(id_token, signing_key.key, **decode_kwargs)
|
||||||
|
else:
|
||||||
|
payload = jwt.decode(id_token, secret, **decode_kwargs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO Token validation failed: {str(e)}")
|
||||||
|
|
||||||
|
# Extract username from claim
|
||||||
|
username_claim = p_config.get("username_claim", "sub")
|
||||||
|
claim_username = payload.get(username_claim)
|
||||||
|
if not claim_username:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Username claim '{username_claim}' not found in SSO Token")
|
||||||
|
|
||||||
|
# Check domain restrictions (allowed_domains)
|
||||||
|
allowed_domains = p_config.get("allowed_domains", [])
|
||||||
|
if allowed_domains:
|
||||||
|
email = payload.get("email")
|
||||||
|
if not email and claim_username and "@" in claim_username:
|
||||||
|
email = claim_username
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Domain restriction enabled but no email claim found in SSO Token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_domain = email.split("@")[-1].strip().lower()
|
||||||
|
except Exception:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Invalid email format in SSO Token: '{email}'")
|
||||||
|
|
||||||
|
allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d]
|
||||||
|
if user_domain not in allowed_domains_lower:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO user domain '{user_domain}' not allowed")
|
||||||
|
|
||||||
|
# Normalize username to alphanumeric/dashes/underscores to match connpy's username regex
|
||||||
|
import re
|
||||||
|
normalized_username = re.sub(r'[^a-zA-Z0-9_-]', '_', claim_username.split('@')[0])
|
||||||
|
|
||||||
|
# If a requested username was sent, verify it matches
|
||||||
|
if username and username != normalized_username:
|
||||||
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Mismatched username. Expected '{normalized_username}', got '{username}'")
|
||||||
|
|
||||||
|
# Check if user exists in connpy registry, otherwise auto-provision
|
||||||
|
try:
|
||||||
|
user_exists = any(u["username"] == normalized_username for u in self.registry.user_service.list_users())
|
||||||
|
if not user_exists:
|
||||||
|
import secrets
|
||||||
|
# Provision new user with random password (never used directly)
|
||||||
|
self.registry.user_service.create_user(normalized_username, secrets.token_hex(32))
|
||||||
|
except Exception as e:
|
||||||
|
context.abort(grpc.StatusCode.INTERNAL, f"Failed to auto-provision user: {str(e)}")
|
||||||
|
|
||||||
|
# Generate native connpy JWT token
|
||||||
|
token = self.registry.user_service.generate_jwt(normalized_username)
|
||||||
|
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
|
||||||
|
|
||||||
|
return connpy_pb2.LoginResponse(
|
||||||
|
token=token,
|
||||||
|
username=normalized_username,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def get_sso_providers(self, request, context):
|
||||||
|
sso_config = {}
|
||||||
|
if self.registry:
|
||||||
|
shared_config = self.registry.get_shared_config()
|
||||||
|
if shared_config:
|
||||||
|
sso_config = shared_config.config.get("sso", {})
|
||||||
|
providers = list(sso_config.get("providers", {}).keys())
|
||||||
|
external_providers = [p for p in providers if p != "trusted_gateway"]
|
||||||
|
return connpy_pb2.SSOProvidersResponse(providers=external_providers)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
username = _current_user.get()
|
username = _current_user.get()
|
||||||
@@ -628,7 +846,9 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
|||||||
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
|
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
|
||||||
<ul class="hlist">
|
<ul class="hlist">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -785,7 +1005,9 @@ def service(self):
|
|||||||
finally:
|
finally:
|
||||||
q.put(None)
|
q.put(None)
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
item = q.get()
|
item = q.get()
|
||||||
@@ -834,7 +1056,9 @@ def service(self):
|
|||||||
finally:
|
finally:
|
||||||
q.put(None)
|
q.put(None)
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
import contextvars
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
threading.Thread(target=lambda: ctx.run(_worker), daemon=True).start()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
item = q.get()
|
item = q.get()
|
||||||
@@ -855,11 +1079,6 @@ def service(self):
|
|||||||
@handle_errors
|
@handle_errors
|
||||||
def run_cli_script(self, request, context):
|
def run_cli_script(self, request, context):
|
||||||
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
|
res = self.service.run_cli_script(request.param1, request.param2, request.parallel)
|
||||||
return connpy_pb2.StructResponse(data=to_struct(res))
|
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
def run_yaml_playbook(self, request, context):
|
|
||||||
res = self.service.run_yaml_playbook(request.param1, request.parallel)
|
|
||||||
return connpy_pb2.StructResponse(data=to_struct(res))</code></pre>
|
return connpy_pb2.StructResponse(data=to_struct(res))</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
@@ -888,7 +1107,6 @@ def service(self):
|
|||||||
<ul class="hlist">
|
<ul class="hlist">
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.ExecutionServiceServicer.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+175
-230
@@ -99,8 +99,7 @@ el.replaceWith(d);
|
|||||||
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
|
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
|
||||||
self.remote_host = remote_host
|
self.remote_host = remote_host
|
||||||
|
|
||||||
@handle_errors
|
def _ai_chat_stream(self, stub_method, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, chunk_callback=None, **overrides):
|
||||||
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
|
||||||
import queue
|
import queue
|
||||||
from rich.prompt import Prompt
|
from rich.prompt import Prompt
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
@@ -135,7 +134,7 @@ el.replaceWith(d);
|
|||||||
if req is None: break
|
if req is None: break
|
||||||
yield req
|
yield req
|
||||||
|
|
||||||
responses = self.stub.ask(request_generator())
|
responses = stub_method(request_generator())
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
header_printed = False
|
header_printed = False
|
||||||
@@ -234,26 +233,32 @@ el.replaceWith(d);
|
|||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
if chunk_callback:
|
||||||
from rich.rule import Rule
|
header_printed = True
|
||||||
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
else:
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
# Print header on first chunk
|
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
alias = "architect" if current_responder == "architect" else "engineer"
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
|
|
||||||
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
|
# Print header on first chunk
|
||||||
header_printed = True
|
alias = "architect" if current_responder == "architect" else "engineer"
|
||||||
|
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
|
||||||
# Initialize parser
|
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
|
||||||
md_parser = IncrementalMarkdownParser(console=stable_console)
|
header_printed = True
|
||||||
|
|
||||||
|
# Initialize parser
|
||||||
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
|
|
||||||
full_content += response.text_chunk
|
full_content += response.text_chunk
|
||||||
md_parser.feed(response.text_chunk)
|
if chunk_callback:
|
||||||
|
chunk_callback(response.text_chunk)
|
||||||
|
elif md_parser:
|
||||||
|
md_parser.feed(response.text_chunk)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.is_final:
|
if response.is_final:
|
||||||
if header_printed:
|
if not chunk_callback and header_printed:
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
md_parser.flush()
|
md_parser.flush()
|
||||||
|
|
||||||
@@ -262,12 +267,8 @@ el.replaceWith(d);
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
final_result = from_struct(response.full_result)
|
final_result = from_struct(response.full_result)
|
||||||
responder = final_result.get("responder", "engineer")
|
|
||||||
alias = "architect" if responder == "architect" else "engineer"
|
|
||||||
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
|
|
||||||
title = f"[bold {alias}]{role_label}[/bold {alias}]"
|
|
||||||
|
|
||||||
if header_printed:
|
if not chunk_callback and header_printed:
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
from ..printer import connpy_theme, get_original_stdout
|
from ..printer import connpy_theme, get_original_stdout
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
@@ -286,6 +287,104 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
return final_result
|
return final_result
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
||||||
|
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
|
||||||
|
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
def _process_unary_stream(self, responses, status=None, chunk_callback=None):
|
||||||
|
full_content = ""
|
||||||
|
header_printed = False
|
||||||
|
final_result = {"response": "", "chat_history": []}
|
||||||
|
md_parser = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for response in responses:
|
||||||
|
if response.status_update:
|
||||||
|
if status:
|
||||||
|
status.update(response.status_update)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.important_message:
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
printer.console.print(Text.from_ansi(response.important_message))
|
||||||
|
if status:
|
||||||
|
try: status.start()
|
||||||
|
except: pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not response.is_final:
|
||||||
|
if response.text_chunk:
|
||||||
|
if not header_printed:
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if chunk_callback:
|
||||||
|
header_printed = True
|
||||||
|
else:
|
||||||
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
|
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
|
||||||
|
# Print default header
|
||||||
|
stable_console.print(Rule("[bold engineer]AI Analysis[/bold engineer]", style="engineer"))
|
||||||
|
header_printed = True
|
||||||
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
|
|
||||||
|
full_content += response.text_chunk
|
||||||
|
if chunk_callback:
|
||||||
|
chunk_callback(response.text_chunk)
|
||||||
|
elif md_parser:
|
||||||
|
md_parser.feed(response.text_chunk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.is_final:
|
||||||
|
if md_parser:
|
||||||
|
md_parser.flush()
|
||||||
|
|
||||||
|
if status:
|
||||||
|
try: status.stop()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
final_result = from_struct(response.full_result)
|
||||||
|
|
||||||
|
if md_parser:
|
||||||
|
from rich.console import Console as RichConsole
|
||||||
|
from rich.rule import Rule
|
||||||
|
from ..printer import connpy_theme, get_original_stdout
|
||||||
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
stable_console.print(Rule(style="engineer"))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, grpc.RpcError):
|
||||||
|
raise
|
||||||
|
printer.warning(f"Stream interrupted: {e}")
|
||||||
|
|
||||||
|
if full_content:
|
||||||
|
final_result["streamed"] = True
|
||||||
|
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.AnalyzeRequest(query=query or "")
|
||||||
|
req.results.CopyFrom(to_struct(results))
|
||||||
|
responses = self.stub.analyze_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)
|
||||||
|
|
||||||
|
@handle_errors
|
||||||
|
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
|
||||||
|
responses = self.stub.predict_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def confirm(self, input_text, console=None):
|
def confirm(self, input_text, console=None):
|
||||||
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
|
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
|
||||||
@@ -333,6 +432,23 @@ el.replaceWith(d);
|
|||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
<h3>Methods</h3>
|
<h3>Methods</h3>
|
||||||
<dl>
|
<dl>
|
||||||
|
<dt id="connpy.grpc_layer.stubs.AIStub.analyze_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results, query=None, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@handle_errors
|
||||||
|
def analyze_execution_results(self, results, query=None, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.AnalyzeRequest(query=query or "")
|
||||||
|
req.results.CopyFrom(to_struct(results))
|
||||||
|
responses = self.stub.analyze_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.stubs.AIStub.ask"><code class="name flex">
|
<dt id="connpy.grpc_layer.stubs.AIStub.ask"><code class="name flex">
|
||||||
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>session_id=None,<br>debug=False,<br>status=None,<br>**overrides)</span>
|
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>session_id=None,<br>debug=False,<br>status=None,<br>**overrides)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -343,190 +459,21 @@ el.replaceWith(d);
|
|||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">@handle_errors
|
<pre><code class="python">@handle_errors
|
||||||
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
|
||||||
import queue
|
return self._ai_chat_stream(self.stub.ask, input_text, dryrun=dryrun, chat_history=chat_history, session_id=session_id, debug=debug, status=status, **overrides)</code></pre>
|
||||||
from rich.prompt import Prompt
|
</details>
|
||||||
from rich.text import Text
|
<div class="desc"></div>
|
||||||
from rich.panel import Panel
|
</dd>
|
||||||
from rich.markdown import Markdown
|
<dt id="connpy.grpc_layer.stubs.AIStub.build_playbook_chat"><code class="name flex">
|
||||||
|
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input, chat_history=None, status=None, chunk_callback=None)</span>
|
||||||
req_queue = queue.Queue()
|
</code></dt>
|
||||||
|
<dd>
|
||||||
initial_req = connpy_pb2.AskRequest(
|
<details class="source">
|
||||||
input_text=input_text,
|
<summary>
|
||||||
dryrun=dryrun,
|
<span>Expand source code</span>
|
||||||
session_id=session_id or "",
|
</summary>
|
||||||
debug=debug,
|
<pre><code class="python">@handle_errors
|
||||||
engineer_model=overrides.get("engineer_model", ""),
|
def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
|
||||||
engineer_api_key=overrides.get("engineer_api_key", ""),
|
return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
architect_model=overrides.get("architect_model", ""),
|
|
||||||
architect_api_key=overrides.get("architect_api_key", ""),
|
|
||||||
trust=overrides.get("trust", False)
|
|
||||||
)
|
|
||||||
if chat_history is not None:
|
|
||||||
initial_req.chat_history.CopyFrom(to_value(chat_history))
|
|
||||||
if "engineer_auth" in overrides and overrides["engineer_auth"]:
|
|
||||||
initial_req.engineer_auth.CopyFrom(to_struct(overrides["engineer_auth"]))
|
|
||||||
if "architect_auth" in overrides and overrides["architect_auth"]:
|
|
||||||
initial_req.architect_auth.CopyFrom(to_struct(overrides["architect_auth"]))
|
|
||||||
|
|
||||||
req_queue.put(initial_req)
|
|
||||||
|
|
||||||
def request_generator():
|
|
||||||
while True:
|
|
||||||
req = req_queue.get()
|
|
||||||
if req is None: break
|
|
||||||
yield req
|
|
||||||
|
|
||||||
responses = self.stub.ask(request_generator())
|
|
||||||
|
|
||||||
full_content = ""
|
|
||||||
header_printed = False
|
|
||||||
current_responder = "engineer"
|
|
||||||
final_result = {"response": "", "chat_history": []}
|
|
||||||
|
|
||||||
# Background thread to pull responses from gRPC into a local queue
|
|
||||||
# This prevents KeyboardInterrupt from corrupting the gRPC iterator state
|
|
||||||
response_queue = queue.Queue()
|
|
||||||
|
|
||||||
def pull_responses():
|
|
||||||
try:
|
|
||||||
for response in responses:
|
|
||||||
response_queue.put(("data", response))
|
|
||||||
except Exception as e:
|
|
||||||
response_queue.put(("error", e))
|
|
||||||
finally:
|
|
||||||
response_queue.put((None, None))
|
|
||||||
|
|
||||||
threading.Thread(target=pull_responses, daemon=True).start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# BLOCKING GET from local queue (interruptible by signal)
|
|
||||||
msg_type, response = response_queue.get()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
# Signal interruption to the server
|
|
||||||
if status:
|
|
||||||
status.update("[error]Interrupted! Closing pending tasks...")
|
|
||||||
|
|
||||||
# Send the interrupt signal to the server
|
|
||||||
req_queue.put(connpy_pb2.AskRequest(interrupt=True))
|
|
||||||
|
|
||||||
# CONTINUE the loop to receive remaining data and summary from the queue
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg_type is None: # Sentinel
|
|
||||||
break
|
|
||||||
|
|
||||||
if msg_type == "error":
|
|
||||||
# Re-raise or handle gRPC error from background thread
|
|
||||||
if isinstance(response, grpc.RpcError):
|
|
||||||
raise response
|
|
||||||
printer.warning(f"Stream interrupted: {response}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if response.status_update:
|
|
||||||
if response.status_update.startswith("__RESPONDER__:"):
|
|
||||||
current_responder = response.status_update.split(":")[1].lower()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if response.requires_confirmation:
|
|
||||||
if status: status.stop()
|
|
||||||
|
|
||||||
# Show prompt and wait for answer
|
|
||||||
prompt_text = Text.from_ansi(response.status_update)
|
|
||||||
ans = Prompt.ask(prompt_text)
|
|
||||||
|
|
||||||
if status:
|
|
||||||
status.update("[ai_status]Agent: Resuming...")
|
|
||||||
status.start()
|
|
||||||
|
|
||||||
req_queue.put(connpy_pb2.AskRequest(confirmation_answer=ans))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if status:
|
|
||||||
status.update(response.status_update)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if response.debug_message:
|
|
||||||
if debug:
|
|
||||||
if status:
|
|
||||||
try: status.stop()
|
|
||||||
except: pass
|
|
||||||
printer.console.print(Text.from_ansi(response.debug_message))
|
|
||||||
if status:
|
|
||||||
try: status.start()
|
|
||||||
except: pass
|
|
||||||
continue
|
|
||||||
|
|
||||||
if response.important_message:
|
|
||||||
if status:
|
|
||||||
try: status.stop()
|
|
||||||
except: pass
|
|
||||||
printer.console.print(Text.from_ansi(response.important_message))
|
|
||||||
if status:
|
|
||||||
try: status.start()
|
|
||||||
except: pass
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not response.is_final:
|
|
||||||
if response.text_chunk:
|
|
||||||
if not header_printed:
|
|
||||||
if status:
|
|
||||||
try: status.stop()
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
|
||||||
from rich.rule import Rule
|
|
||||||
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
|
||||||
|
|
||||||
# Print header on first chunk
|
|
||||||
alias = "architect" if current_responder == "architect" else "engineer"
|
|
||||||
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
|
|
||||||
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
|
|
||||||
header_printed = True
|
|
||||||
|
|
||||||
# Initialize parser
|
|
||||||
md_parser = IncrementalMarkdownParser(console=stable_console)
|
|
||||||
|
|
||||||
full_content += response.text_chunk
|
|
||||||
md_parser.feed(response.text_chunk)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if response.is_final:
|
|
||||||
if header_printed:
|
|
||||||
from rich.rule import Rule
|
|
||||||
md_parser.flush()
|
|
||||||
|
|
||||||
if status:
|
|
||||||
try: status.stop()
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
final_result = from_struct(response.full_result)
|
|
||||||
responder = final_result.get("responder", "engineer")
|
|
||||||
alias = "architect" if responder == "architect" else "engineer"
|
|
||||||
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
|
|
||||||
title = f"[bold {alias}]{role_label}[/bold {alias}]"
|
|
||||||
|
|
||||||
if header_printed:
|
|
||||||
from rich.console import Console as RichConsole
|
|
||||||
from ..printer import connpy_theme, get_original_stdout
|
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
|
||||||
stable_console.print(Rule(style=alias))
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
# Check if it was a gRPC error that we should let handle_errors catch
|
|
||||||
if isinstance(e, grpc.RpcError):
|
|
||||||
raise
|
|
||||||
printer.warning(f"Stream interrupted: {e}")
|
|
||||||
finally:
|
|
||||||
req_queue.put(None)
|
|
||||||
|
|
||||||
if full_content:
|
|
||||||
final_result["streamed"] = True
|
|
||||||
|
|
||||||
return final_result</code></pre>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -644,6 +591,22 @@ def load_session_data(self, session_id):
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.stubs.AIStub.predict_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes, commands, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">@handle_errors
|
||||||
|
def predict_execution_results(self, target_nodes, commands, status=None, chunk_callback=None):
|
||||||
|
req = connpy_pb2.PreflightRequest(target_nodes=target_nodes, commands=commands)
|
||||||
|
responses = self.stub.predict_execution_results(req)
|
||||||
|
return self._process_unary_stream(responses, status, chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"></div>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor"><code class="flex name class">
|
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor"><code class="flex name class">
|
||||||
@@ -1124,12 +1087,7 @@ def update_setting(self, key, value):
|
|||||||
@handle_errors
|
@handle_errors
|
||||||
def run_cli_script(self, nodes_filter, script_path, parallel=10):
|
def run_cli_script(self, nodes_filter, script_path, parallel=10):
|
||||||
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
|
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
|
||||||
return from_struct(self.stub.run_cli_script(req).data)
|
return from_struct(self.stub.run_cli_script(req).data)</code></pre>
|
||||||
|
|
||||||
@handle_errors
|
|
||||||
def run_yaml_playbook(self, playbook_path, parallel=10):
|
|
||||||
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
|
|
||||||
return from_struct(self.stub.run_yaml_playbook(req).data)</code></pre>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
<h3>Methods</h3>
|
<h3>Methods</h3>
|
||||||
@@ -1187,21 +1145,6 @@ def run_commands(self, nodes_filter, commands, variables=None, parallel=10, time
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook"><code class="name flex">
|
|
||||||
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_path, parallel=10)</span>
|
|
||||||
</code></dt>
|
|
||||||
<dd>
|
|
||||||
<details class="source">
|
|
||||||
<summary>
|
|
||||||
<span>Expand source code</span>
|
|
||||||
</summary>
|
|
||||||
<pre><code class="python">@handle_errors
|
|
||||||
def run_yaml_playbook(self, playbook_path, parallel=10):
|
|
||||||
req = connpy_pb2.ScriptRequest(param1=playbook_path, parallel=parallel)
|
|
||||||
return from_struct(self.stub.run_yaml_playbook(req).data)</code></pre>
|
|
||||||
</details>
|
|
||||||
<div class="desc"></div>
|
|
||||||
</dd>
|
|
||||||
<dt id="connpy.grpc_layer.stubs.ExecutionStub.test_commands"><code class="name flex">
|
<dt id="connpy.grpc_layer.stubs.ExecutionStub.test_commands"><code class="name flex">
|
||||||
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter,<br>commands,<br>expected,<br>variables=None,<br>parallel=10,<br>timeout=10,<br>prompt=None,<br>**kwargs)</span>
|
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter,<br>commands,<br>expected,<br>variables=None,<br>parallel=10,<br>timeout=10,<br>prompt=None,<br>**kwargs)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -2815,8 +2758,10 @@ def stop_api(self):
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<h4><code><a title="connpy.grpc_layer.stubs.AIStub" href="#connpy.grpc_layer.stubs.AIStub">AIStub</a></code></h4>
|
<h4><code><a title="connpy.grpc_layer.stubs.AIStub" href="#connpy.grpc_layer.stubs.AIStub">AIStub</a></code></h4>
|
||||||
<ul class="two-column">
|
<ul class="">
|
||||||
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.analyze_execution_results" href="#connpy.grpc_layer.stubs.AIStub.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.ask" href="#connpy.grpc_layer.stubs.AIStub.ask">ask</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.ask" href="#connpy.grpc_layer.stubs.AIStub.ask">ask</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.build_playbook_chat" href="#connpy.grpc_layer.stubs.AIStub.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_mcp" href="#connpy.grpc_layer.stubs.AIStub.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_mcp" href="#connpy.grpc_layer.stubs.AIStub.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_provider" href="#connpy.grpc_layer.stubs.AIStub.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.configure_provider" href="#connpy.grpc_layer.stubs.AIStub.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.confirm" href="#connpy.grpc_layer.stubs.AIStub.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.confirm" href="#connpy.grpc_layer.stubs.AIStub.confirm">confirm</a></code></li>
|
||||||
@@ -2824,6 +2769,7 @@ def stop_api(self):
|
|||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_mcp_servers" href="#connpy.grpc_layer.stubs.AIStub.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_mcp_servers" href="#connpy.grpc_layer.stubs.AIStub.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_sessions" href="#connpy.grpc_layer.stubs.AIStub.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.list_sessions" href="#connpy.grpc_layer.stubs.AIStub.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.AIStub.load_session_data" href="#connpy.grpc_layer.stubs.AIStub.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.load_session_data" href="#connpy.grpc_layer.stubs.AIStub.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.stubs.AIStub.predict_execution_results" href="#connpy.grpc_layer.stubs.AIStub.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -2857,7 +2803,6 @@ def stop_api(self):
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_cli_script" href="#connpy.grpc_layer.stubs.ExecutionStub.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_cli_script" href="#connpy.grpc_layer.stubs.ExecutionStub.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook" href="#connpy.grpc_layer.stubs.ExecutionStub.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.test_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.grpc_layer.stubs.ExecutionStub.test_commands" href="#connpy.grpc_layer.stubs.ExecutionStub.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ el.replaceWith(d);
|
|||||||
def has_users(self) -> bool:
|
def has_users(self) -> bool:
|
||||||
"""Check if any users are registered (enables auth enforcement)."""
|
"""Check if any users are registered (enables auth enforcement)."""
|
||||||
return bool(self.user_service.list_users())
|
return bool(self.user_service.list_users())
|
||||||
|
|
||||||
|
def get_shared_config(self):
|
||||||
|
"""Thread-safe access to the hot-reloaded shared configuration."""
|
||||||
|
with self._lock:
|
||||||
|
self._refresh_shared()
|
||||||
|
return self._shared_config
|
||||||
|
|
||||||
def evict(self, username):
|
def evict(self, username):
|
||||||
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
||||||
@@ -244,6 +250,22 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
|
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config"><code class="name flex">
|
||||||
|
<span>def <span class="ident">get_shared_config</span></span>(<span>self)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def get_shared_config(self):
|
||||||
|
"""Thread-safe access to the hot-reloaded shared configuration."""
|
||||||
|
with self._lock:
|
||||||
|
self._refresh_shared()
|
||||||
|
return self._shared_config</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Thread-safe access to the hot-reloaded shared configuration.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
|
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
|
||||||
<span>def <span class="ident">has_users</span></span>(<span>self) ‑> bool</span>
|
<span>def <span class="ident">has_users</span></span>(<span>self) ‑> bool</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -280,6 +302,7 @@ el.replaceWith(d);
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.evict" href="#connpy.grpc_layer.user_registry.UserRegistry.evict">evict</a></code></li>
|
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.evict" href="#connpy.grpc_layer.user_registry.UserRegistry.evict">evict</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_provider" href="#connpy.grpc_layer.user_registry.UserRegistry.get_provider">get_provider</a></code></li>
|
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_provider" href="#connpy.grpc_layer.user_registry.UserRegistry.get_provider">get_provider</a></code></li>
|
||||||
|
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config" href="#connpy.grpc_layer.user_registry.UserRegistry.get_shared_config">get_shared_config</a></code></li>
|
||||||
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.has_users" href="#connpy.grpc_layer.user_registry.UserRegistry.has_users">has_users</a></code></li>
|
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.has_users" href="#connpy.grpc_layer.user_registry.UserRegistry.has_users">has_users</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+193
-105
@@ -125,6 +125,24 @@ conn ai
|
|||||||
# Run a command on all nodes in a folder
|
# Run a command on all nodes in a folder
|
||||||
conn run @office "uptime"
|
conn run @office "uptime"
|
||||||
</code></pre>
|
</code></pre>
|
||||||
|
<h3 id="sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</h3>
|
||||||
|
<p>In remote mode, <code><a title="connpy" href="#connpy">connpy</a></code> supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the <code>conn sso</code> command suite:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>List configured providers</strong>:
|
||||||
|
<code>bash
|
||||||
|
conn sso --list</code></li>
|
||||||
|
<li><strong>Show provider details</strong> (sensitive credentials like secrets are masked):
|
||||||
|
<code>bash
|
||||||
|
conn sso --show <provider_name></code></li>
|
||||||
|
<li><strong>Add or update a provider</strong> (opens an interactive configuration wizard):
|
||||||
|
<code>bash
|
||||||
|
conn sso --add <provider_name></code></li>
|
||||||
|
<li><strong>Delete a provider</strong>:
|
||||||
|
<code>bash
|
||||||
|
conn sso --del <provider_name></code></li>
|
||||||
|
</ul>
|
||||||
|
<h4 id="security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</h4>
|
||||||
|
<p>To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a <code>$</code> instead of the literal secret during the <code>conn sso --add</code> prompts (e.g., <code>$CONN_SSO_MYPROVIDER_SECRET</code>). The backend gRPC server will dynamically resolve the value from its environment variables at runtime.</p>
|
||||||
<hr>
|
<hr>
|
||||||
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
|
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
|
||||||
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
|
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
|
||||||
@@ -185,6 +203,8 @@ response = myai.ask("What is the status of the BGP neighbors in the office?
|
|||||||
</code></pre>
|
</code></pre>
|
||||||
<hr>
|
<hr>
|
||||||
<p><em>For detailed developer notes and plugin hooks documentation, see the <a href="https://fluzzi.github.io/connpy/">Documentation</a>.</em></p>
|
<p><em>For detailed developer notes and plugin hooks documentation, see the <a href="https://fluzzi.github.io/connpy/">Documentation</a>.</em></p>
|
||||||
|
<h2 id="license">📜 License</h2>
|
||||||
|
<p><a href="LICENSE">PolyForm Noncommercial 1.0.0</a></p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="section-title" id="header-submodules">Sub-modules</h2>
|
<h2 class="section-title" id="header-submodules">Sub-modules</h2>
|
||||||
@@ -644,9 +664,10 @@ class ai:
|
|||||||
self.confirm_handler = confirm_handler or self._local_confirm_handler
|
self.confirm_handler = confirm_handler or self._local_confirm_handler
|
||||||
self.trusted_session = trust # Trust mode for the entire session
|
self.trusted_session = trust # Trust mode for the entire session
|
||||||
self.interrupted = False
|
self.interrupted = False
|
||||||
|
self.one_shot = kwargs.get("one_shot", False)
|
||||||
|
|
||||||
|
|
||||||
# 1. Cargar configuración genérica con herencia/merge global
|
# 1. Load generic configuration with global inheritance/merge
|
||||||
if hasattr(self.config, "get_effective_setting"):
|
if hasattr(self.config, "get_effective_setting"):
|
||||||
aiconfig = self.config.get_effective_setting("ai", {})
|
aiconfig = self.config.get_effective_setting("ai", {})
|
||||||
else:
|
else:
|
||||||
@@ -689,7 +710,7 @@ class ai:
|
|||||||
custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()]
|
custom_trusted = [c.strip() for c in custom_trusted.split(",") if c.strip()]
|
||||||
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
|
self.safe_commands = list(self.SAFE_COMMANDS) + (custom_trusted if isinstance(custom_trusted, list) else [])
|
||||||
|
|
||||||
# Límites
|
# Limits
|
||||||
self.max_history = 30
|
self.max_history = 30
|
||||||
self.max_truncate = 50000
|
self.max_truncate = 50000
|
||||||
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
|
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
|
||||||
@@ -726,7 +747,7 @@ class ai:
|
|||||||
self.session_id = getattr(self.config, "session_id", None)
|
self.session_id = getattr(self.config, "session_id", None)
|
||||||
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
|
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
|
||||||
|
|
||||||
# Prompts base agnósticos
|
# Agnostic base prompts
|
||||||
architect_instructions = ""
|
architect_instructions = ""
|
||||||
if self.has_architect:
|
if self.has_architect:
|
||||||
architect_instructions = """
|
architect_instructions = """
|
||||||
@@ -815,10 +836,13 @@ class ai:
|
|||||||
@property
|
@property
|
||||||
def architect_system_prompt(self):
|
def architect_system_prompt(self):
|
||||||
"""Build architect system prompt with plugin extensions."""
|
"""Build architect system prompt with plugin extensions."""
|
||||||
|
prompt = self._architect_base_prompt
|
||||||
|
if getattr(self, "one_shot", False):
|
||||||
|
prompt += "\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer."
|
||||||
if self.architect_prompt_extensions:
|
if self.architect_prompt_extensions:
|
||||||
extensions = "\n".join(self.architect_prompt_extensions)
|
extensions = "\n".join(self.architect_prompt_extensions)
|
||||||
return self._architect_base_prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
return prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
||||||
return self._architect_base_prompt
|
return prompt
|
||||||
|
|
||||||
def register_ai_tool(self, tool_definition, handler, target="engineer", engineer_prompt=None, architect_prompt=None, status_formatter=None):
|
def register_ai_tool(self, tool_definition, handler, target="engineer", engineer_prompt=None, architect_prompt=None, status_formatter=None):
|
||||||
"""Register an external tool for the AI system.
|
"""Register an external tool for the AI system.
|
||||||
@@ -1263,7 +1287,7 @@ class ai:
|
|||||||
|
|
||||||
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
|
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
|
||||||
"""Internal loop where the Engineer executes technical tasks for the Architect."""
|
"""Internal loop where the Engineer executes technical tasks for the Architect."""
|
||||||
# Optimización de caché para el Ingeniero (Solo para Anthropic directo, Vertex tiene reglas distintas)
|
# Cache optimization for the Engineer (Only for direct Anthropic, Vertex has different rules)
|
||||||
if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
|
if "claude" in self.engineer_model.lower() and "vertex" not in self.engineer_model.lower():
|
||||||
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
||||||
else:
|
else:
|
||||||
@@ -1295,13 +1319,11 @@ class ai:
|
|||||||
if self.interrupted:
|
if self.interrupted:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
# Soft limit warning
|
if status and not chat_history:
|
||||||
if iteration == self.soft_limit_iterations and not soft_limit_warned:
|
status_text = f"[ai_status]Engineer: Analyzing mission... (step {iteration})"
|
||||||
self.console.print(f"[warning]⚠ Engineer has performed {iteration} steps. This is taking longer than expected.[/warning]")
|
if iteration >= self.soft_limit_iterations:
|
||||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary.[/warning]")
|
status_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
soft_limit_warned = True
|
status.update(status_text)
|
||||||
|
|
||||||
if status and not chat_history: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
safe_messages = self._sanitize_messages(messages)
|
safe_messages = self._sanitize_messages(messages)
|
||||||
@@ -1324,19 +1346,25 @@ class ai:
|
|||||||
for tc in resp_msg.tool_calls:
|
for tc in resp_msg.tool_calls:
|
||||||
fn, args = tc.function.name, json.loads(tc.function.arguments)
|
fn, args = tc.function.name, json.loads(tc.function.arguments)
|
||||||
|
|
||||||
# Notificación en tiempo real de la tarea técnica (Only if not in Architect loop)
|
# Real-time notification of the technical task (Only if not in Architect loop)
|
||||||
if status and not chat_history:
|
if status and not chat_history:
|
||||||
if fn == "list_nodes": status.update(f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}")
|
s_text = ""
|
||||||
|
if fn == "list_nodes": s_text = f"[ai_status]Engineer: [SEARCH] {args.get('filter_pattern','.*')}"
|
||||||
elif fn == "run_commands":
|
elif fn == "run_commands":
|
||||||
cmds = args.get('commands', [])
|
cmds = args.get('commands', [])
|
||||||
cmd_str = cmds[0] if cmds else ""
|
cmd_str = cmds[0] if cmds else ""
|
||||||
status.update(f"[ai_status]Engineer: [CMD] {cmd_str}")
|
s_text = f"[ai_status]Engineer: [CMD] {cmd_str}"
|
||||||
elif fn == "get_node_info": status.update(f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}")
|
elif fn == "get_node_info": s_text = f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}"
|
||||||
elif fn.startswith("mcp_"):
|
elif fn.startswith("mcp_"):
|
||||||
server = fn.split("__")[0].replace("mcp_", "")
|
server = fn.split("__")[0].replace("mcp_", "")
|
||||||
tool = fn.split("__")[1] if "__" in fn else fn
|
tool = fn.split("__")[1] if "__" in fn else fn
|
||||||
status.update(f"[ai_status]Engineer: [MCP:{server}] {tool}")
|
s_text = f"[ai_status]Engineer: [MCP:{server}] {tool}"
|
||||||
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
|
elif fn in self.tool_status_formatters: s_text = self.tool_status_formatters[fn](args)
|
||||||
|
|
||||||
|
if s_text:
|
||||||
|
if iteration >= self.soft_limit_iterations:
|
||||||
|
s_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
|
status.update(s_text)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
||||||
@@ -1406,6 +1434,8 @@ class ai:
|
|||||||
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
|
{"type": "function", "function": {"name": "return_to_engineer", "description": "Return control to the Engineer. Use this when your strategic analysis is complete and the Engineer should handle the rest of the conversation.", "parameters": {"type": "object", "properties": {"summary": {"type": "string", "description": "Brief summary of your analysis to hand over to the Engineer."}}, "required": ["summary"]}}},
|
||||||
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
|
{"type": "function", "function": {"name": "manage_memory_tool", "description": "Saves information to long-term memory. MANDATORY: Only use this if the user explicitly asks to remember or save something.", "parameters": {"type": "object", "properties": {"content": {"type": "string"}, "action": {"type": "string", "enum": ["append", "replace"]}}, "required": ["content"]}}}
|
||||||
]
|
]
|
||||||
|
if getattr(self, "one_shot", False):
|
||||||
|
base_tools = [t for t in base_tools if t["function"]["name"] not in ("delegate_to_engineer", "return_to_engineer")]
|
||||||
|
|
||||||
all_tools = base_tools + self.external_architect_tools
|
all_tools = base_tools + self.external_architect_tools
|
||||||
seen_names = set()
|
seen_names = set()
|
||||||
@@ -1541,11 +1571,18 @@ class ai:
|
|||||||
|
|
||||||
@MethodHook
|
@MethodHook
|
||||||
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
||||||
soft_limit_warned = False
|
|
||||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||||
|
|
||||||
|
def update_status(text):
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
if iteration >= self.soft_limit_iterations:
|
||||||
|
warning_suffix = " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
|
if warning_suffix not in text:
|
||||||
|
text += warning_suffix
|
||||||
|
status.update(text)
|
||||||
|
|
||||||
if chat_history is None: chat_history = []
|
if chat_history is None: chat_history = []
|
||||||
|
|
||||||
@@ -1564,7 +1601,7 @@ class ai:
|
|||||||
|
|
||||||
usage = {"input": 0, "output": 0, "total": 0}
|
usage = {"input": 0, "output": 0, "total": 0}
|
||||||
|
|
||||||
# 1. Selector de Rol inicial (Sticky Brain)
|
# 1. Initial Role Selector (Sticky Brain)
|
||||||
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
||||||
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
||||||
|
|
||||||
@@ -1573,7 +1610,7 @@ class ai:
|
|||||||
elif explicit_engineer:
|
elif explicit_engineer:
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
else:
|
else:
|
||||||
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
|
# Sticky Brain: Detect if the Architect was in control in recent history
|
||||||
is_architect_active = False
|
is_architect_active = False
|
||||||
for msg in reversed(chat_history[-5:]):
|
for msg in reversed(chat_history[-5:]):
|
||||||
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
||||||
@@ -1587,7 +1624,7 @@ class ai:
|
|||||||
if is_architect_active: break
|
if is_architect_active: break
|
||||||
current_brain = "architect" if is_architect_active else "engineer"
|
current_brain = "architect" if is_architect_active else "engineer"
|
||||||
|
|
||||||
# 2. Preparación de mensajes y limpieza
|
# 2. Message preparation and cleaning
|
||||||
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
||||||
@@ -1596,13 +1633,13 @@ class ai:
|
|||||||
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
||||||
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
||||||
|
|
||||||
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
|
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
|
||||||
if "claude" in model.lower() and "vertex" not in model.lower():
|
if "claude" in model.lower() and "vertex" not in model.lower():
|
||||||
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
||||||
else:
|
else:
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
# Interleaving de historial
|
# History interleaving
|
||||||
last_role = "system"
|
last_role = "system"
|
||||||
# Sanitize history if the current target model is not compatible with cache_control
|
# Sanitize history if the current target model is not compatible with cache_control
|
||||||
history_to_process = chat_history[-self.max_history:]
|
history_to_process = chat_history[-self.max_history:]
|
||||||
@@ -1622,7 +1659,7 @@ class ai:
|
|||||||
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
||||||
else: messages.append({"role": "user", "content": clean_input})
|
else: messages.append({"role": "user", "content": clean_input})
|
||||||
|
|
||||||
# 3. Bucle de ejecución
|
# 3. Execution loop
|
||||||
iteration = 0
|
iteration = 0
|
||||||
try:
|
try:
|
||||||
# Set up remote interrupt callback if bridge is provided
|
# Set up remote interrupt callback if bridge is provided
|
||||||
@@ -1636,18 +1673,14 @@ class ai:
|
|||||||
if self.interrupted:
|
if self.interrupted:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
# Soft limit warning
|
# Soft limit warning - handled inline within update_status
|
||||||
if iteration == self.soft_limit_iterations and not soft_limit_warned:
|
|
||||||
self.console.print(f"[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]")
|
|
||||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]")
|
|
||||||
soft_limit_warned = True
|
|
||||||
|
|
||||||
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
||||||
if status:
|
if status:
|
||||||
# Notify responder identity for web/remote clients
|
# Notify responder identity for web/remote clients
|
||||||
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
||||||
status.update(f"__RESPONDER__:{current_brain}")
|
status.update(f"__RESPONDER__:{current_brain}")
|
||||||
status.update(f"{label} is thinking... (step {iteration})")
|
update_status(f"{label} is thinking... (step {iteration})")
|
||||||
|
|
||||||
streamed_response = False
|
streamed_response = False
|
||||||
try:
|
try:
|
||||||
@@ -1662,7 +1695,7 @@ class ai:
|
|||||||
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if current_brain == "architect":
|
if current_brain == "architect":
|
||||||
if status: status.update("[unavailable]Architect unavailable! Falling back to Engineer...")
|
if status: update_status("[unavailable]Architect unavailable! Falling back to Engineer...")
|
||||||
# Preserve context when falling back - use clean_input directly
|
# Preserve context when falling back - use clean_input directly
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -1719,8 +1752,8 @@ class ai:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
if fn == "delegate_to_engineer": update_status(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
||||||
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
|
elif fn == "manage_memory_tool": update_status(f"[architect]Architect: [UPDATING MEMORY]")
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
||||||
@@ -1729,7 +1762,7 @@ class ai:
|
|||||||
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
||||||
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
||||||
elif fn == "consult_architect":
|
elif fn == "consult_architect":
|
||||||
if status: status.update("[architect]Engineer consulting Architect...")
|
if status: update_status("[architect]Engineer consulting Architect...")
|
||||||
try:
|
try:
|
||||||
# Consultation only - Engineer stays in control
|
# Consultation only - Engineer stays in control
|
||||||
claude_resp = completion(
|
claude_resp = completion(
|
||||||
@@ -1751,11 +1784,11 @@ class ai:
|
|||||||
try: status.start()
|
try: status.start()
|
||||||
except: pass
|
except: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status: status.update("[unavailable]Architect unavailable! Engineer continuing alone...")
|
if status: update_status("[unavailable]Architect unavailable! Engineer continuing alone...")
|
||||||
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
||||||
|
|
||||||
elif fn == "escalate_to_architect":
|
elif fn == "escalate_to_architect":
|
||||||
if status: status.update("[architect]Transferring control to Architect...")
|
if status: update_status("[architect]Transferring control to Architect...")
|
||||||
# Full escalation - Architect takes over
|
# Full escalation - Architect takes over
|
||||||
current_brain = "architect"
|
current_brain = "architect"
|
||||||
model = self.architect_model
|
model = self.architect_model
|
||||||
@@ -1777,7 +1810,7 @@ class ai:
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
elif fn == "return_to_engineer":
|
elif fn == "return_to_engineer":
|
||||||
if status: status.update("[engineer]Transferring control back to Engineer...")
|
if status: update_status("[engineer]Transferring control back to Engineer...")
|
||||||
# Architect returns control to Engineer
|
# Architect returns control to Engineer
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -1830,7 +1863,7 @@ class ai:
|
|||||||
messages.append(resp_msg.model_dump(exclude_none=True))
|
messages.append(resp_msg.model_dump(exclude_none=True))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status:
|
if status:
|
||||||
status.update(f"[error]Error fetching summary: {e}[/error]")
|
update_status(f"[error]Error fetching summary: {e}[/error]")
|
||||||
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
||||||
@@ -2167,10 +2200,13 @@ Node: {node_name}"""
|
|||||||
<pre><code class="python">@property
|
<pre><code class="python">@property
|
||||||
def architect_system_prompt(self):
|
def architect_system_prompt(self):
|
||||||
"""Build architect system prompt with plugin extensions."""
|
"""Build architect system prompt with plugin extensions."""
|
||||||
|
prompt = self._architect_base_prompt
|
||||||
|
if getattr(self, "one_shot", False):
|
||||||
|
prompt += "\n\nCRITICAL 1-SHOT DIAGNOSTICS DIRECTIVE:\nYou are running in a 1-shot offline diagnostics mode. There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately and directly to the user. Do not suggest or attempt to delegate/return control to the engineer."
|
||||||
if self.architect_prompt_extensions:
|
if self.architect_prompt_extensions:
|
||||||
extensions = "\n".join(self.architect_prompt_extensions)
|
extensions = "\n".join(self.architect_prompt_extensions)
|
||||||
return self._architect_base_prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
return prompt + f"\n\nPlugin Capabilities:\n{extensions}"
|
||||||
return self._architect_base_prompt</code></pre>
|
return prompt</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Build architect system prompt with plugin extensions.</p></div>
|
<div class="desc"><p>Build architect system prompt with plugin extensions.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -2488,11 +2524,18 @@ Node: {node_name}"""
|
|||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">@MethodHook
|
<pre><code class="python">@MethodHook
|
||||||
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
||||||
soft_limit_warned = False
|
|
||||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||||
|
|
||||||
|
def update_status(text):
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
if iteration >= self.soft_limit_iterations:
|
||||||
|
warning_suffix = " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
|
||||||
|
if warning_suffix not in text:
|
||||||
|
text += warning_suffix
|
||||||
|
status.update(text)
|
||||||
|
|
||||||
if chat_history is None: chat_history = []
|
if chat_history is None: chat_history = []
|
||||||
|
|
||||||
@@ -2511,7 +2554,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
|
|
||||||
usage = {"input": 0, "output": 0, "total": 0}
|
usage = {"input": 0, "output": 0, "total": 0}
|
||||||
|
|
||||||
# 1. Selector de Rol inicial (Sticky Brain)
|
# 1. Initial Role Selector (Sticky Brain)
|
||||||
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
explicit_architect = re.match(r'^(architect|arquitecto|@architect)[:\s]', user_input, re.I)
|
||||||
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
explicit_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
|
||||||
|
|
||||||
@@ -2520,7 +2563,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
elif explicit_engineer:
|
elif explicit_engineer:
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
else:
|
else:
|
||||||
# Sticky Brain: Detectar si el Arquitecto estaba al mando en el historial reciente
|
# Sticky Brain: Detect if the Architect was in control in recent history
|
||||||
is_architect_active = False
|
is_architect_active = False
|
||||||
for msg in reversed(chat_history[-5:]):
|
for msg in reversed(chat_history[-5:]):
|
||||||
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
|
||||||
@@ -2534,7 +2577,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
if is_architect_active: break
|
if is_architect_active: break
|
||||||
current_brain = "architect" if is_architect_active else "engineer"
|
current_brain = "architect" if is_architect_active else "engineer"
|
||||||
|
|
||||||
# 2. Preparación de mensajes y limpieza
|
# 2. Message preparation and cleaning
|
||||||
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
clean_input = re.sub(r'^(architect|arquitecto|engineer|ingeniero|@architect|@engineer)[:\s]+', '', user_input, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
system_prompt = self.architect_system_prompt if current_brain == "architect" else self.engineer_system_prompt
|
||||||
@@ -2543,13 +2586,13 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
key = self.architect_key if current_brain == "architect" else self.engineer_key
|
||||||
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
current_auth = self.architect_auth if current_brain == "architect" else self.engineer_auth
|
||||||
|
|
||||||
# Estructura optimizada para Prompt Caching (Solo para Anthropic directo, Vertex tiene reglas distintas)
|
# Optimized structure for Prompt Caching (Only for direct Anthropic, Vertex has different rules)
|
||||||
if "claude" in model.lower() and "vertex" not in model.lower():
|
if "claude" in model.lower() and "vertex" not in model.lower():
|
||||||
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
|
||||||
else:
|
else:
|
||||||
messages = [{"role": "system", "content": system_prompt}]
|
messages = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
# Interleaving de historial
|
# History interleaving
|
||||||
last_role = "system"
|
last_role = "system"
|
||||||
# Sanitize history if the current target model is not compatible with cache_control
|
# Sanitize history if the current target model is not compatible with cache_control
|
||||||
history_to_process = chat_history[-self.max_history:]
|
history_to_process = chat_history[-self.max_history:]
|
||||||
@@ -2569,7 +2612,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
|
||||||
else: messages.append({"role": "user", "content": clean_input})
|
else: messages.append({"role": "user", "content": clean_input})
|
||||||
|
|
||||||
# 3. Bucle de ejecución
|
# 3. Execution loop
|
||||||
iteration = 0
|
iteration = 0
|
||||||
try:
|
try:
|
||||||
# Set up remote interrupt callback if bridge is provided
|
# Set up remote interrupt callback if bridge is provided
|
||||||
@@ -2583,18 +2626,14 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
if self.interrupted:
|
if self.interrupted:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
# Soft limit warning
|
# Soft limit warning - handled inline within update_status
|
||||||
if iteration == self.soft_limit_iterations and not soft_limit_warned:
|
|
||||||
self.console.print(f"[warning]⚠ Agent has performed {iteration} steps. This is taking longer than expected.[/warning]")
|
|
||||||
self.console.print(f"[warning] You can press Ctrl+C to interrupt and get a summary of progress.[/warning]")
|
|
||||||
soft_limit_warned = True
|
|
||||||
|
|
||||||
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
|
||||||
if status:
|
if status:
|
||||||
# Notify responder identity for web/remote clients
|
# Notify responder identity for web/remote clients
|
||||||
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
|
||||||
status.update(f"__RESPONDER__:{current_brain}")
|
status.update(f"__RESPONDER__:{current_brain}")
|
||||||
status.update(f"{label} is thinking... (step {iteration})")
|
update_status(f"{label} is thinking... (step {iteration})")
|
||||||
|
|
||||||
streamed_response = False
|
streamed_response = False
|
||||||
try:
|
try:
|
||||||
@@ -2609,7 +2648,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
response = completion(model=model, messages=safe_messages, tools=tools, num_retries=3, **current_auth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if current_brain == "architect":
|
if current_brain == "architect":
|
||||||
if status: status.update("[unavailable]Architect unavailable! Falling back to Engineer...")
|
if status: update_status("[unavailable]Architect unavailable! Falling back to Engineer...")
|
||||||
# Preserve context when falling back - use clean_input directly
|
# Preserve context when falling back - use clean_input directly
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -2666,8 +2705,8 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
if fn == "delegate_to_engineer": update_status(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
|
||||||
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
|
elif fn == "manage_memory_tool": update_status(f"[architect]Architect: [UPDATING MEMORY]")
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
self._print_debug_observation(f"Decision: {fn}", args, status=status)
|
||||||
@@ -2676,7 +2715,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
obs, eng_usage = self._engineer_loop(args["task"], status=status, debug=debug, chat_history=messages[:-1])
|
||||||
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
usage["input"] += eng_usage["input"]; usage["output"] += eng_usage["output"]; usage["total"] += eng_usage["total"]
|
||||||
elif fn == "consult_architect":
|
elif fn == "consult_architect":
|
||||||
if status: status.update("[architect]Engineer consulting Architect...")
|
if status: update_status("[architect]Engineer consulting Architect...")
|
||||||
try:
|
try:
|
||||||
# Consultation only - Engineer stays in control
|
# Consultation only - Engineer stays in control
|
||||||
claude_resp = completion(
|
claude_resp = completion(
|
||||||
@@ -2698,11 +2737,11 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
try: status.start()
|
try: status.start()
|
||||||
except: pass
|
except: pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status: status.update("[unavailable]Architect unavailable! Engineer continuing alone...")
|
if status: update_status("[unavailable]Architect unavailable! Engineer continuing alone...")
|
||||||
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
obs = f"Architect unavailable ({str(e)}). Proceeding with your best technical judgment."
|
||||||
|
|
||||||
elif fn == "escalate_to_architect":
|
elif fn == "escalate_to_architect":
|
||||||
if status: status.update("[architect]Transferring control to Architect...")
|
if status: update_status("[architect]Transferring control to Architect...")
|
||||||
# Full escalation - Architect takes over
|
# Full escalation - Architect takes over
|
||||||
current_brain = "architect"
|
current_brain = "architect"
|
||||||
model = self.architect_model
|
model = self.architect_model
|
||||||
@@ -2724,7 +2763,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
elif fn == "return_to_engineer":
|
elif fn == "return_to_engineer":
|
||||||
if status: status.update("[engineer]Transferring control back to Engineer...")
|
if status: update_status("[engineer]Transferring control back to Engineer...")
|
||||||
# Architect returns control to Engineer
|
# Architect returns control to Engineer
|
||||||
current_brain = "engineer"
|
current_brain = "engineer"
|
||||||
model = self.engineer_model
|
model = self.engineer_model
|
||||||
@@ -2777,7 +2816,7 @@ def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=Fa
|
|||||||
messages.append(resp_msg.model_dump(exclude_none=True))
|
messages.append(resp_msg.model_dump(exclude_none=True))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if status:
|
if status:
|
||||||
status.update(f"[error]Error fetching summary: {e}[/error]")
|
update_status(f"[error]Error fetching summary: {e}[/error]")
|
||||||
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
printer.warning(f"Failed to fetch final summary from LLM: {e}")
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
if status: status.update("[error]Interrupted! Closing pending tasks...")
|
||||||
@@ -4757,12 +4796,12 @@ class node:
|
|||||||
# Get raw bytes from BytesIO
|
# Get raw bytes from BytesIO
|
||||||
raw_bytes = self.mylog.getvalue()
|
raw_bytes = self.mylog.getvalue()
|
||||||
|
|
||||||
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
|
# Stop terminal reading so prompt_toolkit (in run_session)
|
||||||
# tenga control exclusivo del stdin sin interferencias de LocalStream.
|
# has exclusive control of stdin without LocalStream interference.
|
||||||
if hasattr(stream, 'stop_reading'):
|
if hasattr(stream, 'stop_reading'):
|
||||||
stream.stop_reading()
|
stream.stop_reading()
|
||||||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||||||
# Fallback si no tiene el método (en LocalStream)
|
# Fallback if the method is missing (in LocalStream)
|
||||||
stream._loop.remove_reader(stream.stdin_fd)
|
stream._loop.remove_reader(stream.stdin_fd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -4779,7 +4818,7 @@ class node:
|
|||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
print("\033[2m Returning to session...\033[0m", flush=True)
|
print("\033[2m Returning to session...\033[0m", flush=True)
|
||||||
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
|
# Restart terminal reading to return to interactive SSH/Telnet mode
|
||||||
if hasattr(stream, 'start_reading'):
|
if hasattr(stream, 'start_reading'):
|
||||||
stream.start_reading()
|
stream.start_reading()
|
||||||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||||||
@@ -4847,14 +4886,6 @@ class node:
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -4875,6 +4906,20 @@ class node:
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
# Only set terminal size on devices without a
|
||||||
|
# screen_length_command (e.g. Linux/bash servers).
|
||||||
|
# Routers already disable pagination via that command.
|
||||||
|
# After setwinsize, consume any SIGWINCH re-render
|
||||||
|
# prompt (~40ms on bash) with a short timeout.
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -4957,14 +5002,6 @@ class node:
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -4986,6 +5023,15 @@ class node:
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -5011,13 +5057,28 @@ class node:
|
|||||||
if vars is not None:
|
if vars is not None:
|
||||||
e = e.format(**vars)
|
e = e.format(**vars)
|
||||||
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
||||||
newpattern = f".*({updatedprompt}).*{e}.*"
|
|
||||||
cleaned_output = output
|
cleaned_output = output
|
||||||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
try:
|
||||||
|
newpattern = f".*({updatedprompt}).*{e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
try:
|
||||||
|
escaped_e = re.escape(e)
|
||||||
|
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
pass
|
||||||
|
|
||||||
if e in cleaned_output:
|
if e in cleaned_output:
|
||||||
self.result[e] = True
|
self.result[e] = True
|
||||||
else:
|
else:
|
||||||
self.result[e]= False
|
try:
|
||||||
|
if re.search(e, cleaned_output):
|
||||||
|
self.result[e] = True
|
||||||
|
else:
|
||||||
|
self.result[e] = False
|
||||||
|
except re.error:
|
||||||
|
self.result[e] = False
|
||||||
self.status = 0
|
self.status = 0
|
||||||
return self.result
|
return self.result
|
||||||
if result == 2:
|
if result == 2:
|
||||||
@@ -5425,14 +5486,6 @@ def run(self, commands, vars = None,*, folder = '', prompt = r'>$
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -5453,6 +5506,20 @@ def run(self, commands, vars = None,*, folder = '', prompt = r'>$
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
# Only set terminal size on devices without a
|
||||||
|
# screen_length_command (e.g. Linux/bash servers).
|
||||||
|
# Routers already disable pagination via that command.
|
||||||
|
# After setwinsize, consume any SIGWINCH re-render
|
||||||
|
# prompt (~40ms on bash) with a short timeout.
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -5576,14 +5643,6 @@ def test(self, commands, expected, vars = None,*, folder = '', prompt =
|
|||||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||||
|
|
||||||
# Attempt to set the terminal size
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(65535, 65535)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
self.child.setwinsize(10000, 10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if "prompt" in self.tags:
|
if "prompt" in self.tags:
|
||||||
prompt = self.tags["prompt"]
|
prompt = self.tags["prompt"]
|
||||||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||||||
@@ -5605,6 +5664,15 @@ def test(self, commands, expected, vars = None,*, folder = '', prompt =
|
|||||||
self.status = 1
|
self.status = 1
|
||||||
return self.output
|
return self.output
|
||||||
result = self.child.expect(expects, timeout = timeout)
|
result = self.child.expect(expects, timeout = timeout)
|
||||||
|
if c == commands[0] and "screen_length_command" not in self.tags:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(65535, 65535)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self.child.setwinsize(10000, 10000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.child.expect(expects, timeout = 1)
|
||||||
self.child.sendline(c)
|
self.child.sendline(c)
|
||||||
if result == 2:
|
if result == 2:
|
||||||
break
|
break
|
||||||
@@ -5630,13 +5698,28 @@ def test(self, commands, expected, vars = None,*, folder = '', prompt =
|
|||||||
if vars is not None:
|
if vars is not None:
|
||||||
e = e.format(**vars)
|
e = e.format(**vars)
|
||||||
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
||||||
newpattern = f".*({updatedprompt}).*{e}.*"
|
|
||||||
cleaned_output = output
|
cleaned_output = output
|
||||||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
try:
|
||||||
|
newpattern = f".*({updatedprompt}).*{e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
try:
|
||||||
|
escaped_e = re.escape(e)
|
||||||
|
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
|
||||||
|
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||||||
|
except re.error:
|
||||||
|
pass
|
||||||
|
|
||||||
if e in cleaned_output:
|
if e in cleaned_output:
|
||||||
self.result[e] = True
|
self.result[e] = True
|
||||||
else:
|
else:
|
||||||
self.result[e]= False
|
try:
|
||||||
|
if re.search(e, cleaned_output):
|
||||||
|
self.result[e] = True
|
||||||
|
else:
|
||||||
|
self.result[e] = False
|
||||||
|
except re.error:
|
||||||
|
self.result[e] = False
|
||||||
self.status = 0
|
self.status = 0
|
||||||
return self.result
|
return self.result
|
||||||
if result == 2:
|
if result == 2:
|
||||||
@@ -6368,6 +6451,10 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
|
|||||||
</li>
|
</li>
|
||||||
<li><a href="#usage">Usage</a><ul>
|
<li><a href="#usage">Usage</a><ul>
|
||||||
<li><a href="#basic-examples">Basic Examples:</a></li>
|
<li><a href="#basic-examples">Basic Examples:</a></li>
|
||||||
|
<li><a href="#sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</a><ul>
|
||||||
|
<li><a href="#security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
|
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
|
||||||
@@ -6384,6 +6471,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
|
|||||||
<li><a href="#ai-programmatic-use">AI Programmatic Use</a></li>
|
<li><a href="#ai-programmatic-use">AI Programmatic Use</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a href="#license">📜 License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -369,7 +369,41 @@ el.replaceWith(d);
|
|||||||
"""Load a session's raw data by ID."""
|
"""Load a session's raw data by ID."""
|
||||||
from connpy.ai import ai
|
from connpy.ai import ai
|
||||||
agent = ai(self.config)
|
agent = ai(self.config)
|
||||||
return agent.load_session_data(session_id)</code></pre>
|
return agent.load_session_data(session_id)
|
||||||
|
|
||||||
|
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||||
|
"""Interact with the specialized Playbook Builder Agent."""
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
agent = PlaybookBuilderAgent(self.config)
|
||||||
|
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||||
|
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||||
|
import json
|
||||||
|
results_str = json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||||
|
if query:
|
||||||
|
prompt += f"\nSpecific user request: {query}"
|
||||||
|
prompt += f"\n\nResults Data:\n{results_str}"
|
||||||
|
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||||
|
|
||||||
|
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||||
|
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
|
||||||
|
|
||||||
|
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||||
|
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||||
|
nodes_str = ", ".join(target_nodes)
|
||||||
|
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||||
|
|
||||||
|
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||||
|
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||||
|
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||||
|
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||||
|
|
||||||
|
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
|
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
|
||||||
<p>Initialize the service.</p>
|
<p>Initialize the service.</p>
|
||||||
@@ -402,6 +436,31 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
|
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.ai_service.AIService.analyze_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results: dict, query: str = None, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||||
|
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||||
|
import json
|
||||||
|
results_str = json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||||
|
if query:
|
||||||
|
prompt += f"\nSpecific user request: {query}"
|
||||||
|
prompt += f"\n\nResults Data:\n{results_str}"
|
||||||
|
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||||
|
|
||||||
|
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||||
|
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Analyze actual command execution results using Network Architect 1-shot.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.ai_service.AIService.ask"><code class="name flex">
|
<dt id="connpy.services.ai_service.AIService.ask"><code class="name flex">
|
||||||
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
|
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -559,6 +618,22 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
|
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.ai_service.AIService.build_playbook_chat"><code class="name flex">
|
||||||
|
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input: str, chat_history: list = None, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||||
|
"""Interact with the specialized Playbook Builder Agent."""
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
agent = PlaybookBuilderAgent(self.config)
|
||||||
|
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Interact with the specialized Playbook Builder Agent.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.ai_service.AIService.configure_mcp"><code class="name flex">
|
<dt id="connpy.services.ai_service.AIService.configure_mcp"><code class="name flex">
|
||||||
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
|
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -715,6 +790,29 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Load a session's raw data by ID.</p></div>
|
<div class="desc"><p>Load a session's raw data by ID.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.ai_service.AIService.predict_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes: list, commands: list, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||||
|
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||||
|
nodes_str = ", ".join(target_nodes)
|
||||||
|
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||||
|
|
||||||
|
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||||
|
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||||
|
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||||
|
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||||
|
|
||||||
|
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.ai_service.AIService.process_copilot_input"><code class="name flex">
|
<dt id="connpy.services.ai_service.AIService.process_copilot_input"><code class="name flex">
|
||||||
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) ‑> dict</span>
|
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) ‑> dict</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -813,9 +911,11 @@ el.replaceWith(d);
|
|||||||
<h4><code><a title="connpy.services.ai_service.AIService" href="#connpy.services.ai_service.AIService">AIService</a></code></h4>
|
<h4><code><a title="connpy.services.ai_service.AIService" href="#connpy.services.ai_service.AIService">AIService</a></code></h4>
|
||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.aask_copilot" href="#connpy.services.ai_service.AIService.aask_copilot">aask_copilot</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.aask_copilot" href="#connpy.services.ai_service.AIService.aask_copilot">aask_copilot</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.ai_service.AIService.analyze_execution_results" href="#connpy.services.ai_service.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.ask" href="#connpy.services.ai_service.AIService.ask">ask</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.ask" href="#connpy.services.ai_service.AIService.ask">ask</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.ask_copilot" href="#connpy.services.ai_service.AIService.ask_copilot">ask_copilot</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.ask_copilot" href="#connpy.services.ai_service.AIService.ask_copilot">ask_copilot</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.build_context_blocks" href="#connpy.services.ai_service.AIService.build_context_blocks">build_context_blocks</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.build_context_blocks" href="#connpy.services.ai_service.AIService.build_context_blocks">build_context_blocks</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.ai_service.AIService.build_playbook_chat" href="#connpy.services.ai_service.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.configure_mcp" href="#connpy.services.ai_service.AIService.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.configure_mcp" href="#connpy.services.ai_service.AIService.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.configure_provider" href="#connpy.services.ai_service.AIService.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.configure_provider" href="#connpy.services.ai_service.AIService.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.confirm" href="#connpy.services.ai_service.AIService.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.confirm" href="#connpy.services.ai_service.AIService.confirm">confirm</a></code></li>
|
||||||
@@ -823,6 +923,7 @@ el.replaceWith(d);
|
|||||||
<li><code><a title="connpy.services.ai_service.AIService.list_mcp_servers" href="#connpy.services.ai_service.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.list_mcp_servers" href="#connpy.services.ai_service.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.list_sessions" href="#connpy.services.ai_service.AIService.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.list_sessions" href="#connpy.services.ai_service.AIService.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.load_session_data" href="#connpy.services.ai_service.AIService.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.load_session_data" href="#connpy.services.ai_service.AIService.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.ai_service.AIService.predict_execution_results" href="#connpy.services.ai_service.AIService.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.services.ai_service.AIService.process_copilot_input" href="#connpy.services.ai_service.AIService.process_copilot_input">process_copilot_input</a></code></li>
|
<li><code><a title="connpy.services.ai_service.AIService.process_copilot_input" href="#connpy.services.ai_service.AIService.process_copilot_input">process_copilot_input</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -156,56 +156,7 @@ el.replaceWith(d);
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConnpyError(f"Failed to read script {script_path}: {e}")
|
raise ConnpyError(f"Failed to read script {script_path}: {e}")
|
||||||
|
|
||||||
return self.run_commands(nodes_filter, commands, parallel=parallel)
|
return self.run_commands(nodes_filter, commands, parallel=parallel)</code></pre>
|
||||||
|
|
||||||
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
|
|
||||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
|
||||||
playbook = None
|
|
||||||
if playbook_data.startswith("---YAML---\n"):
|
|
||||||
try:
|
|
||||||
content = playbook_data[len("---YAML---\n"):]
|
|
||||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(playbook_data):
|
|
||||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
|
||||||
try:
|
|
||||||
with open(playbook_data, "r") as f:
|
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
|
||||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
|
||||||
|
|
||||||
action = playbook.get("action", "run")
|
|
||||||
options = playbook.get("options", {})
|
|
||||||
|
|
||||||
# Extract all fields similar to RunHandler.cli_run
|
|
||||||
exec_args = {
|
|
||||||
"nodes_filter": playbook["nodes"],
|
|
||||||
"commands": playbook["commands"],
|
|
||||||
"variables": playbook.get("variables"),
|
|
||||||
"parallel": options.get("parallel", parallel),
|
|
||||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
|
||||||
"prompt": options.get("prompt"),
|
|
||||||
"name": playbook.get("name", "Task")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map 'output' field to folder path if it's not stdout/null
|
|
||||||
output_cfg = playbook.get("output")
|
|
||||||
if output_cfg not in [None, "stdout"]:
|
|
||||||
exec_args["folder"] = output_cfg
|
|
||||||
|
|
||||||
if action == "run":
|
|
||||||
return self.run_commands(**exec_args)
|
|
||||||
elif action == "test":
|
|
||||||
exec_args["expected"] = playbook.get("expected", [])
|
|
||||||
return self.test_commands(**exec_args)
|
|
||||||
else:
|
|
||||||
raise ConnpyError(f"Unsupported playbook action: {action}")</code></pre>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
|
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
|
||||||
<p>Initialize the service.</p>
|
<p>Initialize the service.</p>
|
||||||
@@ -300,65 +251,6 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
|
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.services.execution_service.ExecutionService.run_yaml_playbook"><code class="name flex">
|
|
||||||
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_data: str, parallel: int = 10) ‑> Dict[str, Any]</span>
|
|
||||||
</code></dt>
|
|
||||||
<dd>
|
|
||||||
<details class="source">
|
|
||||||
<summary>
|
|
||||||
<span>Expand source code</span>
|
|
||||||
</summary>
|
|
||||||
<pre><code class="python">def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
|
|
||||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
|
||||||
playbook = None
|
|
||||||
if playbook_data.startswith("---YAML---\n"):
|
|
||||||
try:
|
|
||||||
content = playbook_data[len("---YAML---\n"):]
|
|
||||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(playbook_data):
|
|
||||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
|
||||||
try:
|
|
||||||
with open(playbook_data, "r") as f:
|
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
|
||||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
|
||||||
|
|
||||||
action = playbook.get("action", "run")
|
|
||||||
options = playbook.get("options", {})
|
|
||||||
|
|
||||||
# Extract all fields similar to RunHandler.cli_run
|
|
||||||
exec_args = {
|
|
||||||
"nodes_filter": playbook["nodes"],
|
|
||||||
"commands": playbook["commands"],
|
|
||||||
"variables": playbook.get("variables"),
|
|
||||||
"parallel": options.get("parallel", parallel),
|
|
||||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
|
||||||
"prompt": options.get("prompt"),
|
|
||||||
"name": playbook.get("name", "Task")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map 'output' field to folder path if it's not stdout/null
|
|
||||||
output_cfg = playbook.get("output")
|
|
||||||
if output_cfg not in [None, "stdout"]:
|
|
||||||
exec_args["folder"] = output_cfg
|
|
||||||
|
|
||||||
if action == "run":
|
|
||||||
return self.run_commands(**exec_args)
|
|
||||||
elif action == "test":
|
|
||||||
exec_args["expected"] = playbook.get("expected", [])
|
|
||||||
return self.test_commands(**exec_args)
|
|
||||||
else:
|
|
||||||
raise ConnpyError(f"Unsupported playbook action: {action}")</code></pre>
|
|
||||||
</details>
|
|
||||||
<div class="desc"><p>Run a structured Connpy YAML automation playbook (from path or content).</p></div>
|
|
||||||
</dd>
|
|
||||||
<dt id="connpy.services.execution_service.ExecutionService.test_commands"><code class="name flex">
|
<dt id="connpy.services.execution_service.ExecutionService.test_commands"><code class="name flex">
|
||||||
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) ‑> Dict[str, Dict[str, bool]]</span>
|
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) ‑> Dict[str, Dict[str, bool]]</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -439,7 +331,6 @@ el.replaceWith(d);
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.services.execution_service.ExecutionService.run_cli_script" href="#connpy.services.execution_service.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.services.execution_service.ExecutionService.run_cli_script" href="#connpy.services.execution_service.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.services.execution_service.ExecutionService.run_commands" href="#connpy.services.execution_service.ExecutionService.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.services.execution_service.ExecutionService.run_commands" href="#connpy.services.execution_service.ExecutionService.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.services.execution_service.ExecutionService.run_yaml_playbook" href="#connpy.services.execution_service.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.services.execution_service.ExecutionService.test_commands" href="#connpy.services.execution_service.ExecutionService.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.services.execution_service.ExecutionService.test_commands" href="#connpy.services.execution_service.ExecutionService.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+103
-111
@@ -428,7 +428,41 @@ el.replaceWith(d);
|
|||||||
"""Load a session's raw data by ID."""
|
"""Load a session's raw data by ID."""
|
||||||
from connpy.ai import ai
|
from connpy.ai import ai
|
||||||
agent = ai(self.config)
|
agent = ai(self.config)
|
||||||
return agent.load_session_data(session_id)</code></pre>
|
return agent.load_session_data(session_id)
|
||||||
|
|
||||||
|
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||||
|
"""Interact with the specialized Playbook Builder Agent."""
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
agent = PlaybookBuilderAgent(self.config)
|
||||||
|
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||||
|
|
||||||
|
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||||
|
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||||
|
import json
|
||||||
|
results_str = json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||||
|
if query:
|
||||||
|
prompt += f"\nSpecific user request: {query}"
|
||||||
|
prompt += f"\n\nResults Data:\n{results_str}"
|
||||||
|
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||||
|
|
||||||
|
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||||
|
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
|
||||||
|
|
||||||
|
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||||
|
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||||
|
nodes_str = ", ".join(target_nodes)
|
||||||
|
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||||
|
|
||||||
|
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||||
|
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||||
|
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||||
|
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||||
|
|
||||||
|
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
|
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
|
||||||
<p>Initialize the service.</p>
|
<p>Initialize the service.</p>
|
||||||
@@ -461,6 +495,31 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
|
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.AIService.analyze_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">analyze_execution_results</span></span>(<span>self, results: dict, query: str = None, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||||
|
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||||
|
import json
|
||||||
|
results_str = json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||||
|
if query:
|
||||||
|
prompt += f"\nSpecific user request: {query}"
|
||||||
|
prompt += f"\n\nResults Data:\n{results_str}"
|
||||||
|
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||||
|
|
||||||
|
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||||
|
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Analyze actual command execution results using Network Architect 1-shot.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.AIService.ask"><code class="name flex">
|
<dt id="connpy.services.AIService.ask"><code class="name flex">
|
||||||
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
|
<span>def <span class="ident">ask</span></span>(<span>self,<br>input_text,<br>dryrun=False,<br>chat_history=None,<br>status=None,<br>debug=False,<br>session_id=None,<br>console=None,<br>chunk_callback=None,<br>confirm_handler=None,<br>trust=False,<br>**overrides)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -618,6 +677,22 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
|
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.AIService.build_playbook_chat"><code class="name flex">
|
||||||
|
<span>def <span class="ident">build_playbook_chat</span></span>(<span>self, user_input: str, chat_history: list = None, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||||
|
"""Interact with the specialized Playbook Builder Agent."""
|
||||||
|
from connpy.ai import PlaybookBuilderAgent
|
||||||
|
agent = PlaybookBuilderAgent(self.config)
|
||||||
|
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Interact with the specialized Playbook Builder Agent.</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.AIService.configure_mcp"><code class="name flex">
|
<dt id="connpy.services.AIService.configure_mcp"><code class="name flex">
|
||||||
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
|
<span>def <span class="ident">configure_mcp</span></span>(<span>self, name, url=None, enabled=None, auto_load_on_os=None, remove=False)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -774,6 +849,29 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Load a session's raw data by ID.</p></div>
|
<div class="desc"><p>Load a session's raw data by ID.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt id="connpy.services.AIService.predict_execution_results"><code class="name flex">
|
||||||
|
<span>def <span class="ident">predict_execution_results</span></span>(<span>self, target_nodes: list, commands: list, status=None, chunk_callback=None)</span>
|
||||||
|
</code></dt>
|
||||||
|
<dd>
|
||||||
|
<details class="source">
|
||||||
|
<summary>
|
||||||
|
<span>Expand source code</span>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="python">def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||||
|
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||||
|
nodes_str = ", ".join(target_nodes)
|
||||||
|
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||||
|
|
||||||
|
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||||
|
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||||
|
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||||
|
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||||
|
|
||||||
|
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||||
|
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||||
|
</details>
|
||||||
|
<div class="desc"><p>Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).</p></div>
|
||||||
|
</dd>
|
||||||
<dt id="connpy.services.AIService.process_copilot_input"><code class="name flex">
|
<dt id="connpy.services.AIService.process_copilot_input"><code class="name flex">
|
||||||
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) ‑> dict</span>
|
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) ‑> dict</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1255,56 +1353,7 @@ el.replaceWith(d);
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConnpyError(f"Failed to read script {script_path}: {e}")
|
raise ConnpyError(f"Failed to read script {script_path}: {e}")
|
||||||
|
|
||||||
return self.run_commands(nodes_filter, commands, parallel=parallel)
|
return self.run_commands(nodes_filter, commands, parallel=parallel)</code></pre>
|
||||||
|
|
||||||
def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
|
|
||||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
|
||||||
playbook = None
|
|
||||||
if playbook_data.startswith("---YAML---\n"):
|
|
||||||
try:
|
|
||||||
content = playbook_data[len("---YAML---\n"):]
|
|
||||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(playbook_data):
|
|
||||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
|
||||||
try:
|
|
||||||
with open(playbook_data, "r") as f:
|
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
|
||||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
|
||||||
|
|
||||||
action = playbook.get("action", "run")
|
|
||||||
options = playbook.get("options", {})
|
|
||||||
|
|
||||||
# Extract all fields similar to RunHandler.cli_run
|
|
||||||
exec_args = {
|
|
||||||
"nodes_filter": playbook["nodes"],
|
|
||||||
"commands": playbook["commands"],
|
|
||||||
"variables": playbook.get("variables"),
|
|
||||||
"parallel": options.get("parallel", parallel),
|
|
||||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
|
||||||
"prompt": options.get("prompt"),
|
|
||||||
"name": playbook.get("name", "Task")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map 'output' field to folder path if it's not stdout/null
|
|
||||||
output_cfg = playbook.get("output")
|
|
||||||
if output_cfg not in [None, "stdout"]:
|
|
||||||
exec_args["folder"] = output_cfg
|
|
||||||
|
|
||||||
if action == "run":
|
|
||||||
return self.run_commands(**exec_args)
|
|
||||||
elif action == "test":
|
|
||||||
exec_args["expected"] = playbook.get("expected", [])
|
|
||||||
return self.test_commands(**exec_args)
|
|
||||||
else:
|
|
||||||
raise ConnpyError(f"Unsupported playbook action: {action}")</code></pre>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
|
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
|
||||||
<p>Initialize the service.</p>
|
<p>Initialize the service.</p>
|
||||||
@@ -1399,65 +1448,6 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
|
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.services.ExecutionService.run_yaml_playbook"><code class="name flex">
|
|
||||||
<span>def <span class="ident">run_yaml_playbook</span></span>(<span>self, playbook_data: str, parallel: int = 10) ‑> Dict[str, Any]</span>
|
|
||||||
</code></dt>
|
|
||||||
<dd>
|
|
||||||
<details class="source">
|
|
||||||
<summary>
|
|
||||||
<span>Expand source code</span>
|
|
||||||
</summary>
|
|
||||||
<pre><code class="python">def run_yaml_playbook(self, playbook_data: str, parallel: int = 10) -> Dict[str, Any]:
|
|
||||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
|
||||||
playbook = None
|
|
||||||
if playbook_data.startswith("---YAML---\n"):
|
|
||||||
try:
|
|
||||||
content = playbook_data[len("---YAML---\n"):]
|
|
||||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
|
||||||
else:
|
|
||||||
if not os.path.exists(playbook_data):
|
|
||||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
|
||||||
try:
|
|
||||||
with open(playbook_data, "r") as f:
|
|
||||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
|
||||||
except Exception as e:
|
|
||||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
|
||||||
|
|
||||||
# Basic validation
|
|
||||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
|
||||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
|
||||||
|
|
||||||
action = playbook.get("action", "run")
|
|
||||||
options = playbook.get("options", {})
|
|
||||||
|
|
||||||
# Extract all fields similar to RunHandler.cli_run
|
|
||||||
exec_args = {
|
|
||||||
"nodes_filter": playbook["nodes"],
|
|
||||||
"commands": playbook["commands"],
|
|
||||||
"variables": playbook.get("variables"),
|
|
||||||
"parallel": options.get("parallel", parallel),
|
|
||||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
|
||||||
"prompt": options.get("prompt"),
|
|
||||||
"name": playbook.get("name", "Task")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Map 'output' field to folder path if it's not stdout/null
|
|
||||||
output_cfg = playbook.get("output")
|
|
||||||
if output_cfg not in [None, "stdout"]:
|
|
||||||
exec_args["folder"] = output_cfg
|
|
||||||
|
|
||||||
if action == "run":
|
|
||||||
return self.run_commands(**exec_args)
|
|
||||||
elif action == "test":
|
|
||||||
exec_args["expected"] = playbook.get("expected", [])
|
|
||||||
return self.test_commands(**exec_args)
|
|
||||||
else:
|
|
||||||
raise ConnpyError(f"Unsupported playbook action: {action}")</code></pre>
|
|
||||||
</details>
|
|
||||||
<div class="desc"><p>Run a structured Connpy YAML automation playbook (from path or content).</p></div>
|
|
||||||
</dd>
|
|
||||||
<dt id="connpy.services.ExecutionService.test_commands"><code class="name flex">
|
<dt id="connpy.services.ExecutionService.test_commands"><code class="name flex">
|
||||||
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) ‑> Dict[str, Dict[str, bool]]</span>
|
<span>def <span class="ident">test_commands</span></span>(<span>self,<br>nodes_filter: str,<br>commands: List[str],<br>expected: List[str],<br>variables: Dict[str, Any] | None = None,<br>parallel: int = 10,<br>timeout: int = 20,<br>folder: str | None = None,<br>prompt: str | None = None,<br>on_node_complete: Callable | None = None,<br>logger: Callable | None = None,<br>name: str | None = None) ‑> Dict[str, Dict[str, bool]]</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -4006,9 +3996,11 @@ el.replaceWith(d);
|
|||||||
<h4><code><a title="connpy.services.AIService" href="#connpy.services.AIService">AIService</a></code></h4>
|
<h4><code><a title="connpy.services.AIService" href="#connpy.services.AIService">AIService</a></code></h4>
|
||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.services.AIService.aask_copilot" href="#connpy.services.AIService.aask_copilot">aask_copilot</a></code></li>
|
<li><code><a title="connpy.services.AIService.aask_copilot" href="#connpy.services.AIService.aask_copilot">aask_copilot</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.AIService.analyze_execution_results" href="#connpy.services.AIService.analyze_execution_results">analyze_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.ask" href="#connpy.services.AIService.ask">ask</a></code></li>
|
<li><code><a title="connpy.services.AIService.ask" href="#connpy.services.AIService.ask">ask</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.ask_copilot" href="#connpy.services.AIService.ask_copilot">ask_copilot</a></code></li>
|
<li><code><a title="connpy.services.AIService.ask_copilot" href="#connpy.services.AIService.ask_copilot">ask_copilot</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.build_context_blocks" href="#connpy.services.AIService.build_context_blocks">build_context_blocks</a></code></li>
|
<li><code><a title="connpy.services.AIService.build_context_blocks" href="#connpy.services.AIService.build_context_blocks">build_context_blocks</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.AIService.build_playbook_chat" href="#connpy.services.AIService.build_playbook_chat">build_playbook_chat</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.configure_mcp" href="#connpy.services.AIService.configure_mcp">configure_mcp</a></code></li>
|
<li><code><a title="connpy.services.AIService.configure_mcp" href="#connpy.services.AIService.configure_mcp">configure_mcp</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.configure_provider" href="#connpy.services.AIService.configure_provider">configure_provider</a></code></li>
|
<li><code><a title="connpy.services.AIService.configure_provider" href="#connpy.services.AIService.configure_provider">configure_provider</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.confirm" href="#connpy.services.AIService.confirm">confirm</a></code></li>
|
<li><code><a title="connpy.services.AIService.confirm" href="#connpy.services.AIService.confirm">confirm</a></code></li>
|
||||||
@@ -4016,6 +4008,7 @@ el.replaceWith(d);
|
|||||||
<li><code><a title="connpy.services.AIService.list_mcp_servers" href="#connpy.services.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
<li><code><a title="connpy.services.AIService.list_mcp_servers" href="#connpy.services.AIService.list_mcp_servers">list_mcp_servers</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.list_sessions" href="#connpy.services.AIService.list_sessions">list_sessions</a></code></li>
|
<li><code><a title="connpy.services.AIService.list_sessions" href="#connpy.services.AIService.list_sessions">list_sessions</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.load_session_data" href="#connpy.services.AIService.load_session_data">load_session_data</a></code></li>
|
<li><code><a title="connpy.services.AIService.load_session_data" href="#connpy.services.AIService.load_session_data">load_session_data</a></code></li>
|
||||||
|
<li><code><a title="connpy.services.AIService.predict_execution_results" href="#connpy.services.AIService.predict_execution_results">predict_execution_results</a></code></li>
|
||||||
<li><code><a title="connpy.services.AIService.process_copilot_input" href="#connpy.services.AIService.process_copilot_input">process_copilot_input</a></code></li>
|
<li><code><a title="connpy.services.AIService.process_copilot_input" href="#connpy.services.AIService.process_copilot_input">process_copilot_input</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@@ -4041,7 +4034,6 @@ el.replaceWith(d);
|
|||||||
<ul class="">
|
<ul class="">
|
||||||
<li><code><a title="connpy.services.ExecutionService.run_cli_script" href="#connpy.services.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
<li><code><a title="connpy.services.ExecutionService.run_cli_script" href="#connpy.services.ExecutionService.run_cli_script">run_cli_script</a></code></li>
|
||||||
<li><code><a title="connpy.services.ExecutionService.run_commands" href="#connpy.services.ExecutionService.run_commands">run_commands</a></code></li>
|
<li><code><a title="connpy.services.ExecutionService.run_commands" href="#connpy.services.ExecutionService.run_commands">run_commands</a></code></li>
|
||||||
<li><code><a title="connpy.services.ExecutionService.run_yaml_playbook" href="#connpy.services.ExecutionService.run_yaml_playbook">run_yaml_playbook</a></code></li>
|
|
||||||
<li><code><a title="connpy.services.ExecutionService.test_commands" href="#connpy.services.ExecutionService.test_commands">test_commands</a></code></li>
|
<li><code><a title="connpy.services.ExecutionService.test_commands" href="#connpy.services.ExecutionService.test_commands">test_commands</a></code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -256,18 +256,19 @@ el.replaceWith(d);
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
||||||
|
|
||||||
def generate_jwt(self, username) -> str:
|
def generate_jwt(self, username) -> str:
|
||||||
"""Generates a secure JSON Web Token for the user expiring in 8 hours."""
|
"""Generates a secure JSON Web Token for the user expiring in 12 hours."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
raise ValueError(f"User '{username}' not found")
|
||||||
|
|
||||||
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
|
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"exp": expiration
|
"exp": expiration
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||||
if isinstance(token, bytes):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
@@ -277,7 +278,8 @@ el.replaceWith(d);
|
|||||||
"""Decodes JWT and returns username if token is valid and unexpired."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None</code></pre>
|
return None</code></pre>
|
||||||
@@ -468,24 +470,25 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
|||||||
<span>Expand source code</span>
|
<span>Expand source code</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">def generate_jwt(self, username) -> str:
|
<pre><code class="python">def generate_jwt(self, username) -> str:
|
||||||
"""Generates a secure JSON Web Token for the user expiring in 8 hours."""
|
"""Generates a secure JSON Web Token for the user expiring in 12 hours."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
raise ValueError(f"User '{username}' not found")
|
||||||
|
|
||||||
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
|
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"exp": expiration
|
"exp": expiration
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||||
if isinstance(token, bytes):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
return token</code></pre>
|
return token</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 8 hours.</p></div>
|
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 12 hours.</p></div>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
|
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
|
||||||
<span>def <span class="ident">get_user</span></span>(<span>self, username) ‑> dict</span>
|
<span>def <span class="ident">get_user</span></span>(<span>self, username) ‑> dict</span>
|
||||||
@@ -545,7 +548,8 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
|||||||
"""Decodes JWT and returns username if token is valid and unexpired."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
|
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
|
||||||
|
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None</code></pre>
|
return None</code></pre>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ keywords = networking, automation, docker, kubernetes, ssh, telnet, connection m
|
|||||||
author = Federico Luzzi
|
author = Federico Luzzi
|
||||||
author_email = fluzzi@gmail.com
|
author_email = fluzzi@gmail.com
|
||||||
url = https://github.com/fluzzi/connpy
|
url = https://github.com/fluzzi/connpy
|
||||||
license = Custom Software License
|
license = PolyForm Noncommercial License 1.0.0
|
||||||
license_files = LICENSE
|
license_files = LICENSE
|
||||||
project_urls =
|
project_urls =
|
||||||
Bug Tracker = https://github.com/fluzzi/connpy/issues
|
Bug Tracker = https://github.com/fluzzi/connpy/issues
|
||||||
|
|||||||
Reference in New Issue
Block a user