Compare commits
31 Commits
05fb9951c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 127c1b9fdb | |||
| 744e730672 | |||
| 61a44d004f | |||
| 2b8e637298 | |||
| 721a3642f3 | |||
| e52d300cf1 | |||
| cf866d782a | |||
| 49dfa805e4 | |||
| 1b9751bd23 | |||
| f5e09a55ab | |||
| f6ce48ed8a | |||
| 7b053998f9 | |||
| 58c81a19cb | |||
| 0adaaad971 | |||
| aa542cb6eb | |||
| 6ee953edcf | |||
| 74756f25b2 | |||
| 243718df46 | |||
| 3be9935541 | |||
| cd8eeaad79 | |||
| 4f3af7ca12 | |||
| dce9982454 | |||
| 468868ac18 | |||
| 3ca6f497d1 | |||
| 1db4a045e5 | |||
| 7b01a0c05f | |||
| 5a8b744aa8 | |||
| 9446bafc0c | |||
| 64377f7f30 | |||
| e4fd1adba3 | |||
| b0a914ad7f |
@@ -20,3 +20,10 @@ scratch
|
||||
testall
|
||||
testremote
|
||||
automation-template.yaml
|
||||
|
||||
# Sensitive local files and credentials
|
||||
auth.json
|
||||
key.db
|
||||
config.db
|
||||
*.db
|
||||
testnew/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -3,188 +3,275 @@
|
||||
</p>
|
||||
|
||||
|
||||
# Connpy
|
||||
# Connpy (v6.0.3)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://modelcontextprotocol.io)
|
||||
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
||||
[](https://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 run --rm --remove-orphans connpy-app [command]
|
||||
|
||||
# Pro Tip: Add this alias for a 100% native experience from any folder
|
||||
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
|
||||
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
@@ -5,178 +5,278 @@
|
||||
</p>
|
||||
|
||||
|
||||
# Connpy
|
||||
# Connpy (v6.0.3)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://github.com/fluzzi/connpy)
|
||||
[](https://modelcontextprotocol.io)
|
||||
[](https://github.com/fluzzi/connpy/blob/main/LICENSE)
|
||||
[](https://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 run --rm --remove-orphans connpy-app [command]
|
||||
|
||||
# Pro Tip: Add this alias for a 100% native experience from any folder
|
||||
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
|
||||
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
@@ -1 +1 @@
|
||||
__version__ = "6.0.0b8"
|
||||
__version__ = "6.0.3"
|
||||
|
||||
+481
-102
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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]")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
+100
-84
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+660
-101
File diff suppressed because it is too large
Load Diff
+245
-79
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__"]):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,136 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, ANY
|
||||
from connpy.connapp import connapp
|
||||
import os
|
||||
|
||||
@pytest.fixture
|
||||
def app(populated_config):
|
||||
"""Returns an instance of connapp initialized with mock config."""
|
||||
return connapp(populated_config)
|
||||
|
||||
def test_run_generate_ai_dispatch(app):
|
||||
"""Test that connpy run --generate-ai parses and calls ai_generate."""
|
||||
with patch("connpy.cli.run_handler.RunHandler.ai_generate") as mock_ai_gen:
|
||||
app.start(["run", "--generate-ai", "new_playbook.yaml"])
|
||||
mock_ai_gen.assert_called_once()
|
||||
args = mock_ai_gen.call_args[0][0]
|
||||
assert args.data == ["new_playbook.yaml"]
|
||||
assert args.action == "generate_ai"
|
||||
|
||||
def test_run_preflight_ai_node(app):
|
||||
"""Test that connpy run --preflight-ai calls predict_execution_results and exits."""
|
||||
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
app.start(["run", "router1", "show version", "--preflight-ai"])
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_predict.assert_called_once_with(["router1"], ["show version"], chunk_callback=ANY)
|
||||
|
||||
def test_run_analyze_node(app):
|
||||
"""Test that connpy run --analyze calls analyze_execution_results after execution."""
|
||||
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "success"}})
|
||||
|
||||
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
|
||||
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
|
||||
app.start(["run", "router1", "show version", "--analyze"])
|
||||
mock_run.assert_called_once()
|
||||
mock_analyze.assert_called_once_with(
|
||||
{"router1": {"status": 0, "output": "success"}},
|
||||
query="show version",
|
||||
chunk_callback=ANY
|
||||
)
|
||||
|
||||
def test_run_preflight_ai_playbook(app, tmp_path):
|
||||
"""Test that running a playbook with --preflight-ai predicts results per task."""
|
||||
playbook_path = tmp_path / "test_playbook.yaml"
|
||||
playbook_content = """
|
||||
tasks:
|
||||
- name: test-task
|
||||
action: run
|
||||
nodes: "router1"
|
||||
commands: ["show ip interface brief"]
|
||||
output: stdout
|
||||
"""
|
||||
playbook_path.write_text(playbook_content)
|
||||
|
||||
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||
with patch("connpy.services.ai_service.AIService.predict_execution_results") as mock_predict:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
app.start(["run", str(playbook_path), "--preflight-ai"])
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_predict.assert_called_once_with(["router1"], ["show ip interface brief"], chunk_callback=ANY)
|
||||
|
||||
def test_run_analyze_playbook(app, tmp_path):
|
||||
"""Test that running a playbook with --analyze triggers strategic analysis on all task outcomes."""
|
||||
playbook_path = tmp_path / "test_playbook.yaml"
|
||||
playbook_content = """
|
||||
tasks:
|
||||
- name: test-task
|
||||
action: run
|
||||
nodes: "router1"
|
||||
commands: ["show ip interface brief"]
|
||||
output: stdout
|
||||
"""
|
||||
playbook_path.write_text(playbook_content)
|
||||
|
||||
mock_run = MagicMock(return_value={"router1": {"status": 0, "output": "ok"}})
|
||||
|
||||
with patch("connpy.services.node_service.NodeService.list_nodes", return_value=["router1"]):
|
||||
with patch("connpy.services.execution_service.ExecutionService.run_commands", mock_run):
|
||||
with patch("connpy.services.ai_service.AIService.analyze_execution_results") as mock_analyze:
|
||||
app.start(["run", str(playbook_path), "--analyze"])
|
||||
mock_run.assert_called_once()
|
||||
mock_analyze.assert_called_once_with(
|
||||
{"router1": {"status": 0, "output": "ok"}},
|
||||
query=f"Playbook: {str(playbook_path)}",
|
||||
chunk_callback=ANY
|
||||
)
|
||||
|
||||
def test_ai_generate_wizard_save(app, tmp_path):
|
||||
"""Test that ai_generate wizard runs interactive chat loop, asks for validation and saves YAML."""
|
||||
dest_yaml = tmp_path / "playbook.yaml"
|
||||
|
||||
mock_chat = MagicMock(return_value={
|
||||
"response": "Here is your playbook.",
|
||||
"chat_history": [],
|
||||
"playbook_yaml": "tasks:\n - name: mytask"
|
||||
})
|
||||
app.services.ai.build_playbook_chat = mock_chat
|
||||
|
||||
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save
|
||||
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]):
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
|
||||
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
|
||||
def test_ai_generate_wizard_run(app, tmp_path):
|
||||
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
|
||||
dest_yaml = tmp_path / "playbook_run.yaml"
|
||||
|
||||
mock_chat = MagicMock(return_value={
|
||||
"response": "Here is your playbook.",
|
||||
"chat_history": [],
|
||||
"playbook_yaml": "tasks:\n - name: mytask\n action: run\n nodes: '*'\n commands: ['show version']\n output: stdout"
|
||||
})
|
||||
app.services.ai.build_playbook_chat = mock_chat
|
||||
|
||||
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]):
|
||||
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
|
||||
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
|
||||
mock_yaml_run.assert_called_once()
|
||||
args = mock_yaml_run.call_args[0][0]
|
||||
assert args.data == [str(dest_yaml)]
|
||||
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from connpy.cli.sso_handler import SSOHandler
|
||||
|
||||
def test_sso_handler_add_provider_with_allowed_domains():
|
||||
# 1. Setup mock app structure
|
||||
app_mock = MagicMock()
|
||||
app_mock.services.mode = "local"
|
||||
app_mock.config.config = {"sso": {"providers": {}}}
|
||||
|
||||
handler = SSOHandler(app_mock)
|
||||
|
||||
# Mock inquirer prompts
|
||||
mock_answers = {
|
||||
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
|
||||
"secret": "my-secret-key",
|
||||
"username_claim": "email",
|
||||
"algorithms": "RS256, HS256",
|
||||
"allowed_domains": "yyy.com, company.org"
|
||||
}
|
||||
|
||||
args_mock = MagicMock()
|
||||
args_mock.provider = "google"
|
||||
|
||||
with patch("inquirer.prompt", return_value=mock_answers):
|
||||
handler.add_provider(args_mock)
|
||||
|
||||
# Verify update_setting was called with the correct data structure
|
||||
app_mock.services.config_svc.update_setting.assert_called_once()
|
||||
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
|
||||
|
||||
assert saved_key == "sso"
|
||||
assert "providers" in saved_sso_config
|
||||
assert "google" in saved_sso_config["providers"]
|
||||
|
||||
google_config = saved_sso_config["providers"]["google"]
|
||||
assert google_config["jwks_url"] == "https://accounts.google.com/.well-known/jwks.json"
|
||||
assert google_config["secret"] == "my-secret-key"
|
||||
assert google_config["username_claim"] == "email"
|
||||
assert google_config["algorithms"] == ["RS256", "HS256"]
|
||||
assert google_config["allowed_domains"] == ["yyy.com", "company.org"]
|
||||
|
||||
def test_sso_handler_add_provider_allowed_domains_empty():
|
||||
app_mock = MagicMock()
|
||||
app_mock.services.mode = "local"
|
||||
app_mock.config.config = {"sso": {"providers": {}}}
|
||||
|
||||
handler = SSOHandler(app_mock)
|
||||
|
||||
mock_answers = {
|
||||
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
|
||||
"secret": "",
|
||||
"username_claim": "sub",
|
||||
"algorithms": "RS256",
|
||||
"allowed_domains": " " # empty input
|
||||
}
|
||||
|
||||
args_mock = MagicMock()
|
||||
args_mock.provider = "google"
|
||||
|
||||
with patch("inquirer.prompt", return_value=mock_answers):
|
||||
handler.add_provider(args_mock)
|
||||
|
||||
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
|
||||
google_config = saved_sso_config["providers"]["google"]
|
||||
|
||||
assert "allowed_domains" not in google_config
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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, "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:
|
||||
@@ -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]["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 = {}
|
||||
|
||||
@@ -103,17 +112,24 @@ el.replaceWith(d);
|
||||
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
|
||||
|
||||
@@ -124,7 +140,7 @@ el.replaceWith(d);
|
||||
|
||||
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")
|
||||
@@ -148,7 +164,7 @@ el.replaceWith(d);
|
||||
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"))
|
||||
@@ -161,8 +177,8 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -193,8 +209,7 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -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("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:
|
||||
@@ -294,7 +308,37 @@ el.replaceWith(d);
|
||||
printer.success(f"MCP server '{result['name']}' removed.")
|
||||
|
||||
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 ["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)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
@@ -316,8 +360,7 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -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("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:
|
||||
@@ -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, "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:
|
||||
@@ -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]["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 = {}
|
||||
|
||||
@@ -473,17 +524,24 @@ el.replaceWith(d);
|
||||
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
|
||||
|
||||
@@ -512,7 +570,7 @@ el.replaceWith(d);
|
||||
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"))
|
||||
@@ -525,8 +583,8 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -560,7 +618,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">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")
|
||||
|
||||
@@ -70,8 +70,10 @@ el.replaceWith(d);
|
||||
"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,
|
||||
@@ -178,11 +180,59 @@ el.replaceWith(d);
|
||||
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:
|
||||
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 ["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.")</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
@@ -206,8 +256,10 @@ el.replaceWith(d);
|
||||
"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,
|
||||
@@ -234,10 +286,27 @@ el.replaceWith(d);
|
||||
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))</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
|
||||
@@ -69,7 +69,7 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -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():
|
||||
"""Returns a fresh instance of the theme with current colors."""
|
||||
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):
|
||||
"""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</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("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 = ">"</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:
|
||||
"""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]</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "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}")</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, "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)</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, "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)</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, ".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).")</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, ".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}")</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>
|
||||
@@ -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) <= 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"{matches[0]} deleted successfully")
|
||||
@@ -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"{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"
|
||||
@@ -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("Nothing to do here")
|
||||
@@ -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"{matches[0]} deleted successfully")
|
||||
@@ -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"{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"
|
||||
@@ -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("Nothing to do here")
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -63,13 +63,64 @@ el.replaceWith(d);
|
||||
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
|
||||
|
||||
@@ -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, "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)
|
||||
@@ -127,8 +212,105 @@ el.replaceWith(d);
|
||||
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}")
|
||||
@@ -151,6 +333,29 @@ el.replaceWith(d);
|
||||
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":
|
||||
@@ -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"File '{dest_file}' already exists.")
|
||||
sys.exit(14)
|
||||
|
||||
chat_history = []
|
||||
|
||||
# Consistent layout opening matching global AI (engineer style)
|
||||
from rich.markdown import Markdown
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Operation cancelled by user.")
|
||||
break
|
||||
|
||||
if user_prompt.strip().lower() in ["exit", "quit"]:
|
||||
printer.info("Exiting AI Assistant.")
|
||||
break
|
||||
|
||||
if not user_prompt.strip():
|
||||
continue
|
||||
|
||||
printer.console.print()
|
||||
|
||||
renderer = printer.BlockMarkdownRenderer()
|
||||
first_chunk = True
|
||||
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
|
||||
|
||||
def callback(chunk):
|
||||
nonlocal first_chunk
|
||||
if first_chunk:
|
||||
try:
|
||||
status_context.stop()
|
||||
except:
|
||||
pass
|
||||
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
|
||||
first_chunk = False
|
||||
renderer.feed(chunk)
|
||||
|
||||
try:
|
||||
status_context.start()
|
||||
res = self.app.services.ai.build_playbook_chat(
|
||||
user_prompt,
|
||||
chat_history=chat_history,
|
||||
chunk_callback=callback
|
||||
)
|
||||
if first_chunk:
|
||||
try:
|
||||
status_context.stop()
|
||||
except:
|
||||
pass
|
||||
renderer.flush()
|
||||
if not first_chunk:
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
# Update history
|
||||
if res and "chat_history" in res:
|
||||
chat_history = res["chat_history"]
|
||||
|
||||
# Check if the agent returned a validated playbook YAML
|
||||
if res and "playbook_yaml" in res and res["playbook_yaml"]:
|
||||
yaml_content = res["playbook_yaml"]
|
||||
printer.console.print()
|
||||
printer.success("Playbook YAML successfully generated and validated.")
|
||||
|
||||
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
|
||||
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
|
||||
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
|
||||
printer.console.print(panel)
|
||||
|
||||
# Ask if the user wants to save it
|
||||
try:
|
||||
save_confirm = Prompt.ask(
|
||||
f"\nDo you want to save this playbook to '{dest_file}'?",
|
||||
choices=["y", "n", "run"],
|
||||
default="y"
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Saving skipped.")
|
||||
break
|
||||
|
||||
choice = save_confirm.strip().lower()
|
||||
if choice in ["y", "yes", "run"]:
|
||||
with open(dest_file, "w") as f:
|
||||
f.write(yaml_content)
|
||||
printer.success(f"Playbook saved successfully to '{dest_file}'")
|
||||
if choice == "run":
|
||||
printer.console.print()
|
||||
printer.info("Executing the saved playbook...")
|
||||
self.yaml_run(args)
|
||||
break
|
||||
else:
|
||||
printer.warning("Playbook not saved. You can continue describing changes or exit.")
|
||||
except Exception as e:
|
||||
printer.error(f"Error in AI chat: {e}")</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<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"File '{dest_file}' already exists.")
|
||||
sys.exit(14)
|
||||
|
||||
chat_history = []
|
||||
|
||||
# Consistent layout opening matching global AI (engineer style)
|
||||
from rich.markdown import Markdown
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Operation cancelled by user.")
|
||||
break
|
||||
|
||||
if user_prompt.strip().lower() in ["exit", "quit"]:
|
||||
printer.info("Exiting AI Assistant.")
|
||||
break
|
||||
|
||||
if not user_prompt.strip():
|
||||
continue
|
||||
|
||||
printer.console.print()
|
||||
|
||||
renderer = printer.BlockMarkdownRenderer()
|
||||
first_chunk = True
|
||||
status_context = printer.console.status("[ai_status]Agent is thinking...[/ai_status]")
|
||||
|
||||
def callback(chunk):
|
||||
nonlocal first_chunk
|
||||
if first_chunk:
|
||||
try:
|
||||
status_context.stop()
|
||||
except:
|
||||
pass
|
||||
printer.console.print(Rule(title="[engineer][bold]Playbook Builder AI[/bold][/engineer]", style="engineer"))
|
||||
first_chunk = False
|
||||
renderer.feed(chunk)
|
||||
|
||||
try:
|
||||
status_context.start()
|
||||
res = self.app.services.ai.build_playbook_chat(
|
||||
user_prompt,
|
||||
chat_history=chat_history,
|
||||
chunk_callback=callback
|
||||
)
|
||||
if first_chunk:
|
||||
try:
|
||||
status_context.stop()
|
||||
except:
|
||||
pass
|
||||
renderer.flush()
|
||||
if not first_chunk:
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
# Update history
|
||||
if res and "chat_history" in res:
|
||||
chat_history = res["chat_history"]
|
||||
|
||||
# Check if the agent returned a validated playbook YAML
|
||||
if res and "playbook_yaml" in res and res["playbook_yaml"]:
|
||||
yaml_content = res["playbook_yaml"]
|
||||
printer.console.print()
|
||||
printer.success("Playbook YAML successfully generated and validated.")
|
||||
|
||||
# Show the YAML inside a beautiful panel matching AI style (with engineer borders)
|
||||
syntax = Syntax(yaml_content, "yaml", theme="ansi_dark", word_wrap=True, background_color="default")
|
||||
panel = Panel(syntax, title="[engineer][bold]Resulting Playbook[/bold][/engineer]", border_style="engineer", expand=False)
|
||||
printer.console.print(panel)
|
||||
|
||||
# Ask if the user wants to save it
|
||||
try:
|
||||
save_confirm = Prompt.ask(
|
||||
f"\nDo you want to save this playbook to '{dest_file}'?",
|
||||
choices=["y", "n", "run"],
|
||||
default="y"
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Saving skipped.")
|
||||
break
|
||||
|
||||
choice = save_confirm.strip().lower()
|
||||
if choice in ["y", "yes", "run"]:
|
||||
with open(dest_file, "w") as f:
|
||||
f.write(yaml_content)
|
||||
printer.success(f"Playbook saved successfully to '{dest_file}'")
|
||||
if choice == "run":
|
||||
printer.console.print()
|
||||
printer.info("Executing the saved playbook...")
|
||||
self.yaml_run(args)
|
||||
break
|
||||
else:
|
||||
printer.warning("Playbook not saved. You can continue describing changes or exit.")
|
||||
except Exception as e:
|
||||
printer.error(f"Error in AI chat: {e}")</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.run_handler.RunHandler.cli_run"><code class="name flex">
|
||||
<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, "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":
|
||||
@@ -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) > 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)</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"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
|
||||
|
||||
@@ -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, "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)</code></pre>
|
||||
@@ -412,8 +959,105 @@ el.replaceWith(d);
|
||||
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}")
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.sso_handler API documentation</title>
|
||||
<meta name="description" content="">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
|
||||
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
|
||||
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
|
||||
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
|
||||
<script>window.addEventListener('DOMContentLoaded', () => {
|
||||
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
|
||||
hljs.highlightAll();
|
||||
/* Collapse source docstrings */
|
||||
setTimeout(() => {
|
||||
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
|
||||
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
|
||||
.forEach(el => {
|
||||
let d = document.createElement('details');
|
||||
d.classList.add('hljs-string');
|
||||
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
|
||||
el.replaceWith(d);
|
||||
});
|
||||
}, 100);
|
||||
})</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article id="content">
|
||||
<header>
|
||||
<h1 class="title">Module <code>connpy.cli.sso_handler</code></h1>
|
||||
</header>
|
||||
<section id="section-intro">
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="section-title" id="header-classes">Classes</h2>
|
||||
<dl>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler"><code class="flex name class">
|
||||
<span>class <span class="ident">SSOHandler</span></span>
|
||||
<span>(</span><span>app)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">class SSOHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def dispatch(self, args):
|
||||
if self.app.services.mode == "remote":
|
||||
printer.error("SSO management commands are only available in local/server-side mode.")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse actions from argparse mutually exclusive options
|
||||
if getattr(args, "add", None):
|
||||
args.action = "add"
|
||||
args.provider = args.add[0]
|
||||
elif getattr(args, "delete", None):
|
||||
args.action = "del"
|
||||
args.provider = args.delete[0]
|
||||
elif getattr(args, "list", False):
|
||||
args.action = "list"
|
||||
elif getattr(args, "show", None):
|
||||
args.action = "show"
|
||||
args.provider = args.show[0]
|
||||
|
||||
action = getattr(args, "action", None)
|
||||
|
||||
if action == "add":
|
||||
return self.add_provider(args)
|
||||
elif action == "del":
|
||||
return self.delete_provider(args)
|
||||
elif action == "list":
|
||||
return self.list_providers(args)
|
||||
elif action == "show":
|
||||
return self.show_provider(args)
|
||||
else:
|
||||
printer.error(f"Unknown action: {action}")
|
||||
sys.exit(1)
|
||||
|
||||
def add_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.setdefault("providers", {})
|
||||
|
||||
existing = providers.get(provider, {})
|
||||
if existing:
|
||||
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
|
||||
|
||||
# Interactive questionnaire
|
||||
questions = [
|
||||
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
|
||||
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
|
||||
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
|
||||
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
|
||||
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
|
||||
]
|
||||
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers:
|
||||
printer.warning("Operation cancelled.")
|
||||
sys.exit(130)
|
||||
|
||||
jwks_url = answers["jwks_url"].strip()
|
||||
secret = answers["secret"].strip()
|
||||
username_claim = answers["username_claim"].strip()
|
||||
algorithms_str = answers["algorithms"].strip()
|
||||
allowed_domains_str = answers.get("allowed_domains", "").strip()
|
||||
|
||||
if not jwks_url and not secret:
|
||||
printer.error("You must configure either a JWKS URL or a Secret.")
|
||||
sys.exit(1)
|
||||
|
||||
if not username_claim:
|
||||
printer.error("Username claim cannot be empty.")
|
||||
sys.exit(1)
|
||||
|
||||
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
|
||||
if not algorithms:
|
||||
algorithms = ["RS256"]
|
||||
|
||||
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
|
||||
|
||||
provider_data = {
|
||||
"username_claim": username_claim,
|
||||
"algorithms": algorithms
|
||||
}
|
||||
if jwks_url:
|
||||
provider_data["jwks_url"] = jwks_url
|
||||
if secret:
|
||||
provider_data["secret"] = secret
|
||||
if allowed_domains:
|
||||
provider_data["allowed_domains"] = allowed_domains
|
||||
|
||||
providers[provider] = provider_data
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.app.services.config_svc.update_setting("sso", sso)
|
||||
printer.success(f"SSO Provider '{provider}' saved successfully.")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to save SSO configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def delete_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
|
||||
if provider not in providers:
|
||||
printer.error(f"SSO Provider '{provider}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
# Confirm delete
|
||||
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers or not answers["confirm"]:
|
||||
printer.info("Delete cancelled.")
|
||||
return
|
||||
|
||||
del providers[provider]
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.app.services.config_svc.update_setting("sso", sso)
|
||||
printer.success(f"SSO Provider '{provider}' deleted successfully.")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to save SSO configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def list_providers(self, args):
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
if not providers:
|
||||
printer.warning("No SSO providers configured.")
|
||||
return
|
||||
|
||||
# Print list in YAML format
|
||||
providers_list = list(providers.keys())
|
||||
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
|
||||
printer.data("Configured SSO Providers", yaml_str)
|
||||
|
||||
def show_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
|
||||
if provider not in providers:
|
||||
printer.error(f"SSO Provider '{provider}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
data = providers[provider]
|
||||
|
||||
# Mask client secret for display if it's sensitive and not an env var starting with $
|
||||
display_data = data.copy()
|
||||
secret = display_data.get("secret")
|
||||
if secret and not secret.startswith("$"):
|
||||
display_data["secret"] = "********"
|
||||
|
||||
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
|
||||
printer.data(f"SSO Provider: {provider}", yaml_str)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler.add_provider"><code class="name flex">
|
||||
<span>def <span class="ident">add_provider</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def add_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.setdefault("providers", {})
|
||||
|
||||
existing = providers.get(provider, {})
|
||||
if existing:
|
||||
printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
|
||||
|
||||
# Interactive questionnaire
|
||||
questions = [
|
||||
inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
|
||||
inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
|
||||
inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
|
||||
inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
|
||||
inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
|
||||
]
|
||||
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers:
|
||||
printer.warning("Operation cancelled.")
|
||||
sys.exit(130)
|
||||
|
||||
jwks_url = answers["jwks_url"].strip()
|
||||
secret = answers["secret"].strip()
|
||||
username_claim = answers["username_claim"].strip()
|
||||
algorithms_str = answers["algorithms"].strip()
|
||||
allowed_domains_str = answers.get("allowed_domains", "").strip()
|
||||
|
||||
if not jwks_url and not secret:
|
||||
printer.error("You must configure either a JWKS URL or a Secret.")
|
||||
sys.exit(1)
|
||||
|
||||
if not username_claim:
|
||||
printer.error("Username claim cannot be empty.")
|
||||
sys.exit(1)
|
||||
|
||||
algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
|
||||
if not algorithms:
|
||||
algorithms = ["RS256"]
|
||||
|
||||
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
|
||||
|
||||
provider_data = {
|
||||
"username_claim": username_claim,
|
||||
"algorithms": algorithms
|
||||
}
|
||||
if jwks_url:
|
||||
provider_data["jwks_url"] = jwks_url
|
||||
if secret:
|
||||
provider_data["secret"] = secret
|
||||
if allowed_domains:
|
||||
provider_data["allowed_domains"] = allowed_domains
|
||||
|
||||
providers[provider] = provider_data
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.app.services.config_svc.update_setting("sso", sso)
|
||||
printer.success(f"SSO Provider '{provider}' saved successfully.")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to save SSO configuration: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler.delete_provider"><code class="name flex">
|
||||
<span>def <span class="ident">delete_provider</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def delete_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
|
||||
if provider not in providers:
|
||||
printer.error(f"SSO Provider '{provider}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
# Confirm delete
|
||||
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers or not answers["confirm"]:
|
||||
printer.info("Delete cancelled.")
|
||||
return
|
||||
|
||||
del providers[provider]
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.app.services.config_svc.update_setting("sso", sso)
|
||||
printer.success(f"SSO Provider '{provider}' deleted successfully.")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to save SSO configuration: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler.dispatch"><code class="name flex">
|
||||
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def dispatch(self, args):
|
||||
if self.app.services.mode == "remote":
|
||||
printer.error("SSO management commands are only available in local/server-side mode.")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse actions from argparse mutually exclusive options
|
||||
if getattr(args, "add", None):
|
||||
args.action = "add"
|
||||
args.provider = args.add[0]
|
||||
elif getattr(args, "delete", None):
|
||||
args.action = "del"
|
||||
args.provider = args.delete[0]
|
||||
elif getattr(args, "list", False):
|
||||
args.action = "list"
|
||||
elif getattr(args, "show", None):
|
||||
args.action = "show"
|
||||
args.provider = args.show[0]
|
||||
|
||||
action = getattr(args, "action", None)
|
||||
|
||||
if action == "add":
|
||||
return self.add_provider(args)
|
||||
elif action == "del":
|
||||
return self.delete_provider(args)
|
||||
elif action == "list":
|
||||
return self.list_providers(args)
|
||||
elif action == "show":
|
||||
return self.show_provider(args)
|
||||
else:
|
||||
printer.error(f"Unknown action: {action}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler.list_providers"><code class="name flex">
|
||||
<span>def <span class="ident">list_providers</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def list_providers(self, args):
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
if not providers:
|
||||
printer.warning("No SSO providers configured.")
|
||||
return
|
||||
|
||||
# Print list in YAML format
|
||||
providers_list = list(providers.keys())
|
||||
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
|
||||
printer.data("Configured SSO Providers", yaml_str)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.sso_handler.SSOHandler.show_provider"><code class="name flex">
|
||||
<span>def <span class="ident">show_provider</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def show_provider(self, args):
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.get("providers", {})
|
||||
|
||||
if provider not in providers:
|
||||
printer.error(f"SSO Provider '{provider}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
data = providers[provider]
|
||||
|
||||
# Mask client secret for display if it's sensitive and not an env var starting with $
|
||||
display_data = data.copy()
|
||||
secret = display_data.get("secret")
|
||||
if secret and not secret.startswith("$"):
|
||||
display_data["secret"] = "********"
|
||||
|
||||
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
|
||||
printer.data(f"SSO Provider: {provider}", yaml_str)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</article>
|
||||
<nav id="sidebar">
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
<ul id="index">
|
||||
<li><h3>Super-module</h3>
|
||||
<ul>
|
||||
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><h3><a href="#header-classes">Classes</a></h3>
|
||||
<ul>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.sso_handler.SSOHandler" href="#connpy.cli.sso_handler.SSOHandler">SSOHandler</a></code></h4>
|
||||
<ul class="">
|
||||
<li><code><a title="connpy.cli.sso_handler.SSOHandler.add_provider" href="#connpy.cli.sso_handler.SSOHandler.add_provider">add_provider</a></code></li>
|
||||
<li><code><a title="connpy.cli.sso_handler.SSOHandler.delete_provider" href="#connpy.cli.sso_handler.SSOHandler.delete_provider">delete_provider</a></code></li>
|
||||
<li><code><a title="connpy.cli.sso_handler.SSOHandler.dispatch" href="#connpy.cli.sso_handler.SSOHandler.dispatch">dispatch</a></code></li>
|
||||
<li><code><a title="connpy.cli.sso_handler.SSOHandler.list_providers" href="#connpy.cli.sso_handler.SSOHandler.list_providers">list_providers</a></code></li>
|
||||
<li><code><a title="connpy.cli.sso_handler.SSOHandler.show_provider" href="#connpy.cli.sso_handler.SSOHandler.show_provider">show_provider</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</main>
|
||||
<footer id="footer">
|
||||
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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):
|
||||
"""
|
||||
Runs the interactive Copilot session.
|
||||
on_ai_call: async function(active_buffer, question) -> result_dict
|
||||
@@ -102,9 +103,11 @@ el.replaceWith(d);
|
||||
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,
|
||||
@@ -118,14 +121,14 @@ el.replaceWith(d);
|
||||
}
|
||||
|
||||
# 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')
|
||||
@@ -161,12 +164,12 @@ el.replaceWith(d);
|
||||
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():
|
||||
@@ -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('/') and ' ' not in text:
|
||||
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
||||
matches = [c for c in commands if c.startswith(text.lower())]
|
||||
@@ -205,7 +208,39 @@ el.replaceWith(d);
|
||||
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
|
||||
@@ -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'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('\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)
|
||||
@@ -332,39 +367,67 @@ el.replaceWith(d);
|
||||
# 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:
|
||||
@@ -466,14 +529,13 @@ el.replaceWith(d);
|
||||
return "cancel", None, None
|
||||
|
||||
finally:
|
||||
state['cancelled'] = True
|
||||
self.console.print("[dim]Returning to session...[/dim]")</code></pre>
|
||||
state['cancelled'] = 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):
|
||||
"""
|
||||
Runs the interactive Copilot session.
|
||||
on_ai_call: async function(active_buffer, question) -> result_dict
|
||||
@@ -494,9 +557,11 @@ el.replaceWith(d);
|
||||
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,
|
||||
@@ -510,14 +575,14 @@ el.replaceWith(d);
|
||||
}
|
||||
|
||||
# 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')
|
||||
@@ -553,12 +618,12 @@ el.replaceWith(d);
|
||||
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():
|
||||
@@ -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('/') and ' ' not in text:
|
||||
commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear']
|
||||
matches = [c for c in commands if c.startswith(text.lower())]
|
||||
@@ -597,7 +662,39 @@ el.replaceWith(d);
|
||||
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
|
||||
@@ -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'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('\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)
|
||||
@@ -724,39 +821,67 @@ el.replaceWith(d);
|
||||
# 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:
|
||||
@@ -858,8 +983,7 @@ el.replaceWith(d);
|
||||
return "cancel", None, None
|
||||
|
||||
finally:
|
||||
state['cancelled'] = True
|
||||
self.console.print("[dim]Returning to session...[/dim]")</code></pre>
|
||||
state['cancelled'] = True</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Runs the interactive Copilot session.
|
||||
on_ai_call: async function(active_buffer, question) -> result_dict</p></div>
|
||||
|
||||
@@ -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 == "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)</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, "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)</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, "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)</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 == "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)</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("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)</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, "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)</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, "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)</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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+953
-101
File diff suppressed because it is too large
Load Diff
+573
-313
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
"""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</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):
|
||||
"""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</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) -> 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]</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):
|
||||
"""Thread-safe access to the hot-reloaded shared configuration."""
|
||||
with self._lock:
|
||||
self._refresh_shared()
|
||||
return self._shared_config</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Thread-safe access to the hot-reloaded shared configuration.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
|
||||
<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) -> bool:
|
||||
"""Check if any users are registered (enables auth enforcement)."""
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,10 @@ el.replaceWith(d);
|
||||
|
||||
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 []
|
||||
|
||||
@@ -260,7 +263,10 @@ el.replaceWith(d);
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
@@ -58,10 +58,41 @@ el.replaceWith(d);
|
||||
<pre><code class="python">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'>$|#$|\$$|>.$|#.$|\$.$'
|
||||
@@ -72,29 +103,104 @@ el.replaceWith(d);
|
||||
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:
|
||||
@@ -185,11 +291,14 @@ el.replaceWith(d);
|
||||
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."""
|
||||
@@ -201,13 +310,15 @@ el.replaceWith(d);
|
||||
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)
|
||||
@@ -246,11 +357,53 @@ el.replaceWith(d);
|
||||
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)</code></pre>
|
||||
return agent.load_session_data(session_id)
|
||||
|
||||
def build_playbook_chat(self, user_input: str, chat_history: list = None, status=None, chunk_callback=None):
|
||||
"""Interact with the specialized Playbook Builder Agent."""
|
||||
from connpy.ai import PlaybookBuilderAgent
|
||||
agent = PlaybookBuilderAgent(self.config)
|
||||
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)
|
||||
|
||||
def analyze_execution_results(self, results: dict, query: str = None, status=None, chunk_callback=None):
|
||||
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||
import json
|
||||
results_str = json.dumps(results, indent=2)
|
||||
|
||||
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||
if query:
|
||||
prompt += f"\nSpecific user request: {query}"
|
||||
prompt += f"\n\nResults Data:\n{results_str}"
|
||||
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||
|
||||
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)
|
||||
|
||||
def predict_execution_results(self, target_nodes: list, commands: list, status=None, chunk_callback=None):
|
||||
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||
nodes_str = ", ".join(target_nodes)
|
||||
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||
|
||||
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||
|
||||
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||
</details>
|
||||
<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):
|
||||
"""Analyze actual command execution results using Network Architect 1-shot."""
|
||||
import json
|
||||
results_str = json.dumps(results, indent=2)
|
||||
|
||||
prompt = f"@architect: Please analyze the following actual execution results. Diagnose any issues, highlight successful actions, and suggest strategic remediation steps if needed."
|
||||
if query:
|
||||
prompt += f"\nSpecific user request: {query}"
|
||||
prompt += f"\n\nResults Data:\n{results_str}"
|
||||
prompt += "\n\nCRITICAL DIRECTIVE: You are running in a strictly 1-shot offline diagnostics mode (--analyze). There is no active conversation loop, and you are NOT conversing with a Network Engineer. You MUST deliver your complete strategic analysis immediately. DO NOT suggest, mention, or attempt to delegate the session back to the engineer."
|
||||
|
||||
# Delegate to self.ask, setting stream=True and forwarding callback/status.
|
||||
# This will invoke standard ai.ask with '@architect:' prefix, forcing 1-shot architect brain.
|
||||
return self.ask(prompt, status=status, chunk_callback=chunk_callback, one_shot=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Analyze actual command execution results using Network Architect 1-shot.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.ai_service.AIService.ask"><code class="name flex">
|
||||
<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) -> list:
|
||||
<pre><code class="python">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'>$|#$|\$$|>.$|#.$|\$.$'
|
||||
@@ -338,33 +516,124 @@ el.replaceWith(d);
|
||||
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</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):
|
||||
"""Interact with the specialized Playbook Builder Agent."""
|
||||
from connpy.ai import PlaybookBuilderAgent
|
||||
agent = PlaybookBuilderAgent(self.config)
|
||||
return agent.ask(user_input, chat_history=chat_history, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Interact with the specialized Playbook Builder Agent.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.ai_service.AIService.configure_mcp"><code class="name flex">
|
||||
<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):
|
||||
"""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)</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):
|
||||
"""Return a list of all saved AI sessions."""
|
||||
<pre><code class="python">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", {})</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):
|
||||
"""Return a list of saved AI sessions, optionally limited."""
|
||||
from connpy.ai import ai
|
||||
agent = ai(self.config)
|
||||
return agent._get_sessions()</code></pre>
|
||||
sessions = agent._get_sessions()
|
||||
if limit and len(sessions) > 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):
|
||||
"""Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot)."""
|
||||
nodes_str = ", ".join(target_nodes)
|
||||
commands_str = "\n".join(f"- {cmd}" for cmd in commands)
|
||||
|
||||
prompt = f"@engineer: Act as a Preflight Simulation Agent. Simulate and predict the expected outputs and behaviors of the following commands on the target nodes. Alert about potential safety or configuration risks based on node profiles."
|
||||
prompt += f"\n\nTarget Nodes: {nodes_str}"
|
||||
prompt += f"\nCommands to simulate:\n{commands_str}"
|
||||
prompt += "\n\nCRITICAL SCALABILITY DIRECTIVE: If there are many target nodes, DO NOT list predictions node-by-node. Instead, group them by Operating System, vendor, or platform, and provide a highly concise Executive Summary. Detail individual risks only for nodes that present specific anomalies or security concerns. Focus on overall impact."
|
||||
|
||||
# Delegate to self.ask, using the standard engineer brain but with the simulated preflight prompt.
|
||||
return self.ask(prompt, status=status, chunk_callback=chunk_callback)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Predict and simulate execution results preventively using the Preflight Simulation Agent (1-shot).</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.ai_service.AIService.process_copilot_input"><code class="name flex">
|
||||
<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>
|
||||
|
||||
@@ -156,56 +156,7 @@ el.replaceWith(d);
|
||||
except Exception as e:
|
||||
raise ConnpyError(f"Failed to read script {script_path}: {e}")
|
||||
|
||||
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}")</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) -> Dict[str, Any]:
|
||||
"""Run a structured Connpy YAML automation playbook (from path or content)."""
|
||||
playbook = None
|
||||
if playbook_data.startswith("---YAML---\n"):
|
||||
try:
|
||||
content = playbook_data[len("---YAML---\n"):]
|
||||
playbook = yaml.load(content, Loader=yaml.FullLoader)
|
||||
except Exception as e:
|
||||
raise ConnpyError(f"Failed to parse YAML content: {e}")
|
||||
else:
|
||||
if not os.path.exists(playbook_data):
|
||||
raise ConnpyError(f"Playbook file not found: {playbook_data}")
|
||||
try:
|
||||
with open(playbook_data, "r") as f:
|
||||
playbook = yaml.load(f, Loader=yaml.FullLoader)
|
||||
except Exception as e:
|
||||
raise ConnpyError(f"Failed to load playbook {playbook_data}: {e}")
|
||||
|
||||
# Basic validation
|
||||
if not isinstance(playbook, dict) or "nodes" not in playbook or "commands" not in playbook:
|
||||
raise ConnpyError("Invalid playbook format: missing 'nodes' or 'commands' keys.")
|
||||
|
||||
action = playbook.get("action", "run")
|
||||
options = playbook.get("options", {})
|
||||
|
||||
# Extract all fields similar to RunHandler.cli_run
|
||||
exec_args = {
|
||||
"nodes_filter": playbook["nodes"],
|
||||
"commands": playbook["commands"],
|
||||
"variables": playbook.get("variables"),
|
||||
"parallel": options.get("parallel", parallel),
|
||||
"timeout": playbook.get("timeout", options.get("timeout", 20)),
|
||||
"prompt": options.get("prompt"),
|
||||
"name": playbook.get("name", "Task")
|
||||
}
|
||||
|
||||
# Map 'output' field to folder path if it's not stdout/null
|
||||
output_cfg = playbook.get("output")
|
||||
if output_cfg not in [None, "stdout"]:
|
||||
exec_args["folder"] = output_cfg
|
||||
|
||||
if action == "run":
|
||||
return self.run_commands(**exec_args)
|
||||
elif action == "test":
|
||||
exec_args["expected"] = playbook.get("expected", [])
|
||||
return self.test_commands(**exec_args)
|
||||
else:
|
||||
raise ConnpyError(f"Unsupported playbook action: {action}")</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Run a structured Connpy YAML automation playbook (from path or content).</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.execution_service.ExecutionService.test_commands"><code class="name flex">
|
||||
<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>
|
||||
|
||||
+576
-228
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
"""Explicitly update an existing node."""
|
||||
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):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -227,6 +228,7 @@ el.replaceWith(d);
|
||||
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):
|
||||
@@ -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):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -477,6 +479,7 @@ el.replaceWith(d);
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
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):
|
||||
"""Explicitly update an existing node."""
|
||||
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>
|
||||
|
||||
@@ -58,16 +58,47 @@ el.replaceWith(d);
|
||||
<pre><code class="python">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):
|
||||
@@ -77,12 +108,35 @@ el.replaceWith(d);
|
||||
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]
|
||||
@@ -90,6 +144,7 @@ el.replaceWith(d);
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
|
||||
def add_plugin(self, name, source_file, update=False):
|
||||
"""Add or update a plugin from a local file."""
|
||||
import os
|
||||
@@ -170,6 +225,10 @@ el.replaceWith(d);
|
||||
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):
|
||||
@@ -178,51 +237,80 @@ el.replaceWith(d);
|
||||
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):
|
||||
@@ -262,17 +350,12 @@ el.replaceWith(d);
|
||||
|
||||
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__"]):
|
||||
@@ -425,6 +508,10 @@ el.replaceWith(d);
|
||||
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.")</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, "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}")</code></pre>
|
||||
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.")</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, "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}")</code></pre>
|
||||
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.")</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, "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()</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, "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__"]):
|
||||
@@ -636,11 +747,6 @@ el.replaceWith(d);
|
||||
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):
|
||||
@@ -650,12 +756,35 @@ el.replaceWith(d);
|
||||
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]
|
||||
|
||||
@@ -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("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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "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</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):
|
||||
"""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)</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) -> 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"))</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):
|
||||
"""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)</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) -> 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"]
|
||||
}</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Creates a new user with bcrypt-hashed credentials.</p>
|
||||
<p>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.</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):
|
||||
"""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)</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) -> 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</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) -> 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")
|
||||
}</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) -> 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()
|
||||
]</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) -> 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</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
@@ -59,11 +59,14 @@ el.replaceWith(d);
|
||||
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 = []
|
||||
@@ -75,14 +78,34 @@ el.replaceWith(d);
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
Reference in New Issue
Block a user