Compare commits

..

23 Commits

Author SHA1 Message Date
fluzzi32 127c1b9fdb update readme 2026-06-16 18:04:54 -03:00
fluzzi32 744e730672 feat(auth,cli): add SSO/OIDC authentication and provider management
- Introduce `conn sso` CLI suite for managing Identity Providers (IdP).
- Implement `login_sso` and `get_sso_providers` in gRPC AuthService.
- Add auto-provisioning for users logging in via SSO.
- Support JWT validation via shared secrets (HS256) or JWKS (RS256).
- Add domain restriction (`allowed_domains`) and env-var secret resolution.
- Increase JWT session expiration from 8 to 12 hours.
- Add shell autocompletion for SSO commands and configured providers.
- Bump version to 6.0.3.
2026-06-04 18:33:26 -03:00
fluzzi32 61a44d004f feat(core,grpc): add regex support for node expectations and secure thread context sharing
- Implement dynamic regex matching fallback (re.search) in `node.test` with safe handling of invalid patterns.
- Refactor terminal window resizing (setwinsize) to trigger only on non-router devices and handle SIGWINCH re-renders.
- Introduce `contextvars` context copying for background worker threads in gRPC execution and AI servicers.
- Add unit tests for regex validation, malformed expression fallbacks, and variable formatting in node testing.
- Optimize Playbook Builder AI guidelines for single-task test evaluations.
- Unify codebase comments to English.
2026-06-03 16:49:52 -03:00
fluzzi32 2b8e637298 added AI support for yaml/run 2026-06-01 17:49:19 -03:00
fluzzi32 721a3642f3 bug fixes and moving to production 2026-05-29 17:09:27 -03:00
fluzzi32 e52d300cf1 new version and docs 2026-05-28 18:22:00 -03:00
fluzzi32 cf866d782a new license 2026-05-28 18:20:24 -03:00
fluzzi32 49dfa805e4 fix api multiple debug 2026-05-28 18:01:56 -03:00
fluzzi32 1b9751bd23 multiuser plugins + fixes 2026-05-28 15:23:39 -03:00
fluzzi32 f5e09a55ab feat(cli): agregar comando connpy login --status 2026-05-28 12:48:38 -03:00
fluzzi32 f6ce48ed8a fix(shared-ai): implementar aislamiento de credenciales locales y globales 2026-05-28 11:18:03 -03:00
fluzzi32 7b053998f9 Merge branch 'main' into multiuser
# Conflicts:
#	connpy/grpc_layer/server.py
2026-05-28 10:47:21 -03:00
fluzzi32 58c81a19cb refactor: optimize bulk ops, prioritize exact node matches & fix remote AI deadlock
- Priority Matching: Prioritize exact node matches in connect, delete, show, and modify actions to bypass disambiguation prompts and prevent accidental bulk mutations on partial matches.
- Bulk Operations: Optimize NodeService delete and update operations by deferring configuration writes, syncs, and cache updates to the final element of a batch.
- Remote AI: Prevent client-side CLI deadlocks when the gRPC server encounters AI configuration ValueErrors by returning a clean error state and final stream marker.
- Testing: Add unit test to verify exact-match priority behavior and update existing CLI tests to match new NodeService signatures.
2026-05-28 10:35:15 -03:00
fluzzi32 0adaaad971 feat(multiuser): implementar sistema multiusuario gRPC y configuración compartida de IA/MCP
- Servidor gRPC: Agregar interceptores de autenticación y UserRegistry para aislar sesiones por usuario.
- Contexto de Hilos: Corregir propagación de ContextVar _current_user a hilos secundarios en ExecutionServicer.
- Configuración Compartida: Implementar herencia y deep merge de settings de IA ('ai') y servidores MCP en configfile.
- Hot-Reload: Recarga automática en caliente de la configuración compartida global ante cambios en disco.
- CLI: Agregar comandos e interfaces de usuario para autenticación (login) y administración de usuarios.
- Pruebas: Desarrollar tests unitarios completos (test_shared_ai.py) y resolver regresiones en la suite existente.
2026-05-28 09:27:54 -03:00
fluzzi32 aa542cb6eb ready for production 2026-05-27 14:44:01 -03:00
fluzzi32 6ee953edcf RC version 2026-05-27 12:34:52 -03:00
fluzzi32 74756f25b2 fix paignation block detection 2026-05-22 13:22:54 -03:00
fluzzi32 243718df46 improve blocks in range 2026-05-22 11:01:36 -03:00
fluzzi32 3be9935541 support fro multiple litellm auth methods 2026-05-21 18:20:24 -03:00
fluzzi32 cd8eeaad79 fix blocks in web 2026-05-21 13:03:12 -03:00
fluzzi32 4f3af7ca12 fix bugs long commands in cisco 2026-05-20 17:23:57 -03:00
fluzzi32 dce9982454 feat(ai): enhance session management, inquirer theme, and gRPC MCP support
- AI & Session Management:
  - Add random suffix to session IDs to ensure uniqueness.
  - Implement optional pagination/limit in session listing (default 20).
  - Add `--all` flag to `ai` CLI commands to view all sessions.
  - Keep active session ID and path synced correctly during clean session startups.

- CLI UI/UX:
  - Add a custom `ConnpyTheme` for inquirer prompts that dynamically translates
    active hex style colors to terminal ANSI/blessed escapes.

- gRPC & Services:
  - Implement remote MCP server listing (`list_mcp_servers` RPC and services).
  - Stream responder updates (`__RESPONDER__`) to toggle headers dynamically between
    "Network Engineer" and "Network Architect" on remote/web clients.
  - Fix remote client deadlock risk by ensuring `final_mark` is sent on exceptions.
  - Hydrate client-side chat history correctly on initial streaming request.

- Testing:
  - Add integration tests for AI gRPC services and MCP server listing.
