Compare commits

..

32 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
fluzzi32 3ca6f497d1 bug fix missing manifest 2026-05-18 16:57:02 -03:00
fluzzi32 1db4a045e5 bug fix double sys import 2026-05-18 15:46:21 -03:00
fluzzi32 7b01a0c05f update pdoc 2026-05-18 14:11:28 -03:00
fluzzi32 5a8b744aa8 📌 1. Uncommitted Changes (Staged for next commit)
Focus: UI stability and efficient rendering.
   * Markdown Rendering Rewrite: Removed the dependency on rich.live.Live (which caused flickering and
     high CPU usage by constantly re-rendering the entire panel).
   * New BlockMarkdownRenderer: Implemented in printer.py (alias IncrementalMarkdownParser), it
     accumulates text in a buffer and only prints to the screen when it detects a complete block (e.g.,
     line breaks or
  ` code blocks).
   * UI Optimizations (terminal_ui.py & stubs.py): Waiting spinners now stop cleanly, and the UI
     transitions smoothly to block printing. Fixed visual truncation issues in the bottom "Tab prompt"
     bar for excessively long commands.

  📦 2. Commit History (Last 4 Commits)

  9446baf - improve ai rules
   * Strict Anti-Hallucination (ai.py): Injected MANDATORY rules into the System Prompt for both
     Architect and Engineer agents. Now, if the terminal buffer is empty or only contains an idle prompt
     (e.g., iol#), the AI is strictly forced to state that it lacks data instead of inventing topologies
     or configurations.
   * Language Preference: Explicitly instructed agents to always respond in the same language the user
     used to ask the question.

  64377f7 - move context block logic to server and improvements
   * Context Precision (ai_service.py): Moved context partitioning logic to the service/server side. It
     now calculates exact start_pos and end_pos based on identified commands, preventing mixed outputs
     or residual text from bleeding into the AI's prompt.
   * Token Savings (server.py): The server now selectively strips garbage metadata and UI caches that
     add no value to the LLM before sending the payload over the wire.

  e4fd1ad - fix logclean for 6wind
   * Full ANSI/CSI Support (utils.py): Replaced the legacy rigid escape filter with a complete CSI
     (Control Sequence Introducer) parser. The client can now accurately process numeric cursor
     movements (C, D), inline dynamic erasures (K), and absolute shifts (G), ensuring connpy understands
     exactly what a 6WIND router or other VNFs render on the screen without garbage characters.

  b0a914a - updates al copilot
   * Copilot <> gRPC Integration: Implemented the async plumbing required for the Copilot to work over
     the gRPC tunnel (server.py & stubs.py).
   * Initial Streaming UI: Added the first iteration of chunk callbacks to provide real-time feedback
     while the LLM generates responses, laying the groundwork for the uncommitted block-renderer
     optimizations.
2026-05-18 14:07:24 -03:00
fluzzi32 9446bafc0c improve ai rules 2026-05-15 23:28:22 -03:00
fluzzi32 64377f7f30 move context block logic to server and improvemnets 2026-05-15 18:24:48 -03:00
fluzzi32 e4fd1adba3 fix logclean for6wind 2026-05-15 17:32:09 -03:00
fluzzi32 b0a914ad7f updates al copilot 2026-05-15 16:25:18 -03:00
fluzzi32 05fb9951c6 update readme 2026-05-13 14:49:04 -03:00
84 changed files with 15881 additions and 3785 deletions
+7
View File
@@ -20,3 +20,10 @@ scratch
testall
testremote
automation-template.yaml
# Sensitive local files and credentials
auth.json
key.db
config.db
*.db
testnew/
+9
View File
@@ -146,11 +146,14 @@ package.json
# Development docs
connpy_roadmap.md
testfew/
testnew/
testall/
testremote/
*.db
*.patch
scratch.py
connpy.code-workspace
# Internal planning and implementation docs
PLAN_CAPA_SERVICIOS.md
@@ -164,7 +167,13 @@ connpy_roadmap.md
MULTI_USER_PLAN.md
COPILOT_PLAN.md
ARCHITECTURAL_DEBT_REFACTOR.md
COPILOT_UI_FEATURES.md
MULTI_USER_IMPLEMENTATION_STEPS.md
readme_coverage_analysis.md
#themes
nord.yml
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.
+1 -1
View File
@@ -3,6 +3,6 @@ include README.md
include requirements.txt
recursive-include connpy/core_plugins *
recursive-include connpy/proto *
recursive-include connpy/grpc *.proto
recursive-include connpy/grpc_layer *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
+224 -137
View File
@@ -3,188 +3,275 @@
</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/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/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**.
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.
- **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.
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
---
## 1. 🤖 AI System
### 1a. Terminal Copilot (Ctrl+Space)
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).
- **Advanced Inventory**:
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
- Bulk creation, copying, moving, and export/import of nodes.
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
- Fuzzy search integration with `fzf`.
- Advanced tab completion.
- Syntax highlighting and customizable themes.
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
- **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.
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
---
## 2. ⚙️ Automation & Playbooks
### 2a. Quick Run (conn run)
Run commands in parallel directly on target nodes or folder structures:
```bash
conn run router1 "show interface"
```
### 2b. YAML Playbook Engine
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.
```yaml
# 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
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
git clone https://github.com/fluzzi/connpy
cd connpy
docker compose build
# Run it like a native app (completely silent)
docker compose --log-level ERROR 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 --log-level ERROR run --rm --remove-orphans connpy-app'
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
---
## 🔒 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
### 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`).
### 9d. Theming
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
```bash
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
conn config --theme /path/to/theme.yaml
```
### 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
Connpy supports a robust plugin architecture where scripts can run transparently on a remote gRPC server.
## 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`.
### 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
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"])
print(router.output)
```
### Parallel Tasks with Variables
```python
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes, config=config)
nodes_info = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes_info, config=config)
variables = {
"router1@office": {"id": "1"},
"__global__": {"mask": "255.255.255.0"}
}
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
```
### AI Programmatic Use
```python
import connpy
# 3. AI Copilot prompts
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>
# 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/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/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**.
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.
- **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.
- **Interactive Chat**: Launch with `conn ai` for a collaborative troubleshooting session.
---
## 1. 🤖 AI System
### 1a. Terminal Copilot (Ctrl+Space)
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).
- **Advanced Inventory**:
- Organize nodes in folders (`@folder`) and subfolders (`@subfolder@folder`).
- Use Global Profiles (`@profilename`) to manage shared credentials easily.
- Bulk creation, copying, moving, and export/import of nodes.
- **Modern UI**: High-performance terminal experience with `prompt-toolkit`, including:
- Fuzzy search integration with `fzf`.
- Advanced tab completion.
- Syntax highlighting and customizable themes.
- **Automation Engine**: Run parallel tasks and playbooks on multiple devices with variable support.
- **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.
- **Privacy & Sync**: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.
---
## 2. ⚙️ Automation & Playbooks
### 2a. Quick Run (conn run)
Run commands in parallel directly on target nodes or folder structures:
```bash
conn run router1 "show interface"
```
### 2b. YAML Playbook Engine
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.
```yaml
# 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
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
git clone https://github.com/fluzzi/connpy
cd connpy
docker compose build
# Run it like a native app (completely silent)
docker compose --log-level ERROR 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 --log-level ERROR run --rm --remove-orphans connpy-app'
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
---
## 🔒 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
### 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`).
### 9d. Theming
Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:
```bash
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
conn config --theme /path/to/theme.yaml
```
### 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
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.
## 11. 🐍 Python API
Embed connection and automation routines programmatically in Python:
---
## 🐍 Automation Module (API)
You can use `connpy` as a Python library for your own scripts.
### Basic Execution
```python
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"])
print(router.output)
```
### Parallel Tasks with Variables
```python
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes, config=config)
nodes_info = config.getitem("@office", ["router1", "router2"])
routers = connpy.nodes(nodes_info, config=config)
variables = {
"router1@office": {"id": "1"},
"__global__": {"mask": "255.255.255.0"}
}
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
```
### AI Programmatic Use
```python
import connpy
# 3. AI Copilot prompts
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 .configfile import configfile
@@ -203,5 +303,7 @@ __pdoc__ = {
'nodes.deferred_class_hooks': False,
'connapp': False,
'connapp.encrypt': True,
'printer': False
'printer': False,
'tests': False
}
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b8"
__version__ = "6.0.3"
+481 -102
View File
@@ -1,4 +1,6 @@
import os
import secrets
import sys
import json
import re
@@ -15,7 +17,7 @@ def _init_litellm():
global _litellm_initialized
if not _litellm_initialized:
import litellm
# Silenciar feedback de litellm
# Silence litellm feedback
litellm.suppress_debug_info = True
litellm.set_verbose = False
_litellm_initialized = True
@@ -106,16 +108,20 @@ class ai:
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.console = console or printer.console
self.confirm_handler = confirm_handler or self._local_confirm_handler
self.trusted_session = trust # Trust mode for the entire session
self.interrupted = False
self.one_shot = kwargs.get("one_shot", False)
# 1. Cargar configuración genérica
aiconfig = self.config.config.get("ai", {})
# 1. Load generic configuration with global inheritance/merge
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)
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.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 = aiconfig.get("trusted_commands", [])
if isinstance(custom_trusted, str):
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 [])
# Límites
# Limits
self.max_history = 30
self.max_truncate = 50000
self.soft_limit_iterations = 20 # Show warning and suggest Ctrl+C
@@ -165,12 +194,12 @@ class ai:
# Session Management
self.sessions_dir = os.path.join(self.config.defaultdir, "ai_sessions")
os.makedirs(self.sessions_dir, exist_ok=True)
self.session_id = None
self.session_path = None
self.session_id = getattr(self.config, "session_id", None)
self.session_path = os.path.join(self.sessions_dir, f"{self.session_id}.json") if self.session_id else None
# Prompts base agnósticos
# Agnostic base prompts
architect_instructions = ""
if self.architect_key:
if self.has_architect:
architect_instructions = """
CRITICAL - CONSULT vs ESCALATE:
- ALWAYS use 'consult_architect' for: Configuration planning, design decisions, complex troubleshooting.
@@ -186,7 +215,7 @@ class ai:
else:
architect_instructions = """
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.
- 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.
"""
@@ -205,6 +234,7 @@ class ai:
- COMPLETE MISSIONS: Execute ALL steps of a mission before reporting back.
- DIAGRAM: Use ASCII art or Unicode box-drawing characters directly in your responses to visualize topologies or paths when helpful.
- EVIDENCE: Include 'Key Snippets' from tool outputs. Be token-efficient.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- NO WANDERING: Do not speculate. If stuck, report attempts.
- SAFETY: When you use 'run_commands' with configuration commands, the system automatically prompts the user for confirmation. Just execute - don't ask permission first.
{architect_instructions}
@@ -222,6 +252,7 @@ class ai:
- ENGINEER CAPABILITIES: Your Engineer can:
* Filter nodes (list_nodes), Run CLI commands (run_commands), Get metadata (get_node_info).
- ANALYSIS: Review technical findings to identify patterns or design failures.
- LANGUAGE: You MUST respond in the same language used by the user in their question or instruction.
- MEMORY: Update long-term facts ONLY when the user explicitly requests it.
CRITICAL - EFFICIENT DELEGATION:
@@ -255,10 +286,13 @@ class ai:
@property
def architect_system_prompt(self):
"""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:
extensions = "\n".join(self.architect_prompt_extensions)
return self._architect_base_prompt + f"\n\nPlugin Capabilities:\n{extensions}"
return self._architect_base_prompt
return prompt + f"\n\nPlugin Capabilities:\n{extensions}"
return prompt
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.
@@ -290,22 +324,25 @@ class ai:
if 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.
Returns (response, streamed) where:
- response: reconstructed ModelResponse (same as non-streaming)
- streamed: True if text was rendered to console during streaming
"""
from rich.live import Live
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 = []
full_content = ""
is_streaming_text = False
has_tool_calls = False
live_display = None
header_printed = False
# Determine styling based on current brain
role_label = "Network Architect" if "architect" in label.lower() else "Network Engineer"
@@ -334,7 +371,6 @@ class ai:
if not chunk_callback:
if not is_streaming_text:
# Stop spinner definitively
if status:
try:
status.stop()
@@ -343,35 +379,28 @@ class ai:
# Create a stable, direct Console to bypass _ConsoleProxy recreation bugs
from rich.console import Console as RichConsole
from .printer import connpy_theme, get_original_stdout
from rich.rule import Rule
from .printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
live_display = Live(
Panel(Markdown(full_content), title=title, border_style=border, expand=False),
console=stable_console,
refresh_per_second=8,
transient=False
)
live_display.start()
stable_console.print(Rule(f"[bold {border}]{title}[/bold {border}]", style=border))
header_printed = True
md_parser = IncrementalMarkdownParser(console=stable_console)
is_streaming_text = True
else:
live_display.update(
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
)
md_parser.feed(delta.content)
except Exception as e:
if not chunks:
raise
finally:
if live_display:
# Render final state with complete content
if header_printed:
try:
live_display.update(
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
)
except Exception:
pass
try:
live_display.stop()
md_parser.flush()
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=border))
except Exception:
pass
@@ -708,7 +737,7 @@ class ai:
def _engineer_loop(self, task, status=None, debug=False, chat_history=None):
"""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():
messages = [{"role": "system", "content": [{"type": "text", "text": self.engineer_system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else:
@@ -740,17 +769,15 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
if iteration == self.soft_limit_iterations and not soft_limit_warned:
self.console.print(f"[warning]⚠ Engineer 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.[/warning]")
soft_limit_warned = True
if status and not chat_history: status.update(f"[ai_status]Engineer: Analyzing mission... (step {iteration})")
if status and not chat_history:
status_text = f"[ai_status]Engineer: Analyzing mission... (step {iteration})"
if iteration >= self.soft_limit_iterations:
status_text += " [warning]⚠ Taking longer than expected (Ctrl+C to interrupt)[/warning]"
status.update(status_text)
try:
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:
if status: status.stop()
raise ValueError(f"Engineer failed to connect: {str(e)}")
@@ -769,19 +796,25 @@ class ai:
for tc in resp_msg.tool_calls:
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 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":
cmds = args.get('commands', [])
cmd_str = cmds[0] if cmds else ""
status.update(f"[ai_status]Engineer: [CMD] {cmd_str}")
elif fn == "get_node_info": status.update(f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}")
s_text = f"[ai_status]Engineer: [CMD] {cmd_str}"
elif fn == "get_node_info": s_text = f"[ai_status]Engineer: [INSPECT] {args.get('node_name','')}"
elif fn.startswith("mcp_"):
server = fn.split("__")[0].replace("mcp_", "")
tool = fn.split("__")[1] if "__" in fn else fn
status.update(f"[ai_status]Engineer: [MCP:{server}] {tool}")
elif fn in self.tool_status_formatters: status.update(self.tool_status_formatters[fn](args))
s_text = f"[ai_status]Engineer: [MCP:{server}] {tool}"
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:
self._print_debug_observation(f"Decision: {fn}", args, status=status)
@@ -851,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": "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
seen_names = set()
@@ -884,16 +919,27 @@ class ai:
continue
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."""
sessions = self._get_sessions()
if not sessions:
printer.info("No saved AI sessions found.")
return
total = len(sessions)
if limit and total > limit:
sessions = sessions[:limit]
columns = ["ID", "Title", "Created At", "Model"]
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):
"""Loads a session's raw data by ID."""
@@ -924,8 +970,10 @@ class ai:
return sessions[0]["id"] if sessions else None
def _generate_session_id(self, query):
"""Generates a unique session ID based on timestamp."""
return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
"""Generates a unique session ID based on timestamp and a random suffix."""
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):
"""Saves current history to the session file."""
@@ -934,6 +982,8 @@ class ai:
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_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 not os.path.exists(self.session_path) and not title:
@@ -971,13 +1021,28 @@ class ai:
@MethodHook
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:
raise ValueError("Engineer API key not configured. Use 'connpy config --engineer-api-key <key>' to set it.")
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
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 = []
# 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)
if session_data:
chat_history = session_data.get("history", [])
@@ -986,7 +1051,7 @@ class ai:
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_engineer = re.match(r'^(engineer|ingeniero|@engineer)[:\s]', user_input, re.I)
@@ -995,7 +1060,7 @@ class ai:
elif explicit_engineer:
current_brain = "engineer"
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
for msg in reversed(chat_history[-5:]):
tcs = msg.get('tool_calls') if isinstance(msg, dict) else getattr(msg, 'tool_calls', None)
@@ -1009,21 +1074,22 @@ class ai:
if is_architect_active: break
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()
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()
model = self.architect_model if current_brain == "architect" else self.engineer_model
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():
messages = [{"role": "system", "content": [{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}]}]
else:
messages = [{"role": "system", "content": system_prompt}]
# Interleaving de historial
# History interleaving
last_role = "system"
# Sanitize history if the current target model is not compatible with cache_control
history_to_process = chat_history[-self.max_history:]
@@ -1043,7 +1109,7 @@ class ai:
if last_role == 'user': messages[-1]['content'] += "\n" + clean_input
else: messages.append({"role": "user", "content": clean_input})
# 3. Bucle de ejecución
# 3. Execution loop
iteration = 0
try:
# Set up remote interrupt callback if bridge is provided
@@ -1057,38 +1123,35 @@ class ai:
if self.interrupted:
raise KeyboardInterrupt
# Soft limit warning
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
# Soft limit warning - handled inline within update_status
label = "[architect][bold]Architect[/bold][/architect]" if current_brain == "architect" else "[engineer][bold]Engineer[/bold][/engineer]"
if status:
# Notify responder identity ONLY for web/remote clients (StatusBridge has is_web)
if getattr(status, "is_web", False):
# Notify responder identity for web/remote clients
if getattr(status, "is_web", False) or getattr(status, "is_remote", False):
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
try:
safe_messages = self._sanitize_messages(messages)
if stream:
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,
chunk_callback=chunk_callback
)
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:
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
current_brain = "engineer"
model = self.engineer_model
tools = self._get_engineer_tools()
key = self.engineer_key
current_auth = self.engineer_auth
# Rebuild messages with Engineer system prompt and original user request
messages = [{"role": "system", "content": self.engineer_system_prompt}]
# Add chat history if exists (excluding system prompt)
@@ -1139,8 +1202,8 @@ class ai:
continue
if status:
if fn == "delegate_to_engineer": status.update(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
elif fn == "manage_memory_tool": status.update(f"[architect]Architect: [UPDATING MEMORY]")
if fn == "delegate_to_engineer": update_status(f"[architect]Architect: [DELEGATING MISSION] {args.get('task','')[:40]}...")
elif fn == "manage_memory_tool": update_status(f"[architect]Architect: [UPDATING MEMORY]")
if debug:
self._print_debug_observation(f"Decision: {fn}", args, status=status)
@@ -1149,7 +1212,7 @@ class ai:
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"]
elif fn == "consult_architect":
if status: status.update("[architect]Engineer consulting Architect...")
if status: update_status("[architect]Engineer consulting Architect...")
try:
# Consultation only - Engineer stays in control
claude_resp = completion(
@@ -1171,16 +1234,17 @@ class ai:
try: status.start()
except: pass
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."
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
current_brain = "architect"
model = self.architect_model
tools = self._get_architect_tools()
key = self.architect_key
current_auth = self.architect_auth
messages[0] = {"role": "system", "content": self.architect_system_prompt}
# 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."
@@ -1196,12 +1260,13 @@ class ai:
except: pass
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
current_brain = "engineer"
model = self.engineer_model
tools = self._get_engineer_tools()
key = self.engineer_key
current_auth = self.engineer_auth
messages[0] = {"role": "system", "content": self.engineer_system_prompt}
# 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."
@@ -1243,12 +1308,12 @@ class ai:
messages.append({"role": "user", "content": "Hard iteration limit reached. Please provide a summary of your findings so far."})
try:
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
messages.append(resp_msg.model_dump(exclude_none=True))
except Exception as e:
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}")
except KeyboardInterrupt:
if status: status.update("[error]Interrupted! Closing pending tasks...")
@@ -1263,7 +1328,7 @@ class ai:
try:
safe_messages = self._sanitize_messages(summary_messages)
# 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
messages.append(resp_msg.model_dump(exclude_none=True))
@@ -1320,11 +1385,13 @@ class ai:
if persona == "architect":
system_prompt = f"""Role: NETWORK ARCHITECT. You act as a senior strategic advisor during a live SSH session.
Rules:
1. Answer the user's question directly based on the Terminal Context.
2. Focus on the "why" and "how". Analyze topologies, design patterns, and validate configurations.
3. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices.
4. Keep your guide concise and authoritative.
5. You MUST output your response in the following strict format:
1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. Answer the user's question directly and EXCLUSIVELY based on the Terminal Context.
3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like 'iol#' or 'admin@vrouter>') and no command output, it means YOU DON'T HAVE DATA. In this case, YOU MUST NOT invent any information.
4. Focus on the "why" and "how". Analyze topologies, design patterns, and validate configurations.
5. Do NOT provide commands to execute unless specifically requested. Instead, explain the consequences and best practices.
6. Keep your guide concise and authoritative.
7. You MUST output your response in the following strict format:
<guide>
Your brief tactical guide in markdown.
</guide>
@@ -1333,7 +1400,7 @@ Your brief tactical guide in markdown.
<risk>
low
</risk>
6. Risk level is usually "low" for read-only/no commands.
8. Risk level is usually "low" for read-only/no commands.
Terminal Context:
{terminal_buffer}
@@ -1343,11 +1410,13 @@ Node: {node_name}"""
else:
system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules:
1. Answer the user's question directly based on the Terminal Context.
2. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the <guide> section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves.
3. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block.
4. ULTRA-CONCISE. Keep your guide to the point.
5. You MUST output your response in the following strict format:
1. MANDATORY: You MUST respond in the same language used by the user in their question.
2. EXTREMELY IMPORTANT: Answer EXCLUSIVELY based on the provided Terminal Context.
3. NO HALLUCINATIONS. The Terminal Context is a live buffer. If it contains only a shell prompt (like 'iol#' or 'admin@vrouter>') and no command output, it means YOU DON'T HAVE DATA. In this case, YOU MUST NOT invent any information. Instead, explicitly state that you don't see the data and offer the correct CLI commands to retrieve it.
4. If the user asks you to analyze, parse, or extract data from the Terminal Context, DO IT directly in the <guide> section (you can use markdown tables or lists). Do NOT just give them a command to do it themselves.
5. If the user wants to execute an action, provide the required CLI commands inside a <commands> block, one command per line. If no commands are needed, leave it empty or omit the block.
6. ULTRA-CONCISE. Keep your guide to the point.
7. You MUST output your response in the following strict format:
<guide>
Your brief tactical guide in markdown. 3-4 sentences max.
</guide>
@@ -1358,7 +1427,7 @@ command 2
<risk>
low, high, or destructive
</risk>
6. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
8. Risk level: "low" for read-only/no commands, "high" for config changes, "destructive" for potentially dangerous ops.
Terminal Context:
{terminal_buffer}
@@ -1396,16 +1465,18 @@ Node: {node_name}"""
# Use models based on persona
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_auth = self.architect_auth if persona == "architect" else self.engineer_auth
try:
while iteration < max_iterations:
iteration += 1
response = await acompletion(
model=current_model,
messages=messages,
tools=mcp_tools if mcp_tools else None,
api_key=current_key,
stream=True
stream=True,
**current_auth
)
full_content = ""
@@ -1478,8 +1549,8 @@ Node: {node_name}"""
model=self.engineer_model,
messages=messages,
tools=None,
api_key=self.engineer_key,
stream=True
stream=True,
**self.engineer_auth
)
full_content = ""
@@ -1559,3 +1630,311 @@ Node: {node_name}"""
@MethodHook
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
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
conf = config or configfile()
server = serve(conf, port=port, debug=True)
@@ -56,6 +86,12 @@ def debug_api(port=8048, config=None):
server.stop(0)
from .ai import 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):
try:
+1
View File
@@ -7,4 +7,5 @@ from .api_handler import APIHandler
from .plugin_handler import PluginHandler
from .import_export_handler import ImportExportHandler
from .context_handler import ContextHandler
from .sso_handler import SSOHandler
+65 -21
View File
@@ -15,13 +15,22 @@ class AIHandler:
def dispatch(self, args):
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:
printer.info("No saved AI sessions found.")
return
columns = ["ID", "Title", "Created At", "Model"]
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
if args.delete_session:
@@ -35,18 +44,18 @@ class AIHandler:
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
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
if not session_id:
printer.warning("No previous session found to resume.")
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args > Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args > Local Config
settings = self.app.services.config_svc.get_settings().get("ai", {})
arguments = {}
@@ -57,17 +66,24 @@ class AIHandler:
elif 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)
if getattr(self.app.services, "mode", "local") == "local":
if not arguments.get("engineer_api_key"):
printer.error("Engineer API key not configured. The chat cannot start.")
printer.info("Use 'connpy config --engineer-api-key <key>' to set it.")
if not arguments.get("engineer_api_key") and not arguments.get("engineer_auth"):
printer.error("Engineer API key/auth not configured. The chat cannot start.")
printer.info("Use 'connpy config --engineer-api-key <key>' or 'connpy config --engineer-auth <auth>' to set it.")
sys.exit(1)
if not arguments.get("architect_api_key"):
printer.warning("Architect API key not configured. Architect will be unavailable.")
printer.info("Use 'connpy config --architect-api-key <key>' to enable it.")
if not arguments.get("architect_api_key") and not arguments.get("architect_auth"):
printer.warning("Architect API key/auth not configured. Architect will be unavailable.")
printer.info("Use 'connpy config --architect-api-key <key>' or 'connpy config --architect-auth <auth>' to enable it.")
# 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.ai_overrides = arguments
@@ -78,7 +94,7 @@ class AIHandler:
def single_question(self, args, session_id):
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)
responder = result.get("responder", "engineer")
@@ -102,7 +118,7 @@ class AIHandler:
if history:
mdprint(f"[debug]Analyzing {len(history)} previous messages...[/debug]\n")
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:
mdprint(Rule(style="engineer"))
@@ -115,8 +131,8 @@ class AIHandler:
if not user_query.strip(): continue
if user_query.lower() in ['exit', 'quit', 'bye', 'cancel']: break
with console.status("[ai_status]Agent is thinking...") as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **self.ai_overrides)
with console.status("[ai_status]Agent is thinking...[/ai_status]") as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get("chat_history")
if new_history is not None:
@@ -147,8 +163,7 @@ class AIHandler:
action = mcp_args[0].lower()
if action == "list":
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get("ai", {}).get("mcp_servers", {})
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info("No MCP servers configured.")
else:
@@ -213,8 +228,7 @@ class AIHandler:
from .forms import Forms
self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get("ai", {}).get("mcp_servers", {})
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
@@ -249,3 +263,33 @@ class AIHandler:
except Exception as 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,
"engineer_model": self.set_ai_config,
"engineer_api_key": self.set_ai_config,
"engineer_auth": self.set_ai_config,
"architect_model": self.set_ai_config,
"architect_api_key": self.set_ai_config,
"architect_auth": self.set_ai_config,
"trusted_commands": self.set_ai_config,
"service_mode": self.set_service_mode,
"remote_host": self.set_remote_host,
@@ -127,9 +129,57 @@ class ConfigHandler:
try:
settings = self.app.services.config_svc.get_settings()
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)
printer.success("Config saved")
except ConnpyError as e:
except (ConnpyError, InvalidConfigurationError) as 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 inquirer
from inquirer.themes import Default, term
try:
from pyfzf.pyfzf import FzfPrompt
except ImportError:
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():
home = os.path.expanduser("~")
defaultdir = os.path.join(home, '.config/conn')
@@ -56,7 +127,7 @@ def choose(app, list_, name, action):
return answer[0]
else:
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:
return None
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.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):
if not self.app.case and args.data != None:
args.data = args.data.lower()
@@ -39,6 +56,7 @@ class NodeHandler:
else:
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -73,6 +91,7 @@ class NodeHandler:
matches = self.app.services.nodes.list_folders(args.data)
else:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -87,8 +106,9 @@ class NodeHandler:
sys.exit(7)
try:
for item in matches:
self.app.services.nodes.delete_node(item, is_folder=is_folder)
for i, item in enumerate(matches):
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:
printer.success(f"{matches[0]} deleted successfully")
@@ -144,6 +164,7 @@ class NodeHandler:
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -171,6 +192,7 @@ class NodeHandler:
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -209,7 +231,7 @@ class NodeHandler:
self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f"{args.data} edited successfully")
else:
editcount = 0
changed_items = []
for k in matches:
updated_item = self.app.services.nodes.explode_unique(k)
updated_item["type"] = "connection"
@@ -222,8 +244,12 @@ class NodeHandler:
updated_item[key] = updatenode[key]
if this_item_changed:
editcount += 1
self.app.services.nodes.update_node(k, updated_item)
changed_items.append((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:
printer.info("Nothing to do here")
+321 -4
View File
@@ -15,13 +15,64 @@ class RunHandler:
def dispatch(self, args):
if len(args.data) > 1:
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)
def node_run(self, args):
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:])]
# 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:
header_printed = False
@@ -36,7 +87,7 @@ class RunHandler:
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
expected=args.test_expected,
on_node_complete=_on_node_complete
@@ -53,12 +104,46 @@ class RunHandler:
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
on_node_complete=_on_node_complete
)
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:
printer.error(str(e))
sys.exit(1)
@@ -79,8 +164,105 @@ class RunHandler:
with open(path, "r") as f:
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", []):
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:
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
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:
header_printed = False
if action == "run":
@@ -163,5 +368,117 @@ class RunHandler:
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
except ConnpyError as 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)
+95 -34
View File
@@ -12,7 +12,6 @@ from textwrap import dedent
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.live import Live
from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import HTML
@@ -57,9 +56,10 @@ class CopilotInterface:
async def run_session(self,
raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict,
on_ai_call: Callable):
on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
"""
Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -> result_dict
@@ -69,9 +69,11 @@ class CopilotInterface:
try:
# Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors='replace'))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
blocks.append((len(raw_bytes), last_line[:80]))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = {
'context_cmd': 1,
@@ -85,14 +87,14 @@ class CopilotInterface:
}
# 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(Panel(
"[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\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]",
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.add('c-up')
@@ -128,12 +130,12 @@ class CopilotInterface:
if state['context_mode'] == self.mode_lines:
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
idx = max(0, state['total_cmds'] - state['context_cmd'])
start, preview = blocks[idx]
if state['context_mode'] == self.mode_single and idx + 1 < state['total_cmds']:
end = blocks[idx + 1][0]
start, end, preview = blocks[idx]
if state['context_mode'] == self.mode_single:
active_raw = raw_bytes[start:end]
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'))
def get_prompt_text():
@@ -159,7 +161,7 @@ class CopilotInterface:
if app and app.current_buffer:
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:
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
matches = [c for c in commands if c.startswith(text.lower())]
@@ -172,7 +174,39 @@ class CopilotInterface:
base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]'
else:
idx = max(0, state['total_cmds'] - state['context_cmd'])
desc = blocks[idx][1]
def clean_preview(text):
# Clean newlines and the initial prompt (all up to #, > or $) to leave only the command
original = text.strip().replace('\r', '').replace('\n', ' ')
cleaned = re.sub(r'^.*?[#>\$]\s*', '', 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
if state['context_mode'] == self.mode_range:
range_blocks = blocks[idx:]
# 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:
range_blocks = range_blocks[:-1]
# Clean and truncate very long commands so they don't break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) > 25: p = p[:22] + "..."
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) <= 3:
desc = " + ".join(previews)
else:
desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})"
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f'\u25b6 {desc} [Tab: {m_label}]'
# Wrap base_str in a style to maintain consistency and avoid glitches
@@ -232,8 +266,8 @@ class CopilotInterface:
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don't leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -265,12 +299,12 @@ class CopilotInterface:
except: pass
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.flush()
continue
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'] = ''
clean_question = directive.get("clean_prompt", question)
@@ -299,39 +333,67 @@ class CopilotInterface:
# Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer'))
persona_color = self._get_theme_color(active_persona, fallback="cyan")
persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer"
active_buffer = get_active_buffer()
live_text = "Thinking..."
panel = Panel(live_text, title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color)
live_text = ""
first_chunk = True
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f"[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]",
console=self.console,
spinner="dots"
)
status_spinner.start()
def on_chunk(text):
nonlocal live_text
if live_text == "Thinking...": live_text = ""
nonlocal live_text, first_chunk
if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f"[bold {persona_color}]{persona_title}[/bold {persona_color}]",
style=persona_color
))
first_chunk = False
live_text += text
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
md_parser.feed(text)
# Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info))
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try:
while not ai_task.done():
await asyncio.sleep(0.05)
result = await ai_task
except asyncio.CancelledError:
status_spinner.stop()
return "cancel", None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get("error"):
if result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]")
if first_chunk and result and result.get("error"):
self.console.print(f"[red]Error: {result['error']}[/red]")
return "cancel", None, None
# 4. Handle result
if live_text == "Thinking..." and result.get("guide"):
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]Copilot Guide[/bold {persona_color}]", border_style=persona_color))
# If no chunks were streamed but we have a guide, print it as a panel
if first_chunk and result and result.get("guide"):
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
commands = result.get("commands", [])
if not commands:
@@ -434,5 +496,4 @@ class CopilotInterface:
finally:
state['cancelled'] = True
self.console.print("[dim]Returning to session...[/dim]")
+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
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):
"""Build the declarative CLI navigation tree.
@@ -154,12 +190,17 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
run_after_node.update({
"--test": {"*": run_after_node},
"-t": {"*": run_after_node},
"--analyze": {"*": run_after_node},
"--preflight-ai": run_after_node,
"*": run_after_node # Consume commands
})
run_dict = {
"--generate": {"__extra__": lambda w: get_cwd(w, "--generate")},
"-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},
"-t": {"*": None},
"--help": None,
@@ -181,11 +222,53 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
ai_dict = {"__exclude_used__": True, "--help": None, "-h": None}
for opt in ["--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key"]:
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"]:
ai_dict[opt] = ai_dict # takes no value, loops back
ai_dict["--mcp"] = mcp_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}
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
ls_state = {
@@ -280,22 +363,11 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"--list": None, "--help": None,
"-h": None,
},
"config": {
"--allow-uppercase": ["true", "false"],
"--fzf": ["true", "false"],
"--keepalive": None,
"--completion": ["bash", "zsh"],
"--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,
},
"user": user_dict,
"sso": sso_dict,
"login": {"--help": None, "-h": None, "*": None},
"logout": {"--help": None, "-h": None},
"config": config_dict,
"sync": {
"--login": None, "--logout": None,
"--status": None, "--list": None,
+40 -2
View File
@@ -43,7 +43,8 @@ class configfile:
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:
@@ -149,6 +150,42 @@ class configfile:
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):
"""Verify config data has the required structure."""
if not isinstance(data, dict):
@@ -489,7 +526,8 @@ class configfile:
else:
printer.error("Filter must be a string or a list of strings")
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
@MethodHook
+56 -3
View File
@@ -37,7 +37,7 @@ RichHelpFormatter.group_name_formatter = str.upper
from .cli import (
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
ContextHandler
ContextHandler, SSOHandler
)
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
from .cli.help_text import get_help
@@ -79,11 +79,12 @@ class connapp:
self.debug_api = debug_api
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._getallfolders.register_post_hook(self.services.context.filter_node_list)
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"):
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"):
@@ -109,6 +110,9 @@ class connapp:
except ConnpyError as e:
# If in remote mode, connectivity issues should be reported
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}")
self.nodes_list = []
self.folders = []
@@ -135,6 +139,9 @@ class connapp:
from .cli.context_handler import ContextHandler
from .cli.import_export_handler import ImportExportHandler
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
self._node = NodeHandler(self)
@@ -147,6 +154,9 @@ class connapp:
self._context = ContextHandler(self)
self._import_export = ImportExportHandler(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
from .configfile import configfile
@@ -276,11 +286,14 @@ class connapp:
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-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-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("-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("--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("--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")
@@ -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("-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("--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)
#APIPARSER
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("--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-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("--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("--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-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"])
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)
#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 = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter)
syncparser.error = self._custom_error
+112 -57
View File
@@ -27,10 +27,10 @@ def copilot_terminal_mode():
try:
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)
# 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[1] = new_settings[1] | termios.OPOST
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
@@ -211,6 +211,7 @@ class node:
self.output = ""
self.status = 1
self.result = {}
self.cmd_byte_positions = [(0, None)]
@MethodHook
def _passtx(self, passwords, *, keyfile=None):
@@ -314,8 +315,11 @@ class node:
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()))
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
except OSError:
pass
if logger:
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}")
@@ -352,6 +356,7 @@ class node:
async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
local_stream.setup(resize_callback=resize_callback)
self.current_local_stream = local_stream
try:
child_fd = self.child.child_fd
@@ -385,9 +390,9 @@ class node:
loop = asyncio.get_running_loop()
child_reader_queue = asyncio.Queue()
# Track command byte positions for copilot context navigation
# Reset and track command byte positions for copilot context navigation
# Each entry is (byte_position, command_text_or_None)
cmd_byte_positions = [(0, None)]
self.cmd_byte_positions = [(self.mylog.tell() if hasattr(self, 'mylog') else 0, None)]
def _child_read_ready():
try:
@@ -428,15 +433,22 @@ class node:
node_info["prompt"] = to_str(self.tags.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$'))
# Invoke copilot (async callback handles UI)
await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, cmd_byte_positions)
await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, self.cmd_byte_positions)
continue
# Remove any stray \x00 bytes and forward normally
clean_data = data.replace(b'\x00', b'')
if clean_data:
# Track command boundaries when user hits Enter
if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data):
cmd_byte_positions.append((self.mylog.tell(), None))
# 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 or b'\x03' in clean_data):
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)
@@ -559,8 +571,53 @@ class node:
except Exception:
pass
finally:
self.current_local_stream = None
local_stream.teardown()
@MethodHook
async def inject_commands(self, commands, child_fd, on_inject=None):
"""
Inject a list of commands into the node's PTY.
Handles screen_length_command, history tracking and delays.
"""
if not commands:
return
# 0. Clear line
os.write(child_fd, b'\x15')
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get("screen_length_command") if hasattr(self, 'tags') and isinstance(self.tags, dict) else None
to_send = list(commands)
if slc and slc not in to_send: # avoid duplicates if already there
to_send.insert(0, slc)
# 2. Inject one by one
for cmd in to_send:
# Register in node's official history (SKIP if it's the administrative screen length command)
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
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
os.write(child_fd, (cmd + "\n").encode())
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
if on_inject and cmd != slc:
if asyncio.iscoroutinefunction(on_inject):
await on_inject(cmd)
else:
on_inject(cmd)
# Delay to avoid overwhelming the router
await asyncio.sleep(0.8)
@MethodHook
def interact(self, debug=False, logger=None):
@@ -629,12 +686,12 @@ class node:
# Get raw bytes from BytesIO
raw_bytes = self.mylog.getvalue()
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
# tenga control exclusivo del stdin sin interferencias de LocalStream.
# Stop terminal reading so prompt_toolkit (in run_session)
# has exclusive control of stdin without LocalStream interference.
if hasattr(stream, 'stop_reading'):
stream.stop_reading()
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)
try:
@@ -642,7 +699,7 @@ class node:
while True:
action, commands, custom_cmd = await interface.run_session(
raw_bytes=raw_bytes,
cmd_byte_positions=cmd_byte_positions,
cmd_byte_positions=self.cmd_byte_positions,
node_info=node_info,
on_ai_call=on_ai_call
)
@@ -650,7 +707,8 @@ class node:
continue
break
finally:
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
print("\033[2m Returning to session...\033[0m", flush=True)
# Restart terminal reading to return to interactive SSH/Telnet mode
if hasattr(stream, 'start_reading'):
stream.start_reading()
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
@@ -658,20 +716,7 @@ class node:
if action in ("send_all", "custom"):
cmds_to_send = commands if action == "send_all" else custom_cmd
if cmds_to_send:
os.write(child_fd, b'\x15') # Ctrl+U
await asyncio.sleep(0.1)
# Prepend screen length command to avoid pagination
if "screen_length_command" in self.tags:
cmds_to_send.insert(0, self.tags["screen_length_command"])
for cmd in cmds_to_send:
if cmd_byte_positions is not None:
cmd_byte_positions.append((self.mylog.tell(), cmd))
os.write(child_fd, (cmd + "\n").encode())
await asyncio.sleep(0.8)
await self.inject_commands(cmds_to_send, child_fd)
else:
os.write(child_fd, b'\x15\r')
except Exception as e:
@@ -731,14 +776,6 @@ class node:
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}")
# 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:
prompt = self.tags["prompt"]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -759,6 +796,20 @@ class node:
self.status = 1
return self.output
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)
if result == 2:
break
@@ -841,14 +892,6 @@ class node:
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}")
# 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:
prompt = self.tags["prompt"]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
@@ -870,6 +913,15 @@ class node:
self.status = 1
return self.output
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)
if result == 2:
break
@@ -895,13 +947,28 @@ class node:
if vars is not None:
e = e.format(**vars)
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = output
try:
newpattern = f".*({updatedprompt}).*{e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
cleaned_output = re.sub(newpattern, '', cleaned_output)
except re.error:
pass
if e in cleaned_output:
self.result[e] = True
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
return self.result
if result == 2:
@@ -971,18 +1038,6 @@ class node:
cmd += f" {self.options}"
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
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
File diff suppressed because it is too large Load Diff
+245 -79
View File
@@ -43,7 +43,7 @@ class NodeStub:
self.remote_host = remote_host
self.config = config
def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, cmd_byte_positions, pause_generator, resume_generator, old_tty):
def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty):
import json, asyncio, termios, sys, tty, queue
from ..core import copilot_terminal_mode
from . import connpy_pb2
@@ -51,6 +51,10 @@ class NodeStub:
pause_generator()
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
blocks = node_info.get("context_blocks", [])
interface = CopilotInterface(
self.config,
history=getattr(self, 'copilot_history', None),
@@ -59,8 +63,6 @@ class NodeStub:
self.copilot_history = interface.history
self.copilot_state = interface.session_state
node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info):
# Send request to server
request_queue.put(connpy_pb2.InteractRequest(
@@ -85,9 +87,9 @@ class NodeStub:
while True:
action, commands, custom_cmd = await interface.run_session(
raw_bytes=bytes(client_buffer_bytes),
cmd_byte_positions=cmd_byte_positions,
node_info=node_info,
on_ai_call=on_ai_call_remote
on_ai_call=on_ai_call_remote,
blocks=blocks
)
if action == "continue":
@@ -100,6 +102,7 @@ class NodeStub:
with copilot_terminal_mode():
action, commands, custom_cmd = asyncio.run(run_remote_copilot())
print("\033[2m Returning to session...\033[0m", flush=True)
# Prepare final action for server
action_sent = "cancel"
if action == "send_all" and commands:
@@ -124,7 +127,6 @@ class NodeStub:
request_queue = queue.Queue()
client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False]
wake_r, wake_w = os.pipe()
@@ -171,8 +173,6 @@ class NodeStub:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
break
if b'\r' in data or b'\n' in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError:
break
@@ -246,14 +246,11 @@ class NodeStub:
if res.copilot_prompt:
self._handle_remote_copilot(
res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions,
client_buffer_bytes,
pause_generator, resume_generator, old_tty
)
continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data)
@@ -275,7 +272,6 @@ class NodeStub:
params_json = json.dumps(connection_params)
request_queue = queue.Queue()
client_buffer_bytes = bytearray()
cmd_byte_positions = [(0, None)]
pause_stdin = [False]
wake_r, wake_w = os.pipe()
@@ -323,8 +319,6 @@ class NodeStub:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
break
if b'\r' in data or b'\n' in data:
cmd_byte_positions.append((len(client_buffer_bytes), None))
yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError:
break
@@ -397,14 +391,11 @@ class NodeStub:
if res.copilot_prompt:
self._handle_remote_copilot(
res, request_queue, response_queue,
client_buffer_bytes, cmd_byte_positions,
client_buffer_bytes,
pause_generator, resume_generator, old_tty
)
continue
if res.copilot_injected_command:
cmd_byte_positions.append((len(client_buffer_bytes), res.copilot_injected_command))
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data)
@@ -471,15 +462,17 @@ class NodeStub:
self._trigger_local_cache_sync()
@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)
self.stub.update_node(req)
if save:
self._trigger_local_cache_sync()
@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)
self.stub.delete_node(req)
if save:
self._trigger_local_cache_sync()
@handle_errors
@@ -699,11 +692,6 @@ class ExecutionStub:
req = connpy_pb2.ScriptRequest(param1=nodes_filter, param2=script_path, parallel=parallel)
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:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.ImportExportServiceStub(channel)
@@ -731,12 +719,10 @@ class AIStub:
self.stub = connpy_pb2_grpc.AIServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debug=False, status=None, **overrides):
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):
import queue
from rich.prompt import Prompt
from rich.text import Text
from rich.live import Live
from rich.panel import Panel
from rich.markdown import Markdown
@@ -755,6 +741,10 @@ class AIStub:
)
if chat_history is not None:
initial_req.chat_history.CopyFrom(to_value(chat_history))
if "engineer_auth" in overrides and overrides["engineer_auth"]:
initial_req.engineer_auth.CopyFrom(to_struct(overrides["engineer_auth"]))
if "architect_auth" in overrides and overrides["architect_auth"]:
initial_req.architect_auth.CopyFrom(to_struct(overrides["architect_auth"]))
req_queue.put(initial_req)
@@ -764,10 +754,11 @@ class AIStub:
if req is None: break
yield req
responses = self.stub.ask(request_generator())
responses = stub_method(request_generator())
full_content = ""
live_display = None
header_printed = False
current_responder = "engineer"
final_result = {"response": "", "chat_history": []}
# Background thread to pull responses from gRPC into a local queue
@@ -812,6 +803,10 @@ class AIStub:
break
if response.status_update:
if response.status_update.startswith("__RESPONDER__:"):
current_responder = response.status_update.split(":")[1].lower()
continue
if response.requires_confirmation:
if status: status.stop()
@@ -832,86 +827,72 @@ class AIStub:
if response.debug_message:
if debug:
if live_display:
try: live_display.stop()
except: pass
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.debug_message))
if live_display:
try: live_display.start()
except: pass
elif status:
if status:
try: status.start()
except: pass
continue
if response.important_message:
if live_display:
try: live_display.stop()
except: pass
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if live_display:
try: live_display.start()
except: pass
elif status:
if status:
try: status.start()
except: pass
continue
if not response.is_final:
if response.text_chunk:
full_content += response.text_chunk
if not live_display:
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 ..printer import connpy_theme, get_original_stdout
from rich.rule import Rule
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
# We default to Engineer title during stream, final result will correct it if needed
live_display = Live(
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False),
console=stable_console,
refresh_per_second=8,
transient=False
)
live_display.start()
else:
live_display.update(
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False)
)
# Print header on first chunk
alias = "architect" if current_responder == "architect" else "engineer"
role_label = "Network Architect" if current_responder == "architect" else "Network Engineer"
stable_console.print(Rule(f"[bold {alias}]{role_label}[/bold {alias}]", style=alias))
header_printed = True
# Initialize parser
md_parser = IncrementalMarkdownParser(console=stable_console)
full_content += response.text_chunk
if chunk_callback:
chunk_callback(response.text_chunk)
elif md_parser:
md_parser.feed(response.text_chunk)
continue
if response.is_final:
if live_display:
try: live_display.stop()
except: pass
# Final stop for status to ensure it disappears before the panel
if not chunk_callback and header_printed:
from rich.rule import Rule
md_parser.flush()
if status:
try: status.stop()
except: pass
final_result = from_struct(response.full_result)
responder = final_result.get("responder", "engineer")
alias = "architect" if responder == "architect" else "engineer"
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
title = f"[bold {alias}]{role_label}[/bold {alias}]"
content_to_print = full_content or final_result.get("response", "")
if content_to_print:
if live_display:
# Re-render the final frame with correct title/colors
live_display.update(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False))
else:
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False))
if not chunk_callback and header_printed:
from rich.console import Console as RichConsole
from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias))
break
except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch
@@ -926,21 +907,126 @@ class AIStub:
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
def confirm(self, input_text, console=None):
return self.stub.confirm(connpy_pb2.StringRequest(value=input_text)).value
@handle_errors
def list_sessions(self):
return from_value(self.stub.list_sessions(Empty()).data)
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) > 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):
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 "")
if auth:
req.auth.CopyFrom(to_struct(auth))
self.stub.configure_provider(req)
@handle_errors
@@ -954,6 +1040,11 @@ class AIStub:
)
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)
@@ -982,3 +1073,78 @@ class SystemStub:
@handle_errors
def get_api_status(self):
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 = []
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:
return []
+81
View File
@@ -15,7 +15,17 @@ class ThreadLocalStream:
def write(self, data):
stream = self._get_stream()
if stream:
import time
retries = 0
while True:
try:
stream.write(data)
break
except BlockingIOError:
if retries > 50:
raise
time.sleep(0.01)
retries += 1
def flush(self):
stream = self._get_stream()
@@ -496,3 +506,74 @@ class _ThemeProxy:
return getattr(local.theme, name)
connpy_theme = _ThemeProxy()
class BlockMarkdownRenderer:
"""
Block-buffered streaming markdown renderer.
Accumulates text until block boundaries are detected,
then renders complete blocks using Rich's Markdown.
"""
def __init__(self, console=None):
from rich.console import Console as RichConsole
from .printer import connpy_theme, get_original_stdout
self._console = console or RichConsole(
theme=connpy_theme, file=get_original_stdout()
)
self._line_buf = "" # chars waiting for \n
self._block_lines = [] # complete lines for current block
self._in_code_block = False
def feed(self, text):
self._line_buf += text
while '\n' in self._line_buf:
idx = self._line_buf.index('\n')
line = self._line_buf[:idx + 1]
self._line_buf = self._line_buf[idx + 1:]
self._process_line(line)
def flush(self):
if self._line_buf:
self._block_lines.append(self._line_buf)
self._line_buf = ""
self._flush_block()
def _process_line(self, line):
stripped = line.strip()
if stripped.startswith('```'):
if not self._in_code_block:
# Flush accumulated text before code block
self._flush_block()
self._in_code_block = True
self._block_lines.append(line)
else:
# Include closing fence and flush code block
self._block_lines.append(line)
self._in_code_block = False
self._flush_block()
return
if self._in_code_block:
self._block_lines.append(line)
return
# Blank line = paragraph break
if stripped == '':
self._block_lines.append(line)
self._flush_block()
return
self._block_lines.append(line)
def _flush_block(self):
if not self._block_lines:
return
block_text = ''.join(self._block_lines).strip()
self._block_lines = []
if not block_text:
return
from rich.markdown import Markdown
self._console.print(Markdown(block_text, code_theme="ansi_dark"))
# Alias for backward compatibility
IncrementalMarkdownParser = BlockMarkdownRenderer
+50 -1
View File
@@ -53,7 +53,6 @@ service ExecutionService {
rpc run_commands (RunRequest) returns (stream NodeRunResult) {}
rpc test_commands (TestRequest) returns (stream NodeRunResult) {}
rpc run_cli_script (ScriptRequest) returns (StructResponse) {}
rpc run_yaml_playbook (ScriptRequest) returns (StructResponse) {}
}
service ImportExportService {
@@ -70,7 +69,11 @@ service AIService {
rpc delete_session (StringRequest) returns (google.protobuf.Empty) {}
rpc configure_provider (ProviderRequest) 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 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 {
@@ -234,6 +237,8 @@ message AskRequest {
bool trust = 10;
string confirmation_answer = 11;
bool interrupt = 12;
google.protobuf.Struct engineer_auth = 13;
google.protobuf.Struct architect_auth = 14;
}
message AIResponse {
@@ -254,6 +259,7 @@ message ProviderRequest {
string provider = 1;
string model = 2;
string api_key = 3;
google.protobuf.Struct auth = 4;
}
message IntRequest {
@@ -292,3 +298,46 @@ message MCPRequest {
string auto_load_on_os = 4;
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;
}
+168 -15
View File
@@ -6,10 +6,41 @@ from connpy.utils import log_cleaner
class AIService(BaseService):
"""Business logic for interacting with AI agents and LLM configurations."""
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -> list:
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:
"""Identifies command blocks in the terminal history."""
blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) >= 2 and raw_bytes):
if not raw_bytes:
return blocks
default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
@@ -20,29 +51,104 @@ class AIService(BaseService):
except Exception:
prompt_re = re.compile(re.sub(r'(?<!\\)\$', '', default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) >= 1:
for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0]
if known_cmd:
if known_cmd == "CANCELLED":
parsed_positions.append({"pos": pos, "type": "CANCELLED", "preview": ""})
else:
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()]
prompt_text = prev_lines[-1].strip() if prev_lines else ""
preview = f"{prompt_text}{known_cmd}" if prompt_text else known_cmd
blocks.append((pos, preview[:80]))
if len(preview) > 80:
preview = preview[:77] + "..."
parsed_positions.append({"pos": pos, "type": "VALID_CMD", "preview": preview})
else:
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:
match = prompt_re.search(preview)
cleaned = self._clean_cisco_scrolling(chunk.decode(errors='replace'))
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:
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:
blocks.append((pos, 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:
parsed_positions.append({"pos": pos, "type": "EMPTY_PROMPT", "preview": ""})
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": ""})
else:
parsed_positions.append({"pos": pos, "type": "SCROLLING", "preview": ""})
last_newline = raw_bytes.rfind(b'\n')
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item["type"] == "VALID_CMD":
start_pos = item["pos"]
preview = item["preview"]
# Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
if next_item["type"] in ("VALID_CMD", "EMPTY_PROMPT", "CANCELLED"):
end_pos = next_item["pos"]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else "CURRENT CONTEXT"))
elif blocks[-1][0] < current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else "CURRENT CONTEXT"))
return blocks
def process_copilot_input(self, input_text: str, session_state: dict) -> dict:
@@ -133,11 +239,14 @@ class AIService(BaseService):
return await asyncio.wrap_future(future)
def list_sessions(self):
"""Return a list of all saved AI sessions."""
def list_sessions(self, limit=None):
"""Return a list of saved AI sessions, optionally limited."""
from connpy.ai import ai
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):
"""Delete an AI session by ID."""
@@ -149,13 +258,15 @@ class AIService(BaseService):
else:
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."""
settings = self.config.config.get("ai", {})
if model:
settings[f"{provider}_model"] = model
if 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._saveconfig(self.config.file)
@@ -194,9 +305,51 @@ class AIService(BaseService):
self.config.config["ai"] = ai_settings
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):
"""Load a session's raw data by ID."""
from connpy.ai import ai
agent = ai(self.config)
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
import os
import yaml
from .base import BaseService
from connpy.core import nodes as Nodes
from .exceptions import ConnpyError
@@ -108,52 +107,3 @@ class ExecutionService(BaseService):
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._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."""
all_nodes = self.config._getallnodes()
if unique_id not in all_nodes:
@@ -162,9 +162,10 @@ class NodeService(BaseService):
# config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data)
if save:
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."""
if is_folder:
uniques = self.config._explode_unique(unique_id)
@@ -177,6 +178,7 @@ class NodeService(BaseService):
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file)
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):
"""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):
"""List all core and user-defined plugins with their status and hash."""
import os
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 = {}
def get_hash(path):
@@ -26,12 +57,35 @@ class PluginService(BaseService):
except Exception:
return ""
# User plugins
if os.path.exists(plugin_dir):
for f in os.listdir(plugin_dir):
# 1. Scan core plugins (lowest priority)
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(".py"):
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)}
elif f.endswith(".py.bkp"):
name = f[:-7]
@@ -39,6 +93,7 @@ class PluginService(BaseService):
return all_plugin_info
def add_plugin(self, name, source_file, update=False):
"""Add or update a plugin from a local file."""
import os
@@ -119,6 +174,10 @@ class PluginService(BaseService):
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
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.")
def enable_plugin(self, name):
@@ -127,51 +186,80 @@ class PluginService(BaseService):
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(plugin_file):
return False # Already enabled
if not os.path.exists(disabled_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# 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:
os.rename(disabled_file, plugin_file)
return True
except OSError as 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):
"""Deactivate a plugin by renaming it to a backup file."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(disabled_file):
return False # Already disabled
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as 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):
import os
from ..services.exceptions import InvalidConfigurationError
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
raise InvalidConfigurationError(f"Plugin '{name}' not found")
with open(target, "r") as f:
with open(path, "r") as f:
return f.read()
def invoke_plugin(self, name, args_dict):
@@ -211,17 +299,12 @@ class PluginService(BaseService):
p_manager = Plugins()
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
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
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 .context_service import ContextService
from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config)
@@ -44,6 +45,7 @@ class ServiceProvider:
self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self):
# 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.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = None
if not self.remote_host:
raise InvalidConfigurationError("Remote host must be specified in remote mode")
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)
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.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.execution = ExecutionStub(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)
with pytest.raises(ValueError) as exc:
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):
"""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
# =========================================================================
# 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
# =========================================================================
@@ -409,6 +480,15 @@ class TestToolDefinitions:
names = [t["function"]["name"] for t in tools]
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
@@ -427,12 +507,14 @@ class TestAISessions:
def test_generate_session_id(self, myai):
session_id = myai._generate_session_id("Any query")
# Format: YYYYMMDD-HHMMSS
assert len(session_id) == 15
# Format: YYYYMMDD-HHMMSS-suffix
assert len(session_id) == 20
assert "-" in session_id
parts = session_id.split("-")
assert len(parts) == 3
assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS
assert len(parts[2]) == 4 # suffix
def test_save_and_load_session(self, myai):
history = [
+242
View File
@@ -158,3 +158,245 @@ def test_ingress_task_interception():
assert called_copilot
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
# =========================================================================
# 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_prompt.return_value = {"delete": True}
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.get_node_details")
@@ -165,9 +165,9 @@ def test_ai(mock_status, mock_ask, app):
@patch("connpy.services.execution_service.ExecutionService.run_commands")
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()
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"]
@patch("os.path.exists")
@@ -246,7 +246,7 @@ def test_plugin_disable(mock_disable, app):
@patch("connpy.services.ai_service.AIService.list_sessions")
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"])
mock_list_sessions.assert_called_once()
@@ -262,3 +262,65 @@ def test_type_node_reserved_word(app):
with pytest.raises(SystemExit) as exc:
app._type_node("bulk")
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 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
+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_ExecutionServiceServicer_to_server(server.ExecutionServicer(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')
srv.start()
@@ -143,6 +144,10 @@ class TestGRPCIntegration:
def config_stub(self, channel):
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):
nodes = node_stub.list_nodes()
assert "router1" in nodes
@@ -170,6 +175,12 @@ class TestGRPCIntegration:
settings = config_stub.get_settings()
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):
node_stub.add_node("integration-test-node", {"host": "9.9.9.9"})
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"
+31 -8
View File
@@ -7,11 +7,14 @@ def log_cleaner(data: str) -> str:
if not data:
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')
cleaned_lines = []
# 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:
buffer = []
@@ -23,14 +26,34 @@ def log_cleaner(data: str) -> str:
elif token in ('\b', '\x7f'):
if cursor > 0:
cursor -= 1
elif token == '\x1B[D': # Left Arrow
if cursor > 0:
cursor -= 1
elif token == '\x1B[C': # Right Arrow
if cursor < len(buffer):
cursor += 1
elif token == '\x1B[K': # Clear to end of line
elif token.startswith('\x1B[') and len(token) >= 3:
# Parse CSI: \x1B[ <params> <final_char>
final = token[-1]
param_str = token[2:-1]
n = int(param_str) if param_str.isdigit() else 1
if final == 'D': # CUB Cursor Back
cursor = max(0, cursor - n)
elif final == 'C': # CUF Cursor Forward
cursor = min(len(buffer), cursor + n)
elif final == 'K': # EL Erase in Line
if n == 0 or param_str == '': # Clear to end
buffer = buffer[:cursor]
elif n == 1: # Clear to start
buffer[:cursor] = [' '] * cursor
elif n == 2: # Clear entire line
buffer = []
cursor = 0
elif final == 'G': # CHA Cursor Horizontal Absolute (1-indexed)
cursor = max(0, n - 1)
# Pad buffer if cursor is beyond current length
if cursor > len(buffer):
buffer.extend([' '] * (cursor - len(buffer)))
elif final == 'P': # DCH Delete Characters
del buffer[cursor:cursor + n]
elif final == '@': # ICH Insert Characters
buffer[cursor:cursor] = [' '] * n
# All other CSI sequences are silently discarded
elif token.startswith('\x1B'):
continue
elif len(token) == 1 and ord(token) < 32:
+101 -43
View File
@@ -61,13 +61,22 @@ el.replaceWith(d);
def dispatch(self, args):
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:
printer.info(&#34;No saved AI sessions found.&#34;)
return
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]
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
if args.delete_session:
@@ -81,18 +90,18 @@ el.replaceWith(d);
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
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
if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;)
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -103,17 +112,24 @@ el.replaceWith(d);
elif 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)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;):
printer.error(&#34;Engineer API key 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;)
if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#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; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;):
printer.warning(&#34;Architect API key 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;)
if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#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; 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.ai_overrides = arguments
@@ -124,7 +140,7 @@ el.replaceWith(d);
def single_question(self, args, session_id):
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)
responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
@@ -148,7 +164,7 @@ el.replaceWith(d);
if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
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:
mdprint(Rule(style=&#34;engineer&#34;))
@@ -161,8 +177,8 @@ el.replaceWith(d);
if not user_query.strip(): continue
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:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **self.ai_overrides)
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, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
if new_history is not None:
@@ -193,8 +209,7 @@ el.replaceWith(d);
action = mcp_args[0].lower()
if action == &#34;list&#34;:
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;)
else:
@@ -259,8 +274,7 @@ el.replaceWith(d);
from .forms import Forms
self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
@@ -294,7 +308,37 @@ el.replaceWith(d);
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; removed.&#34;)
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>
<div class="desc"></div>
<h3>Methods</h3>
@@ -316,8 +360,7 @@ el.replaceWith(d);
action = mcp_args[0].lower()
if action == &#34;list&#34;:
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;)
else:
@@ -382,8 +425,7 @@ el.replaceWith(d);
from .forms import Forms
self.app.cli_forms = Forms(self.app)
settings = self.app.services.config_svc.get_settings()
mcp_servers = settings.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
@@ -431,13 +473,22 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def dispatch(self, args):
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:
printer.info(&#34;No saved AI sessions found.&#34;)
return
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]
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
if args.delete_session:
@@ -451,18 +502,18 @@ el.replaceWith(d);
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
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
if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;)
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -473,17 +524,24 @@ el.replaceWith(d);
elif 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)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;):
printer.error(&#34;Engineer API key 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;)
if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#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; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;):
printer.warning(&#34;Architect API key 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;)
if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#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; 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.ai_overrides = arguments
@@ -512,7 +570,7 @@ el.replaceWith(d);
if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
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:
mdprint(Rule(style=&#34;engineer&#34;))
@@ -525,8 +583,8 @@ el.replaceWith(d);
if not user_query.strip(): continue
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:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **self.ai_overrides)
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, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
if new_history is not None:
@@ -560,7 +618,7 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def single_question(self, args, session_id):
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)
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;engineer_model&#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_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host,
@@ -178,11 +180,59 @@ el.replaceWith(d);
try:
settings = self.app.services.config_svc.get_settings()
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)
printer.success(&#34;Config saved&#34;)
except ConnpyError as e:
printer.error(str(e))</code></pre>
except (ConnpyError, InvalidConfigurationError) as e:
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>
<div class="desc"></div>
<h3>Methods</h3>
@@ -206,8 +256,10 @@ el.replaceWith(d);
&#34;theme&#34;: self.set_theme,
&#34;engineer_model&#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_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host,
@@ -234,10 +286,27 @@ el.replaceWith(d);
try:
settings = self.app.services.config_svc.get_settings()
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)
printer.success(&#34;Config saved&#34;)
except ConnpyError as e:
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"></div>
+127 -1
View File
@@ -69,7 +69,7 @@ el.replaceWith(d);
return answer[0]
else:
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:
return None
else:
@@ -115,6 +115,65 @@ el.replaceWith(d);
</details>
<div class="desc"></div>
</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">
<span>def <span class="ident">nodes_completer</span></span>(<span>prefix, parsed_args, **kwargs)</span>
</code></dt>
@@ -181,6 +240,61 @@ el.replaceWith(d);
</dl>
</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>
</article>
<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.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_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.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>
</ul>
</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>
</nav>
</main>
+15
View File
@@ -72,6 +72,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</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>
<dd>
<div class="desc"></div>
@@ -88,6 +92,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</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>
<dd>
<div class="desc"></div>
@@ -96,6 +104,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</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>
<dd>
<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.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.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.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.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.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>
</ul>
</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.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):
if not self.app.case and args.data != None:
args.data = args.data.lower()
@@ -85,6 +102,7 @@ el.replaceWith(d);
else:
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -119,6 +137,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data)
else:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -133,8 +152,9 @@ el.replaceWith(d);
sys.exit(7)
try:
for item in matches:
self.app.services.nodes.delete_node(item, is_folder=is_folder)
for i, item in enumerate(matches):
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:
printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -190,6 +210,7 @@ el.replaceWith(d);
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -217,6 +238,7 @@ el.replaceWith(d);
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -255,7 +277,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;)
else:
editcount = 0
changed_items = []
for k in matches:
updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -268,8 +290,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key]
if this_item_changed:
editcount += 1
self.app.services.nodes.update_node(k, updated_item)
changed_items.append((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:
printer.info(&#34;Nothing to do here&#34;)
@@ -354,6 +380,7 @@ el.replaceWith(d);
else:
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -398,6 +425,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data)
else:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -412,8 +440,9 @@ el.replaceWith(d);
sys.exit(7)
try:
for item in matches:
self.app.services.nodes.delete_node(item, is_folder=is_folder)
for i, item in enumerate(matches):
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:
printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -456,6 +485,7 @@ el.replaceWith(d);
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
@@ -494,7 +524,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;)
else:
editcount = 0
changed_items = []
for k in matches:
updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -507,8 +537,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key]
if this_item_changed:
editcount += 1
self.app.services.nodes.update_node(k, updated_item)
changed_items.append((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:
printer.info(&#34;Nothing to do here&#34;)
@@ -535,6 +569,7 @@ el.replaceWith(d);
try:
matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception:
matches = []
+656 -11
View File
@@ -63,13 +63,64 @@ el.replaceWith(d);
def dispatch(self, args):
if len(args.data) &gt; 1:
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)
def node_run(self, args):
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:])]
# 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:
header_printed = False
@@ -84,7 +135,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
expected=args.test_expected,
on_node_complete=_on_node_complete
@@ -101,12 +152,46 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
on_node_complete=_on_node_complete
)
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:
printer.error(str(e))
sys.exit(1)
@@ -127,8 +212,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f:
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;, []):
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:
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
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:
header_printed = False
if action == &#34;run&#34;:
@@ -211,12 +416,243 @@ el.replaceWith(d);
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
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>
<div class="desc"></div>
<h3>Methods</h3>
<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">
<span>def <span class="ident">cli_run</span></span>(<span>self, script)</span>
</code></dt>
@@ -242,6 +678,29 @@ el.replaceWith(d);
folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None
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:
header_printed = False
if action == &#34;run&#34;:
@@ -302,8 +761,11 @@ el.replaceWith(d);
# ALWAYS show the aggregate summary at the end
printer.test_summary(results)
return results
except ConnpyError as e:
printer.error(str(e))</code></pre>
printer.error(str(e))
return {}</code></pre>
</details>
<div class="desc"></div>
</dd>
@@ -318,7 +780,12 @@ el.replaceWith(d);
<pre><code class="python">def dispatch(self, args):
if len(args.data) &gt; 1:
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>
</details>
<div class="desc"></div>
@@ -333,8 +800,54 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def node_run(self, args):
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:])]
# 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:
header_printed = False
@@ -349,7 +862,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
expected=args.test_expected,
on_node_complete=_on_node_complete
@@ -366,12 +879,46 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
nodes_filter=matched_nodes,
commands=commands,
on_node_complete=_on_node_complete
)
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:
printer.error(str(e))
sys.exit(1)</code></pre>
@@ -412,8 +959,105 @@ el.replaceWith(d);
with open(path, &#34;r&#34;) as f:
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;, []):
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:
printer.error(f&#34;Failed to run playbook {path}: {e}&#34;)
@@ -440,7 +1084,8 @@ el.replaceWith(d);
<ul>
<li>
<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.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>
+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>
+193 -69
View File
@@ -90,9 +90,10 @@ el.replaceWith(d);
async def run_session(self,
raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict,
on_ai_call: Callable):
on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
&#34;&#34;&#34;
Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict
@@ -102,9 +103,11 @@ el.replaceWith(d);
try:
# Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks.append((len(raw_bytes), last_line[:80]))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = {
&#39;context_cmd&#39;: 1,
@@ -118,14 +121,14 @@ el.replaceWith(d);
}
# 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(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\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;,
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.add(&#39;c-up&#39;)
@@ -161,12 +164,12 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
start, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single and idx + 1 &lt; state[&#39;total_cmds&#39;]:
end = blocks[idx + 1][0]
start, end, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single:
active_raw = raw_bytes[start:end]
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;))
def get_prompt_text():
@@ -192,7 +195,7 @@ el.replaceWith(d);
if app and app.current_buffer:
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:
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())]
@@ -205,7 +208,39 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
desc = blocks[idx][1]
def clean_preview(text):
# 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;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, 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
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# 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:
range_blocks = range_blocks[:-1]
# Clean and truncate very long commands so they don&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) &gt; 25: p = p[:22] + &#34;...&#34;
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) &lt;= 3:
desc = &#34; + &#34;.join(previews)
else:
desc = f&#34;{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})&#34;
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39;
# Wrap base_str in a style to maintain consistency and avoid glitches
@@ -265,8 +300,8 @@ el.replaceWith(d);
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -298,12 +333,12 @@ el.replaceWith(d);
except: pass
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.flush()
continue
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;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -332,39 +367,67 @@ el.replaceWith(d);
# Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;))
persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;)
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer()
live_text = &#34;Thinking...&#34;
panel = Panel(live_text, title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color)
live_text = &#34;&#34;
first_chunk = True
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f&#34;[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]&#34;,
console=self.console,
spinner=&#34;dots&#34;
)
status_spinner.start()
def on_chunk(text):
nonlocal live_text
if live_text == &#34;Thinking...&#34;: live_text = &#34;&#34;
nonlocal live_text, first_chunk
if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;,
style=persona_color
))
first_chunk = False
live_text += text
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
md_parser.feed(text)
# Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info))
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try:
while not ai_task.done():
await asyncio.sleep(0.05)
result = await ai_task
except asyncio.CancelledError:
status_spinner.stop()
return &#34;cancel&#34;, None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get(&#34;error&#34;):
if result and result.get(&#34;error&#34;): self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
if first_chunk and result and result.get(&#34;error&#34;):
self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
return &#34;cancel&#34;, None, None
# 4. Handle result
if live_text == &#34;Thinking...&#34; and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
# If no chunks were streamed but we have a guide, print it as a panel
if first_chunk and result and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;, border_style=persona_color))
commands = result.get(&#34;commands&#34;, [])
if not commands:
@@ -466,14 +529,13 @@ el.replaceWith(d);
return &#34;cancel&#34;, None, None
finally:
state[&#39;cancelled&#39;] = True
self.console.print(&#34;[dim]Returning to session...[/dim]&#34;)</code></pre>
state[&#39;cancelled&#39;] = True</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.terminal_ui.CopilotInterface.run_session"><code class="name flex">
<span>async def <span class="ident">run_session</span></span>(<span>self,<br>raw_bytes: bytes,<br>cmd_byte_positions: List[tuple],<br>node_info: dict,<br>on_ai_call: Callable)</span>
<span>async def <span class="ident">run_session</span></span>(<span>self,<br>raw_bytes: bytes,<br>node_info: dict,<br>on_ai_call: Callable,<br>cmd_byte_positions: List[tuple] = None,<br>blocks: List[tuple] = None)</span>
</code></dt>
<dd>
<details class="source">
@@ -482,9 +544,10 @@ el.replaceWith(d);
</summary>
<pre><code class="python">async def run_session(self,
raw_bytes: bytes,
cmd_byte_positions: List[tuple],
node_info: dict,
on_ai_call: Callable):
on_ai_call: Callable,
cmd_byte_positions: List[tuple] = None,
blocks: List[tuple] = None):
&#34;&#34;&#34;
Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict
@@ -494,9 +557,11 @@ el.replaceWith(d);
try:
# Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info)
# Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode)
if blocks is None:
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks.append((len(raw_bytes), last_line[:80]))
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
state = {
&#39;context_cmd&#39;: 1,
@@ -510,14 +575,14 @@ el.replaceWith(d);
}
# 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(Panel(
&#34;[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\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;,
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.add(&#39;c-up&#39;)
@@ -553,12 +618,12 @@ el.replaceWith(d);
if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
start, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single and idx + 1 &lt; state[&#39;total_cmds&#39;]:
end = blocks[idx + 1][0]
start, end, preview = blocks[idx]
if state[&#39;context_mode&#39;] == self.mode_single:
active_raw = raw_bytes[start:end]
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;))
def get_prompt_text():
@@ -584,7 +649,7 @@ el.replaceWith(d);
if app and app.current_buffer:
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:
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())]
@@ -597,7 +662,39 @@ el.replaceWith(d);
base_str = f&#39;\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]&#39;
else:
idx = max(0, state[&#39;total_cmds&#39;] - state[&#39;context_cmd&#39;])
desc = blocks[idx][1]
def clean_preview(text):
# 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;)
cleaned = re.sub(r&#39;^.*?[#&gt;\$]\s*&#39;, &#39;&#39;, 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
if state[&#39;context_mode&#39;] == self.mode_range:
range_blocks = blocks[idx:]
# 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:
range_blocks = range_blocks[:-1]
# Clean and truncate very long commands so they don&#39;t break the UI
previews = []
for b in range_blocks:
p = clean_preview(b[2])
if p:
# Truncar comandos individuales largos
if len(p) &gt; 25: p = p[:22] + &#34;...&#34;
previews.append(p)
if not previews:
desc = clean_preview(blocks[idx][2])
elif len(previews) &lt;= 3:
desc = &#34; + &#34;.join(previews)
else:
desc = f&#34;{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})&#34;
else:
# Modo SINGLE original
desc = clean_preview(blocks[idx][2])
base_str = f&#39;\u25b6 {desc} [Tab: {m_label}]&#39;
# Wrap base_str in a style to maintain consistency and avoid glitches
@@ -657,8 +754,8 @@ el.replaceWith(d);
style=ui_style
)
try:
# Usamos un try/finally interno para asegurar que si algo falla en prompt_async,
# no nos quedemos con la terminal en un estado extraño.
# We use an internal try/finally to ensure that if something fails in prompt_async,
# we don&#39;t leave the terminal in a strange state.
question = await session.prompt_async(
get_prompt_text,
key_bindings=bindings,
@@ -690,12 +787,12 @@ el.replaceWith(d);
except: pass
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.flush()
continue
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;
clean_question = directive.get(&#34;clean_prompt&#34;, question)
@@ -724,39 +821,67 @@ el.replaceWith(d);
# Use persona from overrides (one-shot) or from session state
active_persona = merged_node_info.get(&#39;persona&#39;, self.session_state.get(&#39;persona&#39;, &#39;engineer&#39;))
persona_color = self._get_theme_color(active_persona, fallback=&#34;cyan&#34;)
persona_title = &#34;Network Architect&#34; if active_persona == &#34;architect&#34; else &#34;Network Engineer&#34;
active_buffer = get_active_buffer()
live_text = &#34;Thinking...&#34;
panel = Panel(live_text, title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color)
live_text = &#34;&#34;
first_chunk = True
from rich.rule import Rule
from rich.status import Status
from connpy.printer import IncrementalMarkdownParser
md_parser = IncrementalMarkdownParser(console=self.console)
status_spinner = Status(
f&#34;[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]&#34;,
console=self.console,
spinner=&#34;dots&#34;
)
status_spinner.start()
def on_chunk(text):
nonlocal live_text
if live_text == &#34;Thinking...&#34;: live_text = &#34;&#34;
nonlocal live_text, first_chunk
if first_chunk:
status_spinner.stop()
# Print header rule before first chunk arrives
self.console.print(Rule(
f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;,
style=persona_color
))
first_chunk = False
live_text += text
with Live(panel, console=self.console, refresh_per_second=10) as live:
def update_live(t):
live.update(Panel(Markdown(t), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
md_parser.feed(text)
# Check for interruption during AI call
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info))
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
try:
while not ai_task.done():
await asyncio.sleep(0.05)
result = await ai_task
except asyncio.CancelledError:
status_spinner.stop()
return &#34;cancel&#34;, None, None
# Ensure spinner is stopped if no chunks arrived
if first_chunk:
status_spinner.stop()
# Close the streamed output with a Rule
if not first_chunk:
md_parser.flush()
self.console.print(Rule(style=persona_color))
if not result or result.get(&#34;error&#34;):
if result and result.get(&#34;error&#34;): self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
if first_chunk and result and result.get(&#34;error&#34;):
self.console.print(f&#34;[red]Error: {result[&#39;error&#39;]}[/red]&#34;)
return &#34;cancel&#34;, None, None
# 4. Handle result
if live_text == &#34;Thinking...&#34; and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]Copilot Guide[/bold {persona_color}]&#34;, border_style=persona_color))
# If no chunks were streamed but we have a guide, print it as a panel
if first_chunk and result and result.get(&#34;guide&#34;):
self.console.print(Panel(Markdown(result[&#34;guide&#34;]), title=f&#34;[bold {persona_color}]{persona_title}[/bold {persona_color}]&#34;, border_style=persona_color))
commands = result.get(&#34;commands&#34;, [])
if not commands:
@@ -858,8 +983,7 @@ el.replaceWith(d);
return &#34;cancel&#34;, None, None
finally:
state[&#39;cancelled&#39;] = True
self.console.print(&#34;[dim]Returning to session...[/dim]&#34;)</code></pre>
state[&#39;cancelled&#39;] = True</code></pre>
</details>
<div class="desc"><p>Runs the interactive Copilot session.
on_ai_call: async function(active_buffer, question) -&gt; result_dict</p></div>
+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>
<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>
</article>
<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>
</ul>
</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>
</nav>
</main>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -64,6 +64,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</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>
<dd>
<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.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.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>
</ul>
</li>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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>
+583 -242
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 = []
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:
return []
@@ -260,7 +263,10 @@ el.replaceWith(d);
all_llm_tools = []
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:
return []
+355 -36
View File
@@ -58,10 +58,41 @@ el.replaceWith(d);
<pre><code class="python">class AIService(BaseService):
&#34;&#34;&#34;Business logic for interacting with AI agents and LLM configurations.&#34;&#34;&#34;
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list:
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:
&#34;&#34;&#34;Identifies command blocks in the terminal history.&#34;&#34;&#34;
blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes):
if not raw_bytes:
return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -72,29 +103,104 @@ el.replaceWith(d);
except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0]
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_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()]
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
blocks.append((pos, 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:
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:
match = prompt_re.search(preview)
cleaned = self._clean_cisco_scrolling(chunk.decode(errors=&#39;replace&#39;))
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:
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:
blocks.append((pos, 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:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
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;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
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;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks
def process_copilot_input(self, input_text: str, session_state: dict) -&gt; dict:
@@ -185,11 +291,14 @@ el.replaceWith(d);
return await asyncio.wrap_future(future)
def list_sessions(self):
&#34;&#34;&#34;Return a list of all saved AI sessions.&#34;&#34;&#34;
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
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):
&#34;&#34;&#34;Delete an AI session by ID.&#34;&#34;&#34;
@@ -201,13 +310,15 @@ el.replaceWith(d);
else:
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;
settings = self.config.config.get(&#34;ai&#34;, {})
if model:
settings[f&#34;{provider}_model&#34;] = model
if 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._saveconfig(self.config.file)
@@ -246,11 +357,53 @@ el.replaceWith(d);
self.config.config[&#34;ai&#34;] = ai_settings
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):
&#34;&#34;&#34;Load a session&#39;s raw data by ID.&#34;&#34;&#34;
from connpy.ai import ai
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>
<div class="desc"><p>Business logic for interacting with AI agents and LLM configurations.</p>
<p>Initialize the service.</p>
@@ -283,6 +436,31 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Ask the AI copilot for terminal assistance asynchronously.</p></div>
</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">
<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>
@@ -317,17 +495,17 @@ el.replaceWith(d);
<div class="desc"><p>Ask the AI copilot for terminal assistance.</p></div>
</dd>
<dt id="connpy.services.ai_service.AIService.build_context_blocks"><code class="name flex">
<span>def <span class="ident">build_context_blocks</span></span>(<span>self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) > list</span>
<span>def <span class="ident">build_context_blocks</span></span>(<span>self,<br>raw_bytes: bytes,<br>cmd_byte_positions: list,<br>node_info: dict,<br>last_line: str = '') > list</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict) -&gt; list:
<pre><code class="python">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;
blocks = []
if not (cmd_byte_positions and len(cmd_byte_positions) &gt;= 2 and raw_bytes):
if not raw_bytes:
return blocks
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
@@ -338,33 +516,124 @@ el.replaceWith(d);
except Exception:
prompt_re = re.compile(re.sub(r&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, default_prompt))
parsed_positions = []
if cmd_byte_positions and len(cmd_byte_positions) &gt;= 1:
for i in range(1, len(cmd_byte_positions)):
pos, known_cmd = cmd_byte_positions[i]
prev_pos = cmd_byte_positions[i-1][0]
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_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()]
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
blocks.append((pos, 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:
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:
match = prompt_re.search(preview)
cleaned = self._clean_cisco_scrolling(chunk.decode(errors=&#39;replace&#39;))
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:
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:
blocks.append((pos, 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:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;EMPTY_PROMPT&#34;, &#34;preview&#34;: &#34;&#34;})
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;})
else:
parsed_positions.append({&#34;pos&#34;: pos, &#34;type&#34;: &#34;SCROLLING&#34;, &#34;preview&#34;: &#34;&#34;})
last_newline = raw_bytes.rfind(b&#39;\n&#39;)
current_prompt_pos = last_newline + 1 if last_newline != -1 else 0
current_end = len(raw_bytes)
for i, item in enumerate(parsed_positions):
if item[&#34;type&#34;] == &#34;VALID_CMD&#34;:
start_pos = item[&#34;pos&#34;]
preview = item[&#34;preview&#34;]
# Find the end position: next VALID_CMD or EMPTY_PROMPT or CANCELLED
end_pos = current_prompt_pos
for j in range(i + 1, len(parsed_positions)):
next_item = parsed_positions[j]
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;]
break
blocks.append((start_pos, end_pos, preview))
# Always ensure there is a final block representing the current prompt
if not blocks:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
elif blocks[-1][0] &lt; current_prompt_pos:
blocks.append((current_prompt_pos, current_end, last_line[:80] if last_line else &#34;CURRENT CONTEXT&#34;))
return blocks</code></pre>
</details>
<div class="desc"><p>Identifies command blocks in the terminal history.</p></div>
</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">
<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>
@@ -410,20 +679,22 @@ el.replaceWith(d);
<div class="desc"><p>Update MCP server settings in the configuration with smart merging.</p></div>
</dd>
<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>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</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;
settings = self.config.config.get(&#34;ai&#34;, {})
if model:
settings[f&#34;{provider}_model&#34;] = model
if 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._saveconfig(self.config.file)</code></pre>
@@ -466,21 +737,42 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Delete an AI session by ID.</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)</span>
<dt id="connpy.services.ai_service.AIService.list_mcp_servers"><code class="name flex">
<span>def <span class="ident">list_mcp_servers</span></span>(<span>self) > dict</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_sessions(self):
&#34;&#34;&#34;Return a list of all saved AI sessions.&#34;&#34;&#34;
<pre><code class="python">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;, {})</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
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>
<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>
<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>
@@ -498,6 +790,29 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Load a session's raw data by ID.</p></div>
</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">
<span>def <span class="ident">process_copilot_input</span></span>(<span>self, input_text: str, session_state: dict) > dict</span>
</code></dt>
@@ -596,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>
<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.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_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_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_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.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.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>
</ul>
</li>
+1 -110
View File
@@ -156,56 +156,7 @@ el.replaceWith(d);
except Exception as e:
raise ConnpyError(f&#34;Failed to read script {script_path}: {e}&#34;)
return self.run_commands(nodes_filter, commands, parallel=parallel)
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>
return self.run_commands(nodes_filter, commands, parallel=parallel)</code></pre>
</details>
<div class="desc"><p>Business logic for executing commands on nodes and running automation scripts.</p>
<p>Initialize the service.</p>
@@ -300,65 +251,6 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Execute commands on a set of nodes.</p></div>
</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">
<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>
@@ -439,7 +331,6 @@ el.replaceWith(d);
<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_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>
</ul>
</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._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;
all_nodes = self.config._getallnodes()
if unique_id not in all_nodes:
@@ -212,9 +212,10 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data)
if save:
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;
if is_folder:
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;)
self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file)
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>
</dd>
<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>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</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;
if is_folder:
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;)
self.config._connections_del(**uniques)
if save:
self.config._saveconfig(self.config.file)</code></pre>
</details>
<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>
</dd>
<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>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</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;
all_nodes = self.config._getallnodes()
if unique_id not in all_nodes:
@@ -707,6 +710,7 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data)
if save:
self.config._saveconfig(self.config.file)</code></pre>
</details>
<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):
&#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):
&#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34;
import os
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 = {}
def get_hash(path):
@@ -77,12 +108,35 @@ el.replaceWith(d);
except Exception:
return &#34;&#34;
# User plugins
if os.path.exists(plugin_dir):
for f in os.listdir(plugin_dir):
# 1. Scan core plugins (lowest priority)
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;):
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)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
@@ -90,6 +144,7 @@ el.replaceWith(d);
return all_plugin_info
def add_plugin(self, name, source_file, update=False):
&#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34;
import os
@@ -170,6 +225,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
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;)
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;)
disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
return False # Already enabled
if not os.path.exists(disabled_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# 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:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
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):
&#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34;
import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
return False # Already disabled
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;)
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
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):
import os
from ..services.exceptions import InvalidConfigurationError
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
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()
def invoke_plugin(self, name, args_dict):
@@ -262,17 +350,12 @@ el.replaceWith(d);
p_manager = Plugins()
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
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
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;)
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>
</details>
<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;)
disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
return False # Already disabled
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;)
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
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>
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
</dd>
@@ -471,17 +572,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
return False # Already enabled
if not os.path.exists(disabled_file):
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# 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:
os.rename(disabled_file, plugin_file)
return True
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>
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
</dd>
@@ -497,17 +619,11 @@ el.replaceWith(d);
import os
from ..services.exceptions import InvalidConfigurationError
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
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>
</details>
<div class="desc"></div>
@@ -557,17 +673,12 @@ el.replaceWith(d);
p_manager = Plugins()
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):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if not path:
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
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 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 = {}
def get_hash(path):
@@ -650,12 +756,35 @@ el.replaceWith(d);
except Exception:
return &#34;&#34;
# User plugins
if os.path.exists(plugin_dir):
for f in os.listdir(plugin_dir):
# 1. Scan core plugins (lowest priority)
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;):
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)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
+28 -2
View File
@@ -98,6 +98,7 @@ el.replaceWith(d);
from .import_export_service import ImportExportService
from .context_service import ContextService
from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config)
@@ -109,6 +110,7 @@ el.replaceWith(d);
self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self):
# 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.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = None
if not self.remote_host:
raise InvalidConfigurationError(&#34;Remote host must be specified in remote mode&#34;)
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)
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.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.system = SystemStub(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>
<div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div>
</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>
+31 -8
View File
@@ -59,11 +59,14 @@ el.replaceWith(d);
if not data:
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;)
cleaned_lines = []
# 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:
buffer = []
@@ -75,14 +78,34 @@ el.replaceWith(d);
elif token in (&#39;\b&#39;, &#39;\x7f&#39;):
if cursor &gt; 0:
cursor -= 1
elif token == &#39;\x1B[D&#39;: # Left Arrow
if cursor &gt; 0:
cursor -= 1
elif token == &#39;\x1B[C&#39;: # Right Arrow
if cursor &lt; len(buffer):
cursor += 1
elif token == &#39;\x1B[K&#39;: # Clear to end of line
elif token.startswith(&#39;\x1B[&#39;) and len(token) &gt;= 3:
# Parse CSI: \x1B[ &lt;params&gt; &lt;final_char&gt;
final = token[-1]
param_str = token[2:-1]
n = int(param_str) if param_str.isdigit() else 1
if final == &#39;D&#39;: # CUB Cursor Back
cursor = max(0, cursor - n)
elif final == &#39;C&#39;: # CUF Cursor Forward
cursor = min(len(buffer), cursor + n)
elif final == &#39;K&#39;: # EL Erase in Line
if n == 0 or param_str == &#39;&#39;: # Clear to end
buffer = buffer[:cursor]
elif n == 1: # Clear to start
buffer[:cursor] = [&#39; &#39;] * cursor
elif n == 2: # Clear entire line
buffer = []
cursor = 0
elif final == &#39;G&#39;: # CHA Cursor Horizontal Absolute (1-indexed)
cursor = max(0, n - 1)
# Pad buffer if cursor is beyond current length
if cursor &gt; len(buffer):
buffer.extend([&#39; &#39;] * (cursor - len(buffer)))
elif final == &#39;P&#39;: # DCH Delete Characters
del buffer[cursor:cursor + n]
elif final == &#39;@&#39;: # ICH Insert Characters
buffer[cursor:cursor] = [&#39; &#39;] * n
# All other CSI sequences are silently discarded
elif token.startswith(&#39;\x1B&#39;):
continue
elif len(token) == 1 and ord(token) &lt; 32:
+2
View File
@@ -20,3 +20,5 @@ httpx>=0.27.0
requests>=2.31.0
pytest>=8.0.0
pytest-mock>=3.12.0
bcrypt>=4.1.0
PyJWT>=2.8.0
+10 -8
View File
@@ -8,7 +8,7 @@ keywords = networking, automation, docker, kubernetes, ssh, telnet, connection m
author = Federico Luzzi
author_email = fluzzi@gmail.com
url = https://github.com/fluzzi/connpy
license = Custom Software License
license = PolyForm Noncommercial License 1.0.0
license_files = LICENSE
project_urls =
Bug Tracker = https://github.com/fluzzi/connpy/issues
@@ -37,18 +37,20 @@ install_requires =
pycryptodome>=3.18.0
PyYAML>=6.0.1
pyfzf>=0.3.1
litellm>=1.40.0
grpcio>=1.62.0
grpcio-tools>=1.62.0
litellm>=1.40.0,<2.0.0
grpcio>=1.62.0,<2.0.0
grpcio-tools>=1.62.0,<2.0.0
protobuf>=6.31.1,<7.0.0
google-api-python-client>=2.125.0
google-auth-oauthlib>=1.2.0
google-auth-httplib2>=0.2.0
prompt-toolkit>=3.0.0
mcp>=1.2.0
aiohttp>=3.9.0
httpx>=0.27.0
mcp>=1.2.0,<2.0.0
aiohttp>=3.9.0,<4.0.0
httpx>=0.27.0,<1.0.0
requests>=2.31.0
bcrypt>=4.1.0
PyJWT>=2.8.0
[options.entry_points]
console_scripts =
@@ -60,4 +62,4 @@ console_scripts =
connpy =
core_plugins/*
proto/*
grpc/*.proto
grpc_layer/*