2026-05-20 12:27:02 -03:00
fluzzi32 468868ac18 bug fix remote lenght 2026-05-19 16:23:35 -03:00
83 changed files with 14899 additions and 3342 deletions
+7
View File
@@ -20,3 +20,10 @@ scratch
testall testall
testremote testremote
automation-template.yaml automation-template.yaml
# Sensitive local files and credentials
auth.json
key.db
config.db
*.db
testnew/
+7
View File
@@ -146,11 +146,14 @@ package.json
# Development docs # Development docs
connpy_roadmap.md connpy_roadmap.md
testfew/
testnew/
testall/ testall/
testremote/ testremote/
*.db *.db
*.patch *.patch
scratch.py scratch.py
connpy.code-workspace
# Internal planning and implementation docs # Internal planning and implementation docs
PLAN_CAPA_SERVICIOS.md PLAN_CAPA_SERVICIOS.md
@@ -166,7 +169,11 @@ 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
theme.py theme.py
#ai auth
auth.json
+123 -8
View File
@@ -1,16 +1,131 @@
Custom Software License # PolyForm Noncommercial License 1.0.0
Copyright (c) 2022 Federico Luzzi <https://polyformproject.org/licenses/noncommercial/1.0.0>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, and modify the Software, subject to the following conditions: ## Acceptance
Commercial Use: The use of the Software for commercial purposes, including but not limited to selling, sublicensing, or generating revenue in any form, is expressly prohibited for individuals and entities other than the copyright holder. In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
Personal and Non-commercial Use: Individuals and entities are permitted to use, copy, and modify the Software for personal and non-commercial purposes. ## Copyright License
Distribution: Redistribution of the original or modified Software is allowed, provided the Software is not sold or sublicensed and this license notice is included in all copies or substantial portions of the Software. The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
Support and Sale: The copyright holder reserves the exclusive right to sell or offer support services for the Software to any company or commercial entity. ## Distribution License
Disclaimer: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright (c) 2022-2026 Federico Luzzi (<https://github.com/fluzzi/connpy>)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+224 -137
View File
@@ -3,188 +3,275 @@
</p> </p>
# Connpy # Connpy (v6.0.3)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE) [![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square)](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
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol. ### 1a. Terminal Copilot (Ctrl+Space)
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session. Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
* **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`**.
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
* `/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)
+227 -125
View File
@@ -5,178 +5,278 @@
</p> </p>
# Connpy # Connpy (v6.0.3)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE) [![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square)](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
- **Hybrid Multi-Agent System**: Automatically escalates complex tasks between the **Network Engineer** (execution) and the **Network Architect** (strategy).
- **MCP Integration**: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol. ### 1a. Terminal Copilot (Ctrl+Space)
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session. Invoke the context-aware AI Copilot directly inside any active terminal session by pressing **`Ctrl + Space`**.
* **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`**.
* **Slash Commands (`/`)**: Control the AI persona and safety settings:
* `/architect` / `/engineer`: Swaps the agent between high-level strategist and technical executor.
* `/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
@@ -203,5 +303,7 @@ __pdoc__ = {
'nodes.deferred_class_hooks': False, 'nodes.deferred_class_hooks': False,
'connapp': False, 'connapp': False,
'connapp.encrypt': True, 'connapp.encrypt': True,
'printer': False 'printer': False,
'tests': False
} }
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b11" __version__ = "6.0.3"
+447 -66
View File
@@ -1,4 +1,6 @@
import os import os
import secrets
import sys import sys
import json import json
import re import re
@@ -15,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
@@ -106,16 +108,20 @@ class ai:
r'^systemctl\s+status\s+', r'^journalctl\s+' r'^systemctl\s+status\s+', r'^journalctl\s+'
] ]
def __init__(self, config, org=None, api_key=None, engineer_model=None, architect_model=None, engineer_api_key=None, architect_api_key=None, console=None, confirm_handler=None, trust=False): def __init__(self, config, org=None, api_key=None, engineer_model=None, architect_model=None, engineer_api_key=None, architect_api_key=None, console=None, confirm_handler=None, trust=False, engineer_auth=None, architect_auth=None, **kwargs):
self.config = config self.config = config
self.console = console or printer.console self.console = console or printer.console
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 # 1. Load generic configuration with global inheritance/merge
aiconfig = self.config.config.get("ai", {}) 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 {}
# Modelos (Prioridad: Argumento -> Config -> Default) # Modelos (Prioridad: Argumento -> Config -> Default)
self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite" self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
@@ -125,13 +131,36 @@ class ai:
self.engineer_key = engineer_api_key or aiconfig.get("engineer_api_key") self.engineer_key = engineer_api_key or aiconfig.get("engineer_api_key")
self.architect_key = architect_api_key or aiconfig.get("architect_api_key") self.architect_key = architect_api_key or aiconfig.get("architect_api_key")
# Auth configurations (Prioridad: Argumento -> Config)
self.engineer_auth = engineer_auth if engineer_auth is not None else aiconfig.get("engineer_auth")
if self.engineer_auth is None:
self.engineer_auth = {}
elif not isinstance(self.engineer_auth, dict):
self.engineer_auth = {}
self.architect_auth = architect_auth if architect_auth is not None else aiconfig.get("architect_auth")
if self.architect_auth is None:
self.architect_auth = {}
elif not isinstance(self.architect_auth, dict):
self.architect_auth = {}
# Backward compatibility fallbacks: only inject api_key if the auth dict is empty/not configured
if self.engineer_key and not self.engineer_auth:
self.engineer_auth["api_key"] = self.engineer_key
if self.architect_key and not self.architect_auth:
self.architect_auth["api_key"] = self.architect_key
# Strategic Reasoning Engine (Architect) availability
is_architect_keyless = "vertex" in self.architect_model.lower() or "ollama" in self.architect_model.lower() or "local" in self.architect_model.lower()
self.has_architect = bool(self.architect_key or self.architect_auth or is_architect_keyless)
# Custom Trusted Commands Regexes # Custom Trusted Commands Regexes
custom_trusted = aiconfig.get("trusted_commands", []) custom_trusted = aiconfig.get("trusted_commands", [])
if isinstance(custom_trusted, str): if isinstance(custom_trusted, str):
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
@@ -165,12 +194,12 @@ class ai:
# Session Management # Session Management
self.sessions_dir = os.path.join(self.config.defaultdir, "ai_sessions") self.sessions_dir = os.path.join(self.config.defaultdir, "ai_sessions")
os.makedirs(self.sessions_dir, exist_ok=True) os.makedirs(self.sessions_dir, exist_ok=True)
self.session_id = None self.session_id = getattr(self.config, "session_id", None)
self.session_path = 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.architect_key: if self.has_architect:
architect_instructions = """ architect_instructions = """
CRITICAL - CONSULT vs ESCALATE: CRITICAL - CONSULT vs ESCALATE:
- ALWAYS use 'consult_architect' for: Configuration planning, design decisions, complex troubleshooting. - ALWAYS use 'consult_architect' for: Configuration planning, design decisions, complex troubleshooting.
@@ -186,7 +215,7 @@ class ai:
else: else:
architect_instructions = """ architect_instructions = """
CRITICAL - ARCHITECT UNAVAILABLE: CRITICAL - ARCHITECT UNAVAILABLE:
- The Strategic Reasoning Engine (Architect) is currently UNAVAILABLE because its API key is not configured. - The Strategic Reasoning Engine (Architect) is currently UNAVAILABLE because its API key or authentication is not configured.
- DO NOT attempt to consult or escalate to the architect. - DO NOT attempt to consult or escalate to the architect.
- If the user asks to consult the architect, inform them that the Architect is offline and offer to help them directly to the best of your abilities. - If the user asks to consult the architect, inform them that the Architect is offline and offer to help them directly to the best of your abilities.
""" """
@@ -257,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.
@@ -292,15 +324,19 @@ class ai:
if status_formatter: if status_formatter:
self.tool_status_formatters[name] = status_formatter self.tool_status_formatters[name] = status_formatter
def _stream_completion(self, model, messages, tools, api_key, status=None, label="", debug=False, chunk_callback=None, **kwargs): def _stream_completion(self, model, messages, tools, api_key=None, status=None, label="", debug=False, chunk_callback=None, auth=None, **kwargs):
"""Stream a completion call, rendering styled Markdown in real-time. """Stream a completion call, rendering styled Markdown in real-time.
Returns (response, streamed) where: Returns (response, streamed) where:
- response: reconstructed ModelResponse (same as non-streaming) - response: reconstructed ModelResponse (same as non-streaming)
- streamed: True if text was rendered to console during streaming - streamed: True if text was rendered to console during streaming
""" """
auth_dict = auth if auth is not None else {}
if api_key and "api_key" not in auth_dict:
auth_dict = auth_dict.copy()
auth_dict["api_key"] = api_key
stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs) stream_resp = completion(model=model, messages=messages, tools=tools, stream=True, **auth_dict, **kwargs)
chunks = [] chunks = []
full_content = "" full_content = ""
@@ -701,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:
@@ -733,17 +769,15 @@ 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)
response = completion(model=self.engineer_model, messages=safe_messages, tools=tools, api_key=self.engineer_key) response = completion(model=self.engineer_model, messages=safe_messages, tools=tools, **self.engineer_auth)
except Exception as e: except Exception as e:
if status: status.stop() if status: status.stop()
raise ValueError(f"Engineer failed to connect: {str(e)}") raise ValueError(f"Engineer failed to connect: {str(e)}")
@@ -762,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)
@@ -844,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()
@@ -877,16 +919,27 @@ class ai:
continue continue
return sorted(sessions, key=lambda x: x["created_at"], reverse=True) return sorted(sessions, key=lambda x: x["created_at"], reverse=True)
def list_sessions(self): def list_sessions(self, limit=20):
"""Prints a list of sessions using printer.table.""" """Prints a list of sessions using printer.table."""
sessions = self._get_sessions() sessions = self._get_sessions()
if not sessions: if not sessions:
printer.info("No saved AI sessions found.") printer.info("No saved AI sessions found.")
return return
total = len(sessions)
if limit and total > limit:
sessions = sessions[:limit]
columns = ["ID", "Title", "Created At", "Model"] columns = ["ID", "Title", "Created At", "Model"]
rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions] rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions]
printer.table("AI Persisted Sessions", columns, rows)
title = "AI Persisted Sessions"
if limit and total > limit:
title += f" (Showing last {limit} of {total})"
printer.table(title, columns, rows)
if limit and total > limit:
printer.info(f"Use '--list --all' (if supported) or check the sessions directory to see all {total} sessions.")
def load_session_data(self, session_id): def load_session_data(self, session_id):
"""Loads a session's raw data by ID.""" """Loads a session's raw data by ID."""
@@ -917,8 +970,10 @@ class ai:
return sessions[0]["id"] if sessions else None return sessions[0]["id"] if sessions else None
def _generate_session_id(self, query): def _generate_session_id(self, query):
"""Generates a unique session ID based on timestamp.""" """Generates a unique session ID based on timestamp and a random suffix."""
return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = secrets.token_hex(2)
return f"{ts}-{suffix}"
def save_session(self, history, title=None, model=None): def save_session(self, history, title=None, model=None):
"""Saves current history to the session file.""" """Saves current history to the session file."""
@@ -927,6 +982,8 @@ class ai:
first_user_msg = next((m["content"] for m in history if m["role"] == "user"), "new-session") first_user_msg = next((m["content"] for m in history if m["role"] == "user"), "new-session")
self.session_id = self._generate_session_id(first_user_msg) self.session_id = self._generate_session_id(first_user_msg)
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json")
elif not self.session_path:
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json")
# If it's a new file, we might want to set a better title # If it's a new file, we might want to set a better title
if not os.path.exists(self.session_path) and not title: if not os.path.exists(self.session_path) and not title:
@@ -964,13 +1021,28 @@ 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):
if not self.engineer_key: is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
raise ValueError("Engineer API key not configured. Use 'connpy config --engineer-api-key <key>' to set it.") 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.")
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 = []
# Load session if provided and history is empty # Load session if provided and history is empty
if session_id and not chat_history: if session_id:
# Force the session_id even if it doesn't exist yet
self.session_id = session_id
self.session_path = os.path.join(self.sessions_dir, f"{session_id}.json")
if not chat_history:
session_data = self.load_session_data(session_id) session_data = self.load_session_data(session_id)
if session_data: if session_data:
chat_history = session_data.get("history", []) chat_history = session_data.get("history", [])
@@ -979,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)
@@ -988,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)
@@ -1002,21 +1074,22 @@ 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
tools = self._get_architect_tools() if current_brain == "architect" else self._get_engineer_tools() tools = self._get_architect_tools() if current_brain == "architect" else self._get_engineer_tools()
model = self.architect_model if current_brain == "architect" else self.engineer_model model = self.architect_model if current_brain == "architect" else self.engineer_model
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
# 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:]
@@ -1036,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
@@ -1050,38 +1123,35 @@ 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 ONLY for web/remote clients (StatusBridge has is_web) # Notify responder identity for web/remote clients
if getattr(status, "is_web", 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:
safe_messages = self._sanitize_messages(messages) safe_messages = self._sanitize_messages(messages)
if stream: if stream:
response, streamed_response = self._stream_completion( response, streamed_response = self._stream_completion(
model=model, messages=safe_messages, tools=tools, api_key=key, model=model, messages=safe_messages, tools=tools, auth=current_auth,
status=status, label=label, debug=debug, num_retries=3, status=status, label=label, debug=debug, num_retries=3,
chunk_callback=chunk_callback chunk_callback=chunk_callback
) )
else: else:
response = completion(model=model, messages=safe_messages, tools=tools, api_key=key, num_retries=3) 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
tools = self._get_engineer_tools() tools = self._get_engineer_tools()
key = self.engineer_key key = self.engineer_key
current_auth = self.engineer_auth
# Rebuild messages with Engineer system prompt and original user request # Rebuild messages with Engineer system prompt and original user request
messages = [{"role": "system", "content": self.engineer_system_prompt}] messages = [{"role": "system", "content": self.engineer_system_prompt}]
# Add chat history if exists (excluding system prompt) # Add chat history if exists (excluding system prompt)
@@ -1132,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)
@@ -1142,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(
@@ -1164,16 +1234,17 @@ 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
tools = self._get_architect_tools() tools = self._get_architect_tools()
key = self.architect_key key = self.architect_key
current_auth = self.architect_auth
messages[0] = {"role": "system", "content": self.architect_system_prompt} messages[0] = {"role": "system", "content": self.architect_system_prompt}
# Prepare handover context to inject AFTER all tool responses # Prepare handover context to inject AFTER all tool responses
handover_msg = f"HANDOVER FROM EXECUTION ENGINE\n\nReason: {args['reason']}\n\nContext: {args['context']}\n\nYou are now in control of this conversation." handover_msg = f"HANDOVER FROM EXECUTION ENGINE\n\nReason: {args['reason']}\n\nContext: {args['context']}\n\nYou are now in control of this conversation."
@@ -1189,12 +1260,13 @@ 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
tools = self._get_engineer_tools() tools = self._get_engineer_tools()
key = self.engineer_key key = self.engineer_key
current_auth = self.engineer_auth
messages[0] = {"role": "system", "content": self.engineer_system_prompt} messages[0] = {"role": "system", "content": self.engineer_system_prompt}
# Prepare handover context to inject AFTER all tool responses # Prepare handover context to inject AFTER all tool responses
handover_msg = f"HANDOVER FROM ARCHITECT\n\nSummary: {args['summary']}\n\nYou are now back in control. Continue handling the user's requests." handover_msg = f"HANDOVER FROM ARCHITECT\n\nSummary: {args['summary']}\n\nYou are now back in control. Continue handling the user's requests."
@@ -1236,12 +1308,12 @@ class ai:
messages.append({"role": "user", "content": "Hard iteration limit reached. Please provide a summary of your findings so far."}) messages.append({"role": "user", "content": "Hard iteration limit reached. Please provide a summary of your findings so far."})
try: try:
safe_messages = self._sanitize_messages(messages) safe_messages = self._sanitize_messages(messages)
response = completion(model=model, messages=safe_messages, tools=[], api_key=key) response = completion(model=model, messages=safe_messages, tools=[], **current_auth)
resp_msg = response.choices[0].message resp_msg = response.choices[0].message
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...")
@@ -1256,7 +1328,7 @@ class ai:
try: try:
safe_messages = self._sanitize_messages(summary_messages) safe_messages = self._sanitize_messages(summary_messages)
# Use tools=None to force a text summary during interruption # Use tools=None to force a text summary during interruption
response = completion(model=model, messages=safe_messages, tools=None, api_key=key) response = completion(model=model, messages=safe_messages, tools=None, **current_auth)
resp_msg = response.choices[0].message resp_msg = response.choices[0].message
messages.append(resp_msg.model_dump(exclude_none=True)) messages.append(resp_msg.model_dump(exclude_none=True))
@@ -1393,6 +1465,7 @@ Node: {node_name}"""
# Use models based on persona # Use models based on persona
current_model = self.architect_model if persona == "architect" else self.engineer_model current_model = self.architect_model if persona == "architect" else self.engineer_model
current_key = self.architect_key if persona == "architect" else self.engineer_key current_key = self.architect_key if persona == "architect" else self.engineer_key
current_auth = self.architect_auth if persona == "architect" else self.engineer_auth
try: try:
while iteration < max_iterations: while iteration < max_iterations:
@@ -1402,8 +1475,8 @@ Node: {node_name}"""
model=current_model, model=current_model,
messages=messages, messages=messages,
tools=mcp_tools if mcp_tools else None, tools=mcp_tools if mcp_tools else None,
api_key=current_key, stream=True,
stream=True **current_auth
) )
full_content = "" full_content = ""
@@ -1476,8 +1549,8 @@ Node: {node_name}"""
model=self.engineer_model, model=self.engineer_model,
messages=messages, messages=messages,
tools=None, tools=None,
api_key=self.engineer_key, stream=True,
stream=True **self.engineer_auth
) )
full_content = "" full_content = ""
@@ -1557,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
}
+36
View File
@@ -48,6 +48,36 @@ def stop_api():
return port return port
def debug_api(port=8048, config=None): def debug_api(port=8048, config=None):
# Check if already running via PID file verification
for pid_file in [PID_FILE1, PID_FILE2]:
if os.path.exists(pid_file):
try:
with open(pid_file, "r") as f:
pid = int(f.readline().strip())
os.kill(pid, 0)
# If we get here, process exists
printer.info(f"API is already running (PID {pid})")
return
except (ValueError, OSError, ProcessLookupError):
# Stale PID file, ignore here
pass
# Create PID file for the debug process
written_pid_file = None
my_pid = os.getpid()
try:
with open(PID_FILE1, "w") as f:
f.write(str(my_pid) + "\n" + str(port))
written_pid_file = PID_FILE1
except OSError:
try:
with open(PID_FILE2, "w") as f:
f.write(str(my_pid) + "\n" + str(port))
written_pid_file = PID_FILE2
except OSError:
pass
try:
from .grpc_layer.server import serve from .grpc_layer.server import serve
conf = config or configfile() conf = config or configfile()
server = serve(conf, port=port, debug=True) server = serve(conf, port=port, debug=True)
@@ -56,6 +86,12 @@ def debug_api(port=8048, config=None):
server.stop(0) server.stop(0)
from .ai import cleanup from .ai import cleanup
cleanup() cleanup()
finally:
if written_pid_file and os.path.exists(written_pid_file):
try:
os.remove(written_pid_file)
except OSError:
pass
def start_server(port=8048, config=None): def start_server(port=8048, config=None):
try: try:
+1
View File
@@ -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
+65 -21
View File
@@ -15,13 +15,22 @@ class AIHandler:
def dispatch(self, args): def dispatch(self, args):
if args.list_sessions: if args.list_sessions:
sessions = self.app.services.ai.list_sessions() limit = 20 if not getattr(args, "all", False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions: if not sessions:
printer.info("No saved AI sessions found.") printer.info("No saved AI sessions found.")
return return
columns = ["ID", "Title", "Created At", "Model"] columns = ["ID", "Title", "Created At", "Model"]
rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions] rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions]
printer.table("AI Persisted Sessions", columns, rows)
title = "AI Persisted Sessions"
if limit and total > limit:
title += f" (Showing last {limit} of {total})"
printer.table(title, columns, rows)
if limit and total > limit:
printer.info(f"Use '--list --all' to see all {total} sessions.")
return return
if args.delete_session: if args.delete_session:
@@ -35,18 +44,18 @@ 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()
session_id = sessions[0]["id"] if sessions else None session_id = sessions[0]["id"] if sessions else None
if not session_id: if not session_id:
printer.warning("No previous session found to resume.") printer.warning("No previous session found to resume.")
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 = {}
@@ -57,17 +66,24 @@ class AIHandler:
elif settings.get(key): elif settings.get(key):
arguments[key] = settings.get(key) arguments[key] = settings.get(key)
for key in ["engineer_auth", "architect_auth"]:
cli_val = getattr(args, key, None)
if cli_val:
arguments[key] = self._parse_auth_value(cli_val[0])
elif settings.get(key):
arguments[key] = settings.get(key)
# Check keys only if running in local mode (not remote) # Check keys only if running in local mode (not remote)
if getattr(self.app.services, "mode", "local") == "local": if getattr(self.app.services, "mode", "local") == "local":
if not arguments.get("engineer_api_key"): if not arguments.get("engineer_api_key") and not arguments.get("engineer_auth"):
printer.error("Engineer API key not configured. The chat cannot start.") printer.error("Engineer API key/auth not configured. The chat cannot start.")
printer.info("Use 'connpy config --engineer-api-key <key>' to set it.") printer.info("Use 'connpy config --engineer-api-key <key>' or 'connpy config --engineer-auth <auth>' to set it.")
sys.exit(1) sys.exit(1)
if not arguments.get("architect_api_key"): if not arguments.get("architect_api_key") and not arguments.get("architect_auth"):
printer.warning("Architect API key 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>' 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
@@ -78,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")
@@ -102,7 +118,7 @@ class AIHandler:
if history: if history:
mdprint(f"[debug]Analyzing {len(history)} previous messages...[/debug]\n") mdprint(f"[debug]Analyzing {len(history)} previous messages...[/debug]\n")
else: else:
printer.error(f"Could not load session {session_id}. Starting clean.") printer.info(f"Session '{session_id}' not found. Starting clean.")
if not history: if not history:
mdprint(Rule(style="engineer")) mdprint(Rule(style="engineer"))
@@ -115,8 +131,8 @@ 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, **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")
if new_history is not None: if new_history is not None:
@@ -147,8 +163,7 @@ class AIHandler:
action = mcp_args[0].lower() action = mcp_args[0].lower()
if action == "list": if action == "list":
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get("ai", {}).get("mcp_servers", {})
if not mcp_servers: if not mcp_servers:
printer.info("No MCP servers configured.") printer.info("No MCP servers configured.")
else: else:
@@ -213,8 +228,7 @@ class AIHandler:
from .forms import Forms from .forms import Forms
self.app.cli_forms = Forms(self.app) self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get("ai", {}).get("mcp_servers", {})
result = self.app.cli_forms.mcp_wizard(mcp_servers) result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result: if not result:
@@ -249,3 +263,33 @@ class AIHandler:
except Exception as e: except Exception as e:
printer.error(str(e)) printer.error(str(e))
def _parse_auth_value(self, value):
if not value or value.lower() in ["none", "clear"]:
return None
import os
import yaml
import json
if os.path.exists(value):
try:
with open(value, "r") as f:
content = f.read()
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
printer.error(f"Failed to read/parse auth file '{value}': {e}")
sys.exit(1)
try:
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
printer.error("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")
sys.exit(1)
+52 -2
View File
@@ -19,8 +19,10 @@ class ConfigHandler:
"theme": self.set_theme, "theme": self.set_theme,
"engineer_model": self.set_ai_config, "engineer_model": self.set_ai_config,
"engineer_api_key": self.set_ai_config, "engineer_api_key": self.set_ai_config,
"engineer_auth": self.set_ai_config,
"architect_model": self.set_ai_config, "architect_model": self.set_ai_config,
"architect_api_key": self.set_ai_config, "architect_api_key": self.set_ai_config,
"architect_auth": self.set_ai_config,
"trusted_commands": self.set_ai_config, "trusted_commands": self.set_ai_config,
"service_mode": self.set_service_mode, "service_mode": self.set_service_mode,
"remote_host": self.set_remote_host, "remote_host": self.set_remote_host,
@@ -127,9 +129,57 @@ class ConfigHandler:
try: try:
settings = self.app.services.config_svc.get_settings() settings = self.app.services.config_svc.get_settings()
aiconfig = settings.get("ai", {}) aiconfig = settings.get("ai", {})
aiconfig[args.command] = args.data[0] val = args.data[0]
# Check for unset/clear request
if val.lower() in ["none", "clear", ""]:
if args.command in aiconfig:
del aiconfig[args.command]
else:
# If configuring auth, parse as dictionary (JSON/YAML or file path)
if args.command in ["engineer_auth", "architect_auth"]:
parsed_val = self._parse_auth_value(val)
if parsed_val is not None:
aiconfig[args.command] = parsed_val
else:
if args.command in aiconfig:
del aiconfig[args.command]
else:
aiconfig[args.command] = val
self.app.services.config_svc.update_setting("ai", aiconfig) self.app.services.config_svc.update_setting("ai", aiconfig)
printer.success("Config saved") printer.success("Config saved")
except ConnpyError as e: except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e)) printer.error(str(e))
def _parse_auth_value(self, value):
if value.lower() in ["none", "clear", ""]:
return None
# Check if it's a file path
import os
if os.path.exists(value):
try:
with open(value, "r") as f:
content = f.read()
import json
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
raise InvalidConfigurationError(f"Failed to read/parse auth file '{value}': {e}")
# Try parsing as inline JSON/YAML
try:
import json
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")
+72 -1
View File
@@ -1,10 +1,81 @@
import os import os
import inquirer import inquirer
from inquirer.themes import Default, term
try: try:
from pyfzf.pyfzf import FzfPrompt from pyfzf.pyfzf import FzfPrompt
except ImportError: except ImportError:
FzfPrompt = None FzfPrompt = None
def hex_to_blessed(hex_str):
"""Convert hex color string to blessed/ansi format."""
if not hex_str or not isinstance(hex_str, str):
return term.normal
# Check for bold prefix
prefix = ""
if hex_str.startswith('bold '):
prefix = term.bold
hex_str = hex_str.replace('bold ', '').strip()
# If it's a standard color name
if not hex_str.startswith('#'):
return prefix + getattr(term, hex_str, term.normal)
# Parse hex
try:
h = hex_str.lstrip('#')
if len(h) == 3:
h = ''.join([c*2 for c in h])
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
# Try RGB, fallback to standard cyan if it fails or returns empty
try:
c = term.color_rgb(r, g, b)
if not c: # Some terms return empty for RGB
return prefix + term.cyan
return prefix + c
except:
return prefix + term.cyan
except:
return prefix + term.normal
# Custom inquirer theme matching connpy colors
class ConnpyTheme(Default):
def __init__(self):
super().__init__()
try:
from ..printer import _global_active_styles
# Use user_prompt as primary accent, fallback to info/cyan
accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan"))
accent_color = hex_to_blessed(accent)
self.Question.mark_color = accent_color
self.List.selection_color = accent_color
self.List.selection_cursor = ">"
except:
# Absolute fallback to standard cyan
self.Question.mark_color = term.cyan
self.List.selection_color = term.bold_cyan
self.List.selection_cursor = ">"
def get_theme():
"""Returns a fresh instance of the theme with current colors."""
return ConnpyTheme()
class ThemeProxy:
"""Proxy to ensure theme colors are resolved at runtime."""
def __getattr__(self, name):
return getattr(get_theme(), name)
def __iter__(self):
return iter(get_theme())
def __getitem__(self, item):
return get_theme()[item]
theme = ThemeProxy()
def get_config_dir(): def get_config_dir():
home = os.path.expanduser("~") home = os.path.expanduser("~")
defaultdir = os.path.join(home, '.config/conn') defaultdir = os.path.join(home, '.config/conn')
@@ -56,7 +127,7 @@ def choose(app, list_, name, action):
return answer[0] return answer[0]
else: else:
questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)] questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)]
answer = inquirer.prompt(questions) answer = inquirer.prompt(questions, theme=theme)
if answer == None: if answer == None:
return None return None
else: else:
+143
View File
@@ -0,0 +1,143 @@
import os
import sys
import getpass
from .. import printer
from ..services.exceptions import ConnpyError
class LoginHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
action = getattr(args, "action", None)
if action == "login":
return self.login(args)
elif action == "logout":
return self.logout(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def login(self, args):
if getattr(args, "status", False):
return self.show_status()
if self.app.services.mode != "remote":
printer.warning("Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to 'remote'.")
username = getattr(args, "username", None)
if not username:
try:
username = input("Username: ").strip()
if not username:
printer.error("Username cannot be empty.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
password = getpass.getpass("Password: ")
if not password:
printer.error("Password cannot be empty.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let's instantiate it dynamically if it's not present.
auth_service = getattr(self.app.services, "auth", None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
if not remote_host:
printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f"Failed to connect to remote server for login: {e}")
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res["token"]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, ".token")
with open(token_path, "w") as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f"Logged in successfully as '{username}'. Session expires in 8 hours.")
except ConnpyError as e:
printer.error(f"Login failed: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Login failed with unexpected error: {e}")
sys.exit(1)
def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, ".token")
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success("Logged out successfully. Local session cleared.")
except Exception as e:
printer.error(f"Failed to clear session: {e}")
sys.exit(1)
else:
printer.info("No active session found (already logged out).")
def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, ".token")
if not os.path.exists(token_path):
printer.warning("No active session found. You can log in using 'connpy login'.")
return
try:
with open(token_path, "r") as f:
token = f.read().strip()
parts = token.split(".")
if len(parts) != 3:
printer.error("Invalid local session token format.")
return
payload_b64 = parts[1]
payload_b64 += "=" * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
username = payload.get("sub")
exp = payload.get("exp")
if not exp:
printer.success(f"Active session as '{username}' (Indefinite expiration).")
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now > exp:
printer.error("Session has expired. Please log in again using 'connpy login'.")
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f"Logged in as '{username}'")
printer.info(f"Time remaining: {hours}h {minutes}m")
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
except Exception as e:
printer.error(f"Failed to check local session status: {e}")
+31 -5
View File
@@ -14,6 +14,23 @@ class NodeHandler:
self.app = app self.app = app
self.forms = Forms(app) self.forms = Forms(app)
def _filter_exact_match(self, matches, query):
if not query or len(matches) <= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args): def dispatch(self, args):
if not self.app.case and args.data != None: if not self.app.case and args.data != None:
args.data = args.data.lower() args.data = args.data.lower()
@@ -39,6 +56,7 @@ class NodeHandler:
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -73,6 +91,7 @@ class NodeHandler:
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -87,8 +106,9 @@ class NodeHandler:
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f"{matches[0]} deleted successfully") printer.success(f"{matches[0]} deleted successfully")
@@ -144,6 +164,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -171,6 +192,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -209,7 +231,7 @@ class NodeHandler:
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f"{args.data} edited successfully") printer.success(f"{args.data} edited successfully")
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item["type"] = "connection" updated_item["type"] = "connection"
@@ -222,8 +244,12 @@ class NodeHandler:
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info("Nothing to do here") printer.info("Nothing to do here")
+321 -4
View File
@@ -15,13 +15,64 @@ 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):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f"No nodes found matching filter: {nodes_filter}")
sys.exit(2)
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
@@ -36,7 +87,7 @@ class RunHandler:
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -53,12 +104,46 @@ class RunHandler:
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
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)
@@ -79,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", []): for task in playbook.get("tasks", []):
self.cli_run(task) 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", []):
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}")
@@ -103,6 +285,29 @@ class RunHandler:
folder = output_cfg if output_cfg not in [None, "stdout"] else None folder = output_cfg if output_cfg not in [None, "stdout"] else None
prompt = options.get("prompt") prompt = options.get("prompt")
# Resolve and filter nodes through context-aware list_nodes
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 = []
if not resolved_nodes:
printer.error(f"[{name}] No nodes found matching filter: {nodelist}")
sys.exit(11)
nodelist = resolved_nodes
results = {}
try: try:
header_printed = False header_printed = False
if action == "run": if action == "run":
@@ -163,5 +368,117 @@ 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}")
+162
View File
@@ -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)
+14 -12
View File
@@ -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')
@@ -134,7 +134,8 @@ class CopilotInterface:
if state['context_mode'] == self.mode_single: if state['context_mode'] == self.mode_single:
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] # Concat only the bytes of valid blocks to skip intermediate empty/cancelled prompt noise
active_raw = b"".join(raw_bytes[b[0]:b[1]] for b in blocks[idx:])
return preview + "\n" + log_cleaner(active_raw.decode(errors='replace')) return preview + "\n" + log_cleaner(active_raw.decode(errors='replace'))
def get_prompt_text(): def get_prompt_text():
@@ -160,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())]
@@ -175,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])
@@ -265,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,
@@ -298,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)
@@ -335,6 +336,7 @@ class CopilotInterface:
persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer" persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer"
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = "" live_text = ""
first_chunk = True first_chunk = True
+190
View File
@@ -0,0 +1,190 @@
import sys
import os
import getpass
import yaml
from .. import printer
from ..services.exceptions import ConnpyError
class UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == "remote":
printer.error("User 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.username = args.add[0]
elif getattr(args, "delete", None):
args.action = "del"
args.username = args.delete[0]
elif getattr(args, "list", False):
args.action = "list"
elif getattr(args, "show", None):
args.action = "show"
args.username = args.show[0]
elif getattr(args, "regen_password", None):
args.action = "regen_password"
args.username = args.regen_password[0]
action = getattr(args, "action", None)
if action == "add":
return self.add_user(args)
elif action == "del":
return self.delete_user(args)
elif action == "list":
return self.list_users(args)
elif action == "show":
return self.show_user(args)
elif action == "regen_password":
return self.regen_password(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def add_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --add <username>")
sys.exit(1)
custom_path = getattr(args, "path", None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass("Enter password for new user: ")
if not password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f"User '{username}' created successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to create user: {e}")
sys.exit(1)
def delete_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --del <username>")
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f"User '{username}' deleted successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to delete user: {e}")
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning("No users registered.")
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get("config_path"):
formatted_u["config_path"] = os.path.join(self.app.services.users.users_dir, formatted_u["username"])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data("Registered Users", yaml_str)
except Exception as e:
printer.error(f"Failed to list users: {e}")
sys.exit(1)
def show_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --show <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != "password_hash"}
if not safe_user.get("config_path"):
safe_user["config_path"] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f"User: {username}", yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
def regen_password(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --regen-password <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
try:
new_password = getpass.getpass("Enter new password: ")
if not new_password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm new password: ")
if new_password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f"Password for user '{username}' regenerated successfully.")
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to regenerate password: {e}")
sys.exit(1)
+88 -16
View File
@@ -105,6 +105,42 @@ def _get_plugins(which, defaultdir):
return final_all_plugins return final_all_plugins
def _get_users(configdir):
import yaml
registry_file = os.path.join(configdir, "users", "registry.yaml")
if not os.path.exists(registry_file):
return []
try:
with open(registry_file, "r") as f:
data = yaml.safe_load(f) or {}
if isinstance(data, dict) and "users" in data:
return list(data["users"].keys())
except Exception:
pass
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.
@@ -154,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,
@@ -181,11 +222,53 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
ai_dict = {"__exclude_used__": True, "--help": None, "-h": None} ai_dict = {"__exclude_used__": True, "--help": None, "-h": None}
for opt in ["--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key"]: for opt in ["--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key"]:
ai_dict[opt] = {"*": ai_dict} # takes value, loops back ai_dict[opt] = {"*": ai_dict} # takes value, loops back
ai_dict["--engineer-auth"] = {"__extra__": lambda w: get_cwd(w, "--engineer-auth"), "*": ai_dict}
ai_dict["--architect-auth"] = {"__extra__": lambda w: get_cwd(w, "--architect-auth"), "*": ai_dict}
for opt in ["--debug", "--trust", "--list", "--list-sessions", "--session", "--resume", "--delete", "--delete-session", "-y"]: for opt in ["--debug", "--trust", "--list", "--list-sessions", "--session", "--resume", "--delete", "--delete-session", "-y"]:
ai_dict[opt] = ai_dict # takes no value, loops back ai_dict[opt] = ai_dict # takes no value, loops back
ai_dict["--mcp"] = mcp_dict ai_dict["--mcp"] = mcp_dict
ai_dict["*"] = ai_dict ai_dict["*"] = ai_dict
config_dict = {
"--allow-uppercase": ["true", "false"],
"--fzf": ["true", "false"],
"--completion": ["bash", "zsh"],
"--fzf-wrapper": ["bash", "zsh"],
"--service-mode": ["local", "remote"],
"--sync-remote": ["true", "false"],
"--help": None, "-h": None,
}
for opt in ["--keepalive", "--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key", "--theme", "--remote", "--trusted-commands"]:
config_dict[opt] = {"*": config_dict}
config_dict["--configfolder"] = {"__extra__": lambda w: get_cwd(w, "--configfolder", True), "*": config_dict}
config_dict["--engineer-auth"] = {"__extra__": lambda w: get_cwd(w, "--engineer-auth"), "*": config_dict}
config_dict["--architect-auth"] = {"__extra__": lambda w: get_cwd(w, "--architect-auth"), "*": config_dict}
_users = lambda w=None: _get_users(configdir)
user_dict = {
"--add": {"*": {"--path": {"__extra__": lambda w: get_cwd(w, "--path", True), "*": None}}},
"--del": {"__extra__": _users},
"--rm": {"__extra__": _users},
"--show": {"__extra__": _users},
"--regen-password": {"__extra__": _users},
"--list": None,
"--ls": 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 = {
@@ -280,22 +363,11 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"--list": None, "--help": None, "--list": None, "--help": None,
"-h": None, "-h": None,
}, },
"config": { "user": user_dict,
"--allow-uppercase": ["true", "false"], "sso": sso_dict,
"--fzf": ["true", "false"], "login": {"--help": None, "-h": None, "*": None},
"--keepalive": None, "logout": {"--help": None, "-h": None},
"--completion": ["bash", "zsh"], "config": config_dict,
"--fzf-wrapper": ["bash", "zsh"],
"--configfolder": lambda w: get_cwd(w, "--configfolder", True),
"--engineer-model": None, "--engineer-api-key": None,
"--architect-model": None, "--architect-api-key": None,
"--theme": None,
"--service-mode": ["local", "remote"],
"--remote": None,
"--sync-remote": ["true", "false"],
"--trusted-commands": None,
"--help": None, "-h": None,
},
"sync": { "sync": {
"--login": None, "--logout": None, "--login": None, "--logout": None,
"--status": None, "--list": None, "--status": None, "--list": None,
+40 -2
View File
@@ -43,7 +43,8 @@ class configfile:
passwords. passwords.
''' '''
def __init__(self, conf = None, key = None): def __init__(self, conf = None, key = None, shared_config = None):
self._shared_config = shared_config
''' '''
### Optional Parameters: ### Optional Parameters:
@@ -149,6 +150,42 @@ class configfile:
self._generate_nodes_cache() self._generate_nodes_cache()
def get_effective_setting(self, key, default=None):
"""Get config setting with shared fallback for inheritable keys."""
val = self.config.get(key)
if key == "ai":
if val is not None:
if self._shared_config:
import copy
# Deep merge: shared as base, user overrides
base = copy.deepcopy(self._shared_config.config.get(key, {}))
if isinstance(base, dict) and isinstance(val, dict):
# Credential isolation:
# If user defines engineer credentials, discard shared ones
if "engineer_api_key" in val or "engineer_auth" in val:
base.pop("engineer_api_key", None)
base.pop("engineer_auth", None)
# If user defines architect credentials, discard shared ones
if "architect_api_key" in val or "architect_auth" in val:
base.pop("architect_api_key", None)
base.pop("architect_auth", None)
# Recursive update for inner dictionaries (like mcp_servers or model details)
def deep_merge(d1, d2):
for k, v in d2.items():
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
deep_merge(d1[k], v)
else:
d1[k] = copy.deepcopy(v)
deep_merge(base, val)
return base
return val
elif self._shared_config:
return self._shared_config.config.get(key, default)
return val if val is not None else default
def _validate_config(self, data): def _validate_config(self, data):
"""Verify config data has the required structure.""" """Verify config data has the required structure."""
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -489,7 +526,8 @@ class configfile:
else: else:
printer.error("Filter must be a string or a list of strings") printer.error("Filter must be a string or a list of strings")
sys.exit(1) sys.exit(1)
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)] flags = re.IGNORECASE if not self.config.get("case", False) else 0
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
return nodes return nodes
@MethodHook @MethodHook
+56 -3
View File
@@ -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
@@ -79,11 +79,12 @@ class connapp:
self.debug_api = debug_api self.debug_api = debug_api
self.ai = ai self.ai = ai
# Register context filtering hooks # Register context filtering hooks (only on Client CLI, bypass on gRPC Server)
is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api"
if not is_api_server:
self.services.context.config._getallnodes.register_post_hook(self.services.context.filter_node_list) self.services.context.config._getallnodes.register_post_hook(self.services.context.filter_node_list)
self.services.context.config._getallfolders.register_post_hook(self.services.context.filter_node_list) self.services.context.config._getallfolders.register_post_hook(self.services.context.filter_node_list)
self.services.context.config._getallnodesfull.register_post_hook(self.services.context.filter_node_dict) self.services.context.config._getallnodesfull.register_post_hook(self.services.context.filter_node_dict)
if hasattr(self.services.nodes, "list_nodes") and hasattr(self.services.nodes.list_nodes, "register_post_hook"): if hasattr(self.services.nodes, "list_nodes") and hasattr(self.services.nodes.list_nodes, "register_post_hook"):
self.services.nodes.list_nodes.register_post_hook(self.services.context.filter_node_list) self.services.nodes.list_nodes.register_post_hook(self.services.context.filter_node_list)
if hasattr(self.services.nodes, "list_folders") and hasattr(self.services.nodes.list_folders, "register_post_hook"): if hasattr(self.services.nodes, "list_folders") and hasattr(self.services.nodes.list_folders, "register_post_hook"):
@@ -109,6 +110,9 @@ class connapp:
except ConnpyError as e: except ConnpyError as e:
# If in remote mode, connectivity issues should be reported # If in remote mode, connectivity issues should be reported
if mode == "remote": if mode == "remote":
is_auth_cmd = len(sys.argv) > 1 and sys.argv[1] in ["login", "logout", "user"]
is_unauth = "unauthenticated" in str(e).lower() or "token" in str(e).lower()
if not (is_auth_cmd and is_unauth):
printer.warning(f"Failed to fetch data from remote server: {e}") printer.warning(f"Failed to fetch data from remote server: {e}")
self.nodes_list = [] self.nodes_list = []
self.folders = [] self.folders = []
@@ -135,6 +139,9 @@ class connapp:
from .cli.context_handler import ContextHandler from .cli.context_handler import ContextHandler
from .cli.import_export_handler import ImportExportHandler from .cli.import_export_handler import ImportExportHandler
from .cli.sync_handler import SyncHandler from .cli.sync_handler import SyncHandler
from .cli.user_handler import UserHandler
from .cli.login_handler import LoginHandler
from .cli.sso_handler import SSOHandler
# Instantiate Handlers # Instantiate Handlers
self._node = NodeHandler(self) self._node = NodeHandler(self)
@@ -147,6 +154,9 @@ class connapp:
self._context = ContextHandler(self) self._context = ContextHandler(self)
self._import_export = ImportExportHandler(self) self._import_export = ImportExportHandler(self)
self._sync = SyncHandler(self) self._sync = SyncHandler(self)
self._user = UserHandler(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
@@ -276,11 +286,14 @@ class connapp:
aiparser.add_argument("ask", nargs='*', help="Ask connpy AI something") aiparser.add_argument("ask", nargs='*', help="Ask connpy AI something")
aiparser.add_argument("--engineer-model", nargs=1, help="Override engineer model") aiparser.add_argument("--engineer-model", nargs=1, help="Override engineer model")
aiparser.add_argument("--engineer-api-key", nargs=1, help="Override engineer api key") aiparser.add_argument("--engineer-api-key", nargs=1, help="Override engineer api key")
aiparser.add_argument("--engineer-auth", nargs=1, help="Override engineer auth (inline JSON/YAML or file path)")
aiparser.add_argument("--architect-model", nargs=1, help="Override architect model") aiparser.add_argument("--architect-model", nargs=1, help="Override architect model")
aiparser.add_argument("--architect-api-key", nargs=1, help="Override architect api key") aiparser.add_argument("--architect-api-key", nargs=1, help="Override architect api key")
aiparser.add_argument("--architect-auth", nargs=1, help="Override architect auth (inline JSON/YAML or file path)")
aiparser.add_argument("--debug", action="store_true", help="Show AI reasoning and tool calls") aiparser.add_argument("--debug", action="store_true", help="Show AI reasoning and tool calls")
aiparser.add_argument("-y", "--trust", action="store_true", help="Trust AI to execute unsafe commands without confirmation") aiparser.add_argument("-y", "--trust", action="store_true", help="Trust AI to execute unsafe commands without confirmation")
aiparser.add_argument("--list", "--list-sessions", dest="list_sessions", action="store_true", help="List saved AI sessions") aiparser.add_argument("--list", "--list-sessions", dest="list_sessions", action="store_true", help="List saved AI sessions")
aiparser.add_argument("--all", action="store_true", help="Show all sessions without limit")
aiparser.add_argument("--session", nargs=1, help="Resume a specific AI session by ID") aiparser.add_argument("--session", nargs=1, help="Resume a specific AI session by ID")
aiparser.add_argument("--resume", action="store_true", help="Resume the most recent AI session") aiparser.add_argument("--resume", action="store_true", help="Resume the most recent AI session")
aiparser.add_argument("--delete", "--delete-session", dest="delete_session", nargs=1, help="Delete an AI session by ID") aiparser.add_argument("--delete", "--delete-session", dest="delete_session", nargs=1, help="Delete an AI session by ID")
@@ -292,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)
@@ -340,15 +356,52 @@ class connapp:
configcrud.add_argument("--configfolder", dest="configfolder", nargs=1, action=self._store_type, help="Set the default location for config file", metavar="FOLDER") configcrud.add_argument("--configfolder", dest="configfolder", nargs=1, action=self._store_type, help="Set the default location for config file", metavar="FOLDER")
configcrud.add_argument("--engineer-model", dest="engineer_model", nargs=1, action=self._store_type, help="Set engineer model", metavar="MODEL") configcrud.add_argument("--engineer-model", dest="engineer_model", nargs=1, action=self._store_type, help="Set engineer model", metavar="MODEL")
configcrud.add_argument("--engineer-api-key", dest="engineer_api_key", nargs=1, action=self._store_type, help="Set engineer api_key", metavar="API_KEY") configcrud.add_argument("--engineer-api-key", dest="engineer_api_key", nargs=1, action=self._store_type, help="Set engineer api_key", metavar="API_KEY")
configcrud.add_argument("--engineer-auth", dest="engineer_auth", nargs=1, action=self._store_type, help="Set engineer auth (inline JSON/YAML or file path)", metavar="AUTH")
configcrud.add_argument("--theme", dest="theme", nargs=1, action=self._store_type, help="Set application theme (dark, light, or YAML file path)", metavar="THEME") configcrud.add_argument("--theme", dest="theme", nargs=1, action=self._store_type, help="Set application theme (dark, light, or YAML file path)", metavar="THEME")
configcrud.add_argument("--service-mode", dest="service_mode", nargs=1, action=self._store_type, help="Set the backend service mode (local or remote)", choices=["local", "remote"]) configcrud.add_argument("--service-mode", dest="service_mode", nargs=1, action=self._store_type, help="Set the backend service mode (local or remote)", choices=["local", "remote"])
configcrud.add_argument("--remote", dest="remote_host", nargs=1, action=self._store_type, help="Connect to a remote connpy service via gRPC", metavar="HOST:PORT") configcrud.add_argument("--remote", dest="remote_host", nargs=1, action=self._store_type, help="Connect to a remote connpy service via gRPC", metavar="HOST:PORT")
configcrud.add_argument("--architect-model", dest="architect_model", nargs=1, action=self._store_type, help="Set architect model", metavar="MODEL") configcrud.add_argument("--architect-model", dest="architect_model", nargs=1, action=self._store_type, help="Set architect model", metavar="MODEL")
configcrud.add_argument("--architect-api-key", dest="architect_api_key", nargs=1, action=self._store_type, help="Set architect api_key", metavar="API_KEY") configcrud.add_argument("--architect-api-key", dest="architect_api_key", nargs=1, action=self._store_type, help="Set architect api_key", metavar="API_KEY")
configcrud.add_argument("--architect-auth", dest="architect_auth", nargs=1, action=self._store_type, help="Set architect auth (inline JSON/YAML or file path)", metavar="AUTH")
configcrud.add_argument("--sync-remote", dest="sync_remote", nargs=1, action=self._store_type, help="Sync remote nodes to Google Drive", choices=["true","false"]) configcrud.add_argument("--sync-remote", dest="sync_remote", nargs=1, action=self._store_type, help="Sync remote nodes to Google Drive", choices=["true","false"])
configparser.add_argument("--trusted-commands", dest="trusted_commands", nargs=1, action=self._store_type, help="Set custom trusted commands regexes (comma separated)", metavar="REGEX,REGEX") configparser.add_argument("--trusted-commands", dest="trusted_commands", nargs=1, action=self._store_type, help="Set custom trusted commands regexes (comma separated)", metavar="REGEX,REGEX")
configparser.set_defaults(func=self._config.dispatch) configparser.set_defaults(func=self._config.dispatch)
#USERPARSER
userparser = subparsers.add_parser("user", help="Manage server users", description="Manage server users", formatter_class=RichHelpFormatter)
userparser.error = self._custom_error
usercrud = userparser.add_mutually_exclusive_group(required=True)
usercrud.add_argument("--add", nargs=1, dest="add", help="Add new user", metavar="USERNAME")
usercrud.add_argument("--del", "--rm", nargs=1, dest="delete", help="Delete user", metavar="USERNAME")
usercrud.add_argument("--list", "--ls", dest="list", action="store_true", help="List all users")
usercrud.add_argument("--show", nargs=1, dest="show", help="Show user details", metavar="USERNAME")
usercrud.add_argument("--regen-password", nargs=1, dest="regen_password", help="Regenerate user password", metavar="USERNAME")
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)
#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 = 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.add_argument("username", nargs='?', default=None, help="Username to authenticate")
loginparser.add_argument("-s", "--status", action="store_true", help="Check current login status")
loginparser.set_defaults(func=self._login.dispatch, action="login")
#LOGOUTPARSER
logoutparser = subparsers.add_parser("logout", help="Logout from remote connpy server", description="Logout from remote connpy server", formatter_class=RichHelpFormatter)
logoutparser.error = self._custom_error
logoutparser.set_defaults(func=self._login.dispatch, action="logout")
#SYNCPARSER #SYNCPARSER
syncparser = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter) syncparser = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter)
syncparser.error = self._custom_error syncparser.error = self._custom_error
+68 -40
View File
@@ -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)
@@ -315,8 +315,11 @@ class node:
def _setup_interact_environment(self, debug=False, logger=None, async_mode=False): def _setup_interact_environment(self, debug=False, logger=None, async_mode=False):
try:
size = re.search('columns=([0-9]+).*lines=([0-9]+)',str(os.get_terminal_size())) size = re.search('columns=([0-9]+).*lines=([0-9]+)',str(os.get_terminal_size()))
self.child.setwinsize(int(size.group(2)),int(size.group(1))) self.child.setwinsize(int(size.group(2)),int(size.group(1)))
except OSError:
pass
if logger: if logger:
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}")
@@ -353,6 +356,7 @@ class node:
async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None): async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
local_stream.setup(resize_callback=resize_callback) local_stream.setup(resize_callback=resize_callback)
self.current_local_stream = local_stream
try: try:
child_fd = self.child.child_fd child_fd = self.child.child_fd
@@ -435,11 +439,19 @@ class node:
# Remove any stray \x00 bytes and forward normally # Remove any stray \x00 bytes and forward normally
clean_data = data.replace(b'\x00', b'') clean_data = data.replace(b'\x00', b'')
if clean_data: if clean_data:
# Track command boundaries when user hits Enter # Track command boundaries when user hits Enter or presses Ctrl+C
if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data): if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data or b'\x03' in clean_data):
self.cmd_byte_positions.append((self.mylog.tell(), None)) pos = self.mylog.tell()
marker_cmd = "CANCELLED" if b'\x03' in clean_data else None
self.cmd_byte_positions.append((pos, marker_cmd))
if hasattr(self, 'current_local_stream') and self.current_local_stream is not None:
try:
await self.current_local_stream.write(f'\x1b]133;B;{pos}\x07'.encode())
except Exception:
pass
try: os.write(child_fd, clean_data) try:
os.write(child_fd, clean_data)
except OSError: except OSError:
break break
self.lastinput = time() self.lastinput = time()
@@ -559,6 +571,7 @@ class node:
except Exception: except Exception:
pass pass
finally: finally:
self.current_local_stream = None
local_stream.teardown() local_stream.teardown()
@MethodHook @MethodHook
@@ -587,6 +600,11 @@ class node:
if cmd != slc and hasattr(self, 'cmd_byte_positions') and self.cmd_byte_positions is not None: if cmd != slc and hasattr(self, 'cmd_byte_positions') and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, 'mylog') else 0 log_pos = self.mylog.tell() if hasattr(self, 'mylog') else 0
self.cmd_byte_positions.append((log_pos, cmd)) self.cmd_byte_positions.append((log_pos, cmd))
if hasattr(self, 'current_local_stream') and self.current_local_stream is not None:
try:
await self.current_local_stream.write(f'\x1b]133;B;{log_pos}\x07'.encode())
except Exception:
pass
# Write physically to PTY # Write physically to PTY
os.write(child_fd, (cmd + "\n").encode()) os.write(child_fd, (cmd + "\n").encode())
@@ -668,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:
@@ -690,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'):
@@ -758,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]
@@ -786,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
@@ -868,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]
@@ -897,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
@@ -922,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
try:
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output) 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:
@@ -998,18 +1038,6 @@ class node:
cmd += f" {self.options}" cmd += f" {self.options}"
return cmd return cmd
@MethodHook
def _generate_ssm_cmd(self):
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
cmd = f"aws ssm start-session --target {self.host}"
if region:
cmd += f" --region {region}"
if profile:
cmd += f" --profile {profile}"
if self.options:
cmd += f" {self.options}"
return cmd
@MethodHook @MethodHook
def _get_cmd(self): def _get_cmd(self):
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+611 -69
View File
@@ -4,6 +4,8 @@ from google.protobuf.empty_pb2 import Empty
import os import os
import ctypes import ctypes
import threading import threading
import contextvars
import datetime
# Suppress harmless but noisy gRPC fork() warnings from pexpect child processes # Suppress harmless but noisy gRPC fork() warnings from pexpect child processes
os.environ["GRPC_VERBOSITY"] = "NONE" os.environ["GRPC_VERBOSITY"] = "NONE"
@@ -14,15 +16,7 @@ from .utils import to_value, from_value, to_struct, from_struct
from ..services.exceptions import ConnpyError from ..services.exceptions import ConnpyError
from .. import printer from .. import printer
# Import local services _current_user = contextvars.ContextVar("current_user", default=None)
from ..services.node_service import NodeService
from ..services.profile_service import ProfileService
from ..services.config_service import ConfigService
from ..services.plugin_service import PluginService
from ..services.ai_service import AIService
from ..services.system_service import SystemService
from ..services.execution_service import ExecutionService
from ..services.import_export_service import ImportExportService
def handle_errors(func): def handle_errors(func):
import inspect import inspect
@@ -31,10 +25,16 @@ def handle_errors(func):
try: try:
for item in func(*args, **kwargs): for item in func(*args, **kwargs):
yield item yield item
except grpc.RpcError:
raise
except ConnpyError as e: except ConnpyError as e:
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.INTERNAL, str(e)) context.abort(grpc.StatusCode.INTERNAL, str(e))
except Exception as e: except Exception as e:
if type(e) is Exception and not e.args:
raise e
if e.__class__.__name__ in ("_AbortError", "RpcError"):
raise e
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.UNKNOWN, str(e)) context.abort(grpc.StatusCode.UNKNOWN, str(e))
finally: finally:
@@ -44,10 +44,16 @@ def handle_errors(func):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except grpc.RpcError:
raise
except ConnpyError as e: except ConnpyError as e:
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.INTERNAL, str(e)) context.abort(grpc.StatusCode.INTERNAL, str(e))
except Exception as e: except Exception as e:
if type(e) is Exception and not e.args:
raise e
if e.__class__.__name__ in ("_AbortError", "RpcError"):
raise e
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.UNKNOWN, str(e)) context.abort(grpc.StatusCode.UNKNOWN, str(e))
finally: finally:
@@ -55,25 +61,46 @@ def handle_errors(func):
return wrapper return wrapper
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
def __init__(self, config, debug=False): def __init__(self, provider, registry=None, debug=False):
self.service = NodeService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
self.server_debug = debug self.server_debug = debug
if debug: if debug:
from rich.console import Console from rich.console import Console
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
self.server_console = Console(theme=connpy_theme, file=get_original_stdout()) self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def interact_node(self, request_iterator, context): def interact_node(self, request_iterator, context):
import sys import sys
import os import os
import asyncio import asyncio
from connpy.core import node from connpy.core import node
from ..services.profile_service import ProfileService
from connpy.tunnels import RemoteStream from connpy.tunnels import RemoteStream
import queue import queue
import threading import threading
# Resolve provider once at the start of the RPC stream
provider = self._get_provider()
nodes_service = provider.nodes
profile_service = provider.profiles
ai_service = provider.ai
user_config = provider.config
# Fetch first setup packet # Fetch first setup packet
try: try:
first_req = next(request_iterator) first_req = next(request_iterator)
@@ -100,9 +127,9 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if base_node_id: if base_node_id:
# Look up the base node in config and use its full data # Look up the base node in config and use its full data
nodes = self.service.config._getallnodes(base_node_id) nodes = user_config._getallnodes(base_node_id)
if nodes: if nodes:
device = self.service.config.getitem(nodes[0]) device = user_config.getitem(nodes[0])
# Override device properties with any passed in params # Override device properties with any passed in params
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
@@ -116,11 +143,11 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
device["tags"] = device_tags device["tags"] = device_tags
node_name = params.get("name", base_node_id) node_name = params.get("name", base_node_id)
n = node(node_name, **device, config=self.service.config) n = node(node_name, **device, config=user_config)
else: else:
# base_node not found, fall back to dynamic # base_node not found, fall back to dynamic
node_name = params.get("name", fallback_id) node_name = params.get("name", fallback_id)
n = node(node_name, host=params.get("host", ""), config=self.service.config) n = node(node_name, host=params.get("host", ""), config=user_config)
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
setattr(n, attr, params[attr]) setattr(n, attr, params[attr])
@@ -128,19 +155,22 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
n.tags = params["tags"] n.tags = params["tags"]
else: else:
node_name = params.get("name", fallback_id) node_name = params.get("name", fallback_id)
n = node(node_name, host=params.get("host", ""), config=self.service.config) n = node(node_name, host=params.get("host", ""), config=user_config)
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
setattr(n, attr, params[attr]) setattr(n, attr, params[attr])
if "tags" in params: if "tags" in params:
n.tags = params["tags"] n.tags = params["tags"]
else: else:
node_data = self.service.config.getitem(unique_id, extract=False) try:
node_data = user_config.getitem(unique_id, extract=False)
except (KeyError, TypeError):
node_data = None
if not node_data: if not node_data:
context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found") context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found")
profile_service = ProfileService(self.service.config)
resolved_data = profile_service.resolve_node_data(node_data) resolved_data = profile_service.resolve_node_data(node_data)
n = node(unique_id, **resolved_data, config=self.service.config) n = node(unique_id, **resolved_data, config=user_config)
if sftp: if sftp:
n.protocol = "sftp" n.protocol = "sftp"
@@ -207,9 +237,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
import json import json
import asyncio import asyncio
import os import os
from ..services.ai_service import AIService
service = AIService(self.service.config) service = ai_service
if node_info is None: if node_info is None:
node_info = {} node_info = {}
@@ -375,7 +404,9 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler)) asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler))
except Exception as e: except Exception as e:
pass import traceback
print(f"[ERROR in run_async_loop] {e}")
traceback.print_exc()
finally: finally:
n._teardown_interact_environment() n._teardown_interact_environment()
response_queue.put(None) # Signal EOF response_queue.put(None) # Signal EOF
@@ -477,9 +508,27 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
) )
class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer): class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ProfileService(config) if not hasattr(provider, "mode"):
self.node_service = NodeService(config) from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().profiles
@property
def node_service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def list_profiles(self, request, context): def list_profiles(self, request, context):
@@ -513,8 +562,23 @@ class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
return Empty() return Empty()
class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer): class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ConfigService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().config_svc
@handle_errors @handle_errors
def get_settings(self, request, context): def get_settings(self, request, context):
@@ -543,8 +607,23 @@ class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
return connpy_pb2.StructResponse(data=to_struct(self.service.apply_theme_from_file(request.value))) return connpy_pb2.StructResponse(data=to_struct(self.service.apply_theme_from_file(request.value)))
class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer): class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = PluginService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().plugins
@handle_errors @handle_errors
def list_plugins(self, request, context): def list_plugins(self, request, context):
@@ -586,8 +665,23 @@ class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_gr
yield remote_plugin_pb2.OutputChunk(text=chunk) yield remote_plugin_pb2.OutputChunk(text=chunk)
class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer): class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ExecutionService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().execution
@handle_errors @handle_errors
def run_commands(self, request, context): def run_commands(self, request, context):
@@ -596,6 +690,11 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes) nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
# threading.Thread does NOT inherit ContextVars, so self.service inside
# _worker() would fall back to the admin provider.
execution_service = self.service
q = queue.Queue() q = queue.Queue()
def _on_complete(unique, output, status): def _on_complete(unique, output, status):
@@ -603,7 +702,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker(): def _worker():
try: try:
self.service.run_commands( nodes_filter=nodes_filter, execution_service.run_commands( nodes_filter=nodes_filter,
commands=list(request.commands), commands=list(request.commands),
folder=request.folder if request.folder else None, folder=request.folder if request.folder else None,
prompt=request.prompt if request.prompt else None, prompt=request.prompt if request.prompt else None,
@@ -620,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()
@@ -642,6 +743,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes) nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
execution_service = self.service
q = queue.Queue() q = queue.Queue()
def _on_complete(unique, node_output, node_status, node_result): def _on_complete(unique, node_output, node_status, node_result):
@@ -649,7 +753,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker(): def _worker():
try: try:
self.service.test_commands( execution_service.test_commands(
nodes_filter=nodes_filter, nodes_filter=nodes_filter,
commands=list(request.commands), commands=list(request.commands),
expected=list(request.expected), expected=list(request.expected),
@@ -666,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()
@@ -689,15 +795,28 @@ 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, config): def __init__(self, provider, registry=None):
self.service = ImportExportService(config) if not hasattr(provider, "mode"):
self.node_service = NodeService(config) from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().import_export
@property
def node_service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def export_to_file(self, request, context): def export_to_file(self, request, context):
@@ -729,6 +848,7 @@ class StatusBridge:
self.on_interrupt = self._force_interrupt self.on_interrupt = self._force_interrupt
self.thread = None self.thread = None
self.is_web = is_web self.is_web = is_web
self.is_remote = True
def _force_interrupt(self): def _force_interrupt(self):
"""Forcefully raise KeyboardInterrupt in the target thread.""" """Forcefully raise KeyboardInterrupt in the target thread."""
@@ -811,13 +931,33 @@ class StatusBridge:
return default return default
class AIServicer(connpy_pb2_grpc.AIServiceServicer): class AIServicer(connpy_pb2_grpc.AIServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None, debug=False):
self.service = AIService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
self.server_debug = debug
if debug:
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
@handle_errors def _get_provider(self):
def ask(self, request_iterator, context): if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().ai
def _handle_chat_stream(self, request_iterator, context, service_method):
import queue import queue
import threading import threading
import contextvars
chunk_queue = queue.Queue() chunk_queue = queue.Queue()
request_queue = queue.Queue() request_queue = queue.Queue()
@@ -836,7 +976,15 @@ 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 = self.service.ask( if getattr(service_method, "__name__", None) == "build_playbook_chat":
res = service_method(
input_text,
chat_history=history if history else None,
status=bridge,
chunk_callback=callback
)
else:
res = service_method(
input_text, input_text,
chat_history=history if history else None, chat_history=history if history else None,
session_id=session_id, session_id=session_id,
@@ -850,19 +998,31 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
) )
# 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
chunk_queue.put(("final_mark", res)) chunk_queue.put(("final_mark", res))
except ValueError as e:
# Configuration or LLM provider connection errors are expected, only print in debug mode
if debug or getattr(self, "server_debug", False):
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
c = getattr(self, "server_console", None) or Console(theme=connpy_theme, file=get_original_stdout())
c.print(f"[debug][DEBUG][/debug] AI Task Error: {e}")
chunk_queue.put(("status", f"Error: {str(e)}"))
# Crucial: always send final_mark to avoid client deadlock
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "chat_history": history, "error": True}))
except Exception as e: except Exception as e:
import traceback import traceback
print(f"AI Task Error: {e}") print(f"AI Task Error: {e}")
traceback.print_exc() traceback.print_exc()
chunk_queue.put(("status", f"Error: {str(e)}")) chunk_queue.put(("status", f"Error: {str(e)}"))
# Crucial: always send final_mark to avoid client deadlock
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "chat_history": history, "error": True}))
def request_listener(): def request_listener():
nonlocal bridge, is_web, ai_thread, agent_instance nonlocal bridge, is_web, ai_thread, agent_instance, history
try: try:
for req in request_iterator: for req in request_iterator:
if req.interrupt: if req.interrupt:
@@ -876,17 +1036,26 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
if req.input_text: if req.input_text:
is_web = "web" in (req.session_id or "").lower() or (req.session_id or "").lower().startswith("ws-") is_web = "web" in (req.session_id or "").lower() or (req.session_id or "").lower().startswith("ws-")
# Hydrate history from client if it's the first interaction in this stream
if not history and req.chat_history:
from .utils import from_value
history = from_value(req.chat_history) or []
if not bridge: if not bridge:
bridge = StatusBridge(chunk_queue, request_queue=request_queue, is_web=is_web) bridge = StatusBridge(chunk_queue, request_queue=request_queue, is_web=is_web)
overrides = {} overrides = {}
if req.engineer_model: overrides["engineer_model"] = req.engineer_model if req.engineer_model: overrides["engineer_model"] = req.engineer_model
if req.engineer_api_key: overrides["engineer_api_key"] = req.engineer_api_key if req.engineer_api_key: overrides["engineer_api_key"] = req.engineer_api_key
if req.architect_model: overrides["architect_model"] = req.architect_model
if req.architect_api_key: overrides["architect_api_key"] = req.architect_api_key
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)
# 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()
@@ -898,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:
@@ -923,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)
@@ -946,7 +1183,8 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
@handle_errors @handle_errors
def list_sessions(self, request, context): def list_sessions(self, request, context):
return connpy_pb2.ValueResponse(data=to_value(self.service.list_sessions())) sessions, total = self.service.list_sessions()
return connpy_pb2.ValueResponse(data=to_value(sessions))
@handle_errors @handle_errors
def delete_session(self, request, context): def delete_session(self, request, context):
@@ -955,7 +1193,8 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
@handle_errors @handle_errors
def configure_provider(self, request, context): def configure_provider(self, request, context):
self.service.configure_provider(request.provider, request.model, request.api_key) auth_dict = from_struct(request.auth) if request.HasField("auth") else None
self.service.configure_provider(request.provider, request.model, request.api_key, auth=auth_dict)
return Empty() return Empty()
@handle_errors @handle_errors
@@ -969,13 +1208,33 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
) )
return Empty() return Empty()
@handle_errors
def list_mcp_servers(self, request, context):
mcp_servers = self.service.list_mcp_servers()
return connpy_pb2.ValueResponse(data=to_value(mcp_servers))
@handle_errors @handle_errors
def load_session_data(self, request, context): def load_session_data(self, request, context):
return connpy_pb2.StructResponse(data=to_struct(self.service.load_session_data(request.value))) return connpy_pb2.StructResponse(data=to_struct(self.service.load_session_data(request.value)))
class SystemServicer(connpy_pb2_grpc.SystemServiceServicer): class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = SystemService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().system
@handle_errors @handle_errors
def start_api(self, request, context): def start_api(self, request, context):
@@ -1001,6 +1260,269 @@ class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
def get_api_status(self, request, context): def get_api_status(self, request, context):
return connpy_pb2.BoolResponse(value=self.service.get_api_status()) return connpy_pb2.BoolResponse(value=self.service.get_api_status())
class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
def __init__(self, registry):
self.registry = registry
@handle_errors
def login(self, request, context):
username = request.username
password = request.password
if not self.registry.user_service.authenticate(username, password):
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
token = self.registry.user_service.generate_jwt(username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
username=username,
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
def change_password(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
try:
self.registry.user_service.change_password(username, request.old_password, request.new_password)
self.registry.evict(username)
except ValueError as e:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
return Empty()
class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
def __init__(self, registry):
self.registry = registry
def intercept_service(self, continuation, handler_call_details):
method = handler_call_details.method
if method in self.OPEN_METHODS:
return continuation(handler_call_details)
if not self.registry.has_users():
return continuation(handler_call_details)
token = self._extract_token(handler_call_details.invocation_metadata)
if not token:
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
username = self.registry.user_service.verify_jwt(token)
if not username:
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
handler = continuation(handler_call_details)
if handler is None:
return None
return self._wrap_handler(handler, username)
def _wrap_handler(self, handler, username):
if handler.unary_unary:
original_behavior = handler.unary_unary
def wrapper(request, context):
token = _current_user.set(username)
try:
return original_behavior(request, context)
finally:
_current_user.reset(token)
return grpc.unary_unary_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.unary_stream:
original_behavior = handler.unary_stream
def wrapper(request, context):
token = _current_user.set(username)
try:
for response in original_behavior(request, context):
yield response
finally:
_current_user.reset(token)
return grpc.unary_stream_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.stream_unary:
original_behavior = handler.stream_unary
def wrapper(request_iterator, context):
token = _current_user.set(username)
try:
return original_behavior(request_iterator, context)
finally:
_current_user.reset(token)
return grpc.stream_unary_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.stream_stream:
original_behavior = handler.stream_stream
def wrapper(request_iterator, context):
token = _current_user.set(username)
try:
for response in original_behavior(request_iterator, context):
yield response
finally:
_current_user.reset(token)
return grpc.stream_stream_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
return handler
def _extract_token(self, metadata):
for key, value in metadata:
if key.lower() == "authorization":
if value.startswith("Bearer "):
return value[7:]
return None
def _unauthenticated_handler(self, handler_call_details, message):
def abort_call(request_or_iterator, context):
context.abort(grpc.StatusCode.UNAUTHENTICATED, message)
return grpc.unary_unary_rpc_method_handler(abort_call)
class LoggingInterceptor(grpc.ServerInterceptor): class LoggingInterceptor(grpc.ServerInterceptor):
def __init__(self): def __init__(self):
from rich.console import Console from rich.console import Console
@@ -1025,19 +1547,39 @@ class LoggingInterceptor(grpc.ServerInterceptor):
return result return result
def serve(config, port=8048, debug=False): def serve(config, port=8048, debug=False):
interceptors = [LoggingInterceptor()] if debug else [] from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
fallback_provider = ServiceProvider(config, mode="local")
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 = []
if debug:
interceptors.append(LoggingInterceptor())
interceptors.append(AuthInterceptor(registry))
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors) server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config, debug=debug), server) connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(fallback_provider, registry=registry, debug=debug), server)
connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(config), server) connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server) connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(fallback_provider, registry=registry), server)
plugin_servicer = PluginServicer(config) plugin_servicer = PluginServicer(fallback_provider, registry=registry)
connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server) connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server)
remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server) remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server)
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(config), server) connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(config), server) connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(config), server) connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(fallback_provider, registry=registry, debug=debug), server)
connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(config), server) connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(registry), server)
server.add_insecure_port(f'[::]:{port}') server.add_insecure_port(f'[::]:{port}')
server.start() server.start()
+214 -23
View File
@@ -462,15 +462,17 @@ class NodeStub:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
if save:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
if save:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
@@ -690,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)
@@ -722,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
@@ -745,6 +741,10 @@ class AIStub:
) )
if chat_history is not None: if chat_history is not None:
initial_req.chat_history.CopyFrom(to_value(chat_history)) 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) req_queue.put(initial_req)
@@ -754,10 +754,11 @@ 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
current_responder = "engineer"
final_result = {"response": "", "chat_history": []} final_result = {"response": "", "chat_history": []}
# Background thread to pull responses from gRPC into a local queue # Background thread to pull responses from gRPC into a local queue
@@ -802,6 +803,10 @@ class AIStub:
break break
if response.status_update: if response.status_update:
if response.status_update.startswith("__RESPONDER__:"):
current_responder = response.status_update.split(":")[1].lower()
continue
if response.requires_confirmation: if response.requires_confirmation:
if status: status.stop() if status: status.stop()
@@ -848,24 +853,32 @@ class AIStub:
try: status.stop() try: status.stop()
except: pass except: pass
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from rich.rule import Rule from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk # Print header on first chunk
stable_console.print(Rule("[bold engineer]Network Engineer[/bold engineer]", style="engineer")) 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 header_printed = True
# Initialize parser # Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console) md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk) 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()
@@ -874,19 +887,12 @@ 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())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get("response"):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -901,21 +907,126 @@ 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
@handle_errors @handle_errors
def list_sessions(self): def list_sessions(self, limit=None):
return from_value(self.stub.list_sessions(Empty()).data) from .utils import from_value
res = self.stub.list_sessions(Empty())
sessions = from_value(res.data) or []
if limit and len(sessions) > limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)
@handle_errors @handle_errors
def delete_session(self, session_id): def delete_session(self, session_id):
self.stub.delete_session(connpy_pb2.StringRequest(value=session_id)) self.stub.delete_session(connpy_pb2.StringRequest(value=session_id))
@handle_errors @handle_errors
def configure_provider(self, provider, model=None, api_key=None): def configure_provider(self, provider, model=None, api_key=None, auth=None):
req = connpy_pb2.ProviderRequest(provider=provider, model=model or "", api_key=api_key or "") req = connpy_pb2.ProviderRequest(provider=provider, model=model or "", api_key=api_key or "")
if auth:
req.auth.CopyFrom(to_struct(auth))
self.stub.configure_provider(req) self.stub.configure_provider(req)
@handle_errors @handle_errors
@@ -929,6 +1040,11 @@ class AIStub:
) )
self.stub.configure_mcp(req) self.stub.configure_mcp(req)
@handle_errors
def list_mcp_servers(self):
res = self.stub.list_mcp_servers(Empty())
return from_value(res.data) or {}
@handle_errors @handle_errors
def load_session_data(self, session_id): def load_session_data(self, session_id):
return from_struct(self.stub.load_session_data(connpy_pb2.StringRequest(value=session_id)).data) return from_struct(self.stub.load_session_data(connpy_pb2.StringRequest(value=session_id)).data)
@@ -957,3 +1073,78 @@ class SystemStub:
@handle_errors @handle_errors
def get_api_status(self): def get_api_status(self):
return self.stub.get_api_status(Empty()).value return self.stub.get_api_status(Empty()).value
class _ClientCallDetails(object):
def __init__(self, method, timeout, metadata, credentials, wait_for_ready, compression=None):
self.method = method
self.timeout = timeout
self.metadata = metadata
self.credentials = credentials
self.wait_for_ready = wait_for_ready
self.compression = compression
class AuthClientInterceptor(grpc.UnaryUnaryClientInterceptor,
grpc.UnaryStreamClientInterceptor,
grpc.StreamUnaryClientInterceptor,
grpc.StreamStreamClientInterceptor):
def __init__(self, token_provider):
self.token_provider = token_provider
def _add_metadata(self, client_call_details):
token = self.token_provider()
if not token:
return client_call_details
metadata = []
if client_call_details.metadata:
metadata = list(client_call_details.metadata)
# Check if already present to avoid duplicates
if not any(k.lower() == "authorization" for k, v in metadata):
metadata.append(("authorization", f"Bearer {token}"))
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=client_call_details.wait_for_ready,
compression=client_call_details.compression,
)
def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
class AuthStub:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.AuthServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
"token": resp.token,
"username": resp.username,
"expires_at": resp.expires_at
}
@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)
+113
View File
@@ -0,0 +1,113 @@
import os
import threading
from connpy.configfile import configfile
from connpy.services.provider import ServiceProvider
from connpy.services.user_service import UserService
class UserRegistry:
"""Holds per-user ServiceProviders in memory, thread-safe with hot-reloading."""
def __init__(self, server_config_dir):
self.server_config_dir = os.path.abspath(server_config_dir)
self.user_service = UserService(self.server_config_dir)
self._providers = {} # username → ServiceProvider
self._mtimes = {} # username → last loaded mtime (float)
self._lock = threading.Lock()
# Load shared/global config
self._shared_conf_file = os.path.join(self.server_config_dir, "config.yaml")
if os.path.exists(self._shared_conf_file):
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = os.path.getmtime(self._shared_conf_file)
else:
self._shared_config = None
self._shared_mtime = 0.0
def _refresh_shared(self):
"""Hot-reload shared config if the file changed on disk."""
if not os.path.exists(self._shared_conf_file):
return
current_mtime = os.path.getmtime(self._shared_conf_file)
if current_mtime > self._shared_mtime:
try:
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = current_mtime
# Clear all user providers so they pick up the new shared config
self._providers.clear()
self._mtimes.clear()
except Exception as e:
from connpy import printer
printer.warning(f"Failed to reload shared config: {e}")
def get_provider(self, username) -> ServiceProvider:
"""Get, lazy-load, or hot-reload a user's full ServiceProvider."""
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user's config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get("config_path")
if config_path:
conf_file = os.path.join(config_path, "config.yaml")
else:
conf_file = os.path.join(self.server_config_dir, "users", username, "config.yaml")
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) < current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode="local")
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, "close"):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f"Failed to hot-reload config for user '{username}' (file may be corrupt/incomplete): {e}")
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]
def has_users(self) -> bool:
"""Check if any users are registered (enables auth enforcement)."""
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):
"""Remove and cleanly shut down cached provider (after delete or password change)."""
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, "close"):
try:
provider.close()
except Exception:
pass
+4 -1
View File
@@ -48,7 +48,10 @@ class MCPClientManager:
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "get_effective_setting"):
mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {})
else:
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {}
except Exception: except Exception:
return [] return []
+1 -1
View File
@@ -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
+50 -1
View File
@@ -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 {
@@ -70,7 +69,11 @@ service AIService {
rpc delete_session (StringRequest) returns (google.protobuf.Empty) {} rpc delete_session (StringRequest) returns (google.protobuf.Empty) {}
rpc configure_provider (ProviderRequest) returns (google.protobuf.Empty) {} rpc configure_provider (ProviderRequest) returns (google.protobuf.Empty) {}
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 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 {
@@ -234,6 +237,8 @@ message AskRequest {
bool trust = 10; bool trust = 10;
string confirmation_answer = 11; string confirmation_answer = 11;
bool interrupt = 12; bool interrupt = 12;
google.protobuf.Struct engineer_auth = 13;
google.protobuf.Struct architect_auth = 14;
} }
message AIResponse { message AIResponse {
@@ -254,6 +259,7 @@ message ProviderRequest {
string provider = 1; string provider = 1;
string model = 2; string model = 2;
string api_key = 3; string api_key = 3;
google.protobuf.Struct auth = 4;
} }
message IntRequest { message IntRequest {
@@ -292,3 +298,46 @@ message MCPRequest {
string auto_load_on_os = 4; string auto_load_on_os = 4;
bool remove = 5; bool remove = 5;
} }
service AuthService {
rpc login (LoginRequest) returns (LoginResponse) {}
rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
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 {
string username = 1;
string password = 2;
}
message LoginSSORequest {
string username = 1;
string id_token = 2;
string provider = 3;
}
message LoginResponse {
string token = 1;
string username = 2;
int64 expires_at = 3;
}
message ChangePasswordRequest {
string old_password = 1;
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;
}
+135 -16
View File
@@ -6,6 +6,37 @@ from connpy.utils import log_cleaner
class AIService(BaseService): class AIService(BaseService):
"""Business logic for interacting with AI agents and LLM configurations.""" """Business logic for interacting with AI agents and LLM configurations."""
def _clean_cisco_scrolling(self, text: str) -> str:
"""Resolves horizontal scrolling artifacts (backspaces, \r, ANSI) by merging overlapping segments."""
def merge_overlapping(s1, s2):
s2_clean = s2.lstrip(' $')
max_overlap = min(len(s1), len(s2_clean))
for i in range(max_overlap, 0, -1):
if s1[-i:] == s2_clean[:i]:
return s1 + s2_clean[i:]
return s1 + s2_clean
scroll_re = re.compile(r'(\x08{5,}\s*\$?|\$\r|\x1b\[\d+[GD]\s*\$?)')
parts = scroll_re.split(text)
merged = ""
for part in parts:
if scroll_re.match(part):
continue
cleaned = log_cleaner(part)
if not merged:
merged = cleaned
else:
merged_lines = merged.split('\n')
cleaned_lines = cleaned.split('\n')
merged_lines[-1] = merge_overlapping(merged_lines[-1], cleaned_lines[0])
merged_lines.extend(cleaned_lines[1:])
merged = "\n".join(merged_lines)
return merged
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = "") -> list: def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = "") -> list:
"""Identifies command blocks in the terminal history.""" """Identifies command blocks in the terminal history."""
blocks = [] blocks = []
@@ -27,27 +58,68 @@ class AIService(BaseService):
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
if known_cmd: if known_cmd:
if known_cmd == "CANCELLED":
parsed_positions.append({"pos": pos, "type": "CANCELLED", "preview": ""})
else:
prev_chunk = raw_bytes[prev_pos:pos] prev_chunk = raw_bytes[prev_pos:pos]
prev_cleaned = log_cleaner(prev_chunk.decode(errors='replace')) prev_cleaned = self._clean_cisco_scrolling(prev_chunk.decode(errors='replace'))
prev_lines = [l for l in prev_cleaned.split('\n') if l.strip()] prev_lines = [l for l in prev_cleaned.split('\n') if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else "" prompt_text = prev_lines[-1].strip() if prev_lines else ""
preview = f"{prompt_text}{known_cmd}" if prompt_text else known_cmd preview = f"{prompt_text}{known_cmd}" if prompt_text else known_cmd
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview[:80]})
if len(preview) > 80:
preview = preview[:77] + "..."
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors='replace'))
lines = [l for l in cleaned.split('\n') if l.strip()]
preview = lines[-1].strip() if lines else ""
if preview: cleaned = self._clean_cisco_scrolling(chunk.decode(errors='replace'))
match = prompt_re.search(preview) lines = [l for l in cleaned.split('\n') if l.strip()]
found_in_pass1 = False
if lines:
# Search backwards through the last few lines for the prompt
for idx in range(len(lines) - 1, max(-1, len(lines) - 10), -1):
match = prompt_re.search(lines[idx])
if match: if match:
cmd_text = preview[match.end():].strip() ptxt = match.group(0).strip()
cmd_first_line = lines[idx][match.end():].strip()
cmd_rest = [l.strip() for l in lines[idx+1:]]
cmd_text = " ".join([cmd_first_line] + cmd_rest).strip()
if cmd_text: if cmd_text:
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview[:80]}) pv = f"{ptxt} {cmd_text}".strip()
if len(pv) > 80:
pv = pv[:77] + "..."
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": pv})
else: else:
parsed_positions.append({"pos": pos, "type": "EMPTY_PROMPT", "preview": ""}) parsed_positions.append({"pos": pos, "type": "EMPTY_PROMPT", "preview": ""})
else: found_in_pass1 = True
break
if not found_in_pass1:
# Fallback: The prompt might have been isolated in the previous chunk
# due to asynchronous network delays splitting the output exactly at the newline.
prev_was_valid_cmd = i >= 2 and parsed_positions[i-2]["type"] == "VALID_CMD"
if prev_pos > 0 and not prev_was_valid_cmd:
# Fetch the very last chunk that we just processed
prev_prev_pos = cmd_byte_positions[i-2][0] if i >= 2 else 0
prev_chunk_text = self._clean_cisco_scrolling(raw_bytes[prev_prev_pos:prev_pos].decode(errors='replace'))
prev_lines_text = [l for l in prev_chunk_text.split('\n') if l.strip()]
if prev_lines_text:
prev_match = prompt_re.search(prev_lines_text[-1])
if prev_match:
ptxt = prev_match.group(0).strip()
cmd_text = " ".join([l.strip() for l in lines]).strip()
if cmd_text:
pv = f"{ptxt} {cmd_text}".strip()
if len(pv) > 80:
pv = pv[:77] + "..."
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": pv})
found_in_pass1 = True
if not found_in_pass1:
parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""}) parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""})
else: else:
parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""}) parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""})
@@ -61,11 +133,11 @@ class AIService(BaseService):
start_pos = item["pos"] start_pos = item["pos"]
preview = item["preview"] preview = item["preview"]
# Find the end position: next VALID_CMD or EMPTY_PROMPT # Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)): for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j] next_item = parsed_positions[j]
if next_item["type"] in ("VALID_CMD", "EMPTY_PROMPT"): if next_item["type"] in ("VALID_CMD", "EMPTY_PROMPT", "CANCELLED"):
end_pos = next_item["pos"] end_pos = next_item["pos"]
break break
@@ -167,11 +239,14 @@ class AIService(BaseService):
return await asyncio.wrap_future(future) return await asyncio.wrap_future(future)
def list_sessions(self): def list_sessions(self, limit=None):
"""Return a list of all saved AI sessions.""" """Return a list of saved AI sessions, optionally limited."""
from connpy.ai import ai from connpy.ai import ai
agent = ai(self.config) agent = ai(self.config)
return agent._get_sessions() sessions = agent._get_sessions()
if limit and len(sessions) > limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)
def delete_session(self, session_id): def delete_session(self, session_id):
"""Delete an AI session by ID.""" """Delete an AI session by ID."""
@@ -183,13 +258,15 @@ class AIService(BaseService):
else: else:
raise InvalidConfigurationError(f"Session '{session_id}' not found.") raise InvalidConfigurationError(f"Session '{session_id}' not found.")
def configure_provider(self, provider, model=None, api_key=None): def configure_provider(self, provider, model=None, api_key=None, auth=None):
"""Update AI provider settings in the configuration.""" """Update AI provider settings in the configuration."""
settings = self.config.config.get("ai", {}) settings = self.config.config.get("ai", {})
if model: if model:
settings[f"{provider}_model"] = model settings[f"{provider}_model"] = model
if api_key: if api_key:
settings[f"{provider}_api_key"] = api_key settings[f"{provider}_api_key"] = api_key
if auth is not None:
settings[f"{provider}_auth"] = auth
self.config.config["ai"] = settings self.config.config["ai"] = settings
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
@@ -228,9 +305,51 @@ class AIService(BaseService):
self.config.config["ai"] = ai_settings self.config.config["ai"] = ai_settings
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def list_mcp_servers(self) -> dict:
"""Get the configured MCP servers."""
if hasattr(self.config, "get_effective_setting"):
ai_settings = self.config.get_effective_setting("ai", {})
else:
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
return ai_settings.get("mcp_servers", {})
def load_session_data(self, session_id): def load_session_data(self, session_id):
"""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) 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)
-50
View File
@@ -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}")
+4 -2
View File
@@ -148,7 +148,7 @@ class NodeService(BaseService):
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
"""Explicitly update an existing node.""" """Explicitly update an existing node."""
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -162,9 +162,10 @@ class NodeService(BaseService):
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
if save:
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
"""Logic for deleting a node or folder.""" """Logic for deleting a node or folder."""
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -177,6 +178,7 @@ class NodeService(BaseService):
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.") raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
+120 -37
View File
@@ -7,16 +7,47 @@ from .exceptions import InvalidConfigurationError, NodeNotFoundError
class PluginService(BaseService): class PluginService(BaseService):
"""Business logic for enabling, disabling, and listing plugins.""" """Business logic for enabling, disabling, and listing plugins."""
def _get_plugin_path(self, name, include_disabled=True):
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "user", True
if include_disabled:
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "user", False
# 2. Shared/Global directory
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "shared", True
if include_disabled:
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "shared", False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
p_file = os.path.join(core_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "core", True
return None, None, False
def list_plugins(self): def list_plugins(self):
"""List all core and user-defined plugins with their status and hash.""" """List all core and user-defined plugins with their status and hash."""
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -26,12 +57,35 @@ class PluginService(BaseService):
except Exception: except Exception:
return "" return ""
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(".py"): if f.endswith(".py"):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(user_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"): elif f.endswith(".py.bkp"):
name = f[:-7] name = f[:-7]
@@ -39,6 +93,7 @@ class PluginService(BaseService):
return all_plugin_info return all_plugin_info
def add_plugin(self, name, source_file, update=False): def add_plugin(self, name, source_file, update=False):
"""Add or update a plugin from a local file.""" """Add or update a plugin from a local file."""
import os import os
@@ -119,6 +174,10 @@ class PluginService(BaseService):
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}") raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
if not deleted: if not deleted:
# If not deleted from user directory, check if it's in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in ["shared", "core"]:
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
raise InvalidConfigurationError(f"Plugin '{name}' not found.") raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def enable_plugin(self, name): def enable_plugin(self, name):
@@ -127,51 +186,80 @@ class PluginService(BaseService):
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp" disabled_file = f"{plugin_file}.bkp"
if os.path.exists(plugin_file): if os.path.exists(disabled_file):
return False # Already enabled # Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if not os.path.exists(disabled_file): if os.path.getsize(disabled_file) == 0:
raise InvalidConfigurationError(f"Plugin '{name}' not found.") # Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
else:
try: try:
os.rename(disabled_file, plugin_file) os.rename(disabled_file, plugin_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}") raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
if os.path.exists(plugin_file):
return False # Already enabled
# If it doesn't exist locally, check if it's already an active shared/core plugin
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
return False # Already active/enabled through inheritance
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def disable_plugin(self, name): def disable_plugin(self, name):
"""Deactivate a plugin by renaming it to a backup file.""" """Deactivate a plugin by renaming it to a backup file."""
import os import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp" disabled_file = f"{plugin_file}.bkp"
if os.path.exists(disabled_file): if os.path.exists(plugin_file):
return False # Already disabled # Regular user-level plugin exists. Rename to bkp
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
try: try:
os.rename(plugin_file, disabled_file) os.rename(plugin_file, disabled_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
if os.path.exists(disabled_file):
return False # Already disabled
# Check if it exists in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
# Shadow disable it by creating an empty .py.bkp in user plugins dir
plugin_dir = os.path.dirname(plugin_file)
os.makedirs(plugin_dir, exist_ok=True)
try:
with open(disabled_file, "w") as f:
f.write("")
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
def get_plugin_source(self, name): def get_plugin_source(self, name):
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found") raise InvalidConfigurationError(f"Plugin '{name}' not found")
with open(target, "r") as f: with open(path, "r") as f:
return f.read() return f.read()
def invoke_plugin(self, name, args_dict): def invoke_plugin(self, name, args_dict):
@@ -211,17 +299,12 @@ class PluginService(BaseService):
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found") raise InvalidConfigurationError(f"Plugin '{name}' not found")
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, "Parser") else None parser = module.Parser().parser if hasattr(module, "Parser") else None
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]): if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
+27 -1
View File
@@ -33,6 +33,7 @@ class ServiceProvider:
from .import_export_service import ImportExportService from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config) self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config) self.profiles = ProfileService(self.config)
@@ -44,6 +45,7 @@ class ServiceProvider:
self.import_export = ImportExportService(self.config) self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config) self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self): def _init_remote(self):
# Allow ConfigService to work locally so the user can revert the mode # Allow ConfigService to work locally so the user can revert the mode
@@ -53,14 +55,37 @@ class ServiceProvider:
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config) self.sync = SyncService(self.config)
self.users = None
if not self.remote_host: if not self.remote_host:
raise InvalidConfigurationError("Remote host must be specified in remote mode") raise InvalidConfigurationError("Remote host must be specified in remote mode")
import grpc import grpc
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub import os
from ..grpc_layer.stubs import (
NodeStub, ProfileStub, PluginStub, AIStub,
ExecutionStub, ImportExportStub, SystemStub,
ConfigStub, AuthClientInterceptor, AuthStub
)
def get_token():
token_path = os.path.join(self.config.defaultdir, ".token")
if os.path.exists(token_path):
try:
with open(token_path, "r") as f:
return f.read().strip()
except Exception:
pass
return None
channel = grpc.insecure_channel(self.remote_host) channel = grpc.insecure_channel(self.remote_host)
interceptor = AuthClientInterceptor(get_token)
channel = grpc.intercept_channel(channel, interceptor)
# Surgical fix: Keep ConfigService local for mode/theme management,
# but delegate encryption to the server stub.
config_remote = ConfigStub(channel, remote_host=self.remote_host)
self.config_svc.encrypt_password = config_remote.encrypt_password
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config) self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes) self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
@@ -69,3 +94,4 @@ class ServiceProvider:
self.system = SystemStub(channel, remote_host=self.remote_host) self.system = SystemStub(channel, remote_host=self.remote_host)
self.execution = ExecutionStub(channel, remote_host=self.remote_host) self.execution = ExecutionStub(channel, remote_host=self.remote_host)
self.import_export = ImportExportStub(channel, remote_host=self.remote_host) self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
self.auth = AuthStub(channel, remote_host=self.remote_host)
+239
View File
@@ -0,0 +1,239 @@
import os
import re
import shutil
import secrets
import datetime
import bcrypt
import jwt
import yaml
from pathlib import Path
from connpy.configfile import configfile
class UserService:
def __init__(self, config_dir):
self.config_dir = os.path.abspath(config_dir)
self.users_dir = os.path.join(self.config_dir, "users")
self.registry_file = os.path.join(self.users_dir, "registry.yaml")
# Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True)
def _load_registry(self) -> dict:
"""Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
if not os.path.exists(self.registry_file):
registry = {
"jwt_secret": secrets.token_hex(32),
"users": {}
}
self._save_registry(registry)
return registry
try:
with open(self.registry_file, "r") as f:
registry = yaml.safe_load(f) or {}
except Exception:
registry = {}
if not isinstance(registry, dict):
registry = {}
if "jwt_secret" not in registry:
registry["jwt_secret"] = secrets.token_hex(32)
if "users" not in registry or not isinstance(registry["users"], dict):
registry["users"] = {}
return registry
def _save_registry(self, data: dict):
"""Safely saves registry structure to registry.yaml."""
tmp_file = self.registry_file + ".tmp"
try:
with open(tmp_file, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp_file, self.registry_file)
os.chmod(self.registry_file, 0o600)
except Exception as e:
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except OSError:
pass
raise e
def create_user(self, username, password, config_path=None) -> dict:
"""Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -> Generates config.yaml and .osk key.
Mode B: config_path set -> Reuses existing directory after validating its structure.
"""
if not username or not isinstance(username, str):
raise ValueError("Username cannot be empty")
if not re.match(r"^[a-zA-Z0-9_-]+$", username):
raise ValueError("Username must contain only alphanumeric characters, dashes, or underscores")
if not password or not isinstance(password, str):
raise ValueError("Password cannot be empty")
registry = self._load_registry()
if username in registry["users"]:
raise ValueError(f"User '{username}' already exists")
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, "plugins"), exist_ok=True)
os.makedirs(os.path.join(user_dir, "ai_sessions"), exist_ok=True)
# Create default config.yaml & .osk key via configfile
conf_file = os.path.join(user_dir, "config.yaml")
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, "plugins"), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, "ai_sessions"), exist_ok=True)
# Create default config.yaml & .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, "config.yaml")
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
user_entry = {
"password_hash": password_hash,
"config_path": stored_config_path,
"created": datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry["users"][username] = user_entry
self._save_registry(registry)
return {
"username": username,
"config_path": stored_config_path,
"created": user_entry["created"]
}
def delete_user(self, username):
"""Removes user from the registry and cleans up config directory if server-managed."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
config_path = user_data.get("config_path")
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry["users"][username]
self._save_registry(registry)
def list_users(self) -> list[dict]:
"""Lists all registered users with metadata."""
registry = self._load_registry()
return [
{
"username": name,
"config_path": data.get("config_path"),
"created": data.get("created")
}
for name, data in registry.get("users", {}).items()
]
def get_user(self, username) -> dict:
"""Retrieves raw metadata for a specific user."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
data = registry["users"][username]
return {
"username": username,
"config_path": data.get("config_path"),
"created": data.get("created"),
"password_hash": data.get("password_hash")
}
def change_password(self, username, old_password, new_password):
"""Verifies old password and updates registry with new hashed password."""
if not new_password or not isinstance(new_password, str):
raise ValueError("New password cannot be empty")
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
if not bcrypt.checkpw(old_password.encode("utf-8"), user_data["password_hash"].encode("utf-8")):
raise ValueError("Invalid credentials")
# Update hash
user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
self._save_registry(registry)
def admin_change_password(self, username, new_password):
"""Administrative password override (does not require old password)."""
if not new_password or not isinstance(new_password, str):
raise ValueError("New password cannot be empty")
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
self._save_registry(registry)
def authenticate(self, username, password) -> bool:
"""Verifies if the credentials are valid using bcrypt."""
registry = self._load_registry()
if username not in registry["users"]:
return False
user_data = registry["users"][username]
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
def generate_jwt(self, username) -> str:
"""Generates a secure JSON Web Token for the user expiring in 12 hours."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
"sub": username,
"exp": expiration
}
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
token = jwt.encode(payload, secret, algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
return token
def verify_jwt(self, token) -> str | None:
"""Decodes JWT and returns username if token is valid and unexpired."""
registry = self._load_registry()
try:
secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
payload = jwt.decode(token, secret, algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None
+85 -3
View File
@@ -23,7 +23,7 @@ class TestAIInit:
myai = ai(config) myai = ai(config)
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
myai.ask("hello") myai.ask("hello")
assert "Engineer API key not configured" in str(exc.value) assert "Engineer API key or authentication not configured" in str(exc.value)
def test_init_missing_architect_key_warns(self, ai_config, capsys, mock_litellm): def test_init_missing_architect_key_warns(self, ai_config, capsys, mock_litellm):
"""Warns if architect key is missing but doesn't crash.""" """Warns if architect key is missing but doesn't crash."""
@@ -58,6 +58,77 @@ class TestAIInit:
pass # May fail on other file opens, that's ok pass # May fail on other file opens, that's ok
# =========================================================================
# AI Auth Dict tests
# =========================================================================
class TestAIAuthDict:
def test_init_with_auth_dict(self, ai_config):
"""Initializes correctly when auth dicts are configured."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = None
ai_config.config["ai"]["architect_api_key"] = None
ai_config.config["ai"]["engineer_auth"] = {"my_key": "my_val"}
ai_config.config["ai"]["architect_auth"] = {"another_key": "another_val"}
myai = ai(ai_config)
assert myai.engineer_auth == {"my_key": "my_val"}
assert myai.architect_auth == {"another_key": "another_val"}
def test_compat_key_injection(self, ai_config):
"""Injects API key into auth dict if auth is empty or doesn't have it."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = "compat-eng-key"
ai_config.config["ai"]["architect_api_key"] = "compat-arch-key"
ai_config.config["ai"]["engineer_auth"] = {}
ai_config.config["ai"]["architect_auth"] = {}
myai = ai(ai_config)
assert myai.engineer_auth == {"api_key": "compat-eng-key"}
assert myai.architect_auth == {"api_key": "compat-arch-key"}
def test_has_architect_keyless(self, ai_config):
"""Evaluates has_architect correctly for keyless models and auth configs."""
from connpy.ai import ai
# 1. Keyless model (Vertex)
ai_config.config["ai"]["architect_api_key"] = None
ai_config.config["ai"]["architect_auth"] = {}
ai_config.config["ai"]["architect_model"] = "vertex/gemini-pro"
myai = ai(ai_config)
assert myai.has_architect is True
# 2. Architect auth dict is set
ai_config.config["ai"]["architect_model"] = "custom-model"
ai_config.config["ai"]["architect_auth"] = {"vertex_project": "proj-1"}
myai = ai(ai_config)
assert myai.has_architect is True
def test_ask_unpacks_auth_dict(self, ai_config, mock_litellm):
"""Verifies that ask unpacks engineer_auth when calling completion."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = None
ai_config.config["ai"]["engineer_auth"] = {"vertex_project": "my-project", "vertex_location": "us-east1"}
myai = ai(ai_config)
myai.ask("test query", stream=False)
# Check mock_litellm completion call
mock_litellm["completion"].assert_called()
kwargs = mock_litellm["completion"].call_args.kwargs
assert kwargs.get("vertex_project") == "my-project"
assert kwargs.get("vertex_location") == "us-east1"
assert "api_key" not in kwargs
def test_auth_precedence_no_api_key_injection(self, ai_config):
"""Verifies that api_key is not injected into the auth dict when auth is already set (non-empty)."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = "legacy-eng-key"
ai_config.config["ai"]["architect_api_key"] = "legacy-arch-key"
ai_config.config["ai"]["engineer_auth"] = {"vertex_project": "proj-eng"}
ai_config.config["ai"]["architect_auth"] = {"vertex_project": "proj-arch"}
myai = ai(ai_config)
assert myai.engineer_auth == {"vertex_project": "proj-eng"}
assert "api_key" not in myai.engineer_auth
assert myai.architect_auth == {"vertex_project": "proj-arch"}
assert "api_key" not in myai.architect_auth
# ========================================================================= # =========================================================================
# register_ai_tool tests # register_ai_tool tests
# ========================================================================= # =========================================================================
@@ -409,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
@@ -427,12 +507,14 @@ class TestAISessions:
def test_generate_session_id(self, myai): def test_generate_session_id(self, myai):
session_id = myai._generate_session_id("Any query") session_id = myai._generate_session_id("Any query")
# Format: YYYYMMDD-HHMMSS # Format: YYYYMMDD-HHMMSS-suffix
assert len(session_id) == 15 assert len(session_id) == 20
assert "-" in session_id assert "-" in session_id
parts = session_id.split("-") parts = session_id.split("-")
assert len(parts) == 3
assert len(parts[0]) == 8 # YYYYMMDD assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS assert len(parts[1]) == 6 # HHMMSS
assert len(parts[2]) == 4 # suffix
def test_save_and_load_session(self, myai): def test_save_and_load_session(self, myai):
history = [ history = [
+242
View File
@@ -158,3 +158,245 @@ def test_ingress_task_interception():
assert called_copilot assert called_copilot
asyncio.run(run_test()) asyncio.run(run_test())
def test_build_context_blocks_horizontal_scrolling():
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "RP/0/RP0/CPU0:xrd#"}
part1 = 'RP/0/RP0/CPU0:xrd#s show interfaces * | inc "rate|is up|escr|test1|test2|test3|test4|test5|teest8|test7|t$'
part2 = '|escr|test1|test2|test3|test4|test5|teest8|test7|te s998"show interfaces * | inc "rate|is up|escr|test1|test2|test3|test4|test5|teest8|test7|$'
# Test with \r (classic IOS)
raw_bytes = (part1 + '\r' + part2).encode()
cmd_byte_positions = [(0, None), (len(raw_bytes), None)]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
assert len(blocks) >= 1
start, end, preview = blocks[0]
assert "RP/0/RP0/CPU0:xrd# s show interfaces * | inc" in preview
def test_build_context_blocks_horizontal_scrolling_ansi():
"""Test with CSI cursor repositioning (\\x1B[1G) instead of raw \\r, as used by Cisco IOS XR."""
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "RP/0/RP0/CPU0:xrd#"}
part1 = 'RP/0/RP0/CPU0:xrd#s show interfaces * | inc "rate|is up|escr|test1|test2|test3|test4|test5|teest8|test7|t'
part2 = '$|escr|test1|test2|test3|test4|test5|teest8|test7|te s998"show interfaces * | inc "rate|is up|escr|test1|test2|test3|test4|test5|teest8|test7|$'
# Test with \x1B[1G (CSI Cursor Horizontal Absolute - IOS XR)
raw_bytes = (part1 + '\x1b[1G' + part2).encode()
cmd_byte_positions = [(0, None), (len(raw_bytes), None)]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
assert len(blocks) >= 1
start, end, preview = blocks[0]
assert "RP/0/RP0/CPU0:xrd# s show interfaces * | inc" in preview
def test_build_context_blocks_cancelled_command():
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "router#"}
# Command 1: cancelled with Ctrl+C. Command 2: executed successfully.
raw_bytes = b"router# show plat\x03\r\nrouter# show ver\r\nrouter# "
# 0: initial boundary
# 18: Ctrl+C pressed (ends Command 1, marked CANCELLED)
# 36: Enter pressed (ends Command 2)
cmd_byte_positions = [(0, None), (18, "CANCELLED"), (36, None)]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# The cancelled command block (0 to 18) should NOT be registered as a VALID_CMD block.
# The block for "show ver" should be registered (starting at 36, ending at current_prompt_pos).
# Plus, the final block for "CURRENT CONTEXT".
valid_blocks = [b for b in blocks if "CURRENT CONTEXT" not in b[2]]
assert len(valid_blocks) == 1
assert "show ver" in valid_blocks[0][2]
assert "show plat" not in valid_blocks[0][2]
def test_copilot_range_mode_filtering():
from connpy.cli.terminal_ui import CopilotInterface
# We setup dummy raw_bytes with scrolling garbage in the middle:
# 0 to 10: "show ip" (VALID_CMD)
# 10 to 25: "some scrolling garbage we want to skip"
# 25 to 35: "show run" (VALID_CMD)
# 35 to 45: "current prompt" (final context block)
raw_bytes = b"show ip garbage_to_skip_here show run router#"
blocks = [
(0, 10, "router# show ip"),
(25, 35, "router# show run"),
(35, 45, "router#")
]
# Mock Config
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
interface = CopilotInterface(MockConfig())
# Ensure default is RANGE mode
interface.mode_range = 0
interface.mode_single = 1
interface.mode_lines = 2
captured_buffer = None
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
nonlocal captured_buffer
captured_buffer = active_buffer
return {"guide": "Ok", "commands": [], "risk_level": "low"}
# Mock PromptSession.prompt_async to ask a question once then exit
prompt_calls = 0
async def mock_prompt_async(self, *args, **kwargs):
nonlocal prompt_calls
prompt_calls += 1
if prompt_calls == 1:
# Simulate pressing Ctrl+Up key twice to expand context range from 1 to 3 commands
kb = kwargs.get('key_bindings')
if kb:
class DummyApp:
def invalidate(self): pass
class DummyEvent:
app = DummyApp()
# Find and invoke the 'c-up' handler twice
for b in kb.bindings:
if any('up' in str(k).lower() for k in b.keys):
b.handler(DummyEvent())
b.handler(DummyEvent())
return "how are interfaces looking?"
else:
raise KeyboardInterrupt
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
async def run():
# Run session
return await interface.run_session(
raw_bytes=raw_bytes,
node_info={"name": "test"},
on_ai_call=mock_ai_call,
blocks=blocks
)
asyncio.run(run())
# In range mode: it should have concatenated the valid blocks
# block[0] is raw_bytes[0:10] => b"show ip "
# block[1] is raw_bytes[25:35] => b" show run"
# block[2] is raw_bytes[35:45] => b" router#"
# Note: raw_bytes[10:25] (garbage) must be excluded!
assert captured_buffer is not None
assert "garbage_to_skip_here" not in captured_buffer
assert "show ip" in captured_buffer
assert "show run" in captured_buffer
def test_build_context_blocks_pager_scrolling_enter():
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "sixwind>"}
raw_bytes = (
b"sixwind> show configuration | less\r\n"
b"line 1 of output\nline 2 of output\n\r"
b"line 3 of output\nline 4 of output\n\r"
b"line 5 of output\n(END)\x1b[?1049l\x1b[?47l\r\nsixwind> \r\n"
b"sixwind> \r\n"
b"sixwind> \r\n"
b"sixwind> "
)
cmd_byte_positions = [
(0, None),
(36, None),
(70, None),
(105, None),
(153, None),
(164, None),
(175, None),
(186, None)
]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
valid_blocks = [b for b in blocks if "CURRENT CONTEXT" not in b[2]]
assert len(valid_blocks) == 1
assert "show configuration" in valid_blocks[0][2]
assert valid_blocks[0][0] == 36
assert valid_blocks[0][1] == 153
def test_build_context_blocks_pager_scrolling_space():
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "sixwind>"}
raw_bytes = (
b"sixwind> show configuration | less\r\n"
b"line 1 of output\nline 2 of output\n "
b"line 3 of output\nline 4 of output\n "
b"line 5 of output\n(END)\x1b[?1049l\x1b[?47l\r\n"
b"sixwind> \r\n"
b"sixwind> \r\n"
b"sixwind> \r\n"
b"sixwind> "
)
cmd_byte_positions = [
(0, None),
(36, None),
(144, None),
(155, None),
(166, None),
(177, None)
]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
valid_blocks = [b for b in blocks if "CURRENT CONTEXT" not in b[2]]
assert len(valid_blocks) == 1
assert "show configuration" in valid_blocks[0][2]
assert valid_blocks[0][0] == 36
assert valid_blocks[0][1] == 155
def test_build_context_blocks_pager_scrolling_6wind_escapes():
from connpy.services.ai_service import AIService
svc = AIService(None)
node_info = {"prompt": "6WIND-PE1>", "os": "6wind"}
raw_bytes = (
b"6WIND-PE1> show config running fullpath nodefault\r\n"
b"line 1\r\n"
b"line 2\r\n"
b":\x1b[K\r\x1b[K/ vrf main interface gre gre2 mtu 8400\r\n"
b":\x1b[K\x07\r\x1b[K\x1b[?1l\x1b>6WIND-PE1> \r\n"
b"6WIND-PE1> \r\n"
b"6WIND-PE1> "
)
cmd_byte_positions = [
(0, None),
(52, None),
(136, None),
(177, None),
(177, None),
(190, None),
(203, None)
]
blocks = svc.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
valid_blocks = [b for b in blocks if "CURRENT CONTEXT" not in b[2]]
assert len(valid_blocks) == 1
assert "show config running" in valid_blocks[0][2]
+239
View File
@@ -0,0 +1,239 @@
import os
import pytest
import grpc
import argparse
from unittest.mock import MagicMock, patch
from connpy.connapp import connapp
from connpy.services.provider import ServiceProvider
from connpy.cli.user_handler import UserHandler
from connpy.cli.login_handler import LoginHandler
from connpy.grpc_layer.stubs import AuthClientInterceptor, AuthStub
@pytest.fixture
def mock_config():
config = MagicMock()
config.config = {"service_mode": "local", "remote_host": "localhost:8048"}
config.defaultdir = "/mock/default/dir"
return config
@pytest.fixture
def app_instance(mock_config):
with patch("connpy.services.provider.ServiceProvider") as mock_provider_cls:
mock_provider = MagicMock()
mock_provider.context = MagicMock()
mock_provider.nodes = MagicMock()
mock_provider.profiles = MagicMock()
mock_provider.config_svc = MagicMock()
mock_provider.plugins = MagicMock()
mock_provider.sync = MagicMock()
mock_provider.mode = "local"
mock_provider.remote_host = "localhost:8048"
mock_provider_cls.return_value = mock_provider
app = connapp(mock_config)
# Mock UserService on app services
app.services.users = MagicMock()
return app
class TestCLIMultiUserParsing:
def test_parser_contains_user_login_logout(self, app_instance):
parser, _ = app_instance.get_parser()
# Verify subcommands exist by finding the _SubParsersAction
subparsers_action = None
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
subparsers_action = action
break
assert subparsers_action is not None
subcommands = subparsers_action.choices.keys()
assert "user" in subcommands
assert "login" in subcommands
assert "logout" in subcommands
def test_user_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
# Parse add user
args = parser.parse_args(["user", "--add", "newguy"])
assert args.add == ["newguy"]
assert args.func == app_instance._user.dispatch
# Parse delete user
args = parser.parse_args(["user", "--del", "oldguy"])
assert args.delete == ["oldguy"]
# Parse list users
args = parser.parse_args(["user", "--list"])
assert args.list is True
# Parse show user
args = parser.parse_args(["user", "--show", "someguy"])
assert args.show == ["someguy"]
# Parse regen-password
args = parser.parse_args(["user", "--regen-password", "someguy"])
assert args.regen_password == ["someguy"]
# Parse path
args = parser.parse_args(["user", "--add", "newguy", "--path", "/some/path"])
assert args.add == ["newguy"]
assert args.path == ["/some/path"]
def test_login_logout_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
args = parser.parse_args(["login", "someuser"])
assert args.username == "someuser"
assert args.status is False
assert args.func == app_instance._login.dispatch
args = parser.parse_args(["login", "--status"])
assert args.status is True
args = parser.parse_args(["login", "-s"])
assert args.status is True
args = parser.parse_args(["logout"])
assert args.func == app_instance._login.dispatch
class TestUserHandlerDispatch:
def test_user_handler_fails_in_remote_mode(self, app_instance):
app_instance.services.mode = "remote"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["testuser"]
with pytest.raises(SystemExit) as excinfo:
handler.dispatch(args)
assert excinfo.value.code == 1
def test_user_handler_routes_add_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["newuser"]
args.delete = None
args.list = False
args.show = None
args.regen_password = None
with patch.object(handler, "add_user") as mock_add:
handler.dispatch(args)
assert args.action == "add"
assert args.username == "newuser"
mock_add.assert_called_once_with(args)
def test_user_handler_routes_list_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = None
args.delete = None
args.list = True
args.show = None
args.regen_password = None
with patch.object(handler, "list_users") as mock_list:
handler.dispatch(args)
assert args.action == "list"
mock_list.assert_called_once_with(args)
class TestAuthClientInterceptor:
def test_auth_client_interceptor_adds_bearer_token(self):
# Mock token provider
token_provider = MagicMock(return_value="my-super-secret-token")
interceptor = AuthClientInterceptor(token_provider)
# Mock ClientCallDetails using namedtuple
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata was injected
metadata_dict = dict(intercepted_details.metadata)
assert "authorization" in metadata_dict
assert metadata_dict["authorization"] == "Bearer my-super-secret-token"
def test_auth_client_interceptor_no_token(self):
token_provider = MagicMock(return_value=None)
interceptor = AuthClientInterceptor(token_provider)
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata remains empty
assert len(intercepted_details.metadata) == 0
class TestLoginHandlerStatus:
def test_status_no_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=False):
with patch("connpy.printer.warning") as mock_warning:
handler.show_status()
mock_warning.assert_called_once_with("No active session found. You can log in using 'connpy login'.")
def test_status_invalid_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data="invalid-token")):
with patch("connpy.printer.error") as mock_error:
handler.show_status()
mock_error.assert_called_once_with("Invalid local session token format.")
def test_status_valid_token(self, app_instance):
handler = LoginHandler(app_instance)
# Mock token payload: {"sub": "testuser", "exp": 1780007003}
# Part 1 (header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# Part 2 (payload): eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30
# Part 3 (sig): signature
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30.signature"
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data=token)):
with patch("connpy.printer.success") as mock_success:
with patch("connpy.printer.info") as mock_info:
# Patch time so exp is in the future
with patch("datetime.datetime") as mock_dt:
mock_dt.now.return_value.timestamp.return_value = 1780000000
# Mock fromtimestamp for expiration display
mock_dt.fromtimestamp.return_value.strftime.return_value = "2026-05-28 19:23:23 UTC"
handler.show_status()
mock_success.assert_called_once_with("Logged in as 'testuser'")
def mock_open(*args, **kwargs):
from unittest.mock import mock_open as unittest_mock_open
return unittest_mock_open(*args, **kwargs)
+136
View File
@@ -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)]
+67
View File
@@ -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
+177
View File
@@ -65,4 +65,181 @@ class TestGetCwd:
assert len(dirs_in_result) > 0 assert len(dirs_in_result) > 0
# =========================================================================
# Tree completions tests
# =========================================================================
class TestTreeCompletions:
def test_config_auth_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test config completions
config_completions = resolve_completion(["config", ""], tree)
assert "--engineer-auth" in config_completions
assert "--architect-auth" in config_completions
# Resolve when --engineer-auth is chosen in config
auth_comp = resolve_completion(["config", "--engineer-auth", ""], tree)
assert isinstance(auth_comp, list)
# Loop back check:
# e.g., connpy config --engineer-auth some_val
# should loop back and resolve to config options
loop_back_comp = resolve_completion(["config", "--engineer-auth", "some_val", ""], tree)
assert "--architect-auth" in loop_back_comp
assert "--engineer-auth" in loop_back_comp
def test_ai_auth_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test ai completions
ai_completions = resolve_completion(["ai", ""], tree)
assert "--engineer-auth" in ai_completions
assert "--architect-auth" in ai_completions
# Resolve after choosing option
auth_comp = resolve_completion(["ai", "--engineer-auth", ""], tree)
assert isinstance(auth_comp, list)
# Loop back check:
# e.g., connpy ai --engineer-auth some_val
# should loop back and resolve to ai options, excluding --engineer-auth
loop_back_comp = resolve_completion(["ai", "--engineer-auth", "some_val", ""], tree)
assert "--architect-auth" in loop_back_comp
assert "--engineer-auth" not in loop_back_comp
def test_sixwindmcp_plugin_completions(self):
from connpy.completion import resolve_completion, get_cwd
import importlib.util
# Load the testremote/remote_plugins/sixwindmcp.py plugin
plugin_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"testremote", "remote_plugins", "sixwindmcp.py"
)
spec = importlib.util.spec_from_file_location("sixwindmcp", plugin_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.get_cwd = get_cwd
plugin_node = module._connpy_tree()
assert "--set-path" in plugin_node
assert "--path" in plugin_node
assert "start" in plugin_node
tree = {"sixwindmcp": plugin_node}
# Test resolution when --set-path is chosen
res = resolve_completion(["sixwindmcp", "--set-path", ""], tree)
assert isinstance(res, list)
# Loop back check:
# e.g., connpy sixwindmcp --set-path /tmp start
# should loop back and resolve to plugin options
loop_back_comp = resolve_completion(["sixwindmcp", "--set-path", "/tmp", ""], tree)
assert "start" in loop_back_comp
assert "stop" in loop_back_comp
class TestUserCompletions:
def test_user_command_options(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test options at the "user" level
user_completions = resolve_completion(["user", ""], tree)
assert "--add" in user_completions
assert "--del" in user_completions
assert "--rm" in user_completions
assert "--show" in user_completions
assert "--regen-password" in user_completions
assert "--list" in user_completions
assert "--ls" in user_completions
def test_user_action_completed_users(self, tmp_path):
from connpy.completion import _build_tree, resolve_completion
import yaml
# Create users directory and mock registry
users_dir = tmp_path / "users"
users_dir.mkdir()
registry_file = users_dir / "registry.yaml"
registry_data = {
"users": {
"fluzzi": {"password_hash": "hash1"},
"john": {"password_hash": "hash2"}
}
}
with open(registry_file, "w") as f:
yaml.dump(registry_data, f)
tree = _build_tree([], [], [], {}, str(tmp_path))
# Resolve after --del, --rm, --show, --regen-password
for action in ["--del", "--rm", "--show", "--regen-password"]:
completions = resolve_completion(["user", action, ""], tree)
assert "fluzzi" in completions
assert "john" in completions
# --add username completed options
add_completions = resolve_completion(["user", "--add", "newguy", ""], tree)
assert "--path" in add_completions
def test_login_logout_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test login option resolution
login_completions = resolve_completion(["login", ""], tree)
assert "--help" in login_completions
# Test logout option resolution
logout_completions = resolve_completion(["logout", ""], tree)
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
+66 -4
View File
@@ -40,7 +40,7 @@ def test_node_del(mock_prompt, mock_delete_node, mock_list_nodes, app):
mock_list_nodes.return_value = ["router1"] mock_list_nodes.return_value = ["router1"]
mock_prompt.return_value = {"delete": True} mock_prompt.return_value = {"delete": True}
app.start(["node", "-r", "router1"]) app.start(["node", "-r", "router1"])
mock_delete_node.assert_called_once_with("router1", is_folder=False) mock_delete_node.assert_called_once_with("router1", is_folder=False, save=True)
@patch("connpy.services.node_service.NodeService.list_nodes") @patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.get_node_details") @patch("connpy.services.node_service.NodeService.get_node_details")
@@ -165,9 +165,9 @@ def test_ai(mock_status, mock_ask, app):
@patch("connpy.services.execution_service.ExecutionService.run_commands") @patch("connpy.services.execution_service.ExecutionService.run_commands")
def test_run(mock_run_commands, app): def test_run(mock_run_commands, app):
app.start(["run", "node1", "command1", "command2"]) app.start(["run", "router1", "command1", "command2"])
mock_run_commands.assert_called_once() mock_run_commands.assert_called_once()
assert mock_run_commands.call_args[1]["nodes_filter"] == "node1" assert mock_run_commands.call_args[1]["nodes_filter"] == ["router1"]
assert mock_run_commands.call_args[1]["commands"] == ["command1 command2"] assert mock_run_commands.call_args[1]["commands"] == ["command1 command2"]
@patch("os.path.exists") @patch("os.path.exists")
@@ -246,7 +246,7 @@ def test_plugin_disable(mock_disable, app):
@patch("connpy.services.ai_service.AIService.list_sessions") @patch("connpy.services.ai_service.AIService.list_sessions")
def test_ai_list(mock_list_sessions, app): def test_ai_list(mock_list_sessions, app):
mock_list_sessions.return_value = [{"id": "1", "title": "t", "created_at": "now", "model": "m"}] mock_list_sessions.return_value = ([{"id": "1", "title": "t", "created_at": "now", "model": "m"}], 1)
app.start(["ai", "--list"]) app.start(["ai", "--list"])
mock_list_sessions.assert_called_once() mock_list_sessions.assert_called_once()
@@ -262,3 +262,65 @@ def test_type_node_reserved_word(app):
with pytest.raises(SystemExit) as exc: with pytest.raises(SystemExit) as exc:
app._type_node("bulk") app._type_node("bulk")
assert exc.value.code == 2 assert exc.value.code == 2
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_inline_json(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {}}
app.start(["config", "--engineer-auth", '{"vertex_project": "test-123"}'])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["engineer_auth"] == {"vertex_project": "test-123"}
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_inline_yaml(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {}}
app.start(["config", "--architect-auth", 'project: test-yaml'])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["architect_auth"] == {"project": "test-yaml"}
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_clear_auth(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {"engineer_auth": {"project": "123"}, "engineer_api_key": "some-key"}}
app.start(["config", "--engineer-auth", "clear"])
args, kwargs = mock_update_setting.call_args
assert "engineer_auth" not in args[1]
app.start(["config", "--engineer-api-key", "none"])
args, kwargs = mock_update_setting.call_args
assert "engineer_api_key" not in args[1]
@patch("os.path.exists")
@patch("builtins.open")
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_file_path(mock_get_settings, mock_update_setting, mock_open, mock_exists, app):
mock_get_settings.return_value = {"ai": {}}
mock_exists.side_effect = lambda p: True if p == "/path/to/creds.json" else False
mock_file = MagicMock()
mock_file.read.return_value = '{"vertex_project": "file-project"}'
mock_open.return_value.__enter__.return_value = mock_file
app.start(["config", "--engineer-auth", "/path/to/creds.json"])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["engineer_auth"] == {"vertex_project": "file-project"}
@patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.connect_node")
def test_node_connect_exact_match_priority(mock_connect_node, mock_list_nodes, app):
"""Test that exact matches are prioritized over partial/regex matches when connecting."""
mock_list_nodes.return_value = ["pe1@ctx", "qro1pe1@ctx"]
app.start(["node", "pe1@ctx"])
mock_connect_node.assert_called_once_with("pe1@ctx", sftp=False, debug=False, logger=app._service_logger)
+52
View File
@@ -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
+136
View File
@@ -0,0 +1,136 @@
"""
Tests for gRPC auth serialization/deserialization (engineer_auth, architect_auth, provider auth).
These tests verify that:
1. to_struct/from_struct round-trips correctly for auth dicts.
2. AIStub.ask() correctly serializes engineer_auth and architect_auth into AskRequest.
3. AIServicer.ask() correctly deserializes them and passes them to the service.
4. AIStub.configure_provider() serializes auth into ProviderRequest.
5. AIServicer.configure_provider() deserializes auth and forwards it to the service.
"""
import pytest
from unittest.mock import MagicMock, patch, call
from connpy.grpc_layer import connpy_pb2
from connpy.grpc_layer.utils import to_struct, from_struct
# --- Unit: Struct round-trip ---
class TestStructRoundTrip:
def test_simple_dict(self):
d = {"api_key": "secret", "region": "us-east-1"}
assert from_struct(to_struct(d)) == d
def test_nested_dict(self):
d = {"vertex_project": "my-project", "vertex_location": "us-central1", "nested": {"key": "val"}}
assert from_struct(to_struct(d)) == d
def test_empty_dict(self):
assert from_struct(to_struct({})) == {}
def test_none_returns_empty(self):
assert from_struct(to_struct(None)) == {}
# --- Unit: AskRequest Struct fields ---
class TestAskRequestStructFields:
def test_engineer_auth_round_trip(self):
auth = {"vertex_project": "proj", "vertex_location": "us-central1"}
req = connpy_pb2.AskRequest(input_text="hi")
req.engineer_auth.CopyFrom(to_struct(auth))
assert from_struct(req.engineer_auth) == auth
def test_architect_auth_round_trip(self):
auth = {"api_key": "sk-abc", "base_url": "https://custom.api/v1"}
req = connpy_pb2.AskRequest(input_text="hi")
req.architect_auth.CopyFrom(to_struct(auth))
assert from_struct(req.architect_auth) == auth
def test_has_field_false_when_unset(self):
req = connpy_pb2.AskRequest(input_text="hi")
assert not req.HasField("engineer_auth")
assert not req.HasField("architect_auth")
def test_has_field_true_when_set(self):
req = connpy_pb2.AskRequest(input_text="hi")
req.engineer_auth.CopyFrom(to_struct({"k": "v"}))
assert req.HasField("engineer_auth")
# --- Unit: ProviderRequest Struct field ---
class TestProviderRequestStructField:
def test_auth_round_trip(self):
auth = {"vertex_project": "proj", "vertex_location": "eu-west1"}
req = connpy_pb2.ProviderRequest(provider="vertex", model="gemini-pro")
req.auth.CopyFrom(to_struct(auth))
assert from_struct(req.auth) == auth
def test_has_field_false_when_unset(self):
req = connpy_pb2.ProviderRequest(provider="openai", model="gpt-4o")
assert not req.HasField("auth")
def test_has_field_true_when_set(self):
req = connpy_pb2.ProviderRequest(provider="vertex")
req.auth.CopyFrom(to_struct({"vertex_project": "p"}))
assert req.HasField("auth")
# --- Integration: Server deserializes auth and passes to service ---
class TestAIServicerAuthDeserialization:
@pytest.fixture
def servicer(self, populated_config):
from connpy.grpc_layer.server import AIServicer
return AIServicer(populated_config)
def test_configure_provider_passes_auth_to_service(self, servicer):
auth = {"vertex_project": "my-proj", "vertex_location": "us-central1"}
req = connpy_pb2.ProviderRequest(provider="vertex", model="gemini/gemini-pro", api_key="")
req.auth.CopyFrom(to_struct(auth))
with patch.object(servicer.service, "configure_provider") as mock_cp:
mock_context = MagicMock()
servicer.configure_provider(req, mock_context)
mock_cp.assert_called_once_with("vertex", "gemini/gemini-pro", "", auth=auth)
def test_configure_provider_no_auth(self, servicer):
req = connpy_pb2.ProviderRequest(provider="openai", model="gpt-4o", api_key="sk-test")
with patch.object(servicer.service, "configure_provider") as mock_cp:
mock_context = MagicMock()
servicer.configure_provider(req, mock_context)
mock_cp.assert_called_once_with("openai", "gpt-4o", "sk-test", auth=None)
# --- Integration: Stub serializes auth into request ---
class TestAIStubAuthSerialization:
@pytest.fixture
def ai_stub(self):
from connpy.grpc_layer.stubs import AIStub
mock_channel = MagicMock()
stub = AIStub(mock_channel, "localhost:8048")
return stub
def test_configure_provider_with_auth_serializes_struct(self, ai_stub):
auth = {"vertex_project": "proj", "vertex_location": "us-central1"}
ai_stub.stub.configure_provider = MagicMock()
ai_stub.configure_provider("vertex", model="gemini/gemini-pro", auth=auth)
ai_stub.stub.configure_provider.assert_called_once()
sent_req = ai_stub.stub.configure_provider.call_args[0][0]
assert sent_req.provider == "vertex"
assert sent_req.model == "gemini/gemini-pro"
assert sent_req.HasField("auth")
assert from_struct(sent_req.auth) == auth
def test_configure_provider_without_auth_no_struct(self, ai_stub):
ai_stub.stub.configure_provider = MagicMock()
ai_stub.configure_provider("openai", model="gpt-4o", api_key="sk-x")
sent_req = ai_stub.stub.configure_provider.call_args[0][0]
assert not sent_req.HasField("auth")
+11
View File
@@ -120,6 +120,7 @@ class TestGRPCIntegration:
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(server.ConfigServicer(populated_config), srv) connpy_pb2_grpc.add_ConfigServiceServicer_to_server(server.ConfigServicer(populated_config), srv)
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(server.ExecutionServicer(populated_config), srv) connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(server.ExecutionServicer(populated_config), srv)
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(server.ImportExportServicer(populated_config), srv) connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(server.ImportExportServicer(populated_config), srv)
connpy_pb2_grpc.add_AIServiceServicer_to_server(server.AIServicer(populated_config), srv)
port = srv.add_insecure_port('127.0.0.1:0') port = srv.add_insecure_port('127.0.0.1:0')
srv.start() srv.start()
@@ -143,6 +144,10 @@ class TestGRPCIntegration:
def config_stub(self, channel): def config_stub(self, channel):
return stubs.ConfigStub(channel, "localhost") return stubs.ConfigStub(channel, "localhost")
@pytest.fixture
def ai_stub(self, channel):
return stubs.AIStub(channel, "localhost")
def test_list_nodes_integration(self, node_stub): def test_list_nodes_integration(self, node_stub):
nodes = node_stub.list_nodes() nodes = node_stub.list_nodes()
assert "router1" in nodes assert "router1" in nodes
@@ -170,6 +175,12 @@ class TestGRPCIntegration:
settings = config_stub.get_settings() settings = config_stub.get_settings()
assert settings["idletime"] == 99 assert settings["idletime"] == 99
def test_list_mcp_servers_integration(self, ai_stub):
ai_stub.configure_mcp("test-mcp", url="http://localhost:8080", enabled=True)
servers = ai_stub.list_mcp_servers()
assert "test-mcp" in servers
assert servers["test-mcp"]["url"] == "http://localhost:8080"
def test_add_delete_node_integration(self, node_stub): def test_add_delete_node_integration(self, node_stub):
node_stub.add_node("integration-test-node", {"host": "9.9.9.9"}) node_stub.add_node("integration-test-node", {"host": "9.9.9.9"})
assert "integration-test-node" in node_stub.list_nodes() assert "integration-test-node" in node_stub.list_nodes()
+360
View File
@@ -0,0 +1,360 @@
import os
import pytest
import grpc
from concurrent import futures
from google.protobuf.empty_pb2 import Empty
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
from connpy.configfile import configfile
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing gRPC auth."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
# Initialize basic config file inside it
from connpy.configfile import configfile
conf_file = os.path.join(str(config_dir), "config.yaml")
configfile(conf=conf_file)
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry."""
return UserRegistry(str(test_config_dir))
@pytest.fixture
def auth_grpc_server(test_config_dir, registry):
"""Starts an authenticated local gRPC server for integration testing."""
srv = grpc.server(
futures.ThreadPoolExecutor(max_workers=5),
interceptors=[server.AuthInterceptor(registry)]
)
fallback_provider = ServiceProvider(configfile(conf=os.path.join(str(test_config_dir), "config.yaml")), mode="local")
# Register services
connpy_pb2_grpc.add_NodeServiceServicer_to_server(server.NodeServicer(fallback_provider, registry=registry), srv)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(server.AuthServicer(registry), 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(auth_grpc_server):
with grpc.insecure_channel(auth_grpc_server) as channel:
yield channel
class TestGRPCAuthentication:
def test_backward_compatibility_no_users(self, channel, registry):
"""Verifies that if no users are registered, gRPC calls proceed without authentication."""
assert registry.has_users() is False
# Calling NodeService list_nodes should succeed without any authorization metadata
stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
res = stub.list_nodes(req)
assert res is not None
def test_login_and_authenticated_calls(self, channel, registry):
"""Tests user creation, login to retrieve JWT, and using JWT to access protected endpoints."""
username = "alice"
password = "alicepassword"
# 1. Register a user in the registry
registry.user_service.create_user(username, password)
assert registry.has_users() is True
# 2. Try unauthenticated call - must fail with UNAUTHENTICATED
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
with pytest.raises(grpc.RpcError) as exc:
node_stub.list_nodes(req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Authorization token is missing" in exc.value.details()
# 3. Call login endpoint (open method) - must succeed
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username=username, password=password)
login_res = auth_stub.login(login_req)
assert login_res.username == username
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 4. Make authenticated call using Bearer token - must succeed
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_login_invalid_credentials(self, channel, registry):
"""Verifies login fails and returns UNAUTHENTICATED for incorrect credentials."""
registry.user_service.create_user("bob", "bobpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username="bob", password="wrongpassword")
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Invalid username or password" in exc.value.details()
def test_change_password(self, channel, registry):
"""Tests changing password via gRPC and verifying old password no longer works."""
username = "charlie"
registry.user_service.create_user(username, "oldpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
# 1. Login with old password to get token
login_res = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
token = login_res.token
# 2. Change password via gRPC using the token
metadata = [("authorization", f"Bearer {token}")]
change_req = connpy_pb2.ChangePasswordRequest(old_password="oldpass", new_password="newpass")
auth_stub.change_password(change_req, metadata=metadata)
# 3. Logging in with old password must fail
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
# 4. Logging in with new password must succeed
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
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
+67
View File
@@ -0,0 +1,67 @@
import os
import pytest
from connpy.grpc_layer.server import NodeServicer, _current_user
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
def test_dynamic_routing_isolation(test_config_dir, registry):
"""Verifies that NodeServicer routes list_nodes to the correct user configuration based on _current_user ContextVar."""
# Setup fallback provider
from connpy.configfile import configfile
conf_file = os.path.join(registry.user_service.config_dir, "config.yaml")
config = configfile(conf=conf_file)
fallback_provider = ServiceProvider(config, mode="local")
# Create servicer with fallback and registry
servicer = NodeServicer(fallback_provider, registry=registry)
# Register two users
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
# Add nodes to each user's provider
p1.nodes.add_node("node-for-user-1", {"host": "1.1.1.1"})
p2.nodes.add_node("node-for-user-2", {"host": "2.2.2.2"})
# Verify fallback is empty
fallback_res = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
from connpy.grpc_layer.utils import from_value
assert "node-for-user-1" not in from_value(fallback_res.data)
assert "node-for-user-2" not in from_value(fallback_res.data)
# Set context to User 1
t1 = _current_user.set(u1)
try:
res1 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes1 = from_value(res1.data)
assert "node-for-user-1" in nodes1
assert "node-for-user-2" not in nodes1
finally:
_current_user.reset(t1)
# Set context to User 2
t2 = _current_user.set(u2)
try:
res2 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes2 = from_value(res2.data)
assert "node-for-user-2" in nodes2
assert "node-for-user-1" not in nodes2
finally:
_current_user.reset(t2)
+198
View File
@@ -0,0 +1,198 @@
import os
import shutil
import pytest
from connpy.configfile import configfile
from connpy.services.plugin_service import PluginService
from connpy.services.exceptions import InvalidConfigurationError
@pytest.fixture
def temp_plugins_env(tmp_path):
"""Creates a temporary isolated environment for core, shared, and user plugins."""
base_dir = tmp_path / "plugins_test_env"
base_dir.mkdir()
# Paths for shared config and user config folders
shared_dir = base_dir / "shared"
user_dir = base_dir / "user"
shared_dir.mkdir()
user_dir.mkdir()
# Create plugins subdirectories
(shared_dir / "plugins").mkdir()
(user_dir / "plugins").mkdir()
# Mock core_plugins path by creating a sibling folder
core_dir = base_dir / "core_plugins"
core_dir.mkdir()
# Config file paths
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write empty config templates
import yaml
empty_conf = {"config": {}, "connections": {}, "profiles": {}}
with open(shared_path, "w") as f:
yaml.safe_dump(empty_conf, f)
with open(user_path, "w") as f:
yaml.safe_dump(empty_conf, f)
return {
"shared_dir": shared_dir,
"user_dir": user_dir,
"core_dir": core_dir,
"shared_path": shared_path,
"user_path": user_path
}
def test_plugin_resolution_priority_merge(temp_plugins_env, monkeypatch):
"""Test that list_plugins correctly merges core, shared, and user plugins with overrides."""
env = temp_plugins_env
# 1. Create a core plugin: 'coreplug'
core_file = env["core_dir"] / "coreplug.py"
with open(core_file, "w") as f:
f.write("# core plugin content")
# 2. Create a shared plugin: 'sharedplug'
shared_file = env["shared_dir"] / "plugins" / "sharedplug.py"
with open(shared_file, "w") as f:
f.write("# shared plugin content")
# 3. Create a user plugin: 'userplug'
user_file = env["user_dir"] / "plugins" / "userplug.py"
with open(user_file, "w") as f:
f.write("# user plugin content")
# 4. Create an override plugin: 'overrideplug' in all three directories
with open(env["core_dir"] / "overrideplug.py", "w") as f:
f.write("# core override version")
with open(env["shared_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# shared override version")
with open(env["user_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# user override version")
# Initialize configs
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
# Initialize service
plugin_svc = PluginService(user_cfg)
# Monkeypatch the core plugins folder path inside list_plugins
# in order to use our mock core folder instead of the real one.
# Note: real path is computed via __file__, so we'll mock the internal core path
monkeypatch.setattr(
"os.path.realpath",
lambda path: os.path.join(str(env["core_dir"]), "dummy")
)
plugins_list = plugin_svc.list_plugins()
# Verify all plugins are registered
assert "coreplug" in plugins_list
assert "sharedplug" in plugins_list
assert "userplug" in plugins_list
assert "overrideplug" in plugins_list
# Verify status is Active (enabled=True)
assert plugins_list["coreplug"]["enabled"] is True
assert plugins_list["sharedplug"]["enabled"] is True
assert plugins_list["userplug"]["enabled"] is True
assert plugins_list["overrideplug"]["enabled"] is True
# Verify hashes differ matching user overrides
import hashlib
user_override_hash = hashlib.md5(b"# user override version").hexdigest()
assert plugins_list["overrideplug"]["hash"] == user_override_hash
def test_get_plugin_source_override(temp_plugins_env, monkeypatch):
"""Test that get_plugin_source resolves the highest priority plugin version."""
env = temp_plugins_env
# Create override in shared and user
with open(env["shared_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("shared content")
with open(env["user_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("user override")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Fetch source
source = plugin_svc.get_plugin_source("myplug")
assert source == "user override"
def test_delete_plugin_restrictions(temp_plugins_env):
"""Test that deleting shared plugins is rejected, but deleting user overrides works."""
env = temp_plugins_env
# Create shared plugin
with open(env["shared_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("global content")
# Create user plugin override
with open(env["user_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("user content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# 1. Delete plugin (should delete the user override first)
plugin_svc.delete_plugin("globalplug")
# Verify user override is gone, but shared plugin remains
assert not os.path.exists(env["user_dir"] / "plugins" / "globalplug.py")
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
# 2. Try to delete again (now only exists in shared/global folder)
with pytest.raises(InvalidConfigurationError) as exc:
plugin_svc.delete_plugin("globalplug")
assert "Global and core plugins are read-only" in str(exc.value)
# Verify shared plugin is still present
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
def test_shadow_disable_and_enable_mechanisms(temp_plugins_env):
"""Test that disabling a shared plugin creates a shadow backup file and enabling it removes it."""
env = temp_plugins_env
# Create a shared plugin
with open(env["shared_dir"] / "plugins" / "sharedplug.py", "w") as f:
f.write("shared content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Ensure it's active initially
list_initial = plugin_svc.list_plugins()
assert list_initial["sharedplug"]["enabled"] is True
# 1. Disable the shared plugin (should shadow-disable it in user dir)
res = plugin_svc.disable_plugin("sharedplug")
assert res is True
# Verify shadow bkp file exists in user plugins and has 0 bytes
shadow_bkp = env["user_dir"] / "plugins" / "sharedplug.py.bkp"
assert os.path.exists(shadow_bkp)
assert os.path.getsize(shadow_bkp) == 0
# Verify list_plugins lists it as disabled
list_disabled = plugin_svc.list_plugins()
assert list_disabled["sharedplug"]["enabled"] is False
# 2. Re-enable the shadow-disabled plugin (should delete the user shadow file)
res_enable = plugin_svc.enable_plugin("sharedplug")
assert res_enable is True
# Verify shadow file is deleted
assert not os.path.exists(shadow_bkp)
# Verify list_plugins lists it as active again
list_active = plugin_svc.list_plugins()
assert list_active["sharedplug"]["enabled"] is True
+296
View File
@@ -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]
+217
View File
@@ -0,0 +1,217 @@
import os
import time
import pytest
import yaml
from connpy.configfile import configfile
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def temp_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
config_dir = tmp_path / "conn_shared_test"
config_dir.mkdir()
return config_dir
def test_shared_ai_deep_merge(temp_config_dir):
"""Test get_effective_setting deep merge logic for 'ai' settings."""
shared_dir = os.path.join(temp_config_dir, "shared")
user_dir = os.path.join(temp_config_dir, "user")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write shared configuration
shared_data = {
"config": {
"theme": "dark",
"case": False,
"ai": {
"engineer_model": "shared-eng-model",
"architect_model": "shared-arch-model",
"engineer_api_key": "shared-key",
"mcp_servers": {
"global-server": {
"url": "http://global-server/sse",
"enabled": True
},
"override-server": {
"url": "http://override-shared/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# Write user configuration with overrides
user_data = {
"config": {
"case": True,
"ai": {
"engineer_model": "user-custom-eng-model",
"mcp_servers": {
"override-server": {
"enabled": False
},
"user-server": {
"url": "http://user-server/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
# Initialize configfile instances
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
# Verify non-inheritable settings (theme, case)
assert user_config.get_effective_setting("case") is True
assert user_config.get_effective_setting("theme") is None # Should NOT inherit "theme"
# Verify AI setting deep merge
effective_ai = user_config.get_effective_setting("ai")
# Model override
assert effective_ai.get("engineer_model") == "user-custom-eng-model"
# Model inheritance
assert effective_ai.get("architect_model") == "shared-arch-model"
# API key inheritance
assert effective_ai.get("engineer_api_key") == "shared-key"
# MCP Servers merge
mcp = effective_ai.get("mcp_servers", {})
# Inherited server
assert "global-server" in mcp
assert mcp["global-server"]["url"] == "http://global-server/sse"
assert mcp["global-server"]["enabled"] is True
# Merged & overridden server
assert "override-server" in mcp
assert mcp["override-server"]["url"] == "http://override-shared/sse" # inherited
assert mcp["override-server"]["enabled"] is False # overridden
# User-only server
assert "user-server" in mcp
assert mcp["user-server"]["url"] == "http://user-server/sse"
def test_registry_injection_and_hot_reload(temp_config_dir):
"""Test that UserRegistry correctly injects shared config and hot-reloads it when it changes on disk."""
registry = UserRegistry(str(temp_config_dir))
# Define paths
shared_path = os.path.join(temp_config_dir, "config.yaml")
# 1. Create a global config file
global_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_model": "global-model"
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Re-init registry to pick up the newly created shared config file
registry = UserRegistry(str(temp_config_dir))
# Register user
username = "testuser"
registry.user_service.create_user(username, "testpassword")
# Check initial injection
provider = registry.get_provider(username)
ai_settings = provider.config.get_effective_setting("ai")
assert ai_settings.get("engineer_api_key") == "global-initial-key"
assert ai_settings.get("engineer_model") == "global-model"
# 2. Modify global config on disk
global_data["config"]["ai"]["engineer_api_key"] = "global-updated-key"
# Sleep briefly to ensure mtime change is detectable
time.sleep(0.1)
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Set the mtime forward explicitly to avoid filesystem resolution limits
new_mtime = os.path.getmtime(shared_path) + 10.0
os.utime(shared_path, (new_mtime, new_mtime))
# Retrieve provider again - should trigger hot-reload of shared config
provider2 = registry.get_provider(username)
ai_settings_updated = provider2.config.get_effective_setting("ai")
assert ai_settings_updated.get("engineer_api_key") == "global-updated-key"
assert ai_settings_updated.get("engineer_model") == "global-model"
def test_shared_ai_credential_isolation(temp_config_dir):
"""Test that setting user engineer/architect credentials discards corresponding shared credentials."""
shared_dir = os.path.join(temp_config_dir, "shared_isolation")
user_dir = os.path.join(temp_config_dir, "user_isolation")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Shared has both api_key and auth
shared_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_auth": {"vertex_project": "shared-project", "api_key": "shared-auth-key"},
"architect_api_key": "global-arch-key",
"architect_auth": {"project": "arch-project"}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# User configures ONLY engineer_api_key (expects engineer_auth to be discarded)
# and ONLY architect_auth (expects architect_api_key to be discarded)
user_data = {
"config": {
"ai": {
"engineer_api_key": "user-custom-key",
"architect_auth": {"project": "user-project", "api_key": "user-auth-key"}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
effective_ai = user_config.get_effective_setting("ai")
# 1. Engineer: local api_key is present, so shared engineer_auth must be completely discarded
assert effective_ai.get("engineer_api_key") == "user-custom-key"
assert "engineer_auth" not in effective_ai
# 2. Architect: local auth is present, so shared architect_api_key must be completely discarded
assert effective_ai.get("architect_auth") == {"project": "user-project", "api_key": "user-auth-key"}
assert "architect_api_key" not in effective_ai
+134
View File
@@ -0,0 +1,134 @@
import os
import pytest
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
class TestUserRegistry:
def test_has_users_empty(self, registry):
"""Verifies has_users is False when no users exist."""
assert registry.has_users() is False
def test_get_provider_returns_service_provider(self, registry):
"""Tests that get_provider lazy-loads a valid ServiceProvider instance."""
username = "alice"
registry.user_service.create_user(username, "password")
assert registry.has_users() is True
provider = registry.get_provider(username)
assert isinstance(provider, ServiceProvider)
assert provider.mode == "local"
def test_get_provider_cached(self, registry):
"""Verifies that subsequent calls return the cached singleton instance."""
username = "bob"
registry.user_service.create_user(username, "password")
p1 = registry.get_provider(username)
p2 = registry.get_provider(username)
assert p1 is p2 # must be exact same object reference
def test_two_users_isolated(self, registry):
"""Ensures different users get completely separate ServiceProviders and configs."""
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
assert p1 is not p2
assert p1.config is not p2.config
# Add a node for user1 and verify user2 is unaffected
p1.nodes.add_node("node1", {"host": "1.1.1.1"})
assert "node1" in p1.nodes.list_nodes()
assert "node1" not in p2.nodes.list_nodes()
def test_evict_clears_cache(self, registry):
"""Verifies that eviction deletes the cached provider from memory."""
username = "evictuser"
registry.user_service.create_user(username, "pass")
p1 = registry.get_provider(username)
assert username in registry._providers
registry.evict(username)
assert username not in registry._providers
# Calling get_provider again spawns a new instance
p2 = registry.get_provider(username)
assert p1 is not p2
def test_provider_hot_reload_on_external_change(self, registry):
"""Verifies that UserRegistry hot-reloads the provider if config.yaml is updated externally."""
username = "charlie"
registry.user_service.create_user(username, "password")
# Initial load (no nodes)
p1 = registry.get_provider(username)
assert len(p1.nodes.list_nodes()) == 0
# Resolve config.yaml file path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Modify the config file physically on disk by appending a node
from connpy.configfile import configfile
cfg = configfile(conf=conf_file)
cfg._connections_add(id="testnode", host="8.8.8.8")
cfg._saveconfig(cfg.file)
# Artificially increase mtime to force reload
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetch provider again
p2 = registry.get_provider(username)
# Verify it hot-reloaded and the new node is immediately visible
assert p1 is not p2
assert "testnode" in p2.nodes.list_nodes()
def test_provider_hot_reload_fails_on_corrupt_file_keeps_old_provider(self, registry):
"""Verifies that UserRegistry keeps serving the old provider if disk config is corrupt."""
username = "danny"
registry.user_service.create_user(username, "password")
# Initial load
p1 = registry.get_provider(username)
p1.nodes.add_node("nodeA", {"host": "2.2.2.2"})
assert "nodeA" in p1.nodes.list_nodes()
# Resolve config.yaml path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Write corrupted content directly to config.yaml
with open(conf_file, "w") as f:
f.write("corrupt yaml content ::: invalid syntax :::")
# Artificially increase mtime to force reload attempt
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetching provider again should fallback to old_provider instead of failing completely
p2 = registry.get_provider(username)
# Verify fallback
assert p1 is p2
assert "nodeA" in p2.nodes.list_nodes()
+217
View File
@@ -0,0 +1,217 @@
import os
import shutil
import pytest
import datetime
import jwt
import yaml
from pathlib import Path
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def user_service(test_config_dir):
"""Initializes UserService pointing to a temporary directory."""
return UserService(str(test_config_dir))
class TestUserService:
def test_no_users(self, user_service):
"""Verifies that a new registry is empty by default."""
users = user_service.list_users()
assert users == []
def test_create_user_default(self, user_service):
"""Tests Mode A: fresh user config and key creation."""
username = "testuser"
res = user_service.create_user(username, "mypassword")
assert res["username"] == username
assert res["config_path"] is None
assert "created" in res
# Verify folder, config.yaml and .osk key are created
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
assert os.path.isdir(os.path.join(user_dir, "plugins"))
assert os.path.isdir(os.path.join(user_dir, "ai_sessions"))
assert os.path.isfile(os.path.join(user_dir, "config.yaml"))
assert os.path.isfile(os.path.join(user_dir, ".osk"))
def test_create_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: using an existing valid config path."""
# Setup existing custom config directory
custom_dir = tmp_path / "custom_user_conn"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
# Write basic config.yaml
config_data = {
"config": {"case": False, "idletime": 30, "fzf": False},
"connections": {},
"profiles": {}
}
with open(config_file, "w") as f:
yaml.dump(config_data, f)
res = user_service.create_user("fluzzi", "fluzzipass", config_path=str(custom_dir))
assert res["username"] == "fluzzi"
assert res["config_path"] == str(custom_dir)
# Verify no directory is created under the server's user folder
user_dir = os.path.join(user_service.users_dir, "fluzzi")
assert not os.path.exists(user_dir)
def test_create_user_custom_path_auto_init(self, user_service, tmp_path):
"""Ensures create_user automatically initializes a missing directory and default config.yaml."""
custom_dir = tmp_path / "new_custom_config"
# Test creation where the directory does not exist yet
res = user_service.create_user("john", "pass", config_path=str(custom_dir))
assert res["username"] == "john"
assert res["config_path"] == str(custom_dir)
# Verify custom path and subdirs/configs were created
assert os.path.isdir(custom_dir)
assert os.path.exists(os.path.join(custom_dir, "config.yaml"))
assert os.path.isdir(os.path.join(custom_dir, "plugins"))
assert os.path.isdir(os.path.join(custom_dir, "ai_sessions"))
def test_create_duplicate_user(self, user_service):
"""Ensures duplicate usernames are rejected."""
user_service.create_user("dupuser", "password")
with pytest.raises(ValueError, match="already exists"):
user_service.create_user("dupuser", "anotherpass")
def test_delete_user_default(self, user_service):
"""Tests Mode A: deleting a server-managed user cleans up directories."""
username = "deluser"
user_service.create_user(username, "password")
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
user_service.delete_user(username)
# Directory should be cleaned up
assert not os.path.exists(user_dir)
# Registry should be updated
assert len(user_service.list_users()) == 0
def test_delete_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: deleting a custom-path user leaves files untouched."""
custom_dir = tmp_path / "fluzzi_custom"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
with open(config_file, "w") as f:
yaml.dump({"config": {}, "connections": {}, "profiles": {}}, f)
username = "fluzzi"
user_service.create_user(username, "pass", config_path=str(custom_dir))
user_service.delete_user(username)
# Registry cleared
assert len(user_service.list_users()) == 0
# Files remain untouched
assert os.path.isdir(str(custom_dir))
assert os.path.isfile(str(config_file))
def test_list_users(self, user_service):
"""Tests listing all registered users with their metadata."""
user_service.create_user("user1", "pass1")
user_service.create_user("user2", "pass2")
users = user_service.list_users()
assert len(users) == 2
usernames = [u["username"] for u in users]
assert "user1" in usernames
assert "user2" in usernames
def test_get_user(self, user_service):
"""Tests retrieving a single user's configuration metadata."""
user_service.create_user("user1", "pass1")
user = user_service.get_user("user1")
assert user["username"] == "user1"
assert user["config_path"] is None
assert "created" in user
with pytest.raises(ValueError, match="not found"):
user_service.get_user("nonexistent")
def test_authenticate_valid(self, user_service):
"""Verifies successful authentication."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "my-secure-password") is True
def test_authenticate_invalid(self, user_service):
"""Verifies unsuccessful authentication on incorrect or missing credentials."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "wrong-password") is False
assert user_service.authenticate("nonexistent", "my-secure-password") is False
def test_jwt_roundtrip(self, user_service):
"""Tests generating a JWT token and verifying it back to the username."""
username = "jwttester"
user_service.create_user(username, "pass")
token = user_service.generate_jwt(username)
assert isinstance(token, str)
verified_user = user_service.verify_jwt(token)
assert verified_user == username
def test_jwt_expired(self, user_service):
"""Tests that expired JWT tokens are rejected and return None."""
username = "jwttester"
user_service.create_user(username, "pass")
# Manually generate an expired token by setting exp to the past
registry = user_service._load_registry()
expired_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=10)
payload = {
"sub": username,
"exp": expired_time
}
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
verified_user = user_service.verify_jwt(token)
assert verified_user is None
def test_change_password(self, user_service):
"""Tests changing password for a user."""
username = "passchanger"
user_service.create_user(username, "oldpass")
# Old credentials authenticate
assert user_service.authenticate(username, "oldpass") is True
# Change password
user_service.change_password(username, "oldpass", "newpass")
# Old password fails, new password works
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
# Change with invalid old password should fail
with pytest.raises(ValueError, match="Invalid credentials"):
user_service.change_password(username, "wrongold", "evennewer")
def test_admin_change_password(self, user_service):
"""Tests administrative password change (no old password required)."""
username = "adminpasschanger"
user_service.create_user(username, "oldpass")
# Admin changes password directly
user_service.admin_change_password(username, "newpass")
# Verify credentials
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
+32
View File
@@ -0,0 +1,32 @@
import pytest
from connpy.utils import log_cleaner
def test_log_cleaner_empty():
assert log_cleaner("") == ""
assert log_cleaner(None) == ""
def test_log_cleaner_plain_text():
assert log_cleaner("hello world") == "hello world"
def test_log_cleaner_ansi_colors():
# \x1b[31m is red, \x1b[0m is reset
assert log_cleaner("\x1b[31mhello\x1b[0m world") == "hello world"
def test_log_cleaner_osc_window_title():
# Set window title OSC: \x1b]0;my title\x07 followed by prompt
sample = "\x1b]0;fluzzi32@norman: ~\x07fluzzi32@norman:~$"
assert log_cleaner(sample) == "fluzzi32@norman:~$"
def test_log_cleaner_osc_with_st_terminator():
# OSC can also be terminated by \x1b\\ (ST)
sample = "\x1b]0;some title\x1b\\my_prompt>"
assert log_cleaner(sample) == "my_prompt>"
def test_log_cleaner_mixed_ansi_and_osc():
sample = "\x1b]0;title\x07\x1b[32muser@host\x1b[0m:\x1b[34m/path\x1b[0m$ "
assert log_cleaner(sample) == "user@host:/path$"
def test_log_cleaner_carriage_return_and_backspace():
# Test that standard control sequences like \r and \b still work as expected
assert log_cleaner("hello\rworld") == "world"
assert log_cleaner("hell\bo") == "helo"
+4 -1
View File
@@ -7,11 +7,14 @@ def log_cleaner(data: str) -> str:
if not data: if not data:
return "" return ""
# Remove OSC (Operating System Command) sequences (e.g., set window title \x1b]0;...\x07)
data = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', data)
lines = data.split('\n') lines = data.split('\n')
cleaned_lines = [] cleaned_lines = []
# Regex to capture: ANSI sequences, control characters (\r, \b, etc), and plain text chunks # Regex to capture: ANSI sequences, control characters (\r, \b, etc), and plain text chunks
token_re = re.compile(r'(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/ ]*[@-~])|\r|\b|\x7f|[\x00-\x1F]|[^\x1B\r\b\x7f\x00-\x1F]+)') token_re = re.compile(r'(\x1B(?:[\x30-\x5A\x5C-\x7E]|\[[0-?]*[ -/ ]*[@-~])|\r|\b|\x7f|[\x00-\x1F]|[^\x1B\r\b\x7f\x00-\x1F]+)')
for line in lines: for line in lines:
buffer = [] buffer = []
+101 -43
View File
@@ -61,13 +61,22 @@ el.replaceWith(d);
def dispatch(self, args): def dispatch(self, args):
if args.list_sessions: if args.list_sessions:
sessions = self.app.services.ai.list_sessions() limit = 20 if not getattr(args, &#34;all&#34;, False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions: if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;) printer.info(&#34;No saved AI sessions found.&#34;)
return return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;] columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions] rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)
title = &#34;AI Persisted Sessions&#34;
if limit and total &gt; limit:
title += f&#34; (Showing last {limit} of {total})&#34;
printer.table(title, columns, rows)
if limit and total &gt; limit:
printer.info(f&#34;Use &#39;--list --all&#39; to see all {total} sessions.&#34;)
return return
if args.delete_session: if args.delete_session:
@@ -81,18 +90,18 @@ 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()
session_id = sessions[0][&#34;id&#34;] if sessions else None session_id = sessions[0][&#34;id&#34;] if sessions else None
if not session_id: if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;) printer.warning(&#34;No previous session found to resume.&#34;)
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 &gt; Configuración Local # Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {}) settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {} arguments = {}
@@ -103,17 +112,24 @@ el.replaceWith(d);
elif settings.get(key): elif settings.get(key):
arguments[key] = settings.get(key) arguments[key] = settings.get(key)
for key in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
cli_val = getattr(args, key, None)
if cli_val:
arguments[key] = self._parse_auth_value(cli_val[0])
elif settings.get(key):
arguments[key] = settings.get(key)
# Check keys only if running in local mode (not remote) # Check keys only if running in local mode (not remote)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;: if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;): if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#34;):
printer.error(&#34;Engineer API key not configured. The chat cannot start.&#34;) printer.error(&#34;Engineer API key/auth not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; to set it.&#34;) printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1) sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;): if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#34;):
printer.warning(&#34;Architect API key not configured. Architect will be unavailable.&#34;) printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; to enable it.&#34;) printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# 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
@@ -124,7 +140,7 @@ el.replaceWith(d);
def single_question(self, args, session_id): def single_question(self, args, session_id):
query = &#34; &#34;.join(args.ask) query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status: with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) 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(&#34;responder&#34;, &#34;engineer&#34;) responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
@@ -148,7 +164,7 @@ el.replaceWith(d);
if history: if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;) mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
else: else:
printer.error(f&#34;Could not load session {session_id}. Starting clean.&#34;) printer.info(f&#34;Session &#39;{session_id}&#39; not found. Starting clean.&#34;)
if not history: if not history:
mdprint(Rule(style=&#34;engineer&#34;)) mdprint(Rule(style=&#34;engineer&#34;))
@@ -161,8 +177,8 @@ el.replaceWith(d);
if not user_query.strip(): continue if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status: with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **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(&#34;chat_history&#34;) new_history = result.get(&#34;chat_history&#34;)
if new_history is not None: if new_history is not None:
@@ -193,8 +209,7 @@ el.replaceWith(d);
action = mcp_args[0].lower() action = mcp_args[0].lower()
if action == &#34;list&#34;: if action == &#34;list&#34;:
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
if not mcp_servers: if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;) printer.info(&#34;No MCP servers configured.&#34;)
else: else:
@@ -259,8 +274,7 @@ el.replaceWith(d);
from .forms import Forms from .forms import Forms
self.app.cli_forms = Forms(self.app) self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
result = self.app.cli_forms.mcp_wizard(mcp_servers) result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result: if not result:
@@ -294,7 +308,37 @@ el.replaceWith(d);
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; removed.&#34;) printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; removed.&#34;)
except Exception as e: except Exception as e:
printer.error(str(e))</code></pre> printer.error(str(e))
def _parse_auth_value(self, value):
if not value or value.lower() in [&#34;none&#34;, &#34;clear&#34;]:
return None
import os
import yaml
import json
if os.path.exists(value):
try:
with open(value, &#34;r&#34;) as f:
content = f.read()
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
printer.error(f&#34;Failed to read/parse auth file &#39;{value}&#39;: {e}&#34;)
sys.exit(1)
try:
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
printer.error(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)
sys.exit(1)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -316,8 +360,7 @@ el.replaceWith(d);
action = mcp_args[0].lower() action = mcp_args[0].lower()
if action == &#34;list&#34;: if action == &#34;list&#34;:
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
if not mcp_servers: if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;) printer.info(&#34;No MCP servers configured.&#34;)
else: else:
@@ -382,8 +425,7 @@ el.replaceWith(d);
from .forms import Forms from .forms import Forms
self.app.cli_forms = Forms(self.app) self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings() mcp_servers = self.app.services.ai.list_mcp_servers()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
result = self.app.cli_forms.mcp_wizard(mcp_servers) result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result: if not result:
@@ -431,13 +473,22 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def dispatch(self, args): <pre><code class="python">def dispatch(self, args):
if args.list_sessions: if args.list_sessions:
sessions = self.app.services.ai.list_sessions() limit = 20 if not getattr(args, &#34;all&#34;, False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions: if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;) printer.info(&#34;No saved AI sessions found.&#34;)
return return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;] columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions] rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)
title = &#34;AI Persisted Sessions&#34;
if limit and total &gt; limit:
title += f&#34; (Showing last {limit} of {total})&#34;
printer.table(title, columns, rows)
if limit and total &gt; limit:
printer.info(f&#34;Use &#39;--list --all&#39; to see all {total} sessions.&#34;)
return return
if args.delete_session: if args.delete_session:
@@ -451,18 +502,18 @@ 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()
session_id = sessions[0][&#34;id&#34;] if sessions else None session_id = sessions[0][&#34;id&#34;] if sessions else None
if not session_id: if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;) printer.warning(&#34;No previous session found to resume.&#34;)
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 &gt; Configuración Local # Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {}) settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {} arguments = {}
@@ -473,17 +524,24 @@ el.replaceWith(d);
elif settings.get(key): elif settings.get(key):
arguments[key] = settings.get(key) arguments[key] = settings.get(key)
for key in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
cli_val = getattr(args, key, None)
if cli_val:
arguments[key] = self._parse_auth_value(cli_val[0])
elif settings.get(key):
arguments[key] = settings.get(key)
# Check keys only if running in local mode (not remote) # Check keys only if running in local mode (not remote)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;: if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;): if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#34;):
printer.error(&#34;Engineer API key not configured. The chat cannot start.&#34;) printer.error(&#34;Engineer API key/auth not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; to set it.&#34;) printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1) sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;): if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#34;):
printer.warning(&#34;Architect API key not configured. Architect will be unavailable.&#34;) printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; to enable it.&#34;) printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# 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
@@ -512,7 +570,7 @@ el.replaceWith(d);
if history: if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;) mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
else: else:
printer.error(f&#34;Could not load session {session_id}. Starting clean.&#34;) printer.info(f&#34;Session &#39;{session_id}&#39; not found. Starting clean.&#34;)
if not history: if not history:
mdprint(Rule(style=&#34;engineer&#34;)) mdprint(Rule(style=&#34;engineer&#34;))
@@ -525,8 +583,8 @@ el.replaceWith(d);
if not user_query.strip(): continue if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status: with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **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(&#34;chat_history&#34;) new_history = result.get(&#34;chat_history&#34;)
if new_history is not None: if new_history is not None:
@@ -560,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 = &#34; &#34;.join(args.ask) query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status: with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) 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(&#34;responder&#34;, &#34;engineer&#34;) responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
+74 -5
View File
@@ -70,8 +70,10 @@ el.replaceWith(d);
&#34;theme&#34;: self.set_theme, &#34;theme&#34;: self.set_theme,
&#34;engineer_model&#34;: self.set_ai_config, &#34;engineer_model&#34;: self.set_ai_config,
&#34;engineer_api_key&#34;: self.set_ai_config, &#34;engineer_api_key&#34;: self.set_ai_config,
&#34;engineer_auth&#34;: self.set_ai_config,
&#34;architect_model&#34;: self.set_ai_config, &#34;architect_model&#34;: self.set_ai_config,
&#34;architect_api_key&#34;: self.set_ai_config, &#34;architect_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config, &#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode, &#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host, &#34;remote_host&#34;: self.set_remote_host,
@@ -178,11 +180,59 @@ el.replaceWith(d);
try: try:
settings = self.app.services.config_svc.get_settings() settings = self.app.services.config_svc.get_settings()
aiconfig = settings.get(&#34;ai&#34;, {}) aiconfig = settings.get(&#34;ai&#34;, {})
aiconfig[args.command] = args.data[0] val = args.data[0]
# Check for unset/clear request
if val.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if args.command in aiconfig:
del aiconfig[args.command]
else:
# If configuring auth, parse as dictionary (JSON/YAML or file path)
if args.command in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
parsed_val = self._parse_auth_value(val)
if parsed_val is not None:
aiconfig[args.command] = parsed_val
else:
if args.command in aiconfig:
del aiconfig[args.command]
else:
aiconfig[args.command] = val
self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig) self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig)
printer.success(&#34;Config saved&#34;) printer.success(&#34;Config saved&#34;)
except ConnpyError as e: except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre> printer.error(str(e))
def _parse_auth_value(self, value):
if value.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
return None
# Check if it&#39;s a file path
import os
if os.path.exists(value):
try:
with open(value, &#34;r&#34;) as f:
content = f.read()
import json
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
raise InvalidConfigurationError(f&#34;Failed to read/parse auth file &#39;{value}&#39;: {e}&#34;)
# Try parsing as inline JSON/YAML
try:
import json
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
raise InvalidConfigurationError(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -206,8 +256,10 @@ el.replaceWith(d);
&#34;theme&#34;: self.set_theme, &#34;theme&#34;: self.set_theme,
&#34;engineer_model&#34;: self.set_ai_config, &#34;engineer_model&#34;: self.set_ai_config,
&#34;engineer_api_key&#34;: self.set_ai_config, &#34;engineer_api_key&#34;: self.set_ai_config,
&#34;engineer_auth&#34;: self.set_ai_config,
&#34;architect_model&#34;: self.set_ai_config, &#34;architect_model&#34;: self.set_ai_config,
&#34;architect_api_key&#34;: self.set_ai_config, &#34;architect_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config, &#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode, &#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host, &#34;remote_host&#34;: self.set_remote_host,
@@ -234,10 +286,27 @@ el.replaceWith(d);
try: try:
settings = self.app.services.config_svc.get_settings() settings = self.app.services.config_svc.get_settings()
aiconfig = settings.get(&#34;ai&#34;, {}) aiconfig = settings.get(&#34;ai&#34;, {})
aiconfig[args.command] = args.data[0] val = args.data[0]
# Check for unset/clear request
if val.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if args.command in aiconfig:
del aiconfig[args.command]
else:
# If configuring auth, parse as dictionary (JSON/YAML or file path)
if args.command in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
parsed_val = self._parse_auth_value(val)
if parsed_val is not None:
aiconfig[args.command] = parsed_val
else:
if args.command in aiconfig:
del aiconfig[args.command]
else:
aiconfig[args.command] = val
self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig) self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig)
printer.success(&#34;Config saved&#34;) printer.success(&#34;Config saved&#34;)
except ConnpyError as e: except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre> printer.error(str(e))</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
+127 -1
View File
@@ -69,7 +69,7 @@ el.replaceWith(d);
return answer[0] return answer[0]
else: else:
questions = [inquirer.List(name, message=&#34;Pick {} to {}:&#34;.format(name,action), choices=list_, carousel=True)] questions = [inquirer.List(name, message=&#34;Pick {} to {}:&#34;.format(name,action), choices=list_, carousel=True)]
answer = inquirer.prompt(questions) answer = inquirer.prompt(questions, theme=theme)
if answer == None: if answer == None:
return None return None
else: else:
@@ -115,6 +115,65 @@ el.replaceWith(d);
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.cli.helpers.get_theme"><code class="name flex">
<span>def <span class="ident">get_theme</span></span>(<span>)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_theme():
&#34;&#34;&#34;Returns a fresh instance of the theme with current colors.&#34;&#34;&#34;
return ConnpyTheme()</code></pre>
</details>
<div class="desc"><p>Returns a fresh instance of the theme with current colors.</p></div>
</dd>
<dt id="connpy.cli.helpers.hex_to_blessed"><code class="name flex">
<span>def <span class="ident">hex_to_blessed</span></span>(<span>hex_str)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def hex_to_blessed(hex_str):
&#34;&#34;&#34;Convert hex color string to blessed/ansi format.&#34;&#34;&#34;
if not hex_str or not isinstance(hex_str, str):
return term.normal
# Check for bold prefix
prefix = &#34;&#34;
if hex_str.startswith(&#39;bold &#39;):
prefix = term.bold
hex_str = hex_str.replace(&#39;bold &#39;, &#39;&#39;).strip()
# If it&#39;s a standard color name
if not hex_str.startswith(&#39;#&#39;):
return prefix + getattr(term, hex_str, term.normal)
# Parse hex
try:
h = hex_str.lstrip(&#39;#&#39;)
if len(h) == 3:
h = &#39;&#39;.join([c*2 for c in h])
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
# Try RGB, fallback to standard cyan if it fails or returns empty
try:
c = term.color_rgb(r, g, b)
if not c: # Some terms return empty for RGB
return prefix + term.cyan
return prefix + c
except:
return prefix + term.cyan
except:
return prefix + term.normal</code></pre>
</details>
<div class="desc"><p>Convert hex color string to blessed/ansi format.</p></div>
</dd>
<dt id="connpy.cli.helpers.nodes_completer"><code class="name flex"> <dt id="connpy.cli.helpers.nodes_completer"><code class="name flex">
<span>def <span class="ident">nodes_completer</span></span>(<span>prefix, parsed_args, **kwargs)</span> <span>def <span class="ident">nodes_completer</span></span>(<span>prefix, parsed_args, **kwargs)</span>
</code></dt> </code></dt>
@@ -181,6 +240,61 @@ el.replaceWith(d);
</dl> </dl>
</section> </section>
<section> <section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.helpers.ConnpyTheme"><code class="flex name class">
<span>class <span class="ident">ConnpyTheme</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class ConnpyTheme(Default):
def __init__(self):
super().__init__()
try:
from ..printer import _global_active_styles
# Use user_prompt as primary accent, fallback to info/cyan
accent = _global_active_styles.get(&#34;user_prompt&#34;, _global_active_styles.get(&#34;info&#34;, &#34;cyan&#34;))
accent_color = hex_to_blessed(accent)
self.Question.mark_color = accent_color
self.List.selection_color = accent_color
self.List.selection_cursor = &#34;&gt;&#34;
except:
# Absolute fallback to standard cyan
self.Question.mark_color = term.cyan
self.List.selection_color = term.bold_cyan
self.List.selection_cursor = &#34;&gt;&#34;</code></pre>
</details>
<div class="desc"></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>inquirer.themes.Default</li>
<li>inquirer.themes.Theme</li>
</ul>
</dd>
<dt id="connpy.cli.helpers.ThemeProxy"><code class="flex name class">
<span>class <span class="ident">ThemeProxy</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class ThemeProxy:
&#34;&#34;&#34;Proxy to ensure theme colors are resolved at runtime.&#34;&#34;&#34;
def __getattr__(self, name):
return getattr(get_theme(), name)
def __iter__(self):
return iter(get_theme())
def __getitem__(self, item):
return get_theme()[item]</code></pre>
</details>
<div class="desc"><p>Proxy to ensure theme colors are resolved at runtime.</p></div>
</dd>
</dl>
</section> </section>
</article> </article>
<nav id="sidebar"> <nav id="sidebar">
@@ -198,11 +312,23 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.helpers.choose" href="#connpy.cli.helpers.choose">choose</a></code></li> <li><code><a title="connpy.cli.helpers.choose" href="#connpy.cli.helpers.choose">choose</a></code></li>
<li><code><a title="connpy.cli.helpers.folders_completer" href="#connpy.cli.helpers.folders_completer">folders_completer</a></code></li> <li><code><a title="connpy.cli.helpers.folders_completer" href="#connpy.cli.helpers.folders_completer">folders_completer</a></code></li>
<li><code><a title="connpy.cli.helpers.get_config_dir" href="#connpy.cli.helpers.get_config_dir">get_config_dir</a></code></li> <li><code><a title="connpy.cli.helpers.get_config_dir" href="#connpy.cli.helpers.get_config_dir">get_config_dir</a></code></li>
<li><code><a title="connpy.cli.helpers.get_theme" href="#connpy.cli.helpers.get_theme">get_theme</a></code></li>
<li><code><a title="connpy.cli.helpers.hex_to_blessed" href="#connpy.cli.helpers.hex_to_blessed">hex_to_blessed</a></code></li>
<li><code><a title="connpy.cli.helpers.nodes_completer" href="#connpy.cli.helpers.nodes_completer">nodes_completer</a></code></li> <li><code><a title="connpy.cli.helpers.nodes_completer" href="#connpy.cli.helpers.nodes_completer">nodes_completer</a></code></li>
<li><code><a title="connpy.cli.helpers.profiles_completer" href="#connpy.cli.helpers.profiles_completer">profiles_completer</a></code></li> <li><code><a title="connpy.cli.helpers.profiles_completer" href="#connpy.cli.helpers.profiles_completer">profiles_completer</a></code></li>
<li><code><a title="connpy.cli.helpers.toplevel_completer" href="#connpy.cli.helpers.toplevel_completer">toplevel_completer</a></code></li> <li><code><a title="connpy.cli.helpers.toplevel_completer" href="#connpy.cli.helpers.toplevel_completer">toplevel_completer</a></code></li>
</ul> </ul>
</li> </li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.helpers.ConnpyTheme" href="#connpy.cli.helpers.ConnpyTheme">ConnpyTheme</a></code></h4>
</li>
<li>
<h4><code><a title="connpy.cli.helpers.ThemeProxy" href="#connpy.cli.helpers.ThemeProxy">ThemeProxy</a></code></h4>
</li>
</ul>
</li>
</ul> </ul>
</nav> </nav>
</main> </main>
+15
View File
@@ -72,6 +72,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.cli.login_handler" href="login_handler.html">connpy.cli.login_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></dt> <dt><code class="name"><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -88,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>
@@ -96,6 +104,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></dt> <dt><code class="name"><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -129,12 +141,15 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.help_text" href="help_text.html">connpy.cli.help_text</a></code></li> <li><code><a title="connpy.cli.help_text" href="help_text.html">connpy.cli.help_text</a></code></li>
<li><code><a title="connpy.cli.helpers" href="helpers.html">connpy.cli.helpers</a></code></li> <li><code><a title="connpy.cli.helpers" href="helpers.html">connpy.cli.helpers</a></code></li>
<li><code><a title="connpy.cli.import_export_handler" href="import_export_handler.html">connpy.cli.import_export_handler</a></code></li> <li><code><a title="connpy.cli.import_export_handler" href="import_export_handler.html">connpy.cli.import_export_handler</a></code></li>
<li><code><a title="connpy.cli.login_handler" href="login_handler.html">connpy.cli.login_handler</a></code></li>
<li><code><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></li> <li><code><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_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.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.validators" href="validators.html">connpy.cli.validators</a></code></li> <li><code><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></li>
</ul> </ul>
</li> </li>
+408
View File
@@ -0,0 +1,408 @@
<!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.login_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.login_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.login_handler.LoginHandler"><code class="flex name class">
<span>class <span class="ident">LoginHandler</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 LoginHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def login(self, args):
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
auth_service = getattr(self.app.services, &#34;auth&#34;, None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)
def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)
def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.login_handler.LoginHandler.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):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def login(self, args):
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
auth_service = getattr(self.app.services, &#34;auth&#34;, None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.logout"><code class="name flex">
<span>def <span class="ident">logout</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.show_status"><code class="name flex">
<span>def <span class="ident">show_status</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</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.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.login_handler.LoginHandler.dispatch" href="#connpy.cli.login_handler.LoginHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.login" href="#connpy.cli.login_handler.LoginHandler.login">login</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.logout" href="#connpy.cli.login_handler.LoginHandler.logout">logout</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.show_status" href="#connpy.cli.login_handler.LoginHandler.show_status">show_status</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>
+45 -10
View File
@@ -60,6 +60,23 @@ el.replaceWith(d);
self.app = app self.app = app
self.forms = Forms(app) self.forms = Forms(app)
def _filter_exact_match(self, matches, query):
if not query or len(matches) &lt;= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args): def dispatch(self, args):
if not self.app.case and args.data != None: if not self.app.case and args.data != None:
args.data = args.data.lower() args.data = args.data.lower()
@@ -85,6 +102,7 @@ el.replaceWith(d);
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -119,6 +137,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -133,8 +152,9 @@ el.replaceWith(d);
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;) printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -190,6 +210,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -217,6 +238,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -255,7 +277,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;) printer.success(f&#34;{args.data} edited successfully&#34;)
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34; updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -268,8 +290,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info(&#34;Nothing to do here&#34;) printer.info(&#34;Nothing to do here&#34;)
@@ -354,6 +380,7 @@ el.replaceWith(d);
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -398,6 +425,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -412,8 +440,9 @@ el.replaceWith(d);
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;) printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -456,6 +485,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -494,7 +524,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;) printer.success(f&#34;{args.data} edited successfully&#34;)
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34; updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -507,8 +537,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info(&#34;Nothing to do here&#34;) printer.info(&#34;Nothing to do here&#34;)
@@ -535,6 +569,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
+656 -11
View File
@@ -63,13 +63,64 @@ el.replaceWith(d);
def dispatch(self, args): def dispatch(self, args):
if len(args.data) &gt; 1: if len(args.data) &gt; 1:
args.action = &#34;noderun&#34; args.action = &#34;noderun&#34;
actions = {&#34;noderun&#34;: self.node_run, &#34;generate&#34;: self.yaml_generate, &#34;run&#34;: self.yaml_run} actions = {
&#34;noderun&#34;: self.node_run,
&#34;generate&#34;: self.yaml_generate,
&#34;generate_ai&#34;: self.ai_generate,
&#34;run&#34;: 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):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f&#34;No nodes found matching filter: {nodes_filter}&#34;)
sys.exit(2)
commands = [&#34; &#34;.join(args.data[1:])] commands = [&#34; &#34;.join(args.data[1:])]
# Check for Preflight AI simulation
if getattr(args, &#34;preflight_ai&#34;, False):
matched_node_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in matched_nodes]
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed: {e}&#34;)
sys.exit(1)
sys.exit(0)
try: try:
header_printed = False header_printed = False
@@ -84,7 +135,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -101,12 +152,46 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
printer.run_summary(results) printer.run_summary(results)
# Analyze execution results if requested
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else &#34; &#34;.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=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except ConnpyError as e: except ConnpyError as e:
printer.error(str(e)) printer.error(str(e))
sys.exit(1) sys.exit(1)
@@ -127,8 +212,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f: with open(path, &#34;r&#34;) 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, &#34;preflight_ai&#34;, False):
preflight_failed = False
for task in playbook.get(&#34;tasks&#34;, []): for task in playbook.get(&#34;tasks&#34;, []):
self.cli_run(task) name = task.get(&#34;name&#34;, &#34;Task&#34;)
nodelist = task.get(&#34;nodes&#34;, [])
commands = task.get(&#34;commands&#34;, [])
# 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(&#34;name&#34;) if isinstance(n, dict) else n for n in resolved_nodes]
printer.console.print(f&#34;\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)&#34;)
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed for task {name}: {e}&#34;)
preflight_failed = True
if preflight_failed:
sys.exit(1)
sys.exit(0)
# Standard run
results_all = {}
for task in playbook.get(&#34;tasks&#34;, []):
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, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing playbook execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f&#34;Playbook: {path}&#34;
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=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except Exception as e: except Exception as e:
printer.error(f&#34;Failed to run playbook {path}: {e}&#34;) printer.error(f&#34;Failed to run playbook {path}: {e}&#34;)
@@ -151,6 +333,29 @@ el.replaceWith(d);
folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None
prompt = options.get(&#34;prompt&#34;) prompt = options.get(&#34;prompt&#34;)
# Resolve and filter nodes through context-aware list_nodes
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 = []
if not resolved_nodes:
printer.error(f&#34;[{name}] No nodes found matching filter: {nodelist}&#34;)
sys.exit(11)
nodelist = resolved_nodes
results = {}
try: try:
header_printed = False header_printed = False
if action == &#34;run&#34;: if action == &#34;run&#34;:
@@ -211,12 +416,243 @@ 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&#34;File &#39;{dest_file}&#39; already exists.&#34;)
sys.exit(14)
chat_history = []
# Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;))
while True:
try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;)
break
if user_prompt.strip().lower() in [&#34;exit&#34;, &#34;quit&#34;]:
printer.info(&#34;Exiting AI Assistant.&#34;)
break
if not user_prompt.strip():
continue
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try:
status_context.stop()
except:
pass
printer.console.print(Rule(title=&#34;[engineer][bold]Playbook Builder AI[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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=&#34;engineer&#34;))
# Update history
if res and &#34;chat_history&#34; in res:
chat_history = res[&#34;chat_history&#34;]
# Check if the agent returned a validated playbook YAML
if res and &#34;playbook_yaml&#34; in res and res[&#34;playbook_yaml&#34;]:
yaml_content = res[&#34;playbook_yaml&#34;]
printer.console.print()
printer.success(&#34;Playbook YAML successfully generated and validated.&#34;)
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
syntax = Syntax(yaml_content, &#34;yaml&#34;, theme=&#34;ansi_dark&#34;, word_wrap=True, background_color=&#34;default&#34;)
panel = Panel(syntax, title=&#34;[engineer][bold]Resulting Playbook[/bold][/engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
printer.console.print(panel)
# Ask if the user wants to save it
try:
save_confirm = Prompt.ask(
f&#34;\nDo you want to save this playbook to &#39;{dest_file}&#39;?&#34;,
choices=[&#34;y&#34;, &#34;n&#34;, &#34;run&#34;],
default=&#34;y&#34;
)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Saving skipped.&#34;)
break
choice = save_confirm.strip().lower()
if choice in [&#34;y&#34;, &#34;yes&#34;, &#34;run&#34;]:
with open(dest_file, &#34;w&#34;) as f:
f.write(yaml_content)
printer.success(f&#34;Playbook saved successfully to &#39;{dest_file}&#39;&#34;)
if choice == &#34;run&#34;:
printer.console.print()
printer.info(&#34;Executing the saved playbook...&#34;)
self.yaml_run(args)
break
else:
printer.warning(&#34;Playbook not saved. You can continue describing changes or exit.&#34;)
except Exception as e:
printer.error(f&#34;Error in AI chat: {e}&#34;)</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&#34;File &#39;{dest_file}&#39; already exists.&#34;)
sys.exit(14)
chat_history = []
# Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;))
while True:
try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;)
break
if user_prompt.strip().lower() in [&#34;exit&#34;, &#34;quit&#34;]:
printer.info(&#34;Exiting AI Assistant.&#34;)
break
if not user_prompt.strip():
continue
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try:
status_context.stop()
except:
pass
printer.console.print(Rule(title=&#34;[engineer][bold]Playbook Builder AI[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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=&#34;engineer&#34;))
# Update history
if res and &#34;chat_history&#34; in res:
chat_history = res[&#34;chat_history&#34;]
# Check if the agent returned a validated playbook YAML
if res and &#34;playbook_yaml&#34; in res and res[&#34;playbook_yaml&#34;]:
yaml_content = res[&#34;playbook_yaml&#34;]
printer.console.print()
printer.success(&#34;Playbook YAML successfully generated and validated.&#34;)
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
syntax = Syntax(yaml_content, &#34;yaml&#34;, theme=&#34;ansi_dark&#34;, word_wrap=True, background_color=&#34;default&#34;)
panel = Panel(syntax, title=&#34;[engineer][bold]Resulting Playbook[/bold][/engineer]&#34;, border_style=&#34;engineer&#34;, expand=False)
printer.console.print(panel)
# Ask if the user wants to save it
try:
save_confirm = Prompt.ask(
f&#34;\nDo you want to save this playbook to &#39;{dest_file}&#39;?&#34;,
choices=[&#34;y&#34;, &#34;n&#34;, &#34;run&#34;],
default=&#34;y&#34;
)
except (KeyboardInterrupt, EOFError):
printer.console.print()
printer.warning(&#34;Saving skipped.&#34;)
break
choice = save_confirm.strip().lower()
if choice in [&#34;y&#34;, &#34;yes&#34;, &#34;run&#34;]:
with open(dest_file, &#34;w&#34;) as f:
f.write(yaml_content)
printer.success(f&#34;Playbook saved successfully to &#39;{dest_file}&#39;&#34;)
if choice == &#34;run&#34;:
printer.console.print()
printer.info(&#34;Executing the saved playbook...&#34;)
self.yaml_run(args)
break
else:
printer.warning(&#34;Playbook not saved. You can continue describing changes or exit.&#34;)
except Exception as e:
printer.error(f&#34;Error in AI chat: {e}&#34;)</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>
@@ -242,6 +678,29 @@ el.replaceWith(d);
folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None
prompt = options.get(&#34;prompt&#34;) prompt = options.get(&#34;prompt&#34;)
# Resolve and filter nodes through context-aware list_nodes
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 = []
if not resolved_nodes:
printer.error(f&#34;[{name}] No nodes found matching filter: {nodelist}&#34;)
sys.exit(11)
nodelist = resolved_nodes
results = {}
try: try:
header_printed = False header_printed = False
if action == &#34;run&#34;: if action == &#34;run&#34;:
@@ -302,8 +761,11 @@ 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>
@@ -318,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) &gt; 1: if len(args.data) &gt; 1:
args.action = &#34;noderun&#34; args.action = &#34;noderun&#34;
actions = {&#34;noderun&#34;: self.node_run, &#34;generate&#34;: self.yaml_generate, &#34;run&#34;: self.yaml_run} actions = {
&#34;noderun&#34;: self.node_run,
&#34;generate&#34;: self.yaml_generate,
&#34;generate_ai&#34;: self.ai_generate,
&#34;run&#34;: 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>
@@ -333,8 +800,54 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def node_run(self, args): <pre><code class="python">def node_run(self, args):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f&#34;No nodes found matching filter: {nodes_filter}&#34;)
sys.exit(2)
commands = [&#34; &#34;.join(args.data[1:])] commands = [&#34; &#34;.join(args.data[1:])]
# Check for Preflight AI simulation
if getattr(args, &#34;preflight_ai&#34;, False):
matched_node_names = [n.get(&#34;name&#34;) if isinstance(n, dict) else n for n in matched_nodes]
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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=&#34;[engineer][bold]Preflight AI Simulation[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed: {e}&#34;)
sys.exit(1)
sys.exit(0)
try: try:
header_printed = False header_printed = False
@@ -349,7 +862,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -366,12 +879,46 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
printer.run_summary(results) printer.run_summary(results)
# Analyze execution results if requested
if getattr(args, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else &#34; &#34;.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=&#34;[architect][bold]Network Architect AI Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
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>
@@ -412,8 +959,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f: with open(path, &#34;r&#34;) 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, &#34;preflight_ai&#34;, False):
preflight_failed = False
for task in playbook.get(&#34;tasks&#34;, []): for task in playbook.get(&#34;tasks&#34;, []):
self.cli_run(task) name = task.get(&#34;name&#34;, &#34;Task&#34;)
nodelist = task.get(&#34;nodes&#34;, [])
commands = task.get(&#34;commands&#34;, [])
# 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(&#34;name&#34;) if isinstance(n, dict) else n for n in resolved_nodes]
printer.console.print(f&#34;\n[bold]Task: {name}[/bold] (Preflight for {len(resolved_names)} nodes)&#34;)
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Simulating execution...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=f&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
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&#34;[engineer][bold]Preflight AI Simulation: {name}[/bold][/engineer]&#34;, style=&#34;engineer&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;engineer&#34;))
except Exception as e:
printer.error(f&#34;Preflight AI simulation failed for task {name}: {e}&#34;)
preflight_failed = True
if preflight_failed:
sys.exit(1)
sys.exit(0)
# Standard run
results_all = {}
for task in playbook.get(&#34;tasks&#34;, []):
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, &#34;analyze&#34;, None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status(&#34;[ai_status]Analyzing playbook execution results...[/ai_status]&#34;)
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f&#34;Playbook: {path}&#34;
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=&#34;[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]&#34;, style=&#34;architect&#34;))
renderer.flush()
printer.console.print(Rule(style=&#34;architect&#34;))
except Exception as e:
printer.error(f&#34;AI Analysis failed: {e}&#34;)
except Exception as e: except Exception as e:
printer.error(f&#34;Failed to run playbook {path}: {e}&#34;) printer.error(f&#34;Failed to run playbook {path}: {e}&#34;)
@@ -440,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>
+459
View File
@@ -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 == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
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(&#34;Configured SSO Providers&#34;, yaml_str)
def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, 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(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
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(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
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 == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
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(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
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(&#34;Configured SSO Providers&#34;, 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(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, 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>
+28 -28
View File
@@ -121,14 +121,14 @@ el.replaceWith(d);
} }
# 1. Visual Separation # 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;)) self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel( self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34; &#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;, &#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34; border_style=&#34;cyan&#34;
)) ))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings() bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;) @bindings.add(&#39;c-up&#39;)
@@ -168,7 +168,8 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_single: if state[&#39;context_mode&#39;] == self.mode_single:
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] # Concat only the bytes of valid blocks to skip intermediate empty/cancelled prompt noise
active_raw = b&#34;&#34;.join(raw_bytes[b[0]:b[1]] for b in blocks[idx:])
return preview + &#34;\n&#34; + log_cleaner(active_raw.decode(errors=&#39;replace&#39;)) return preview + &#34;\n&#34; + log_cleaner(active_raw.decode(errors=&#39;replace&#39;))
def get_prompt_text(): def get_prompt_text():
@@ -194,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(&#39;/&#39;) and &#39; &#39; not in text: if text.startswith(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;] commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())] matches = [c for c in commands if c.startswith(text.lower())]
@@ -207,22 +208,21 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else: else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
import re
def clean_preview(text): def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando # Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;) original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original) cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original # If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range: if state[&#39;context_mode&#39;] == 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) &gt; 1: if len(range_blocks) &gt; 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&#39;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&#39;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(&#39;\x1b[1A\x1b[2K&#39;) sys.stdout.write(&#39;\x1b[1A\x1b[2K&#39;)
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[&#39;toolbar_msg&#39;] = &#39;&#39; state[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question) clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -370,10 +370,10 @@ el.replaceWith(d);
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34; persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = &#34;&#34; live_text = &#34;&#34;
first_chunk = True first_chunk = True
import sys
from rich.rule import Rule from rich.rule import Rule
from rich.status import Status from rich.status import Status
from connpy.printer import IncrementalMarkdownParser from connpy.printer import IncrementalMarkdownParser
@@ -575,14 +575,14 @@ el.replaceWith(d);
} }
# 1. Visual Separation # 1. Visual Separation
self.console.print(&#34;&#34;) # Salto de línea real self.console.print(&#34;&#34;) # Real line break
self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;)) self.console.print(Rule(title=&#34;[bold cyan] AI TERMINAL COPILOT [/bold cyan]&#34;, style=&#34;cyan&#34;))
self.console.print(Panel( self.console.print(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34; &#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n&#34;
&#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;, &#34;Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]&#34;,
border_style=&#34;cyan&#34; border_style=&#34;cyan&#34;
)) ))
self.console.print(&#34;\n&#34;) # Pequeño espacio antes del prompt del copilot self.console.print(&#34;\n&#34;) # Small space before the copilot prompt
bindings = KeyBindings() bindings = KeyBindings()
@bindings.add(&#39;c-up&#39;) @bindings.add(&#39;c-up&#39;)
@@ -622,7 +622,8 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_single: if state[&#39;context_mode&#39;] == self.mode_single:
active_raw = raw_bytes[start:end] active_raw = raw_bytes[start:end]
else: else:
active_raw = raw_bytes[start:] # Concat only the bytes of valid blocks to skip intermediate empty/cancelled prompt noise
active_raw = b&#34;&#34;.join(raw_bytes[b[0]:b[1]] for b in blocks[idx:])
return preview + &#34;\n&#34; + log_cleaner(active_raw.decode(errors=&#39;replace&#39;)) return preview + &#34;\n&#34; + log_cleaner(active_raw.decode(errors=&#39;replace&#39;))
def get_prompt_text(): def get_prompt_text():
@@ -648,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(&#39;/&#39;) and &#39; &#39; not in text: if text.startswith(&#39;/&#39;) and &#39; &#39; not in text:
commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;] commands = [&#39;/os&#39;, &#39;/prompt&#39;, &#39;/architect&#39;, &#39;/engineer&#39;, &#39;/trust&#39;, &#39;/untrust&#39;, &#39;/memorize&#39;, &#39;/clear&#39;]
matches = [c for c in commands if c.startswith(text.lower())] matches = [c for c in commands if c.startswith(text.lower())]
@@ -661,22 +662,21 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39; base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else: else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;]) idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
import re
def clean_preview(text): def clean_preview(text):
# Limpia saltos de línea y el prompt inicial (todo hasta #, &gt; o $) para que quede solo el comando # Clean newlines and the initial prompt (all up to #, &gt; or $) to leave only the command
original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;) original = text.strip().replace(&#39;\r&#39;, &#39;&#39;).replace(&#39;\n&#39;, &#39; &#39;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original) cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, original)
# Si limpiar el prompt nos deja con un string vacío (ej: era solo &#34;iol#&#34;), devolvemos el original # If cleaning the prompt leaves us with an empty string (e.g. it was just &#34;iol#&#34;), return the original
return cleaned if cleaned else original return cleaned if cleaned else original
if state[&#39;context_mode&#39;] == self.mode_range: if state[&#39;context_mode&#39;] == 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) &gt; 1: if len(range_blocks) &gt; 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&#39;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&#39;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(&#39;\x1b[1A\x1b[2K&#39;) sys.stdout.write(&#39;\x1b[1A\x1b[2K&#39;)
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[&#39;toolbar_msg&#39;] = &#39;&#39; state[&#39;toolbar_msg&#39;] = &#39;&#39;
clean_question = directive.get(&#34;clean_prompt&#34;, question) clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -824,10 +824,10 @@ el.replaceWith(d);
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34; persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer() active_buffer = get_active_buffer()
live_text = &#34;&#34; live_text = &#34;&#34;
first_chunk = True first_chunk = True
import sys
from rich.rule import Rule from rich.rule import Rule
from rich.status import Status from rich.status import Status
from connpy.printer import IncrementalMarkdownParser from connpy.printer import IncrementalMarkdownParser
+522
View File
@@ -0,0 +1,522 @@
<!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.user_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.user_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.user_handler.UserHandler"><code class="flex name class">
<span>class <span class="ident">UserHandler</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 UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)
def delete_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)
def show_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.user_handler.UserHandler.add_user"><code class="name flex">
<span>def <span class="ident">add_user</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_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.delete_user"><code class="name flex">
<span>def <span class="ident">delete_user</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_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.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 == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.list_users"><code class="name flex">
<span>def <span class="ident">list_users</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_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.regen_password"><code class="name flex">
<span>def <span class="ident">regen_password</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.show_user"><code class="name flex">
<span>def <span class="ident">show_user</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_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)</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.user_handler.UserHandler" href="#connpy.cli.user_handler.UserHandler">UserHandler</a></code></h4>
<ul class="two-column">
<li><code><a title="connpy.cli.user_handler.UserHandler.add_user" href="#connpy.cli.user_handler.UserHandler.add_user">add_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.delete_user" href="#connpy.cli.user_handler.UserHandler.delete_user">delete_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.dispatch" href="#connpy.cli.user_handler.UserHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.list_users" href="#connpy.cli.user_handler.UserHandler.list_users">list_users</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.regen_password" href="#connpy.cli.user_handler.UserHandler.regen_password">regen_password</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.show_user" href="#connpy.cli.user_handler.UserHandler.show_user">show_user</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>
-807
View File
@@ -45,617 +45,6 @@ el.replaceWith(d);
<section> <section>
</section> </section>
<section> <section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.AIResponse"><code class="flex name class">
<span>class <span class="ident">AIResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.AIResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.AskRequest"><code class="flex name class">
<span>class <span class="ident">AskRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.AskRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.BoolResponse"><code class="flex name class">
<span>class <span class="ident">BoolResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.BoolResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.BulkRequest"><code class="flex name class">
<span>class <span class="ident">BulkRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.BulkRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.CopilotRequest"><code class="flex name class">
<span>class <span class="ident">CopilotRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.CopilotRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.CopilotResponse"><code class="flex name class">
<span>class <span class="ident">CopilotResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.CopilotResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.DeleteRequest"><code class="flex name class">
<span>class <span class="ident">DeleteRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.DeleteRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ExportRequest"><code class="flex name class">
<span>class <span class="ident">ExportRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ExportRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.FilterRequest"><code class="flex name class">
<span>class <span class="ident">FilterRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.FilterRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.FullReplaceRequest"><code class="flex name class">
<span>class <span class="ident">FullReplaceRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.FullReplaceRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.IdRequest"><code class="flex name class">
<span>class <span class="ident">IdRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.IdRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.IntRequest"><code class="flex name class">
<span>class <span class="ident">IntRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.IntRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.InteractRequest"><code class="flex name class">
<span>class <span class="ident">InteractRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.InteractRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.InteractResponse"><code class="flex name class">
<span>class <span class="ident">InteractResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.InteractResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ListRequest"><code class="flex name class">
<span>class <span class="ident">ListRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ListRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.MCPRequest"><code class="flex name class">
<span>class <span class="ident">MCPRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.MCPRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.MessageValue"><code class="flex name class">
<span>class <span class="ident">MessageValue</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.MessageValue.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.MoveRequest"><code class="flex name class">
<span>class <span class="ident">MoveRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.MoveRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.NodeRequest"><code class="flex name class">
<span>class <span class="ident">NodeRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.NodeRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.NodeRunResult"><code class="flex name class">
<span>class <span class="ident">NodeRunResult</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.NodeRunResult.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.PluginRequest"><code class="flex name class">
<span>class <span class="ident">PluginRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.PluginRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ProfileRequest"><code class="flex name class">
<span>class <span class="ident">ProfileRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ProfileRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ProviderRequest"><code class="flex name class">
<span>class <span class="ident">ProviderRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ProviderRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.RunRequest"><code class="flex name class">
<span>class <span class="ident">RunRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.RunRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ScriptRequest"><code class="flex name class">
<span>class <span class="ident">ScriptRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ScriptRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.StringRequest"><code class="flex name class">
<span>class <span class="ident">StringRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.StringRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.StringResponse"><code class="flex name class">
<span>class <span class="ident">StringResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.StringResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.StructRequest"><code class="flex name class">
<span>class <span class="ident">StructRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.StructRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.StructResponse"><code class="flex name class">
<span>class <span class="ident">StructResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.StructResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.TestRequest"><code class="flex name class">
<span>class <span class="ident">TestRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.TestRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.UpdateRequest"><code class="flex name class">
<span>class <span class="ident">UpdateRequest</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.UpdateRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2.ValueResponse"><code class="flex name class">
<span>class <span class="ident">ValueResponse</span></span>
<span>(</span><span>*args, **kwargs)</span>
</code></dt>
<dd>
<div class="desc"><p>A ProtocolMessage</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>google._upb._message.Message</li>
<li>google.protobuf.message.Message</li>
</ul>
<h3>Class variables</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2.ValueResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section> </section>
</article> </article>
<nav id="sidebar"> <nav id="sidebar">
@@ -668,202 +57,6 @@ el.replaceWith(d);
<li><code><a title="connpy.grpc_layer" href="index.html">connpy.grpc_layer</a></code></li> <li><code><a title="connpy.grpc_layer" href="index.html">connpy.grpc_layer</a></code></li>
</ul> </ul>
</li> </li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.AIResponse" href="#connpy.grpc_layer.connpy_pb2.AIResponse">AIResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.AIResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.AIResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.AskRequest" href="#connpy.grpc_layer.connpy_pb2.AskRequest">AskRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.AskRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.AskRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.BoolResponse" href="#connpy.grpc_layer.connpy_pb2.BoolResponse">BoolResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.BoolResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.BoolResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.BulkRequest" href="#connpy.grpc_layer.connpy_pb2.BulkRequest">BulkRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.BulkRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.BulkRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.CopilotRequest" href="#connpy.grpc_layer.connpy_pb2.CopilotRequest">CopilotRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.CopilotRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.CopilotRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.CopilotResponse" href="#connpy.grpc_layer.connpy_pb2.CopilotResponse">CopilotResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.CopilotResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.CopilotResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.DeleteRequest" href="#connpy.grpc_layer.connpy_pb2.DeleteRequest">DeleteRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.DeleteRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.DeleteRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ExportRequest" href="#connpy.grpc_layer.connpy_pb2.ExportRequest">ExportRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ExportRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ExportRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.FilterRequest" href="#connpy.grpc_layer.connpy_pb2.FilterRequest">FilterRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.FilterRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.FilterRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.FullReplaceRequest" href="#connpy.grpc_layer.connpy_pb2.FullReplaceRequest">FullReplaceRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.FullReplaceRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.FullReplaceRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.IdRequest" href="#connpy.grpc_layer.connpy_pb2.IdRequest">IdRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.IdRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.IdRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.IntRequest" href="#connpy.grpc_layer.connpy_pb2.IntRequest">IntRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.IntRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.IntRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.InteractRequest" href="#connpy.grpc_layer.connpy_pb2.InteractRequest">InteractRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.InteractRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.InteractRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.InteractResponse" href="#connpy.grpc_layer.connpy_pb2.InteractResponse">InteractResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.InteractResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.InteractResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ListRequest" href="#connpy.grpc_layer.connpy_pb2.ListRequest">ListRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ListRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ListRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.MCPRequest" href="#connpy.grpc_layer.connpy_pb2.MCPRequest">MCPRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.MCPRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.MCPRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.MessageValue" href="#connpy.grpc_layer.connpy_pb2.MessageValue">MessageValue</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.MessageValue.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.MessageValue.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.MoveRequest" href="#connpy.grpc_layer.connpy_pb2.MoveRequest">MoveRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.MoveRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.MoveRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.NodeRequest" href="#connpy.grpc_layer.connpy_pb2.NodeRequest">NodeRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.NodeRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.NodeRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.NodeRunResult" href="#connpy.grpc_layer.connpy_pb2.NodeRunResult">NodeRunResult</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.NodeRunResult.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.NodeRunResult.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.PluginRequest" href="#connpy.grpc_layer.connpy_pb2.PluginRequest">PluginRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.PluginRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.PluginRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ProfileRequest" href="#connpy.grpc_layer.connpy_pb2.ProfileRequest">ProfileRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ProfileRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ProfileRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ProviderRequest" href="#connpy.grpc_layer.connpy_pb2.ProviderRequest">ProviderRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ProviderRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ProviderRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.RunRequest" href="#connpy.grpc_layer.connpy_pb2.RunRequest">RunRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.RunRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.RunRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ScriptRequest" href="#connpy.grpc_layer.connpy_pb2.ScriptRequest">ScriptRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ScriptRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ScriptRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.StringRequest" href="#connpy.grpc_layer.connpy_pb2.StringRequest">StringRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.StringRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.StringRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.StringResponse" href="#connpy.grpc_layer.connpy_pb2.StringResponse">StringResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.StringResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.StringResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.StructRequest" href="#connpy.grpc_layer.connpy_pb2.StructRequest">StructRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.StructRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.StructRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.StructResponse" href="#connpy.grpc_layer.connpy_pb2.StructResponse">StructResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.StructResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.StructResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.TestRequest" href="#connpy.grpc_layer.connpy_pb2.TestRequest">TestRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.TestRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.TestRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.UpdateRequest" href="#connpy.grpc_layer.connpy_pb2.UpdateRequest">UpdateRequest</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.UpdateRequest.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.UpdateRequest.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2.ValueResponse" href="#connpy.grpc_layer.connpy_pb2.ValueResponse">ValueResponse</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2.ValueResponse.DESCRIPTOR" href="#connpy.grpc_layer.connpy_pb2.ValueResponse.DESCRIPTOR">DESCRIPTOR</a></code></li>
</ul>
</li>
</ul>
</li>
</ul> </ul>
</nav> </nav>
</main> </main>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -64,6 +64,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.grpc_layer.user_registry" href="user_registry.html">connpy.grpc_layer.user_registry</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></dt> <dt><code class="name"><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -95,6 +99,7 @@ el.replaceWith(d);
<li><code><a title="connpy.grpc_layer.remote_plugin_pb2_grpc" href="remote_plugin_pb2_grpc.html">connpy.grpc_layer.remote_plugin_pb2_grpc</a></code></li> <li><code><a title="connpy.grpc_layer.remote_plugin_pb2_grpc" href="remote_plugin_pb2_grpc.html">connpy.grpc_layer.remote_plugin_pb2_grpc</a></code></li>
<li><code><a title="connpy.grpc_layer.server" href="server.html">connpy.grpc_layer.server</a></code></li> <li><code><a title="connpy.grpc_layer.server" href="server.html">connpy.grpc_layer.server</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs" href="stubs.html">connpy.grpc_layer.stubs</a></code></li> <li><code><a title="connpy.grpc_layer.stubs" href="stubs.html">connpy.grpc_layer.stubs</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry" href="user_registry.html">connpy.grpc_layer.user_registry</a></code></li>
<li><code><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></li> <li><code><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></li>
</ul> </ul>
</li> </li>
File diff suppressed because it is too large Load Diff
+517 -204
View File
@@ -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
@@ -122,6 +121,10 @@ el.replaceWith(d);
) )
if chat_history is not None: if chat_history is not None:
initial_req.chat_history.CopyFrom(to_value(chat_history)) initial_req.chat_history.CopyFrom(to_value(chat_history))
if &#34;engineer_auth&#34; in overrides and overrides[&#34;engineer_auth&#34;]:
initial_req.engineer_auth.CopyFrom(to_struct(overrides[&#34;engineer_auth&#34;]))
if &#34;architect_auth&#34; in overrides and overrides[&#34;architect_auth&#34;]:
initial_req.architect_auth.CopyFrom(to_struct(overrides[&#34;architect_auth&#34;]))
req_queue.put(initial_req) req_queue.put(initial_req)
@@ -131,10 +134,11 @@ 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 = &#34;&#34; full_content = &#34;&#34;
header_printed = False header_printed = False
current_responder = &#34;engineer&#34;
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []} final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
# Background thread to pull responses from gRPC into a local queue # Background thread to pull responses from gRPC into a local queue
@@ -179,6 +183,10 @@ el.replaceWith(d);
break break
if response.status_update: if response.status_update:
if response.status_update.startswith(&#34;__RESPONDER__:&#34;):
current_responder = response.status_update.split(&#34;:&#34;)[1].lower()
continue
if response.requires_confirmation: if response.requires_confirmation:
if status: status.stop() if status: status.stop()
@@ -225,24 +233,32 @@ el.replaceWith(d);
try: status.stop() try: status.stop()
except: pass except: pass
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from rich.rule import Rule from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk # Print header on first chunk
stable_console.print(Rule(&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, style=&#34;engineer&#34;)) alias = &#34;architect&#34; if current_responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if current_responder == &#34;architect&#34; else &#34;Network Engineer&#34;
stable_console.print(Rule(f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;, style=alias))
header_printed = True header_printed = True
# Initialize parser # Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console) md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk) 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()
@@ -251,19 +267,12 @@ 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(&#34;responder&#34;, &#34;engineer&#34;)
alias = &#34;architect&#34; if responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
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())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get(&#34;response&#34;):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -279,157 +288,26 @@ el.replaceWith(d);
return final_result return final_result
@handle_errors @handle_errors
def confirm(self, input_text, console=None): def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value 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 @handle_errors
def list_sessions(self): def build_playbook_chat(self, user_input, chat_history=None, status=None, chunk_callback=None):
return from_value(self.stub.list_sessions(Empty()).data) return self._ai_chat_stream(self.stub.build_playbook_chat, user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
@handle_errors
def delete_session(self, session_id):
self.stub.delete_session(connpy_pb2.StringRequest(value=session_id))
@handle_errors
def configure_provider(self, provider, model=None, api_key=None):
req = connpy_pb2.ProviderRequest(provider=provider, model=model or &#34;&#34;, api_key=api_key or &#34;&#34;)
self.stub.configure_provider(req)
@handle_errors
def configure_mcp(self, name, url=None, enabled=True, auto_load_on_os=None, remove=False):
req = connpy_pb2.MCPRequest(
name=name,
url=url or &#34;&#34;,
enabled=enabled,
auto_load_on_os=auto_load_on_os or &#34;&#34;,
remove=remove
)
self.stub.configure_mcp(req)
@handle_errors
def load_session_data(self, session_id):
return from_struct(self.stub.load_session_data(connpy_pb2.StringRequest(value=session_id)).data)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
import queue
from rich.prompt import Prompt
from rich.text import Text
from rich.panel import Panel
from rich.markdown import Markdown
req_queue = queue.Queue()
initial_req = connpy_pb2.AskRequest(
input_text=input_text,
dryrun=dryrun,
session_id=session_id or &#34;&#34;,
debug=debug,
engineer_model=overrides.get(&#34;engineer_model&#34;, &#34;&#34;),
engineer_api_key=overrides.get(&#34;engineer_api_key&#34;, &#34;&#34;),
architect_model=overrides.get(&#34;architect_model&#34;, &#34;&#34;),
architect_api_key=overrides.get(&#34;architect_api_key&#34;, &#34;&#34;),
trust=overrides.get(&#34;trust&#34;, False)
)
if chat_history is not None:
initial_req.chat_history.CopyFrom(to_value(chat_history))
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())
def _process_unary_stream(self, responses, status=None, chunk_callback=None):
full_content = &#34;&#34; full_content = &#34;&#34;
header_printed = False header_printed = False
final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []} final_result = {&#34;response&#34;: &#34;&#34;, &#34;chat_history&#34;: []}
md_parser = None
# 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: try:
for response in responses: for response in responses:
response_queue.put((&#34;data&#34;, response))
except Exception as e:
response_queue.put((&#34;error&#34;, 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(&#34;[error]Interrupted! Closing pending tasks...&#34;)
# 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 == &#34;error&#34;:
# Re-raise or handle gRPC error from background thread
if isinstance(response, grpc.RpcError):
raise response
printer.warning(f&#34;Stream interrupted: {response}&#34;)
break
if response.status_update: if response.status_update:
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(&#34;[ai_status]Agent: Resuming...&#34;)
status.start()
req_queue.put(connpy_pb2.AskRequest(confirmation_answer=ans))
continue
if status: if status:
status.update(response.status_update) status.update(response.status_update)
continue 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 response.important_message:
if status: if status:
try: status.stop() try: status.stop()
@@ -447,25 +325,28 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
try: status.stop() try: status.stop()
except: pass except: pass
if chunk_callback:
header_printed = True
else:
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from rich.rule import Rule from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# Print header on first chunk # Print default header
stable_console.print(Rule(&#34;[bold engineer]Network Engineer[/bold engineer]&#34;, style=&#34;engineer&#34;)) stable_console.print(Rule(&#34;[bold engineer]AI Analysis[/bold engineer]&#34;, style=&#34;engineer&#34;))
header_printed = True header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console) md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk) md_parser.feed(response.text_chunk)
continue continue
if response.is_final: if response.is_final:
if header_printed: if md_parser:
from rich.rule import Rule
md_parser.flush() md_parser.flush()
if status: if status:
@@ -473,32 +354,126 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
except: pass except: pass
final_result = from_struct(response.full_result) final_result = from_struct(response.full_result)
responder = final_result.get(&#34;responder&#34;, &#34;engineer&#34;)
alias = &#34;architect&#34; if responder == &#34;architect&#34; else &#34;engineer&#34;
role_label = &#34;Network Architect&#34; if responder == &#34;architect&#34; else &#34;Network Engineer&#34;
title = f&#34;[bold {alias}]{role_label}[/bold {alias}]&#34;
if header_printed: if md_parser:
from rich.console import Console as RichConsole from rich.console import Console as RichConsole
from rich.rule import Rule
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())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=&#34;engineer&#34;))
elif not full_content and final_result.get(&#34;response&#34;):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch
if isinstance(e, grpc.RpcError): if isinstance(e, grpc.RpcError):
raise raise
printer.warning(f&#34;Stream interrupted: {e}&#34;) printer.warning(f&#34;Stream interrupted: {e}&#34;)
finally:
req_queue.put(None)
if full_content: if full_content:
final_result[&#34;streamed&#34;] = True final_result[&#34;streamed&#34;] = True
return final_result</code></pre> 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 &#34;&#34;)
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
def confirm(self, input_text, console=None):
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
@handle_errors
def list_sessions(self, limit=None):
from .utils import from_value
res = self.stub.list_sessions(Empty())
sessions = from_value(res.data) or []
if limit and len(sessions) &gt; limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)
@handle_errors
def delete_session(self, session_id):
self.stub.delete_session(connpy_pb2.StringRequest(value=session_id))
@handle_errors
def configure_provider(self, provider, model=None, api_key=None, auth=None):
req = connpy_pb2.ProviderRequest(provider=provider, model=model or &#34;&#34;, api_key=api_key or &#34;&#34;)
if auth:
req.auth.CopyFrom(to_struct(auth))
self.stub.configure_provider(req)
@handle_errors
def configure_mcp(self, name, url=None, enabled=True, auto_load_on_os=None, remove=False):
req = connpy_pb2.MCPRequest(
name=name,
url=url or &#34;&#34;,
enabled=enabled,
auto_load_on_os=auto_load_on_os or &#34;&#34;,
remove=remove
)
self.stub.configure_mcp(req)
@handle_errors
def list_mcp_servers(self):
res = self.stub.list_mcp_servers(Empty())
return from_value(res.data) or {}
@handle_errors
def load_session_data(self, session_id):
return from_struct(self.stub.load_session_data(connpy_pb2.StringRequest(value=session_id)).data)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<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 &#34;&#34;)
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">
<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>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@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)</code></pre>
</details>
<div class="desc"></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@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)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
@@ -524,7 +499,7 @@ def configure_mcp(self, name, url=None, enabled=True, auto_load_on_os=None, remo
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.AIStub.configure_provider"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.AIStub.configure_provider"><code class="name flex">
<span>def <span class="ident">configure_provider</span></span>(<span>self, provider, model=None, api_key=None)</span> <span>def <span class="ident">configure_provider</span></span>(<span>self, provider, model=None, api_key=None, auth=None)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -532,8 +507,10 @@ def configure_mcp(self, name, url=None, enabled=True, auto_load_on_os=None, remo
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def configure_provider(self, provider, model=None, api_key=None): def configure_provider(self, provider, model=None, api_key=None, auth=None):
req = connpy_pb2.ProviderRequest(provider=provider, model=model or &#34;&#34;, api_key=api_key or &#34;&#34;) req = connpy_pb2.ProviderRequest(provider=provider, model=model or &#34;&#34;, api_key=api_key or &#34;&#34;)
if auth:
req.auth.CopyFrom(to_struct(auth))
self.stub.configure_provider(req)</code></pre> self.stub.configure_provider(req)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -566,8 +543,8 @@ def delete_session(self, session_id):
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.AIStub.list_sessions"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.AIStub.list_mcp_servers"><code class="name flex">
<span>def <span class="ident">list_sessions</span></span>(<span>self)</span> <span>def <span class="ident">list_mcp_servers</span></span>(<span>self)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -575,8 +552,28 @@ def delete_session(self, session_id):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def list_sessions(self): def list_mcp_servers(self):
return from_value(self.stub.list_sessions(Empty()).data)</code></pre> res = self.stub.list_mcp_servers(Empty())
return from_value(res.data) or {}</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AIStub.list_sessions"><code class="name flex">
<span>def <span class="ident">list_sessions</span></span>(<span>self, limit=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def list_sessions(self, limit=None):
from .utils import from_value
res = self.stub.list_sessions(Empty())
sessions = from_value(res.data) or []
if limit and len(sessions) &gt; limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
@@ -594,6 +591,319 @@ 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>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor"><code class="flex name class">
<span>class <span class="ident">AuthClientInterceptor</span></span>
<span>(</span><span>token_provider)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthClientInterceptor(grpc.UnaryUnaryClientInterceptor,
grpc.UnaryStreamClientInterceptor,
grpc.StreamUnaryClientInterceptor,
grpc.StreamStreamClientInterceptor):
def __init__(self, token_provider):
self.token_provider = token_provider
def _add_metadata(self, client_call_details):
token = self.token_provider()
if not token:
return client_call_details
metadata = []
if client_call_details.metadata:
metadata = list(client_call_details.metadata)
# Check if already present to avoid duplicates
if not any(k.lower() == &#34;authorization&#34; for k, v in metadata):
metadata.append((&#34;authorization&#34;, f&#34;Bearer {token}&#34;))
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=client_call_details.wait_for_ready,
compression=client_call_details.compression,
)
def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Affords intercepting unary-unary invocations.</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>grpc.UnaryUnaryClientInterceptor</li>
<li>grpc.UnaryStreamClientInterceptor</li>
<li>grpc.StreamUnaryClientInterceptor</li>
<li>grpc.StreamStreamClientInterceptor</li>
<li>abc.ABC</li>
</ul>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream"><code class="name flex">
<span>def <span class="ident">intercept_stream_stream</span></span>(<span>self, continuation, client_call_details, request_iterator)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Intercepts a stream-stream invocation.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_iterator = continuation(client_call_details, request_iterator)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and an iterator for response values.
Drawing response values from the returned Call-iterator may
raise RpcError indicating termination of the RPC with non-OK
status.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request_iterator</code></strong></dt>
<dd>An iterator that yields request values for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and an iterator of
response values. Drawing response values from the returned
Call-iterator may raise RpcError indicating termination of
the RPC with non-OK status. This object <em>should</em> also fulfill the
Future interface, though it may not.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary"><code class="name flex">
<span>def <span class="ident">intercept_stream_unary</span></span>(<span>self, continuation, client_call_details, request_iterator)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Intercepts a stream-unary invocation asynchronously.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_future = continuation(client_call_details, request_iterator)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and a Future. In the event of RPC completion,
the return Call-Future's result value will be the response message
of the RPC. Should the event terminate with non-OK status, the
returned Call-Future's exception value will be an RpcError.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request_iterator</code></strong></dt>
<dd>An iterator that yields request values for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and a Future.
In the event of RPC completion, the return Call-Future's
result value will be the response message of the RPC.
Should the event terminate with non-OK status, the returned
Call-Future's exception value will be an RpcError.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream"><code class="name flex">
<span>def <span class="ident">intercept_unary_stream</span></span>(<span>self, continuation, client_call_details, request)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)</code></pre>
</details>
<div class="desc"><p>Intercepts a unary-stream invocation.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_iterator = continuation(client_call_details, request)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and an iterator for response values.
Drawing response values from the returned Call-iterator may
raise RpcError indicating termination of the RPC with non-OK
status.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request</code></strong></dt>
<dd>The request value for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and an iterator of
response values. Drawing response values from the returned
Call-iterator may raise RpcError indicating termination of
the RPC with non-OK status. This object <em>should</em> also fulfill the
Future interface, though it may not.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary"><code class="name flex">
<span>def <span class="ident">intercept_unary_unary</span></span>(<span>self, continuation, client_call_details, request)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)</code></pre>
</details>
<div class="desc"><p>Intercepts a unary-unary invocation asynchronously.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_future = continuation(client_call_details, request)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and a Future. In the event of RPC
completion, the return Call-Future's result value will be
the response message of the RPC. Should the event terminate
with non-OK status, the returned Call-Future's exception value
will be an RpcError.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request</code></strong></dt>
<dd>The request value for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and a Future.
In the event of RPC completion, the return Call-Future's
result value will be the response message of the RPC.
Should the event terminate with non-OK status, the returned
Call-Future's exception value will be an RpcError.</p></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthStub"><code class="flex name class">
<span>class <span class="ident">AuthStub</span></span>
<span>(</span><span>channel, remote_host)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthStub:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.AuthServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
&#34;token&#34;: resp.token,
&#34;username&#34;: resp.username,
&#34;expires_at&#34;: resp.expires_at
}
@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.stubs.AuthStub.change_password"><code class="name flex">
<span>def <span class="ident">change_password</span></span>(<span>self, old_password, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthStub.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, username, password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
&#34;token&#34;: resp.token,
&#34;username&#34;: resp.username,
&#34;expires_at&#34;: resp.expires_at
}</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.ConfigStub"><code class="flex name class"> <dt id="connpy.grpc_layer.stubs.ConfigStub"><code class="flex name class">
@@ -777,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>
@@ -840,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>
@@ -1411,15 +1701,17 @@ def set_reserved_names(self, names):
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
if save:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
if save:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
@@ -1801,7 +2093,7 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.NodeStub.delete_node"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.NodeStub.delete_node"><code class="name flex">
<span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False)</span> <span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -1809,9 +2101,10 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
if save:
self._trigger_local_cache_sync()</code></pre> self._trigger_local_cache_sync()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -1972,7 +2265,7 @@ def set_reserved_names(self, names):
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.NodeStub.update_node"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.NodeStub.update_node"><code class="name flex">
<span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data)</span> <span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -1980,9 +2273,10 @@ def set_reserved_names(self, names):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
if save:
self._trigger_local_cache_sync()</code></pre> self._trigger_local_cache_sync()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -2464,14 +2758,34 @@ 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>
<li><code><a title="connpy.grpc_layer.stubs.AIStub.delete_session" href="#connpy.grpc_layer.stubs.AIStub.delete_session">delete_session</a></code></li> <li><code><a title="connpy.grpc_layer.stubs.AIStub.delete_session" href="#connpy.grpc_layer.stubs.AIStub.delete_session">delete_session</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>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor" href="#connpy.grpc_layer.stubs.AuthClientInterceptor">AuthClientInterceptor</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream">intercept_stream_stream</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary">intercept_stream_unary</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream">intercept_unary_stream</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary">intercept_unary_unary</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.stubs.AuthStub" href="#connpy.grpc_layer.stubs.AuthStub">AuthStub</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.change_password" href="#connpy.grpc_layer.stubs.AuthStub.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.login" href="#connpy.grpc_layer.stubs.AuthStub.login">login</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
@@ -2489,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>
+318
View File
@@ -0,0 +1,318 @@
<!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.grpc_layer.user_registry 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.grpc_layer.user_registry</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.grpc_layer.user_registry.UserRegistry"><code class="flex name class">
<span>class <span class="ident">UserRegistry</span></span>
<span>(</span><span>server_config_dir)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class UserRegistry:
&#34;&#34;&#34;Holds per-user ServiceProviders in memory, thread-safe with hot-reloading.&#34;&#34;&#34;
def __init__(self, server_config_dir):
self.server_config_dir = os.path.abspath(server_config_dir)
self.user_service = UserService(self.server_config_dir)
self._providers = {} # username → ServiceProvider
self._mtimes = {} # username → last loaded mtime (float)
self._lock = threading.Lock()
# Load shared/global config
self._shared_conf_file = os.path.join(self.server_config_dir, &#34;config.yaml&#34;)
if os.path.exists(self._shared_conf_file):
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = os.path.getmtime(self._shared_conf_file)
else:
self._shared_config = None
self._shared_mtime = 0.0
def _refresh_shared(self):
&#34;&#34;&#34;Hot-reload shared config if the file changed on disk.&#34;&#34;&#34;
if not os.path.exists(self._shared_conf_file):
return
current_mtime = os.path.getmtime(self._shared_conf_file)
if current_mtime &gt; self._shared_mtime:
try:
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = current_mtime
# Clear all user providers so they pick up the new shared config
self._providers.clear()
self._mtimes.clear()
except Exception as e:
from connpy import printer
printer.warning(f&#34;Failed to reload shared config: {e}&#34;)
def get_provider(self, username) -&gt; ServiceProvider:
&#34;&#34;&#34;Get, lazy-load, or hot-reload a user&#39;s full ServiceProvider.&#34;&#34;&#34;
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user&#39;s config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get(&#34;config_path&#34;)
if config_path:
conf_file = os.path.join(config_path, &#34;config.yaml&#34;)
else:
conf_file = os.path.join(self.server_config_dir, &#34;users&#34;, username, &#34;config.yaml&#34;)
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) &lt; current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode=&#34;local&#34;)
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, &#34;close&#34;):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f&#34;Failed to hot-reload config for user &#39;{username}&#39; (file may be corrupt/incomplete): {e}&#34;)
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]
def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())
def get_shared_config(self):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
with self._lock:
self._refresh_shared()
return self._shared_config
def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, &#34;close&#34;):
try:
provider.close()
except Exception:
pass</code></pre>
</details>
<div class="desc"><p>Holds per-user ServiceProviders in memory, thread-safe with hot-reloading.</p></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.evict"><code class="name flex">
<span>def <span class="ident">evict</span></span>(<span>self, username)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, &#34;close&#34;):
try:
provider.close()
except Exception:
pass</code></pre>
</details>
<div class="desc"><p>Remove and cleanly shut down cached provider (after delete or password change).</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.get_provider"><code class="name flex">
<span>def <span class="ident">get_provider</span></span>(<span>self, username) > <a title="connpy.services.provider.ServiceProvider" href="../services/provider.html#connpy.services.provider.ServiceProvider">ServiceProvider</a></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_provider(self, username) -&gt; ServiceProvider:
&#34;&#34;&#34;Get, lazy-load, or hot-reload a user&#39;s full ServiceProvider.&#34;&#34;&#34;
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user&#39;s config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get(&#34;config_path&#34;)
if config_path:
conf_file = os.path.join(config_path, &#34;config.yaml&#34;)
else:
conf_file = os.path.join(self.server_config_dir, &#34;users&#34;, username, &#34;config.yaml&#34;)
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) &lt; current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode=&#34;local&#34;)
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, &#34;close&#34;):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f&#34;Failed to hot-reload config for user &#39;{username}&#39; (file may be corrupt/incomplete): {e}&#34;)
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]</code></pre>
</details>
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
</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):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
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">
<span>def <span class="ident">has_users</span></span>(<span>self) > bool</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())</code></pre>
</details>
<div class="desc"><p>Check if any users are registered (enables auth enforcement).</p></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.grpc_layer" href="index.html">connpy.grpc_layer</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.grpc_layer.user_registry.UserRegistry" href="#connpy.grpc_layer.user_registry.UserRegistry">UserRegistry</a></code></h4>
<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.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>
</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>
+434 -175
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -86,7 +86,10 @@ el.replaceWith(d);
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
mcp_config = self.config.get_effective_setting(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
else:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
except Exception: except Exception:
return [] return []
@@ -260,7 +263,10 @@ el.replaceWith(d);
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
mcp_config = self.config.get_effective_setting(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
else:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
except Exception: except Exception:
return [] return []
+288 -37
View File
@@ -58,6 +58,37 @@ el.replaceWith(d);
<pre><code class="python">class AIService(BaseService): <pre><code class="python">class AIService(BaseService):
&#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34;
def _clean_cisco_scrolling(self, text: str) -&gt; str:
&#34;&#34;&#34;Resolves horizontal scrolling artifacts (backspaces, \r, ANSI) by merging overlapping segments.&#34;&#34;&#34;
def merge_overlapping(s1, s2):
s2_clean = s2.lstrip(&#39; $&#39;)
max_overlap = min(len(s1), len(s2_clean))
for i in range(max_overlap, 0, -1):
if s1[-i:] == s2_clean[:i]:
return s1 + s2_clean[i:]
return s1 + s2_clean
scroll_re = re.compile(r&#39;(\x08{5,}\s*\$?|\$\r|\x1b\[\d+[GD]\s*\$?)&#39;)
parts = scroll_re.split(text)
merged = &#34;&#34;
for part in parts:
if scroll_re.match(part):
continue
cleaned = log_cleaner(part)
if not merged:
merged = cleaned
else:
merged_lines = merged.split(&#39;\n&#39;)
cleaned_lines = cleaned.split(&#39;\n&#39;)
merged_lines[-1] = merge_overlapping(merged_lines[-1], cleaned_lines[0])
merged_lines.extend(cleaned_lines[1:])
merged = &#34;\n&#34;.join(merged_lines)
return merged
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list: def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = &#34;&#34;) -&gt; list:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34; &#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = [] blocks = []
@@ -79,27 +110,68 @@ el.replaceWith(d);
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
if known_cmd: if known_cmd:
if known_cmd == &#34;CANCELLED&#34;:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;CANCELLED&#34;, &#34;preview&#34;: &#34;&#34;})
else:
prev_chunk = raw_bytes[prev_pos:pos] prev_chunk = raw_bytes[prev_pos:pos]
prev_cleaned = log_cleaner(prev_chunk.decode(errors=&#39;replace&#39;)) prev_cleaned = self._clean_cisco_scrolling(prev_chunk.decode(errors=&#39;replace&#39;))
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
if len(preview) &gt; 80:
preview = preview[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
lines = [l for l in cleaned.split(&#39;\n&#39;) if l.strip()]
preview = lines[-1].strip() if lines else &#34;&#34;
if preview: cleaned = self._clean_cisco_scrolling(chunk.decode(errors=&#39;replace&#39;))
match = prompt_re.search(preview) lines = [l for l in cleaned.split(&#39;\n&#39;) if l.strip()]
found_in_pass1 = False
if lines:
# Search backwards through the last few lines for the prompt
for idx in range(len(lines) - 1, max(-1, len(lines) - 10), -1):
match = prompt_re.search(lines[idx])
if match: if match:
cmd_text = preview[match.end():].strip() ptxt = match.group(0).strip()
cmd_first_line = lines[idx][match.end():].strip()
cmd_rest = [l.strip() for l in lines[idx+1:]]
cmd_text = &#34; &#34;.join([cmd_first_line] + cmd_rest).strip()
if cmd_text: if cmd_text:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]}) pv = f&#34;{ptxt} {cmd_text}&#34;.strip()
if len(pv) &gt; 80:
pv = pv[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: pv})
else: else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else: found_in_pass1 = True
break
if not found_in_pass1:
# Fallback: The prompt might have been isolated in the previous chunk
# due to asynchronous network delays splitting the output exactly at the newline.
prev_was_valid_cmd = i &gt;= 2 and parsed_positions[i-2][&#34;type&#34;] == &#34;VALID_CMD&#34;
if prev_pos &gt; 0 and not prev_was_valid_cmd:
# Fetch the very last chunk that we just processed
prev_prev_pos = cmd_byte_positions[i-2][0] if i &gt;= 2 else 0
prev_chunk_text = self._clean_cisco_scrolling(raw_bytes[prev_prev_pos:prev_pos].decode(errors=&#39;replace&#39;))
prev_lines_text = [l for l in prev_chunk_text.split(&#39;\n&#39;) if l.strip()]
if prev_lines_text:
prev_match = prompt_re.search(prev_lines_text[-1])
if prev_match:
ptxt = prev_match.group(0).strip()
cmd_text = &#34; &#34;.join([l.strip() for l in lines]).strip()
if cmd_text:
pv = f&#34;{ptxt} {cmd_text}&#34;.strip()
if len(pv) &gt; 80:
pv = pv[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: pv})
found_in_pass1 = True
if not found_in_pass1:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else: else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
@@ -113,11 +185,11 @@ el.replaceWith(d);
start_pos = item[&#34;pos&#34;] start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;] preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT # Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)): for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j] next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;): if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;, &#34;CANCELLED&#34;):
end_pos = next_item[&#34;pos&#34;] end_pos = next_item[&#34;pos&#34;]
break break
@@ -219,11 +291,14 @@ el.replaceWith(d);
return await asyncio.wrap_future(future) return await asyncio.wrap_future(future)
def list_sessions(self): def list_sessions(self, limit=None):
&#34;&#34;&#34;Return a list of all saved AI sessions.&#34;&#34;&#34; &#34;&#34;&#34;Return a list of saved AI sessions, optionally limited.&#34;&#34;&#34;
from connpy.ai import ai from connpy.ai import ai
agent = ai(self.config) agent = ai(self.config)
return agent._get_sessions() sessions = agent._get_sessions()
if limit and len(sessions) &gt; limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)
def delete_session(self, session_id): def delete_session(self, session_id):
&#34;&#34;&#34;Delete an AI session by ID.&#34;&#34;&#34; &#34;&#34;&#34;Delete an AI session by ID.&#34;&#34;&#34;
@@ -235,13 +310,15 @@ el.replaceWith(d);
else: else:
raise InvalidConfigurationError(f&#34;Session &#39;{session_id}&#39; not found.&#34;) raise InvalidConfigurationError(f&#34;Session &#39;{session_id}&#39; not found.&#34;)
def configure_provider(self, provider, model=None, api_key=None): def configure_provider(self, provider, model=None, api_key=None, auth=None):
&#34;&#34;&#34;Update AI provider settings in the configuration.&#34;&#34;&#34; &#34;&#34;&#34;Update AI provider settings in the configuration.&#34;&#34;&#34;
settings = self.config.config.get(&#34;ai&#34;, {}) settings = self.config.config.get(&#34;ai&#34;, {})
if model: if model:
settings[f&#34;{provider}_model&#34;] = model settings[f&#34;{provider}_model&#34;] = model
if api_key: if api_key:
settings[f&#34;{provider}_api_key&#34;] = api_key settings[f&#34;{provider}_api_key&#34;] = api_key
if auth is not None:
settings[f&#34;{provider}_auth&#34;] = auth
self.config.config[&#34;ai&#34;] = settings self.config.config[&#34;ai&#34;] = settings
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
@@ -280,11 +357,53 @@ el.replaceWith(d);
self.config.config[&#34;ai&#34;] = ai_settings self.config.config[&#34;ai&#34;] = ai_settings
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {})
def load_session_data(self, session_id): def load_session_data(self, session_id):
&#34;&#34;&#34;Load a session&#39;s raw data by ID.&#34;&#34;&#34; &#34;&#34;&#34;Load a session&#39;s raw data by ID.&#34;&#34;&#34;
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):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
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):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\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.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; 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):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@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.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\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.&#34;
# 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>
@@ -317,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):
&#34;&#34;&#34;Analyze actual command execution results using Network Architect 1-shot.&#34;&#34;&#34;
import json
results_str = json.dumps(results, indent=2)
prompt = f&#34;@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed.&#34;
if query:
prompt += f&#34;\nSpecific user request: {query}&#34;
prompt += f&#34;\n\nResults Data:\n{results_str}&#34;
prompt += &#34;\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.&#34;
# Delegate to self.ask, setting stream=True and forwarding callback/status.
# This will invoke standard ai.ask with &#39;@architect:&#39; 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>
@@ -379,27 +523,68 @@ el.replaceWith(d);
prev_pos = cmd_byte_positions[i-1][0] prev_pos = cmd_byte_positions[i-1][0]
if known_cmd: if known_cmd:
if known_cmd == &#34;CANCELLED&#34;:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;CANCELLED&#34;, &#34;preview&#34;: &#34;&#34;})
else:
prev_chunk = raw_bytes[prev_pos:pos] prev_chunk = raw_bytes[prev_pos:pos]
prev_cleaned = log_cleaner(prev_chunk.decode(errors=&#39;replace&#39;)) prev_cleaned = self._clean_cisco_scrolling(prev_chunk.decode(errors=&#39;replace&#39;))
prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()] prev_lines = [l for l in prev_cleaned.split(&#39;\n&#39;) if l.strip()]
prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34; prompt_text = prev_lines[-1].strip() if prev_lines else &#34;&#34;
preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd preview = f&#34;{prompt_text}{known_cmd}&#34; if prompt_text else known_cmd
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]})
if len(preview) &gt; 80:
preview = preview[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview})
else: else:
chunk = raw_bytes[prev_pos:pos] chunk = raw_bytes[prev_pos:pos]
cleaned = log_cleaner(chunk.decode(errors=&#39;replace&#39;))
lines = [l for l in cleaned.split(&#39;\n&#39;) if l.strip()]
preview = lines[-1].strip() if lines else &#34;&#34;
if preview: cleaned = self._clean_cisco_scrolling(chunk.decode(errors=&#39;replace&#39;))
match = prompt_re.search(preview) lines = [l for l in cleaned.split(&#39;\n&#39;) if l.strip()]
found_in_pass1 = False
if lines:
# Search backwards through the last few lines for the prompt
for idx in range(len(lines) - 1, max(-1, len(lines) - 10), -1):
match = prompt_re.search(lines[idx])
if match: if match:
cmd_text = preview[match.end():].strip() ptxt = match.group(0).strip()
cmd_first_line = lines[idx][match.end():].strip()
cmd_rest = [l.strip() for l in lines[idx+1:]]
cmd_text = &#34; &#34;.join([cmd_first_line] + cmd_rest).strip()
if cmd_text: if cmd_text:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: preview[:80]}) pv = f&#34;{ptxt} {cmd_text}&#34;.strip()
if len(pv) &gt; 80:
pv = pv[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: pv})
else: else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
else: found_in_pass1 = True
break
if not found_in_pass1:
# Fallback: The prompt might have been isolated in the previous chunk
# due to asynchronous network delays splitting the output exactly at the newline.
prev_was_valid_cmd = i &gt;= 2 and parsed_positions[i-2][&#34;type&#34;] == &#34;VALID_CMD&#34;
if prev_pos &gt; 0 and not prev_was_valid_cmd:
# Fetch the very last chunk that we just processed
prev_prev_pos = cmd_byte_positions[i-2][0] if i &gt;= 2 else 0
prev_chunk_text = self._clean_cisco_scrolling(raw_bytes[prev_prev_pos:prev_pos].decode(errors=&#39;replace&#39;))
prev_lines_text = [l for l in prev_chunk_text.split(&#39;\n&#39;) if l.strip()]
if prev_lines_text:
prev_match = prompt_re.search(prev_lines_text[-1])
if prev_match:
ptxt = prev_match.group(0).strip()
cmd_text = &#34; &#34;.join([l.strip() for l in lines]).strip()
if cmd_text:
pv = f&#34;{ptxt} {cmd_text}&#34;.strip()
if len(pv) &gt; 80:
pv = pv[:77] + &#34;...&#34;
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;VALID_CMD&#34;, &#34;preview&#34;: pv})
found_in_pass1 = True
if not found_in_pass1:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
else: else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;}) parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
@@ -413,11 +598,11 @@ el.replaceWith(d);
start_pos = item[&#34;pos&#34;] start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;] preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT # Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)): for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j] next_item = parsed_positions[j]
if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;): if next_item[&#34;type&#34;] in (&#34;VALID_CMD&#34;, &#34;EMPTY_PROMPT&#34;, &#34;CANCELLED&#34;):
end_pos = next_item[&#34;pos&#34;] end_pos = next_item[&#34;pos&#34;]
break break
@@ -433,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):
&#34;&#34;&#34;Interact with the specialized Playbook Builder Agent.&#34;&#34;&#34;
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>
@@ -478,20 +679,22 @@ el.replaceWith(d);
<div class="desc"><p>Update MCP server settings in the configuration with smart merging.</p></div> <div class="desc"><p>Update MCP server settings in the configuration with smart merging.</p></div>
</dd> </dd>
<dt id="connpy.services.ai_service.AIService.configure_provider"><code class="name flex"> <dt id="connpy.services.ai_service.AIService.configure_provider"><code class="name flex">
<span>def <span class="ident">configure_provider</span></span>(<span>self, provider, model=None, api_key=None)</span> <span>def <span class="ident">configure_provider</span></span>(<span>self, provider, model=None, api_key=None, auth=None)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def configure_provider(self, provider, model=None, api_key=None): <pre><code class="python">def configure_provider(self, provider, model=None, api_key=None, auth=None):
&#34;&#34;&#34;Update AI provider settings in the configuration.&#34;&#34;&#34; &#34;&#34;&#34;Update AI provider settings in the configuration.&#34;&#34;&#34;
settings = self.config.config.get(&#34;ai&#34;, {}) settings = self.config.config.get(&#34;ai&#34;, {})
if model: if model:
settings[f&#34;{provider}_model&#34;] = model settings[f&#34;{provider}_model&#34;] = model
if api_key: if api_key:
settings[f&#34;{provider}_api_key&#34;] = api_key settings[f&#34;{provider}_api_key&#34;] = api_key
if auth is not None:
settings[f&#34;{provider}_auth&#34;] = auth
self.config.config[&#34;ai&#34;] = settings self.config.config[&#34;ai&#34;] = settings
self.config._saveconfig(self.config.file)</code></pre> self.config._saveconfig(self.config.file)</code></pre>
@@ -534,21 +737,42 @@ el.replaceWith(d);
</details> </details>
<div class="desc"><p>Delete an AI session by ID.</p></div> <div class="desc"><p>Delete an AI session by ID.</p></div>
</dd> </dd>
<dt id="connpy.services.ai_service.AIService.list_sessions"><code class="name flex"> <dt id="connpy.services.ai_service.AIService.list_mcp_servers"><code class="name flex">
<span>def <span class="ident">list_sessions</span></span>(<span>self)</span> <span>def <span class="ident">list_mcp_servers</span></span>(<span>self) > dict</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def list_sessions(self): <pre><code class="python">def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Return a list of all saved AI sessions.&#34;&#34;&#34; &#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {})</code></pre>
</details>
<div class="desc"><p>Get the configured MCP servers.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.list_sessions"><code class="name flex">
<span>def <span class="ident">list_sessions</span></span>(<span>self, limit=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_sessions(self, limit=None):
&#34;&#34;&#34;Return a list of saved AI sessions, optionally limited.&#34;&#34;&#34;
from connpy.ai import ai from connpy.ai import ai
agent = ai(self.config) agent = ai(self.config)
return agent._get_sessions()</code></pre> sessions = agent._get_sessions()
if limit and len(sessions) &gt; limit:
return sessions[:limit], len(sessions)
return sessions, len(sessions)</code></pre>
</details> </details>
<div class="desc"><p>Return a list of all saved AI sessions.</p></div> <div class="desc"><p>Return a list of saved AI sessions, optionally limited.</p></div>
</dd> </dd>
<dt id="connpy.services.ai_service.AIService.load_session_data"><code class="name flex"> <dt id="connpy.services.ai_service.AIService.load_session_data"><code class="name flex">
<span>def <span class="ident">load_session_data</span></span>(<span>self, session_id)</span> <span>def <span class="ident">load_session_data</span></span>(<span>self, session_id)</span>
@@ -566,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):
&#34;&#34;&#34;Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).&#34;&#34;&#34;
nodes_str = &#34;, &#34;.join(target_nodes)
commands_str = &#34;\n&#34;.join(f&#34;- {cmd}&#34; for cmd in commands)
prompt = f&#34;@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.&#34;
prompt += f&#34;\n\nTarget Nodes: {nodes_str}&#34;
prompt += f&#34;\nCommands to simulate:\n{commands_str}&#34;
prompt += &#34;\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.&#34;
# 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>
@@ -664,15 +911,19 @@ 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>
<li><code><a title="connpy.services.ai_service.AIService.delete_session" href="#connpy.services.ai_service.AIService.delete_session">delete_session</a></code></li> <li><code><a title="connpy.services.ai_service.AIService.delete_session" href="#connpy.services.ai_service.AIService.delete_session">delete_session</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>
+1 -110
View File
@@ -156,56 +156,7 @@ el.replaceWith(d);
except Exception as e: except Exception as e:
raise ConnpyError(f&#34;Failed to read script {script_path}: {e}&#34;) raise ConnpyError(f&#34;Failed to read script {script_path}: {e}&#34;)
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) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</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) -&gt; Dict[str, Any]:
&#34;&#34;&#34;Run a structured Connpy YAML automation playbook (from path or content).&#34;&#34;&#34;
playbook = None
if playbook_data.startswith(&#34;---YAML---\n&#34;):
try:
content = playbook_data[len(&#34;---YAML---\n&#34;):]
playbook = yaml.load(content, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to parse YAML content: {e}&#34;)
else:
if not os.path.exists(playbook_data):
raise ConnpyError(f&#34;Playbook file not found: {playbook_data}&#34;)
try:
with open(playbook_data, &#34;r&#34;) as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f&#34;Failed to load playbook {playbook_data}: {e}&#34;)
# Basic validation
if not isinstance(playbook, dict) or &#34;nodes&#34; not in playbook or &#34;commands&#34; not in playbook:
raise ConnpyError(&#34;Invalid playbook format: missing &#39;nodes&#39; or &#39;commands&#39; keys.&#34;)
action = playbook.get(&#34;action&#34;, &#34;run&#34;)
options = playbook.get(&#34;options&#34;, {})
# Extract all fields similar to RunHandler.cli_run
exec_args = {
&#34;nodes_filter&#34;: playbook[&#34;nodes&#34;],
&#34;commands&#34;: playbook[&#34;commands&#34;],
&#34;variables&#34;: playbook.get(&#34;variables&#34;),
&#34;parallel&#34;: options.get(&#34;parallel&#34;, parallel),
&#34;timeout&#34;: playbook.get(&#34;timeout&#34;, options.get(&#34;timeout&#34;, 20)),
&#34;prompt&#34;: options.get(&#34;prompt&#34;),
&#34;name&#34;: playbook.get(&#34;name&#34;, &#34;Task&#34;)
}
# Map &#39;output&#39; field to folder path if it&#39;s not stdout/null
output_cfg = playbook.get(&#34;output&#34;)
if output_cfg not in [None, &#34;stdout&#34;]:
exec_args[&#34;folder&#34;] = output_cfg
if action == &#34;run&#34;:
return self.run_commands(**exec_args)
elif action == &#34;test&#34;:
exec_args[&#34;expected&#34;] = playbook.get(&#34;expected&#34;, [])
return self.test_commands(**exec_args)
else:
raise ConnpyError(f&#34;Unsupported playbook action: {action}&#34;)</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>
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -198,7 +198,7 @@ el.replaceWith(d);
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -212,9 +212,10 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
if save:
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -227,6 +228,7 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
@@ -457,14 +459,14 @@ el.replaceWith(d);
<div class="desc"><p>Interact with a node directly.</p></div> <div class="desc"><p>Interact with a node directly.</p></div>
</dd> </dd>
<dt id="connpy.services.node_service.NodeService.delete_node"><code class="name flex"> <dt id="connpy.services.node_service.NodeService.delete_node"><code class="name flex">
<span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False)</span> <span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def delete_node(self, unique_id, is_folder=False): <pre><code class="python">def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -477,6 +479,7 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file)</code></pre> self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Logic for deleting a node or folder.</p></div> <div class="desc"><p>Logic for deleting a node or folder.</p></div>
@@ -686,14 +689,14 @@ el.replaceWith(d);
<div class="desc"><p>Move or copy a node.</p></div> <div class="desc"><p>Move or copy a node.</p></div>
</dd> </dd>
<dt id="connpy.services.node_service.NodeService.update_node"><code class="name flex"> <dt id="connpy.services.node_service.NodeService.update_node"><code class="name flex">
<span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data)</span> <span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def update_node(self, unique_id, data): <pre><code class="python">def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -707,6 +710,7 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
if save:
self.config._saveconfig(self.config.file)</code></pre> self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Explicitly update an existing node.</p></div> <div class="desc"><p>Explicitly update an existing node.</p></div>
+205 -76
View File
@@ -58,16 +58,47 @@ el.replaceWith(d);
<pre><code class="python">class PluginService(BaseService): <pre><code class="python">class PluginService(BaseService):
&#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34;
def _get_plugin_path(self, name, include_disabled=True):
&#34;&#34;&#34;Resolves the physical path of a plugin by name. Priority: user, shared/global, core.&#34;&#34;&#34;
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;user&#34;, True
if include_disabled:
bkp_file = os.path.join(user_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;user&#34;, False
# 2. Shared/Global directory
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;shared&#34;, True
if include_disabled:
bkp_file = os.path.join(shared_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;shared&#34;, False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
p_file = os.path.join(core_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;core&#34;, True
return None, None, False
def list_plugins(self): def list_plugins(self):
&#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34; &#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34;
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -77,12 +108,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(user_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
@@ -90,6 +144,7 @@ el.replaceWith(d);
return all_plugin_info return all_plugin_info
def add_plugin(self, name, source_file, update=False): def add_plugin(self, name, source_file, update=False):
&#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34; &#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34;
import os import os
@@ -170,6 +225,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
def enable_plugin(self, name): def enable_plugin(self, name):
@@ -178,51 +237,80 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file): if os.path.exists(disabled_file):
return False # Already enabled # Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if not os.path.exists(disabled_file): if os.path.getsize(disabled_file) == 0:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) # Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try: try:
os.rename(disabled_file, plugin_file) os.rename(disabled_file, plugin_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file):
return False # Already enabled
# If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
def disable_plugin(self, name): def disable_plugin(self, name):
&#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34; &#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34;
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file): if os.path.exists(plugin_file):
return False # Already disabled # Regular user-level plugin exists. Rename to bkp
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;)
try: try:
os.rename(plugin_file, disabled_file) os.rename(plugin_file, disabled_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file):
return False # Already disabled
# Check if it exists in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
# Shadow disable it by creating an empty .py.bkp in user plugins dir
plugin_dir = os.path.dirname(plugin_file)
os.makedirs(plugin_dir, exist_ok=True)
try:
with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)
def get_plugin_source(self, name): def get_plugin_source(self, name):
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read() return f.read()
def invoke_plugin(self, name, args_dict): def invoke_plugin(self, name, args_dict):
@@ -262,17 +350,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -425,6 +508,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre> raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Remove a plugin file permanently.</p></div> <div class="desc"><p>Remove a plugin file permanently.</p></div>
@@ -443,17 +530,31 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file): if os.path.exists(plugin_file):
return False # Already disabled # Regular user-level plugin exists. Rename to bkp
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;)
try: try:
os.rename(plugin_file, disabled_file) os.rename(plugin_file, disabled_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)</code></pre> raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file):
return False # Already disabled
# Check if it exists in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
# Shadow disable it by creating an empty .py.bkp in user plugins dir
plugin_dir = os.path.dirname(plugin_file)
os.makedirs(plugin_dir, exist_ok=True)
try:
with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div> <div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
</dd> </dd>
@@ -471,17 +572,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file): if os.path.exists(disabled_file):
return False # Already enabled # Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if not os.path.exists(disabled_file): if os.path.getsize(disabled_file) == 0:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) # Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try: try:
os.rename(disabled_file, plugin_file) os.rename(disabled_file, plugin_file)
return True return True
except OSError as e: except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)</code></pre> raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file):
return False # Already enabled
# If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div> <div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
</dd> </dd>
@@ -497,17 +619,11 @@ el.replaceWith(d);
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read()</code></pre> return f.read()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -557,17 +673,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -636,11 +747,6 @@ el.replaceWith(d);
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -650,12 +756,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(user_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
+28 -2
View File
@@ -98,6 +98,7 @@ el.replaceWith(d);
from .import_export_service import ImportExportService from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config) self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config) self.profiles = ProfileService(self.config)
@@ -109,6 +110,7 @@ el.replaceWith(d);
self.import_export = ImportExportService(self.config) self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config) self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self): def _init_remote(self):
# Allow ConfigService to work locally so the user can revert the mode # Allow ConfigService to work locally so the user can revert the mode
@@ -118,14 +120,37 @@ el.replaceWith(d);
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config) self.sync = SyncService(self.config)
self.users = None
if not self.remote_host: if not self.remote_host:
raise InvalidConfigurationError(&#34;Remote host must be specified in remote mode&#34;) raise InvalidConfigurationError(&#34;Remote host must be specified in remote mode&#34;)
import grpc import grpc
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub import os
from ..grpc_layer.stubs import (
NodeStub, ProfileStub, PluginStub, AIStub,
ExecutionStub, ImportExportStub, SystemStub,
ConfigStub, AuthClientInterceptor, AuthStub
)
def get_token():
token_path = os.path.join(self.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
with open(token_path, &#34;r&#34;) as f:
return f.read().strip()
except Exception:
pass
return None
channel = grpc.insecure_channel(self.remote_host) channel = grpc.insecure_channel(self.remote_host)
interceptor = AuthClientInterceptor(get_token)
channel = grpc.intercept_channel(channel, interceptor)
# Surgical fix: Keep ConfigService local for mode/theme management,
# but delegate encryption to the server stub.
config_remote = ConfigStub(channel, remote_host=self.remote_host)
self.config_svc.encrypt_password = config_remote.encrypt_password
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config) self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes) self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
@@ -133,7 +158,8 @@ el.replaceWith(d);
self.ai = AIStub(channel, remote_host=self.remote_host) self.ai = AIStub(channel, remote_host=self.remote_host)
self.system = SystemStub(channel, remote_host=self.remote_host) self.system = SystemStub(channel, remote_host=self.remote_host)
self.execution = ExecutionStub(channel, remote_host=self.remote_host) self.execution = ExecutionStub(channel, remote_host=self.remote_host)
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)</code></pre> self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
self.auth = AuthStub(channel, remote_host=self.remote_host)</code></pre>
</details> </details>
<div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div> <div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div>
</dd> </dd>
+599
View File
@@ -0,0 +1,599 @@
<!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.services.user_service 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.services.user_service</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.services.user_service.UserService"><code class="flex name class">
<span>class <span class="ident">UserService</span></span>
<span>(</span><span>config_dir)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class UserService:
def __init__(self, config_dir):
self.config_dir = os.path.abspath(config_dir)
self.users_dir = os.path.join(self.config_dir, &#34;users&#34;)
self.registry_file = os.path.join(self.users_dir, &#34;registry.yaml&#34;)
# Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True)
def _load_registry(self) -&gt; dict:
&#34;&#34;&#34;Loads registry from file. If it doesn&#39;t exist, initializes it with a new JWT secret.&#34;&#34;&#34;
if not os.path.exists(self.registry_file):
registry = {
&#34;jwt_secret&#34;: secrets.token_hex(32),
&#34;users&#34;: {}
}
self._save_registry(registry)
return registry
try:
with open(self.registry_file, &#34;r&#34;) as f:
registry = yaml.safe_load(f) or {}
except Exception:
registry = {}
if not isinstance(registry, dict):
registry = {}
if &#34;jwt_secret&#34; not in registry:
registry[&#34;jwt_secret&#34;] = secrets.token_hex(32)
if &#34;users&#34; not in registry or not isinstance(registry[&#34;users&#34;], dict):
registry[&#34;users&#34;] = {}
return registry
def _save_registry(self, data: dict):
&#34;&#34;&#34;Safely saves registry structure to registry.yaml.&#34;&#34;&#34;
tmp_file = self.registry_file + &#34;.tmp&#34;
try:
with open(tmp_file, &#34;w&#34;) as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp_file, self.registry_file)
os.chmod(self.registry_file, 0o600)
except Exception as e:
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except OSError:
pass
raise e
def create_user(self, username, password, config_path=None) -&gt; dict:
&#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.
&#34;&#34;&#34;
if not username or not isinstance(username, str):
raise ValueError(&#34;Username cannot be empty&#34;)
if not re.match(r&#34;^[a-zA-Z0-9_-]+$&#34;, username):
raise ValueError(&#34;Username must contain only alphanumeric characters, dashes, or underscores&#34;)
if not password or not isinstance(password, str):
raise ValueError(&#34;Password cannot be empty&#34;)
registry = self._load_registry()
if username in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; already exists&#34;)
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(user_dir, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile
conf_file = os.path.join(user_dir, &#34;config.yaml&#34;)
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, &#34;config.yaml&#34;)
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
user_entry = {
&#34;password_hash&#34;: password_hash,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry[&#34;users&#34;][username] = user_entry
self._save_registry(registry)
return {
&#34;username&#34;: username,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: user_entry[&#34;created&#34;]
}
def delete_user(self, username):
&#34;&#34;&#34;Removes user from the registry and cleans up config directory if server-managed.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
config_path = user_data.get(&#34;config_path&#34;)
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry[&#34;users&#34;][username]
self._save_registry(registry)
def list_users(self) -&gt; list[dict]:
&#34;&#34;&#34;Lists all registered users with metadata.&#34;&#34;&#34;
registry = self._load_registry()
return [
{
&#34;username&#34;: name,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;)
}
for name, data in registry.get(&#34;users&#34;, {}).items()
]
def get_user(self, username) -&gt; dict:
&#34;&#34;&#34;Retrieves raw metadata for a specific user.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
data = registry[&#34;users&#34;][username]
return {
&#34;username&#34;: username,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;),
&#34;password_hash&#34;: data.get(&#34;password_hash&#34;)
}
def change_password(self, username, old_password, new_password):
&#34;&#34;&#34;Verifies old password and updates registry with new hashed password.&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
if not bcrypt.checkpw(old_password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;)):
raise ValueError(&#34;Invalid credentials&#34;)
# Update hash
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)
def admin_change_password(self, username, new_password):
&#34;&#34;&#34;Administrative password override (does not require old password).&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)
def authenticate(self, username, password) -&gt; bool:
&#34;&#34;&#34;Verifies if the credentials are valid using bcrypt.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
return False
user_data = registry[&#34;users&#34;][username]
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))
def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token
def verify_jwt(self, token) -&gt; str | None:
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.services.user_service.UserService.admin_change_password"><code class="name flex">
<span>def <span class="ident">admin_change_password</span></span>(<span>self, username, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def admin_change_password(self, username, new_password):
&#34;&#34;&#34;Administrative password override (does not require old password).&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Administrative password override (does not require old password).</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.authenticate"><code class="name flex">
<span>def <span class="ident">authenticate</span></span>(<span>self, username, password) > bool</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def authenticate(self, username, password) -&gt; bool:
&#34;&#34;&#34;Verifies if the credentials are valid using bcrypt.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
return False
user_data = registry[&#34;users&#34;][username]
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))</code></pre>
</details>
<div class="desc"><p>Verifies if the credentials are valid using bcrypt.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.change_password"><code class="name flex">
<span>def <span class="ident">change_password</span></span>(<span>self, username, old_password, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def change_password(self, username, old_password, new_password):
&#34;&#34;&#34;Verifies old password and updates registry with new hashed password.&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
if not bcrypt.checkpw(old_password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;)):
raise ValueError(&#34;Invalid credentials&#34;)
# Update hash
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Verifies old password and updates registry with new hashed password.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.create_user"><code class="name flex">
<span>def <span class="ident">create_user</span></span>(<span>self, username, password, config_path=None) > dict</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_user(self, username, password, config_path=None) -&gt; dict:
&#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.
&#34;&#34;&#34;
if not username or not isinstance(username, str):
raise ValueError(&#34;Username cannot be empty&#34;)
if not re.match(r&#34;^[a-zA-Z0-9_-]+$&#34;, username):
raise ValueError(&#34;Username must contain only alphanumeric characters, dashes, or underscores&#34;)
if not password or not isinstance(password, str):
raise ValueError(&#34;Password cannot be empty&#34;)
registry = self._load_registry()
if username in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; already exists&#34;)
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(user_dir, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile
conf_file = os.path.join(user_dir, &#34;config.yaml&#34;)
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, &#34;config.yaml&#34;)
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
user_entry = {
&#34;password_hash&#34;: password_hash,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry[&#34;users&#34;][username] = user_entry
self._save_registry(registry)
return {
&#34;username&#34;: username,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: user_entry[&#34;created&#34;]
}</code></pre>
</details>
<div class="desc"><p>Creates a new user with bcrypt-hashed credentials.</p>
<p>Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.delete_user"><code class="name flex">
<span>def <span class="ident">delete_user</span></span>(<span>self, username)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_user(self, username):
&#34;&#34;&#34;Removes user from the registry and cleans up config directory if server-managed.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
config_path = user_data.get(&#34;config_path&#34;)
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry[&#34;users&#34;][username]
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Removes user from the registry and cleans up config directory if server-managed.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.generate_jwt"><code class="name flex">
<span>def <span class="ident">generate_jwt</span></span>(<span>self, username) > str</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token</code></pre>
</details>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 12 hours.</p></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_user(self, username) -&gt; dict:
&#34;&#34;&#34;Retrieves raw metadata for a specific user.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
data = registry[&#34;users&#34;][username]
return {
&#34;username&#34;: username,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;),
&#34;password_hash&#34;: data.get(&#34;password_hash&#34;)
}</code></pre>
</details>
<div class="desc"><p>Retrieves raw metadata for a specific user.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.list_users"><code class="name flex">
<span>def <span class="ident">list_users</span></span>(<span>self) > list[dict]</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_users(self) -&gt; list[dict]:
&#34;&#34;&#34;Lists all registered users with metadata.&#34;&#34;&#34;
registry = self._load_registry()
return [
{
&#34;username&#34;: name,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;)
}
for name, data in registry.get(&#34;users&#34;, {}).items()
]</code></pre>
</details>
<div class="desc"><p>Lists all registered users with metadata.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.verify_jwt"><code class="name flex">
<span>def <span class="ident">verify_jwt</span></span>(<span>self, token) > str | None</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def verify_jwt(self, token) -&gt; str | None:
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
</details>
<div class="desc"><p>Decodes JWT and returns username if token is valid and unexpired.</p></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.services" href="index.html">connpy.services</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.services.user_service.UserService" href="#connpy.services.user_service.UserService">UserService</a></code></h4>
<ul class="">
<li><code><a title="connpy.services.user_service.UserService.admin_change_password" href="#connpy.services.user_service.UserService.admin_change_password">admin_change_password</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.authenticate" href="#connpy.services.user_service.UserService.authenticate">authenticate</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.change_password" href="#connpy.services.user_service.UserService.change_password">change_password</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.create_user" href="#connpy.services.user_service.UserService.create_user">create_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.delete_user" href="#connpy.services.user_service.UserService.delete_user">delete_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.generate_jwt" href="#connpy.services.user_service.UserService.generate_jwt">generate_jwt</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.get_user" href="#connpy.services.user_service.UserService.get_user">get_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.list_users" href="#connpy.services.user_service.UserService.list_users">list_users</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.verify_jwt" href="#connpy.services.user_service.UserService.verify_jwt">verify_jwt</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>
+4 -1
View File
@@ -59,11 +59,14 @@ el.replaceWith(d);
if not data: if not data:
return &#34;&#34; return &#34;&#34;
# Remove OSC (Operating System Command) sequences (e.g., set window title \x1b]0;...\x07)
data = re.sub(r&#39;\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)&#39;, &#39;&#39;, data)
lines = data.split(&#39;\n&#39;) lines = data.split(&#39;\n&#39;)
cleaned_lines = [] cleaned_lines = []
# Regex to capture: ANSI sequences, control characters (\r, \b, etc), and plain text chunks # Regex to capture: ANSI sequences, control characters (\r, \b, etc), and plain text chunks
token_re = re.compile(r&#39;(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/ ]*[@-~])|\r|\b|\x7f|[\x00-\x1F]|[^\x1B\r\b\x7f\x00-\x1F]+)&#39;) token_re = re.compile(r&#39;(\x1B(?:[\x30-\x5A\x5C-\x7E]|\[[0-?]*[ -/ ]*[@-~])|\r|\b|\x7f|[\x00-\x1F]|[^\x1B\r\b\x7f\x00-\x1F]+)&#39;)
for line in lines: for line in lines:
buffer = [] buffer = []
+2
View File
@@ -20,3 +20,5 @@ httpx>=0.27.0
requests>=2.31.0 requests>=2.31.0
pytest>=8.0.0 pytest>=8.0.0
pytest-mock>=3.12.0 pytest-mock>=3.12.0
bcrypt>=4.1.0
PyJWT>=2.8.0
+9 -7
View File
@@ -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
@@ -37,18 +37,20 @@ install_requires =
pycryptodome>=3.18.0 pycryptodome>=3.18.0
PyYAML>=6.0.1 PyYAML>=6.0.1
pyfzf>=0.3.1 pyfzf>=0.3.1
litellm>=1.40.0 litellm>=1.40.0,<2.0.0
grpcio>=1.62.0 grpcio>=1.62.0,<2.0.0
grpcio-tools>=1.62.0 grpcio-tools>=1.62.0,<2.0.0
protobuf>=6.31.1,<7.0.0 protobuf>=6.31.1,<7.0.0
google-api-python-client>=2.125.0 google-api-python-client>=2.125.0
google-auth-oauthlib>=1.2.0 google-auth-oauthlib>=1.2.0
google-auth-httplib2>=0.2.0 google-auth-httplib2>=0.2.0
prompt-toolkit>=3.0.0 prompt-toolkit>=3.0.0
mcp>=1.2.0 mcp>=1.2.0,<2.0.0
aiohttp>=3.9.0 aiohttp>=3.9.0,<4.0.0
httpx>=0.27.0 httpx>=0.27.0,<1.0.0
requests>=2.31.0 requests>=2.31.0
bcrypt>=4.1.0
PyJWT>=2.8.0
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =