Compare commits

...
58 Commits
Author SHA1 Message Date
fluzzi32 0632a510ad feat(cli,core): add local interactive shell (conn shell), Ctrl+Space passthrough and bump to v6.1.0
- Add `conn shell` CLI subcommand for spawning local interactive shells with AI Copilot support.

- Implement `protocol="local"` on core `node` class with PTY process spawning, dynamic PWD tracking (/proc/PID/cwd + OSC 7), and AI metadata mapping.

- Implement `_is_child_connpy_active()` to inspect PTY slave foreground process group (tcgetpgrp + /proc/PGID/cmdline).

- Automatically pass `Ctrl+Space` (b'\x00') down to child foreground `conn` / `connpy` processes when running nested sessions in local shell.

- Add `--shell-command`, `--shell-prompt`, and `--shell-os` configuration options to `conn config`.

- Update `README.md` and `connpy/__init__.py` documentation to include `conn shell` and PAT API Token management.

- Add comprehensive unit tests in `test_local_shell.py` and `test_completion.py`.

- Bump version to v6.1.0 and regenerate full HTML documentation in `docs/` via pdoc.
2026-08-12 12:03:59 -03:00
fluzzi32 835283eba6 perf(ai): decouple AI suite from startup and enable lazy loading across services and CLI
- Decouple connapp.ai from get_parser() via DeferredAIProxy to queue plugin modifications without importing connpy.ai or Pydantic v2 upfront.
- Guard connapp.start() finally block to invoke ai.cleanup() only if connpy.ai was actively imported.
- Defer CopilotInterface and AIService imports in core.py to execute strictly upon receiving Copilot hotkey (\x00).
- Convert heavy services (ai, sync, users, import_export, system, execution) into lazy properties on ServiceProvider and services/__init__.py.
- Implement O(1) globals() dict caching in module __getattr__ (PEP 562) across connpy/__init__.py and services/__init__.py.
- Defer Google Drive SDK in sync_service.py and interactive CLI components (inquirer) across handlers.
- Reduced CLI startup & parser initialization latency from ~1.47s to ~183ms (~87% improvement).
2026-08-11 19:31:55 -03:00
fluzzi32 bf5bddda5a perf(core): implement lazy loading for heavy services, Google SDK and CLI forms
- Defer instantiation of heavy services (SyncService, UserService,
  ImportExportService, SystemService, ExecutionService) using cached
  properties in ServiceProvider and module __getattr__ in services.
- Defer Google Drive SDK imports in sync_service.py using module-level
  __getattr__ and _get_google_libs() helper while retaining mock patchability.
- Lazy load inquirer, Forms, Validators and Blessed theme adapters in
  connpy/cli/* to prevent loading CLI form dependencies at startup.
- Clean up unused top-level service imports in connapp.py.
2026-08-11 17:06:40 -03:00
fluzzi32 fe189885d4 feat(auth,ai): add Personal Access Tokens support and persist
AI copilot context state

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

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

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

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

  📦 2. Commit History (Last 4 Commits)

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

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

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

  b0a914a - updates al copilot
   * Copilot <> gRPC Integration: Implemented the async plumbing required for the Copilot to work over
     the gRPC tunnel (server.py & stubs.py).
   * Initial Streaming UI: Added the first iteration of chunk callbacks to provide real-time feedback
     while the LLM generates responses, laying the groundwork for the uncommitted block-renderer
     optimizations.
2026-05-18 14:07:24 -03:00
fluzzi32 9446bafc0c improve ai rules 2026-05-15 23:28:22 -03:00
fluzzi32 64377f7f30 move context block logic to server and improvemnets 2026-05-15 18:24:48 -03:00
fluzzi32 e4fd1adba3 fix logclean for6wind 2026-05-15 17:32:09 -03:00
fluzzi32 b0a914ad7f updates al copilot 2026-05-15 16:25:18 -03:00
fluzzi32 05fb9951c6 update readme 2026-05-13 14:49:04 -03:00
fluzzi32 12543c683e 1. Persistence Setup: Optimized the dockerfile to manually create the /root/.config/conn/.folder file
pointing to /config. This avoids running the conn command during the build process and ensures a
      cleaner setup.
   2. Copilot UI Fix: Resolved a double-escaping bug in the terminal bottom bar. Device prompts (like
      6WIND-PE1>) will now render correctly instead of showing HTML entities like &gt;.
   3. AI Model Update: Updated the default engineer model in connpy/ai.py to
      gemini/gemini-3.1-flash-lite, removing the deprecated -preview suffix.
   4. Standardized Timeouts: Unified all default timeouts to 20 seconds across the board. This includes
      direct execution (run/test), modern playbooks (v2), and classic task-based playbooks (v1).
   5. Documentation Update: Regenerated the full documentation site in the docs/ directory using pdoc to
      reflect the latest changes.
   6. Cleanup: Removed all debug prints from connpy/core.py and restored the docker/logs/.gitignore
      file.
2026-05-13 14:16:14 -03:00
fluzzi32 3ad4f6da1f add utils file 2026-05-12 13:19:35 -03:00
fluzzi32 5c4e2116c8 refactor to support future command center 2026-05-12 13:19:06 -03:00
fluzzi32 2666a3db1f feat(v6): integrate MCP support, async copilot streaming, and enhanced terminal UI logic 2026-05-12 12:20:50 -03:00
fluzzi32 dba7e24dda feat(copilot): stabilize concurrency and enhance terminal context management
- AI Concurrency:
  - Implemented a dedicated background event loop (ConnpyAILoop) in a separate thread for AI tasks to ensure thread safety and event loop affinity.
  - Added 'run_ai_async' utility to funnel all LiteLLM calls through the dedicated loop.
  - Implemented global 'cleanup()' for safe closure of sync/async LiteLLM sessions.

- gRPC & Remote Sessions:
  - Enhanced 'NodeServicer' to identify command blocks within the terminal buffer using prompt regex/byte tracking.
  - Added support for selective context retrieval via 'context_start_pos' in the gRPC Interact stream.
  - Synchronized remote Copilot behavior by enriching questions with session history (last 5 queries) in 'NodeStub'.
  - Optimized token usage by cleaning 'node_info' metadata before AI transmission.

- Terminal Context & Core:
  - Modified 'node.connect' to always initialize 'mylog' (BytesIO) buffer regardless of disk logging configuration, ensuring Copilot context availability.
  - Integrated 'ai.cleanup()' in CLI (connapp) and Server (api) exit points for graceful shutdowns.
  - Suppressed LiteLLM internal streaming coroutine warnings during task cancellation.
2026-05-11 12:30:43 -03:00
fluzzi32 1103393be6 minor bugs 2026-05-09 19:45:19 -03:00
fluzzi32 a08d84139d remoto con fixes de todo 2026-05-08 21:53:57 -03:00
fluzzi32 d62af8b037 copilot remoto con bugs 2026-05-08 18:45:42 -03:00
fluzzi32 14a298dd4d context ai 2026-05-07 17:30:43 -03:00
fluzzi32 61552cffa9 add stream 2026-05-07 16:43:32 -03:00
fluzzi32 c4d704f473 first implementation phase 1 and 2 2026-05-07 15:17:00 -03:00
fluzzi32 f6078b6fde update version 2026-05-05 18:25:33 -03:00
fluzzi32 37db74f47d refactor(core): stabilize gRPC streaming, plugin invocation, and CLI UX
- Implement threaded plugin execution with Queue-based streaming in PluginService
- Refactor remote logger to preserve ANSI colors and fix TTY line endings (\r\n)
- Intelligent terminal filtering: disable SSM screen-clearing filter after success
- Sanitize SSH-only flags in core.py when using SFTP protocol
- Rewrite completion tree with pre/post-node states and flag deduplication
- Update gRPC unit tests to match new streaming response structure
2026-05-05 18:24:31 -03:00
fluzzi32 16868828c6 Fix stream ai bug 2026-05-01 23:54:24 -03:00
fluzzi32 a192bd1912 connpy v6.0.0b4: AI Stability, Remote Sync & UI Polish (Clean Commit) 2026-05-01 18:55:25 -03:00
fluzzi32 c81f6e049f feat: advanced jumphost capabilities and core bug fixes
- **Core/Jumphost**: Implement native jumphost support for `ssm`, `kubectl`, and `docker` protocols via transparent ProxyCommands.
- **Core/Tags**: Introduce `ssh_options` tag to pass custom arguments (e.g., `-i key.pem`) inside nested SSM SSH tunnels.
- **Core/Tags**: Introduce `nc_command` tag to override default `nc` behavior in Docker/Kube tunnels (crucial for IOS-XRd VRF netns isolation).
- **Core/Async**: Fix a critical bug where interactive terminal sessions disconnected immediately due to premature `asyncio.FIRST_COMPLETED` task resolution when logs or idletime were disabled.
- **Services/Node**: Fix logic error in `validate_parent_folder` that prevented the creation of new nested subfolders (CLI was incorrectly checking for the subfolder's own existence instead of the parent's).
- **CLI/Help**: Update documentation to include the new `region`, `profile`, `ssh_options`, and `nc_command` well-known tags.
2026-04-30 19:25:17 -03:00
fluzzi32 918fc9a07c up version 2026-04-30 14:32:45 -03:00
fluzzi32 7967c413c9 feat: simplify node selection, enhance gRPC execution logic, and improve CLI aggregate summaries 2026-04-30 14:18:41 -03:00
fluzzi32 96049b4028 feat: Implement Smart Tunnel async architecture (v6.0.0b1)
- Replaced blocking pexpect interact with asyncio-based multiplexing.
- Implemented LocalStream and RemoteStream for agnostic I/O handling.
- Real-time SIGWINCH window resizing support.
- 'Ghost buffer' mitigation for clean, artifact-free session handovers.
- Upgraded _logclean into a mini terminal emulator to accurately process ANSI, backspaces, and inline clears.
- Continuous auto-saving for logs without blocking the main thread.
- Bumped version to 6.0.0b1 and regenerated pdoc documentation.
2026-04-27 15:12:28 -03:00
146 changed files with 33197 additions and 6025 deletions
+29
View File
@@ -0,0 +1,29 @@
.git
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.venv
venv
env
node_modules
dist
build
*.egg-info
docker
docker-compose.yml
.gemini
.github
docs
scratch
testall
testremote
automation-template.yaml
# Sensitive local files and credentials
auth.json
key.db
config.db
*.db
testnew/
+17
View File
@@ -50,6 +50,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
scratch/
# Translations
*.mo
@@ -145,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
@@ -160,3 +164,16 @@ ssm_implemmetaiton_plan.md
async_interact_plan.md
repo_consolidado_limpio.md
connpy_roadmap.md
MULTI_USER_PLAN.md
COPILOT_PLAN.md
ARCHITECTURAL_DEBT_REFACTOR.md
COPILOT_UI_FEATURES.md
MULTI_USER_IMPLEMENTATION_STEPS.md
readme_coverage_analysis.md
#themes
nord.yml
theme.py
#ai auth
auth.json
+123 -8
View File
@@ -1,16 +1,131 @@
Custom Software License
# PolyForm Noncommercial License 1.0.0
Copyright (c) 2022 Federico Luzzi
<https://polyformproject.org/licenses/noncommercial/1.0.0>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, and modify the Software, subject to the following conditions:
## Acceptance
Commercial Use: The use of the Software for commercial purposes, including but not limited to selling, sublicensing, or generating revenue in any form, is expressly prohibited for individuals and entities other than the copyright holder.
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
Personal and Non-commercial Use: Individuals and entities are permitted to use, copy, and modify the Software for personal and non-commercial purposes.
## Copyright License
Distribution: Redistribution of the original or modified Software is allowed, provided the Software is not sold or sublicensed and this license notice is included in all copies or substantial portions of the Software.
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
Support and Sale: The copyright holder reserves the exclusive right to sell or offer support services for the Software to any company or commercial entity.
## Distribution License
Disclaimer: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright (c) 2022-2026 Federico Luzzi (<https://github.com/fluzzi/connpy>)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+8
View File
@@ -0,0 +1,8 @@
include LICENSE
include README.md
include requirements.txt
recursive-include connpy/core_plugins *
recursive-include connpy/proto *
recursive-include connpy/grpc_layer *
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
+259 -489
View File
@@ -3,525 +3,295 @@
</p>
# Connpy
# Connpy (v6.1.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
Connpy is a SSH, SFTP, Telnet, kubectl, Docker pod, and AWS SSM connection manager and automation module for Linux, Mac, and Docker.
**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 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.
## Installation
---
## 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
```
### 1d. Local Interactive Shell (conn shell)
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
```bash
conn shell # Start local shell (default: $SHELL or /bin/bash)
conn shell -c /bin/zsh # Override shell executable
conn shell --capture session.log # Log session output to file
```
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
```bash
conn config --shell-command /bin/zsh
conn config --shell-prompt "\$\s*$"
conn config --shell-os ubuntu
```
---
## 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`
---
## 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 & API Tokens
Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
```bash
conn user --add username
conn user --list
conn user --regen-password username
# Personal Access Tokens (PAT) for non-interactive API access
conn user --create-token "CI/CD Token" --expires-in 30
conn user --list-tokens
conn user --revoke-token <token_id>
```
Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
### 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 using docker
```
git clone https://github.com/fluzzi/connpy
docker compose -f path/to/folder/docker-compose.yml build
docker compose -f path/to/folder/docker-compose.yml run -it connpy-app
```
## Connection manager
### Privacy Policy
Connpy is committed to protecting your privacy. Our privacy policy explains how we handle user data:
- **Data Access**: Connpy accesses data necessary for managing remote host connections, including server addresses, usernames, and passwords. This data is stored locally on your machine and is not transmitted or shared with any third parties.
- **Data Usage**: User data is used solely for the purpose of managing and automating SSH, Telnet, and SSM connections.
- **Data Storage**: All connection details are stored locally and securely on your device. We do not store or process this data on our servers.
- **Data Sharing**: We do not share any user data with third parties.
### Google Integration
Connpy integrates with Google services for backup purposes:
- **Configuration Backup**: The app allows users to store their device information in the app configuration. This configuration can be synced with Google services to create backups.
- **Data Access**: Connpy only accesses its own files and does not access any other files on your Google account.
- **Data Usage**: The data is used solely for backup and restore purposes, ensuring that your device information and configurations are safe and recoverable.
- **Data Sharing**: Connpy does not share any user data with third parties, including Google. The backup data is only accessible by the user.
For more detailed information, please read our [Privacy Policy](https://connpy.gederico.dynu.net/fluzzi32/connpy/src/branch/main/PRIVATE_POLICY.md).
### Features
- Manage connections using SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
- Set contexts to manage specific nodes from specific contexts (work/home/clients/etc).
- You can generate profiles and reference them from nodes using @profilename so you don't
need to edit multiple nodes when changing passwords or other information.
- Nodes can be stored on @folder or @subfolder@folder to organize your devices. They can
be referenced using node@subfolder@folder or node@folder.
- If you have too many nodes, get a completion script using: conn config --completion.
Or use fzf by installing pyfzf and running conn config --fzf true.
- Create in bulk, copy, move, export, and import nodes for easy management.
- Run automation scripts on network devices.
- Use AI with a multi-agent system (Engineer/Architect) to manage devices.
Supports any LLM provider via litellm (OpenAI, Anthropic, Google, etc.).
Features streaming responses, interactive chat, and extensible plugin tools.
- Add plugins with your own scripts, and execute them remotely.
- Fully decoupled gRPC Client/Server architecture.
- Unified UI with syntax highlighting and theming.
- Much more!
### Usage:
```
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,mv,copy,cp,list,ls,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
positional arguments:
node|folder node[@subfolder][@folder]
Connect to specific node or show all matching nodes
[@subfolder][@folder]
Show all available connections globally or in specified path
options:
-h, --help show this help message and exit
-v, --version Show version
-a, --add Add new node[@subfolder][@folder] or [@subfolder]@folder
-r, --del, --rm Delete node[@subfolder][@folder] or [@subfolder]@folder
-e, --mod, --edit Modify node[@subfolder][@folder]
-s, --show Show node[@subfolder][@folder]
-d, --debug Display all conections steps
-t, --sftp Connects using sftp instead of ssh
--service-mode Set the backend service mode (local or remote)
--remote Connect to a remote connpy service via gRPC
--theme UI Output theme (dark, light, or path)
Commands:
profile Manage profiles
move(mv) Move node
copy(cp) Copy node
list(ls) List profiles, nodes or folders
bulk Add nodes in bulk
export Export connection folder to Yaml file
import Import connection folder to config from Yaml file
ai Make request to an AI
run Run scripts or commands on nodes
api Start and stop connpy api
plugin Manage plugins
config Manage app config
sync Sync config with Google
context Manage contexts with regex matching
### 9b. Shell Completion + FZF
Install autocompletions and fuzzy-search wrappers into your shell profile:
```bash
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
### Manage profiles:
```
usage: conn profile [-h] (--add | --del | --mod | --show) profile
positional arguments:
profile Name of profile to manage
options:
-h, --help show this help message and exit
-a, --add Add new profile
-r, --del, --rm Delete profile
-e, --mod, --edit Modify profile
-s, --show Show profile
### 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
conn config --theme /path/to/theme.yaml
```
### Examples:
```
#Add new profile
conn profile --add office-user
#Add new folder
conn --add @office
#Add new subfolder
conn --add @datacenter@office
#Add node to subfolder
conn --add server@datacenter@office
#Add node to folder
conn --add pc@office
#Show node information
conn --show server@datacenter@office
#Connect to nodes
conn pc@office
conn server
#Create and set new context
conn context -a office .*@office
conn context --set office
#Run a command in a node
conn run server ls -la
```
## Plugin Requirements for Connpy
### 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, ensuring the plugin runs securely against the server's inventory without passing sensitive data to the client.
- You can manage remote plugins using the `--remote` flag (e.g. `connpy plugin --add myplugin script.py --remote`).
---
### General Structure
- The plugin script must be a Python file.
- Only the following top-level elements are allowed in the plugin script:
- Class definitions
- Function definitions
- Import statements
- The `if __name__ == "__main__":` block for standalone execution
- Pass statements
## 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`.
### Specific Class Requirements
- The plugin script must define specific classes with particular attributes and methods. Each class serves a distinct role within the plugin's architecture:
1. **Class `Parser`**:
- **Purpose**: Handles parsing of command-line arguments.
- **Requirements**:
- Must contain only one method: `__init__`.
- The `__init__` method must initialize at least one attribute:
- `self.parser`: An instance of `argparse.ArgumentParser`.
2. **Class `Entrypoint`**:
- **Purpose**: Acts as the entry point for plugin execution, utilizing parsed arguments and integrating with the main application.
- **Requirements**:
- Must have an `__init__` method that accepts exactly three parameters besides `self`:
- `args`: Arguments passed to the plugin.
- The parser instance (typically `self.parser` from the `Parser` class).
- The Connapp instance to interact with the Connpy app.
3. **Class `Preload`**:
- **Purpose**: Performs any necessary preliminary setup or configuration independent of the main parsing and entry logic.
- **Requirements**:
- Contains at least an `__init__` method that accepts parameter connapp besides `self`.
### Class Dependencies and Combinations
- **Dependencies**:
- `Parser` and `Entrypoint` are interdependent and must both be present if one is included.
- `Preload` is independent and may exist alone or alongside the other classes.
- **Valid Combinations**:
- `Parser` and `Entrypoint` together.
- `Preload` alone.
- All three classes (`Parser`, `Entrypoint`, `Preload`).
---
### Preload Modifications and Hooks
In the `Preload` class of the plugin system, you have the ability to customize the behavior of existing classes and methods within the application through a robust hooking system. This documentation explains how to use the `modify`, `register_pre_hook`, and `register_post_hook` methods to tailor plugin functionality to your needs.
#### Modifying Classes with `modify`
The `modify` method allows you to alter instances of a class at the time they are created or after their creation. This is particularly useful for setting or modifying configuration settings, altering default behaviors, or adding new functionalities to existing classes without changing the original class definitions.
- **Usage**: Modify a class to include additional configurations or changes
- **Modify Method Signature**:
- `modify(modification_method)`: A function that is invoked with an instance of the class as its argument. This function should perform any modifications directly on this instance.
- **Modification Method Signature**:
- **Arguments**:
- `cls`: This function accepts a single argument, the class instance, which it then modifies.
- **Modifiable Classes**:
- `connapp.config`
- `connapp.node`
- `connapp.nodes`
- `connapp.ai`
- ```python
def modify_config(cls):
# Example modification: adding a new attribute or modifying an existing one
cls.new_attribute = 'New Value'
class Preload:
def __init__(self, connapp):
# Applying modification to the config class instance
connapp.config.modify(modify_config)
```
#### Implementing Method Hooks
There are 2 methods that allows you to define custom logic to be executed before (`register_pre_hook`) or after (`register_post_hook`) the main logic of a method. This is particularly useful for logging, auditing, preprocessing inputs, postprocessing outputs or adding functionalities.
- **Usage**: Register hooks to methods to execute additional logic before or after the main method execution.
- **Registration Methods Signature**:
- `register_pre_hook(pre_hook_method)`: A function that is invoked before the main method is executed. This function should do preprocessing of the arguments.
- `register_post_hook(post_hook_method)`: A function that is invoked after the main method is executed. This function should do postprocessing of the outputs.
- **Method Signatures for Pre-Hooks**
- `pre_hook_method(*args, **kwargs)`
- **Arguments**:
- `*args`, `**kwargs`: The arguments and keyword arguments that will be passed to the method being hooked. The pre-hook function has the opportunity to inspect and modify these arguments before they are passed to the main method.
- **Return**:
- Must return a tuple `(args, kwargs)`, which will be used as the new arguments for the main method. If the original arguments are not modified, the function should return them as received.
- **Method Signatures for Post-Hooks**:
- `post_hook_method(*args, **kwargs)`
- **Arguments**:
- `*args`, `**kwargs`: The arguments and keyword arguments that were passed to the main method.
- `kwargs["result"]`: The value returned by the main method. This allows the post-hook to inspect and even alter the result before it is returned to the original caller.
- **Return**:
- Can return a modified result, which will replace the original result of the main method, or simply return `kwargs["result"]` to return the original method result.
- ```python
def pre_processing_hook(*args, **kwargs):
print("Pre-processing logic here")
# Modify arguments or perform any checks
return args, kwargs # Return modified or unmodified args and kwargs
def post_processing_hook(*args, **kwargs):
print("Post-processing logic here")
# Modify the result or perform any final logging or cleanup
return kwargs["result"] # Return the modified or unmodified result
class Preload:
def __init__(self, connapp):
# Registering a pre-hook
connapp.ai.some_method.register_pre_hook(pre_processing_hook)
# Registering a post-hook
connapp.node.another_method.register_post_hook(post_processing_hook)
```
### Executable Block
- The plugin script can include an executable block:
- `if __name__ == "__main__":`
- This block allows the plugin to be run as a standalone script for testing or independent use.
### Command Completion Support
Plugins can provide intelligent **tab completion** by defining autocompletion logic. There are two supported methods, with the tree-based approach being the most modern and recommended.
#### 1. Tree-based Completion (Recommended)
Define a function called `_connpy_tree` that returns a declarative navigation tree. This method is highly efficient, supports complex state loops, and is very simple to implement for most use cases.
## 11. 🐍 Python API
Embed connection and automation routines programmatically in Python:
```python
def _connpy_tree(info=None):
nodes = info.get("nodes", [])
return {
"__exclude_used__": True, # Filter out words already typed
"__extra__": nodes, # Suggest nodes at this level
"--format": ["json", "yaml", "table"], # Fixed suggestions
"*": { # Wildcard matches any positional word
"interface1": None,
"interface2": None,
"--verbose": None
}
}
```
- **Keys**: Literal completions (exact matches).
- **`*` Key**: A wildcard that matches any positional word typed by the user.
- **`__extra__`**: A list or a callable `(words) -> list` that adds dynamic suggestions.
- **`__exclude_used__`**: (Boolean) If True, automatically filters out words already present in the command line.
#### 2. Legacy Function-based Completion
For backward compatibility or highly custom logic, you can define `_connpy_completion`.
```python
def _connpy_completion(wordsnumber, words, info=None):
if wordsnumber == 3:
return ["--help", "--verbose", "start", "stop"]
elif wordsnumber == 4 and words[2] == "start":
return info["nodes"] # Suggest node names
return []
```
| Parameter | Description |
|----------------|-------------|
| `wordsnumber` | Integer indicating the total number of words on the command line. For plugins, this typically starts at 3. |
| `words` | A list of tokens (words) already typed. `words[0]` is always the name of the plugin. |
| `info` | A dictionary of structured context data (`nodes`, `folders`, `profiles`, `config`). |
> In this example, if the user types `connpy myplugin start ` and presses Tab, it will suggest node names.
### Handling Unknown Arguments
Plugins can choose to accept and process unknown arguments that are **not explicitly defined** in the parser. To enable this behavior, the plugin must define the following hidden argument in its `Parser` class:
```
self.parser.add_argument(
"--unknown-args",
action="store_true",
default=True,
help=argparse.SUPPRESS
)
```
#### Behavior:
- When this argument is present, Connpy will parse the known arguments and capture any extra (unknown) ones.
- These unknown arguments will be passed to the plugin as `args.unknown_args` inside the `Entrypoint`.
- If the user does not pass any unknown arguments, `args.unknown_args` will contain the default value (`True`, unless overridden).
#### Example:
If a plugin accepts unknown tcpdump flags like this:
```
connpy myplugin -nn -s0
```
And defines the hidden `--unknown-args` flag as shown above, then:
- `args.unknown_args` inside `Entrypoint.__init__()` will be: `['-nn', '-s0']`
> This allows the plugin to receive and process arguments intended for external tools (e.g., `tcpdump`) without argparse raising an error.
#### Note:
If a plugin does **not** define `--unknown-args`, any extra arguments passed will cause argparse to fail with an unrecognized arguments error.
### Script Verification
- The `verify_script` method in `plugins.py` is used to check the plugin script's compliance with these standards.
- Non-compliant scripts will be rejected to ensure consistency and proper functionality within the plugin system.
### Example Script
For a practical example of how to write a compatible plugin script, please refer to the following example:
[Example Plugin Script](https://github.com/fluzzi/awspy)
This script demonstrates the required structure and implementation details according to the plugin system's standards.
## Automation module usage
### Standalone module
```
import connpy
router = connpy.node("uniqueName","ip/host", user="username", password="password")
router.run(["term len 0","show run"])
# 1. Direct single node interaction
router = connpy.node("router1", "1.1.1.1", user="admin")
router.run(["show ip int brief"])
print(router.output)
hasip = router.test("show ip int brief","1.1.1.1")
if hasip:
print("Router has ip 1.1.1.1")
else:
print("router does not have ip 1.1.1.1")
```
### Using manager configuration
```
import connpy
conf = connpy.configfile()
device = conf.getitem("router@office")
router = connpy.node("unique name", **device, config=conf)
result = router.run("show ip int brief")
print(result)
```
### Running parallel tasks on multiple devices
```
import connpy
conf = connpy.configfile()
#You can get the nodes from the config from a folder and fitlering in it
nodes = conf.getitem("@office", ["router1", "router2", "router3"])
#You can also get each node individually:
nodes = {}
nodes["router1"] = conf.getitem("router1@office")
nodes["router2"] = conf.getitem("router2@office")
nodes["router10"] = conf.getitem("router10@datacenter")
#Also, you can create the nodes manually:
nodes = {}
nodes["router1"] = {"host": "1.1.1.1", "user": "user", "password": "password1"}
nodes["router2"] = {"host": "1.1.1.2", "user": "user", "password": "password2"}
nodes["router3"] = {"host": "1.1.1.2", "user": "user", "password": "password3"}
#Finally you run some tasks on the nodes
mynodes = connpy.nodes(nodes, config = conf)
result = mynodes.test(["show ip int br"], "1.1.1.2")
for i in result:
print("---" + i + "---")
print(result[i])
print()
# Or for one specific node
mynodes.router1.run(["term len 0". "show run"], folder = "/home/user/logs")
```
### Using variables
```
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2", "router3"])
commands = []
commands.append("config t")
commands.append("interface lo {id}")
commands.append("ip add {ip} {mask}")
commands.append("end")
variables = {}
variables["router1@office"] = {"ip": "10.57.57.1"}
variables["router2@office"] = {"ip": "10.57.57.2"}
variables["router3@office"] = {"ip": "10.57.57.3"}
variables["__global__"] = {"id": "57"}
variables["__global__"]["mask"] = "255.255.255.255"
expected = "!"
routers = connpy.nodes(nodes, config = config)
routers.run(commands, variables)
routers.test("ping {ip}", expected, variables)
for key in routers.result:
print(key, ' ---> ', ("pass" if routers.result[key] else "fail"))
```
### Using AI
The AI module uses a multi-agent architecture with an **Engineer** (fast execution) and an **Architect** (strategic reasoning). It supports any LLM provider through [litellm](https://github.com/BerriAI/litellm).
```python
import connpy
conf = connpy.configfile()
# Uses models and API keys from config, or override them:
myai = connpy.ai(conf, engineer_model="gemini/gemini-2.5-flash", engineer_api_key="your-key")
result = myai.ask("go to router1 and show me the running configuration")
print(result["response"])
# Streaming is enabled by default for CLI, disable for programmatic use:
result = myai.ask("show interfaces on all routers", stream=False)
print(result["response"])
```
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 Plugin Tool Registration
Plugins can extend the AI system by registering custom tools via the `Preload` class:
```python
def _register_my_tools(ai_instance):
tool_def = {
"type": "function",
"function": {
"name": "my_custom_tool",
"description": "Does something useful.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}
}
ai_instance.register_ai_tool(
tool_definition=tool_def,
handler=my_handler_function,
target="engineer", # or "architect" or "both"
engineer_prompt="- My tool: does X.",
architect_prompt=" * My tool (my_custom_tool)."
)
class Preload:
def __init__(self, connapp):
connapp.ai.modify(_register_my_tools)
# 3. AI Copilot prompts
myai = connpy.ai(connpy.configfile())
response = myai.ask("Show BGP status.")
print(response)
```
## gRPC Service Architecture
Connpy features a completely decoupled gRPC Client/Server architecture. You can run Connpy as a standalone background service and connect to it remotely via the CLI or other clients.
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
### 1. Start the Server
Start the gRPC service by running:
---
## 12. 🐳 Docker Deployment
Run `connpy` containerized and silent:
```bash
connpy api -s 50051
```
The server will handle all configurations, connections, AI sessions, and plugin execution locally on the machine it runs on.
### 2. Connect the Client
Configure your local CLI client to connect to the remote server:
```bash
connpy config --service-mode remote
connpy config --remote-host localhost:50051
```
Once configured, all commands (`connpy node`, `connpy list`, `connpy ai`, etc.) will execute transparently on the remote server via thin-client proxies. You can revert back to standalone execution at any time by running `connpy config --service-mode local`.
### Programmatic Access (gRPC & SOA)
If you wish to build your own application (Web, Desktop, or Scripts) using the Connpy backend, you can use the `ServiceProvider` to interact with either a local or remote service transparently.
```python
import connpy
from connpy.services.provider import ServiceProvider
# Initialize local config
config = connpy.configfile()
# Connect to the remote gRPC service
services = ServiceProvider(
config,
mode="remote",
remote_host="localhost:50051"
)
# Use any service (the logic is identical to local mode)
nodes = services.nodes.list_nodes()
for name in nodes:
print(f"Found node: {name}")
# Run a command remotely via streaming
for chunk in services.execution.run_commands(nodes=["server1"], commands=["uptime"]):
print(chunk["output"], end="")
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)
+282 -448
View File
@@ -1,487 +1,319 @@
#!/usr/bin/env python3
'''
## Connection manager
<p align="center">
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
</p>
Connpy is a SSH, SFTP, Telnet, kubectl, Docker pod, and AWS SSM connection manager and automation module for Linux, Mac, and Docker.
### Features
- Manage connections using SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.
- Set contexts to manage specific nodes from specific contexts (work/home/clients/etc).
- You can generate profiles and reference them from nodes using @profilename so you don't
need to edit multiple nodes when changing passwords or other information.
- Nodes can be stored on @folder or @subfolder@folder to organize your devices. They can
be referenced using node@subfolder@folder or node@folder.
- If you have too many nodes, get a completion script using: conn config --completion.
Or use fzf by installing pyfzf and running conn config --fzf true.
- Create in bulk, copy, move, export, and import nodes for easy management.
- Run automation scripts on network devices.
- Use AI with a multi-agent system (Engineer/Architect) to help you manage your devices.
Supports any LLM provider via litellm (OpenAI, Anthropic, Google, etc.).
- Add plugins with your own scripts, and execute them remotely.
- Fully decoupled gRPC Client/Server architecture.
- Unified UI with syntax highlighting and theming.
- Much more!
# Connpy (v6.1.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/backend-gRPC-blue?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square)](https://github.com/fluzzi/connpy)
[![](https://img.shields.io/badge/MCP-compatible-orange?style=flat-square)](https://modelcontextprotocol.io)
[![](https://img.shields.io/pypi/l/connpy.svg?style=flat-square)](https://github.com/fluzzi/connpy/blob/main/LICENSE)
### Usage
```
usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
conn {profile,move,mv,copy,cp,list,ls,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
**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**.
positional arguments:
node|folder node[@subfolder][@folder]
Connect to specific node or show all matching nodes
[@subfolder][@folder]
Show all available connections globally or in specified path
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.
options:
-h, --help show this help message and exit
-v, --version Show version
-a, --add Add new node[@subfolder][@folder] or [@subfolder]@folder
-r, --del, --rm Delete node[@subfolder][@folder] or [@subfolder]@folder
-e, --mod, --edit Modify node[@subfolder][@folder]
-s, --show Show node[@subfolder][@folder]
-d, --debug Display all conections steps
-t, --sftp Connects using sftp instead of ssh
--service-mode Set the backend service mode (local or remote)
--remote Connect to a remote connpy service via gRPC
--theme UI Output theme (dark, light, or path)
Commands:
profile Manage profiles
move(mv) Move node
copy(cp) Copy node
list(ls) List profiles, nodes or folders
bulk Add nodes in bulk
export Export connection folder to Yaml file
import Import connection folder to config from Yaml file
ai Make request to an AI
run Run scripts or commands on nodes
api Start and stop connpy api
plugin Manage plugins
config Manage app config
sync Sync config with Google
context Manage contexts with regex matching
```
---
### Manage profiles
```
usage: conn profile [-h] (--add | --del | --mod | --show) profile
## 1. 🤖 AI System
positional arguments:
profile Name of profile to manage
### 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.
options:
-h, --help show this help message and exit
-a, --add Add new profile
-r, --del, --rm Delete profile
-e, --mod, --edit Modify profile
-s, --show Show profile
```
### Examples
```
#Add new profile
conn profile --add office-user
#Add new folder
conn --add @office
#Add new subfolder
conn --add @datacenter@office
#Add node to subfolder
conn --add server@datacenter@office
#Add node to folder
conn --add pc@office
#Show node information
conn --show server@datacenter@office
#Connect to nodes
conn pc@office
conn server
#Create and set new context
conn context -a office .*@office
conn context --set office
#Run a command in a node
conn run server ls -la
```
## Plugin Requirements for Connpy
### 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, ensuring the plugin runs securely against the server's inventory without passing sensitive data to the client.
- You can manage remote plugins using the `--remote` flag (e.g. `connpy plugin --add myplugin script.py --remote`).
### General Structure
- The plugin script must be a Python file.
- Only the following top-level elements are allowed in the plugin script:
- Class definitions
- Function definitions
- Import statements
- The `if __name__ == "__main__":` block for standalone execution
- Pass statements
### Specific Class Requirements
- The plugin script must define specific classes with particular attributes and methods. Each class serves a distinct role within the plugin's architecture:
1. **Class `Parser`**:
- **Purpose**: Handles parsing of command-line arguments.
- **Requirements**:
- Must contain only one method: `__init__`.
- The `__init__` method must initialize at least one attribute:
- `self.parser`: An instance of `argparse.ArgumentParser`.
2. **Class `Entrypoint`**:
- **Purpose**: Acts as the entry point for plugin execution, utilizing parsed arguments and integrating with the main application.
- **Requirements**:
- Must have an `__init__` method that accepts exactly three parameters besides `self`:
- `args`: Arguments passed to the plugin.
- The parser instance (typically `self.parser` from the `Parser` class).
- The Connapp instance to interact with the Connpy app.
3. **Class `Preload`**:
- **Purpose**: Performs any necessary preliminary setup or configuration independent of the main parsing and entry logic.
- **Requirements**:
- Contains at least an `__init__` method that accepts parameter connapp besides `self`.
### Class Dependencies and Combinations
- **Dependencies**:
- `Parser` and `Entrypoint` are interdependent and must both be present if one is included.
- `Preload` is independent and may exist alone or alongside the other classes.
- **Valid Combinations**:
- `Parser` and `Entrypoint` together.
- `Preload` alone.
- All three classes (`Parser`, `Entrypoint`, `Preload`).
### Preload Modifications and Hooks
In the `Preload` class of the plugin system, you have the ability to customize the behavior of existing classes and methods within the application through a robust hooking system. This documentation explains how to use the `modify`, `register_pre_hook`, and `register_post_hook` methods to tailor plugin functionality to your needs.
#### Modifying Classes with `modify`
The `modify` method allows you to alter instances of a class at the time they are created or after their creation. This is particularly useful for setting or modifying configuration settings, altering default behaviors, or adding new functionalities to existing classes without changing the original class definitions.
- **Usage**: Modify a class to include additional configurations or changes
- **Modify Method Signature**:
- `modify(modification_method)`: A function that is invoked with an instance of the class as its argument. This function should perform any modifications directly on this instance.
- **Modification Method Signature**:
- **Arguments**:
- `cls`: This function accepts a single argument, the class instance, which it then modifies.
- **Modifiable Classes**:
- `connapp.config`
- `connapp.node`
- `connapp.nodes`
- `connapp.ai`
- ```python
def modify_config(cls):
# Example modification: adding a new attribute or modifying an existing one
cls.new_attribute = 'New Value'
class Preload:
def __init__(self, connapp):
# Applying modification to the config class instance
connapp.config.modify(modify_config)
```
#### Implementing Method Hooks
There are 2 methods that allows you to define custom logic to be executed before (`register_pre_hook`) or after (`register_post_hook`) the main logic of a method. This is particularly useful for logging, auditing, preprocessing inputs, postprocessing outputs or adding functionalities.
- **Usage**: Register hooks to methods to execute additional logic before or after the main method execution.
- **Registration Methods Signature**:
- `register_pre_hook(pre_hook_method)`: A function that is invoked before the main method is executed. This function should do preprocessing of the arguments.
- `register_post_hook(post_hook_method)`: A function that is invoked after the main method is executed. This function should do postprocessing of the outputs.
- **Method Signatures for Pre-Hooks**
- `pre_hook_method(*args, **kwargs)`
- **Arguments**:
- `*args`, `**kwargs`: The arguments and keyword arguments that will be passed to the method being hooked. The pre-hook function has the opportunity to inspect and modify these arguments before they are passed to the main method.
- **Return**:
- Must return a tuple `(args, kwargs)`, which will be used as the new arguments for the main method. If the original arguments are not modified, the function should return them as received.
- **Method Signatures for Post-Hooks**:
- `post_hook_method(*args, **kwargs)`
- **Arguments**:
- `*args`, `**kwargs`: The arguments and keyword arguments that were passed to the main method.
- `kwargs["result"]`: The value returned by the main method. This allows the post-hook to inspect and even alter the result before it is returned to the original caller.
- **Return**:
- Can return a modified result, which will replace the original result of the main method, or simply return `kwargs["result"]` to return the original method result.
- ```python
def pre_processing_hook(*args, **kwargs):
print("Pre-processing logic here")
# Modify arguments or perform any checks
return args, kwargs # Return modified or unmodified args and kwargs
def post_processing_hook(*args, **kwargs):
print("Post-processing logic here")
# Modify the result or perform any final logging or cleanup
return kwargs["result"] # Return the modified or unmodified result
class Preload:
def __init__(self, connapp):
# Registering a pre-hook
connapp.ai.some_method.register_pre_hook(pre_processing_hook)
# Registering a post-hook
connapp.node.another_method.register_post_hook(post_processing_hook)
```
### Executable Block
- The plugin script can include an executable block:
- `if __name__ == "__main__":`
- This block allows the plugin to be run as a standalone script for testing or independent use.
### Command Completion Support
Plugins can provide intelligent **tab completion** by defining autocompletion logic. There are two supported methods, with the tree-based approach being the most modern and recommended.
#### 1. Tree-based Completion (Recommended)
Define a function called `_connpy_tree` that returns a declarative navigation tree. This method is highly efficient, supports complex state loops, and is very simple to implement for most use cases.
```python
def _connpy_tree(info=None):
nodes = info.get("nodes", [])
return {
"__exclude_used__": True, # Filter out words already typed
"__extra__": nodes, # Suggest nodes at this level
"--format": ["json", "yaml", "table"], # Fixed suggestions
"*": { # Wildcard matches any positional word
"interface1": None,
"interface2": None,
"--verbose": None
}
}
```
- **Keys**: Literal completions (exact matches).
- **`*` Key**: A wildcard that matches any positional word typed by the user.
- **`__extra__`**: A list or a callable `(words) -> list` that adds dynamic suggestions.
- **`__exclude_used__`**: (Boolean) If True, automatically filters out words already present in the command line.
#### 2. Legacy Function-based Completion
For backward compatibility or highly custom logic, you can define `_connpy_completion`.
```python
def _connpy_completion(wordsnumber, words, info=None):
if wordsnumber == 3:
return ["--help", "--verbose", "start", "stop"]
elif wordsnumber == 4 and words[2] == "start":
return info["nodes"] # Suggest node names
return []
```
| Parameter | Description |
|----------------|-------------|
| `wordsnumber` | Integer indicating the total number of words on the command line. For plugins, this typically starts at 3. |
| `words` | A list of tokens (words) already typed. `words[0]` is always the name of the plugin. |
| `info` | A dictionary of structured context data (`nodes`, `folders`, `profiles`, `config`). |
> In this example, if the user types `connpy myplugin start ` and presses Tab, it will suggest node names.
### Handling Unknown Arguments
Plugins can choose to accept and process unknown arguments that are **not explicitly defined** in the parser. To enable this behavior, the plugin must define the following hidden argument in its `Parser` class:
```
self.parser.add_argument(
"--unknown-args",
action="store_true",
default=True,
help=argparse.SUPPRESS
)
```
#### Behavior:
- When this argument is present, Connpy will parse the known arguments and capture any extra (unknown) ones.
- These unknown arguments will be passed to the plugin as `args.unknown_args` inside the `Entrypoint`.
- If the user does not pass any unknown arguments, `args.unknown_args` will contain the default value (`True`, unless overridden).
#### Example:
If a plugin accepts unknown tcpdump flags like this:
```
connpy myplugin -nn -s0
```
And defines the hidden `--unknown-args` flag as shown above, then:
- `args.unknown_args` inside `Entrypoint.__init__()` will be: `['-nn', '-s0']`
> This allows the plugin to receive and process arguments intended for external tools (e.g., `tcpdump`) without argparse raising an error.
#### Note:
If a plugin does **not** define `--unknown-args`, any extra arguments passed will cause argparse to fail with an unrecognized arguments error.
### Script Verification
- The `verify_script` method in `plugins.py` is used to check the plugin script's compliance with these standards.
- Non-compliant scripts will be rejected to ensure consistency and proper functionality within the plugin system.
-
### Example Script
For a practical example of how to write a compatible plugin script, please refer to the following example:
[Example Plugin Script](https://github.com/fluzzi/awspy)
This script demonstrates the required structure and implementation details according to the plugin system's standards.
## gRPC Service Architecture
Connpy features a completely decoupled gRPC Client/Server architecture. You can run Connpy as a standalone background service and connect to it remotely via the CLI or other clients.
### 1. Start the Server
Start the gRPC service by running:
### 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
connpy api -s 50051
conn ai "how do i check bgp summary on cisco?"
```
The server will handle all configurations, connections, AI sessions, and plugin execution locally on the machine it runs on.
### 2. Connect the Client
Configure your local CLI client to connect to the remote server:
### 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
connpy config --service-mode remote
connpy config --remote-host localhost:50051
conn ai --mcp
```
Once configured, all commands (`connpy node`, `connpy list`, `connpy ai`, etc.) will execute transparently on the remote server via thin-client proxies. You can revert back to standalone execution at any time by running `connpy config --service-mode local`.
### Programmatic Access (gRPC & SOA)
Developers can build their own applications using the Connpy backend by utilizing the `ServiceProvider`:
### 1d. Local Interactive Shell (conn shell)
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
```bash
conn shell # Start local shell (default: $SHELL or /bin/bash)
conn shell -c /bin/zsh # Override shell executable
conn shell --capture session.log # Log session output to file
```
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
```bash
conn config --shell-command /bin/zsh
conn config --shell-prompt "\$\s*$"
conn config --shell-os ubuntu
```
---
## 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`
---
## 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 & API Tokens
Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
```bash
conn user --add username
conn user --list
conn user --regen-password username
# Personal Access Tokens (PAT) for non-interactive API access
conn user --create-token "CI/CD Token" --expires-in 30
conn user --list-tokens
conn user --revoke-token <token_id>
```
Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
### 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
```
### 9b. Shell Completion + FZF
Install autocompletions and fuzzy-search wrappers into your shell profile:
```bash
eval "$(conn config --completion bash)"
eval "$(conn config --fzf-wrapper bash)"
```
### 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
conn config --theme /path/to/theme.yaml
```
---
## 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`.
---
## 11. 🐍 Python API
Embed connection and automation routines programmatically in Python:
```python
from connpy.services.provider import ServiceProvider
services = ServiceProvider(config, mode="remote", remote_host="localhost:50051")
nodes = services.nodes.list_nodes()
```
## Automation module
The automation module
### Standalone module
```
import connpy
router = connpy.node("uniqueName","ip/host", user="user", password="pass")
router.run(["term len 0","show run"])
# 1. Direct single node interaction
router = connpy.node("router1", "1.1.1.1", user="admin")
router.run(["show ip int brief"])
print(router.output)
hasip = router.test("show ip int brief","1.1.1.1")
if hasip:
print("Router has ip 1.1.1.1")
else:
print("router does not have ip 1.1.1.1")
```
### Using manager configuration
```
import connpy
conf = connpy.configfile()
device = conf.getitem("server@office")
server = connpy.node("unique name", **device, config=conf)
result = server.run(["cd /", "ls -la"])
print(result)
```
### Running parallel tasks
```
import connpy
conf = connpy.configfile()
#You can get the nodes from the config from a folder and fitlering in it
nodes = conf.getitem("@office", ["router1", "router2", "router3"])
#You can also get each node individually:
nodes = {}
nodes["router1"] = conf.getitem("router1@office")
nodes["router2"] = conf.getitem("router2@office")
nodes["router10"] = conf.getitem("router10@datacenter")
#Also, you can create the nodes manually:
nodes = {}
nodes["router1"] = {"host": "1.1.1.1", "user": "user", "password": "pass1"}
nodes["router2"] = {"host": "1.1.1.2", "user": "user", "password": "pass2"}
nodes["router3"] = {"host": "1.1.1.2", "user": "user", "password": "pass3"}
#Finally you run some tasks on the nodes
mynodes = connpy.nodes(nodes, config = conf)
result = mynodes.test(["show ip int br"], "1.1.1.2")
for i in result:
print("---" + i + "---")
print(result[i])
print()
# Or for one specific node
mynodes.router1.run(["term len 0". "show run"], folder = "/home/user/logs")
```
### Using variables
```
import connpy
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes = config.getitem("@office", ["router1", "router2", "router3"])
commands = []
commands.append("config t")
commands.append("interface lo {id}")
commands.append("ip add {ip} {mask}")
commands.append("end")
variables = {}
variables["router1@office"] = {"ip": "10.57.57.1"}
variables["router2@office"] = {"ip": "10.57.57.2"}
variables["router3@office"] = {"ip": "10.57.57.3"}
variables["__global__"] = {"id": "57"}
variables["__global__"]["mask"] = "255.255.255.255"
expected = "!"
routers = connpy.nodes(nodes, config = config)
routers.run(commands, variables)
routers.test("ping {ip}", expected, variables)
for key in routers.result:
print(key, ' ---> ', ("pass" if routers.result[key] else "fail"))
```
### Using AI
```
import connpy
conf = connpy.configfile()
# Uses models and API keys from config, or override them:
myai = connpy.ai(conf, engineer_model="gemini/gemini-2.5-flash", engineer_api_key="your-key")
result = myai.ask("go to router1 and show me the running configuration")
print(result["response"])
# Streaming is enabled by default for CLI, disable for programmatic use:
result = myai.ask("show interfaces on all routers", stream=False)
print(result["response"])
```
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 Plugin Tool Registration
Plugins can register custom tools with the AI system using `register_ai_tool()` in their `Preload` class:
# 3. AI Copilot prompts
myai = connpy.ai(connpy.configfile())
response = myai.ask("Show BGP status.")
print(response)
```
def _register_my_tools(ai_instance):
tool_def = {
"type": "function",
"function": {
"name": "my_custom_tool",
"description": "Does something useful.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}
}
ai_instance.register_ai_tool(
tool_definition=tool_def,
handler=my_handler_function,
target="engineer", # or "architect" or "both"
engineer_prompt="- My tool: does X.",
architect_prompt=" * My tool (my_custom_tool)."
)
*Supports additional programmatic features like `node.test()`, `node.interact()`, `configfile.encrypt()`, `connapp` embeds, and `ClassHook` / `MethodHook` plugin hooks.*
class Preload:
def __init__(self, connapp):
connapp.ai.modify(_register_my_tools)
---
## 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.
## Developer Notes (SOA Architecture)
As of version 2.0, Connpy has migrated to a **Service-Oriented Architecture (SOA)**:
- **`connpy/cli/`**: Contains all CLI handlers. These are responsible for argument parsing, user interaction (via `inquirer`), and visual output (via `printer`).
- **`connpy/services/`**: Contains pure logic services (Node, Profile, Execution, etc.).
- **Zero-Print Policy**: Services must never use `print()`. All output must be returned as data structures or generators to the caller (CLI handlers).
- **ServiceProvider**: Access services via `connapp.services`. This allows transparent switching between local and remote (gRPC) backends without modifying CLI logic.
---
## 13. 📜 License
[PolyForm Noncommercial 1.0.0](LICENSE)
'''
from .core import node,nodes
from .configfile import configfile
from .connapp import connapp
from .api import *
from .ai import ai
from .plugins import Plugins
from ._version import __version__
from . import printer
__all__ = ["node", "nodes", "configfile", "connapp", "ai", "Plugins", "printer"]
def __getattr__(name: str):
if name == "ai":
from .ai import ai
globals()["ai"] = ai
return ai
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = ["node", "nodes", "configfile", "connapp", "Plugins", "printer"]
__author__ = "Federico Luzzi"
__pdoc__ = {
'core': False,
@@ -497,5 +329,7 @@ __pdoc__ = {
'nodes.deferred_class_hooks': False,
'connapp': False,
'connapp.encrypt': True,
'printer': False
'printer': False,
'tests': False
}
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
import sys
from connpy import *
from connpy import configfile, connapp
def main():
conf = configfile()
+1 -1
View File
@@ -1 +1 @@
__version__ = "5.1b6"
__version__ = "6.1.0"
+882 -105
View File
File diff suppressed because it is too large Load Diff
+47 -6
View File
@@ -48,12 +48,50 @@ def stop_api():
return port
def debug_api(port=8048, config=None):
from .grpc_layer.server import serve
conf = config or configfile()
server = serve(conf, port=port, debug=True)
printer.info(f"gRPC Server running in debug mode on port {port}...")
_wait_for_termination()
server.stop(0)
# 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)
printer.info(f"gRPC Server running in debug mode on port {port}...")
_wait_for_termination()
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:
@@ -67,6 +105,9 @@ def start_server(port=8048, config=None):
conf = config or configfile()
server = serve(conf, port=port, debug=False)
_wait_for_termination()
server.stop(0)
from .ai import cleanup
cleanup()
except Exception as e:
printer.error(f"Background API failed to start: {e}")
os._exit(1)
+1
View File
@@ -7,4 +7,5 @@ from .api_handler import APIHandler
from .plugin_handler import PluginHandler
from .import_export_handler import ImportExportHandler
from .context_handler import ContextHandler
from .sso_handler import SSOHandler
+177 -20
View File
@@ -15,13 +15,22 @@ class AIHandler:
def dispatch(self, args):
if args.list_sessions:
sessions = self.app.services.ai.list_sessions()
limit = 20 if not getattr(args, "all", False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions:
printer.info("No saved AI sessions found.")
return
columns = ["ID", "Title", "Created At", "Model"]
rows = [[s["id"], s["title"], s["created_at"], s["model"]] for s in sessions]
printer.table("AI Persisted Sessions", columns, rows)
title = "AI Persisted Sessions"
if limit and total > limit:
title += f" (Showing last {limit} of {total})"
printer.table(title, columns, rows)
if limit and total > limit:
printer.info(f"Use '--list --all' to see all {total} sessions.")
return
if args.delete_session:
@@ -31,19 +40,22 @@ class AIHandler:
except Exception as e:
printer.error(str(e))
return
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 = {}
@@ -53,18 +65,25 @@ class AIHandler:
arguments[key] = cli_val[0]
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
@@ -75,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")
@@ -88,7 +107,6 @@ class AIHandler:
if "usage" in result:
u = result["usage"]
console.print(f"[debug]Tokens: {u['total']} (Input: {u['input']}, Output: {u['output']})[/debug]")
console.print()
def interactive_chat(self, args, session_id):
history = None
@@ -100,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"))
@@ -111,10 +129,10 @@ class AIHandler:
try:
user_query = Prompt.ask("[user_prompt]User[/user_prompt]")
if not user_query.strip(): continue
if user_query.lower() in ['exit', 'quit', 'bye']: break
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:
@@ -132,7 +150,146 @@ class AIHandler:
if "usage" in result:
u = result["usage"]
console.print(f"[debug]Tokens: {u['total']} (Input: {u['input']}, Output: {u['output']})[/debug]")
console.print()
except (KeyboardInterrupt, EOFError):
console.print("\n[dim]Session closed.[/dim]")
break
def configure_mcp(self, args):
"""Handle MCP server configuration via CLI tokens or interactive wizard."""
mcp_args = args.mcp
# 1. Non-interactive CLI Mode (if arguments are provided)
if mcp_args:
action = mcp_args[0].lower()
if action == "list":
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info("No MCP servers configured.")
else:
columns = ["Name", "URL", "Enabled", "Auto-load OS"]
rows = []
for name, cfg in mcp_servers.items():
rows.append([
name,
cfg.get("url", ""),
"[green]Yes[/green]" if cfg.get("enabled", True) else "[red]No[/red]",
cfg.get("auto_load_on_os", "Any")
])
printer.table("Configured MCP Servers", columns, rows)
return
elif action == "add":
if len(mcp_args) < 3:
printer.error("Usage: connpy ai --mcp add <name> <url> [os_filter]")
return
name, url = mcp_args[1], mcp_args[2]
os_filter = mcp_args[3] if len(mcp_args) > 3 else None
try:
self.app.services.ai.configure_mcp(name, url=url, auto_load_on_os=os_filter)
printer.success(f"MCP server '{name}' added/updated.")
except Exception as e:
printer.error(str(e))
return
elif action == "remove":
if len(mcp_args) < 2:
printer.error("Usage: connpy ai --mcp remove <name>")
return
name = mcp_args[1]
try:
self.app.services.ai.configure_mcp(name, remove=True)
printer.success(f"MCP server '{name}' removed.")
except Exception as e:
printer.error(str(e))
return
elif action in ["enable", "disable"]:
if len(mcp_args) < 2:
printer.error(f"Usage: connpy ai --mcp {action} <name>")
return
name = mcp_args[1]
enabled = (action == "enable")
try:
self.app.services.ai.configure_mcp(name, enabled=enabled)
printer.success(f"MCP server '{name}' {'enabled' if enabled else 'disabled'}.")
except Exception as e:
printer.error(str(e))
return
else:
printer.error(f"Unknown MCP action: {action}")
printer.info("Available actions: list, add, remove, enable, disable")
return
# 2. Interactive Wizard Mode (if no arguments provided)
# Import forms dynamically to avoid circular dependencies if any
if not hasattr(self.app, "cli_forms"):
from .forms import Forms
self.app.cli_forms = Forms(self.app)
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
return
action = result["action"]
try:
if action == "list":
# Recursive call to the non-interactive list logic
args.mcp = ["list"]
return self.configure_mcp(args)
elif action == "add":
self.app.services.ai.configure_mcp(
result["name"],
url=result["url"],
enabled=result["enabled"],
auto_load_on_os=result["os"]
)
printer.success(f"MCP server '{result['name']}' saved.")
elif action == "update": # Used for toggle
self.app.services.ai.configure_mcp(
result["name"],
enabled=result["enabled"]
)
printer.success(f"MCP server '{result['name']}' updated.")
elif action == "remove":
self.app.services.ai.configure_mcp(result["name"], remove=True)
printer.success(f"MCP server '{result['name']}' removed.")
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)
+72 -3
View File
@@ -19,12 +19,17 @@ 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,
"sync_remote": self.set_sync_remote
"sync_remote": self.set_sync_remote,
"shell_command": self.set_shell_config,
"shell_prompt": self.set_shell_config,
"shell_os": self.set_shell_config
}
handler = actions.get(getattr(args, "command", None))
if handler:
@@ -127,9 +132,73 @@ 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.")
def set_shell_config(self, args):
key = args.command.replace("shell_", "")
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get("shell", {}) if isinstance(settings.get("shell"), dict) else {}
if str(val).lower() in ["none", "clear", ""]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting("shell", shell_cfg)
printer.success("Config saved")
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))
+86 -1
View File
@@ -1,5 +1,4 @@
import ast
import inquirer
from .validators import Validators
class Forms:
@@ -8,6 +7,7 @@ class Forms:
self.validators = Validators(app)
def questions_edit(self):
import inquirer
questions = []
questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?"))
questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?"))
@@ -21,6 +21,7 @@ class Forms:
return inquirer.prompt(questions)
def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try:
defaults = self.app.services.nodes.get_node_details(unique)
if "tags" not in defaults:
@@ -98,6 +99,7 @@ class Forms:
return result
def questions_profiles(self, unique, edit=None):
import inquirer
try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if "tags" not in defaults:
@@ -163,6 +165,7 @@ class Forms:
return result
def questions_bulk(self, nodes="", hosts=""):
import inquirer
questions = []
questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation))
@@ -197,3 +200,85 @@ class Forms:
answer["tags"] = ast.literal_eval(answer["tags"])
return answer
def mcp_wizard(self, mcp_servers):
"""Interactive wizard to manage MCP servers."""
import inquirer
from .helpers import theme
while True:
options = [
("List Configured Servers", "list"),
("Add/Update Server", "add"),
("Enable/Disable Server", "toggle"),
("Remove Server", "remove"),
("Back", "exit")
]
questions = [
inquirer.List("action", message="MCP Configuration", choices=options)
]
answers = inquirer.prompt(questions, theme=theme)
if not answers or answers["action"] == "exit":
return None
action = answers["action"]
if action == "list":
if not mcp_servers:
print("\nNo MCP servers configured.\n")
else:
return {"action": "list"}
elif action == "add":
questions = [
inquirer.Text("name", message="Server Name (identifier)"),
inquirer.Text("url", message="SSE URL (e.g., http://localhost:8000/sse)"),
inquirer.Confirm("enabled", message="Enabled?", default=True),
inquirer.Text("auto_load_os", message="Auto-load on specific OS (blank for any)")
]
answers = inquirer.prompt(questions, theme=theme)
if answers:
return {
"action": "add",
"name": answers["name"],
"url": answers["url"],
"enabled": answers["enabled"],
"os": answers["auto_load_os"]
}
elif action == "toggle":
if not mcp_servers:
print("\nNo servers to toggle.\n")
continue
choices = []
for name, cfg in mcp_servers.items():
status = "[Enabled]" if cfg.get("enabled", True) else "[Disabled]"
choices.append((f"{name} {status}", name))
questions = [
inquirer.List("name", message="Select server to toggle", choices=choices + [("Cancel", None)])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers["name"]:
current = mcp_servers[answers["name"]].get("enabled", True)
return {
"action": "update",
"name": answers["name"],
"enabled": not current
}
elif action == "remove":
if not mcp_servers:
print("\nNo servers to remove.\n")
continue
questions = [
inquirer.List("name", message="Select server to remove", choices=list(mcp_servers.keys()) + ["Cancel"])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers["name"] != "Cancel":
return {"action": "remove", "name": answers["name"]}
return None
+6 -7
View File
@@ -55,6 +55,10 @@ Here are some important instructions and tips for configuring your new node:
- `prompt`: Replaces default app prompt to identify the end of output or where the user can start inputting commands.
- `kube_command`: Replaces the default command (`/bin/bash`) for `kubectl exec`.
- `docker_command`: Replaces the default command for `docker exec`.
- `region`: AWS Region used for `aws ssm start-session`.
- `profile`: AWS Profile used for `aws ssm start-session`.
- `ssh_options`: Additional SSH options injected when an SSM node is used as a jumphost (e.g., `-i ~/.ssh/key.pem`).
- `nc_command`: Replaces the default `nc` command used when bridging connections through Docker or Kubernetes (e.g., `ip netns exec global-vrf nc`).
"""
if type == "bashcompletion":
return '''
@@ -153,9 +157,7 @@ tasks:
nodes: #List of nodes to work on. Mandatory
- 'router1@office' #You can add specific nodes
- '@aws' #entire folders or subfolders
- '@office': #or filter inside a folder or subfolder
- 'router2'
- 'router7'
- 'router.*@office' #or use regex to filter inside a folder
commands: #List of commands to send, use {name} to pass variables
- 'term len 0'
@@ -181,7 +183,7 @@ tasks:
vrouterN@aws:
id: 5
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path only works on 'run' action.
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path works on both 'run' and 'test' actions.
options:
prompt: r'>$|#$|\$$|>.$|#.$|\$.$' #Optional prompt to check on your devices, default should work on most devices.
@@ -193,9 +195,6 @@ tasks:
nodes:
- 'router1@office'
- '@aws'
- '@office':
- 'router2'
- 'router7'
commands:
- 'ping 10.100.100.{id}'
expected: '!' #Expected text to find when running test action. Mandatory for 'test'
+74 -2
View File
@@ -1,10 +1,81 @@
import os
import inquirer
try:
from pyfzf.pyfzf import FzfPrompt
except ImportError:
FzfPrompt = None
def hex_to_blessed(hex_str):
"""Convert hex color string to blessed/ansi format."""
from inquirer.themes import term
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
def get_theme():
"""Returns a fresh instance of the theme with current colors."""
from inquirer.themes import Default, term
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 = ">"
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')
@@ -55,8 +126,9 @@ def choose(app, list_, name, action):
else:
return answer[0]
else:
import inquirer
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:
+13 -3
View File
@@ -1,19 +1,29 @@
import os
import sys
import inquirer
from .. import printer
from ..services.exceptions import ConnpyError
from .forms import Forms
class ImportExportHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch_import(self, args):
file_path = args.data[0]
try:
printer.warning("This could overwrite your current configuration!")
import inquirer
question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")]
confirm = inquirer.prompt(question)
if confirm == None or not confirm["import"]:
+245
View File
@@ -0,0 +1,245 @@
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):
# Handle token management actions first
if getattr(args, "create_token", None):
return self.create_token(args)
if getattr(args, "list_tokens", False):
return self.list_tokens(args)
if getattr(args, "revoke_token", None):
return self.revoke_token(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}")
def _get_auth_service(self):
"""Gets an authenticated auth service stub, reusing existing or creating one."""
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:
# Load existing session token for authentication
token_path = os.path.join(self.app.config.defaultdir, ".token")
if not os.path.exists(token_path):
printer.error("No active session. Please log in first using 'connpy login'.")
sys.exit(1)
with open(token_path, "r") as f:
session_token = f.read().strip()
from ..grpc_layer.stubs import AuthClientInterceptor
interceptor = AuthClientInterceptor(lambda: session_token)
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f"Failed to connect to remote server: {e}")
sys.exit(1)
return auth_service
def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, "expires_days", 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f"API token '{name}' created successfully.")
printer.warning("⚠ Copy this token now. It will NOT be shown again:")
printer.data("Token", result["raw_token"])
printer.info(f"Token ID: {result['token_id']}")
if expires_days > 0:
printer.info(f"Expires in: {expires_days} days")
else:
printer.info("Expires: Never (permanent)")
except ConnpyError as e:
printer.error(f"Failed to create token: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to create token: {e}")
sys.exit(1)
def list_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info("No API tokens found.")
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
"token_id": t["token_id"],
"name": t["name"],
"prefix": t["token_prefix"],
"created": t["created_at"] or "N/A",
"last_used": t["last_used_at"] or "Never",
"expires": t["expires_at"] or "Never",
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data("API Tokens", yaml_str)
except ConnpyError as e:
printer.error(f"Failed to list tokens: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to list tokens: {e}")
sys.exit(1)
def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f"Token '{token_id}' revoked successfully.")
except ConnpyError as e:
printer.error(f"Failed to revoke token: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to revoke token: {e}")
sys.exit(1)
+45 -9
View File
@@ -1,18 +1,44 @@
import sys
import yaml
import inquirer
from rich.markdown import Markdown
from .. import printer
from ..services.exceptions import ConnpyError, InvalidConfigurationError
from .helpers import choose
from .forms import Forms
from .help_text import get_instructions
class NodeHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
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:
@@ -39,6 +65,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 = []
@@ -58,7 +85,7 @@ class NodeHandler:
debug=args.debug,
logger=self.app._service_logger
)
except ConnpyError as e:
except (ConnpyError, ValueError) as e:
printer.error(str(e))
sys.exit(1)
@@ -73,6 +100,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 = []
@@ -81,14 +109,16 @@ class NodeHandler:
sys.exit(2)
printer.info(f"Removing: {matches}")
import inquirer
question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")]
confirm = inquirer.prompt(question)
if confirm == None or not confirm["delete"]:
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 +174,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 +202,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 +241,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 +254,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")
+8 -2
View File
@@ -115,6 +115,8 @@ class PluginHandler:
# Populate local plugins
for name, details in local_plugins.items():
if details.get("origin") == "core":
continue
state = "Disabled" if not details.get("enabled", True) else "Active"
color = "red" if state == "Disabled" else "green"
@@ -123,11 +125,14 @@ class PluginHandler:
state = "Shadowed (Override by Remote)"
color = "yellow"
table.add_row(name, f"[{color}]{state}[/{color}]", "Local")
origin = details.get("origin", "Local").capitalize()
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
# Populate remote plugins
if self.app.services.mode == "remote":
for name, details in remote_plugins.items():
if details.get("origin") == "core":
continue
state = "Disabled" if not details.get("enabled", True) else "Active"
color = "red" if state == "Disabled" else "green"
@@ -138,7 +143,8 @@ class PluginHandler:
state = "Shadowed (Override by Local)"
color = "yellow"
table.add_row(name, f"[{color}]{state}[/{color}]", "Remote")
origin = details.get("origin", "Remote").capitalize()
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
if not local_plugins and not remote_plugins:
printer.console.print(" No plugins found.")
+13 -3
View File
@@ -1,15 +1,24 @@
import sys
import yaml
import inquirer
from .. import printer
from ..services.exceptions import ConnpyError, ProfileNotFoundError
from .forms import Forms
class ProfileHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch(self, args):
if not self.app.case:
@@ -29,6 +38,7 @@ class ProfileHandler:
printer.error("Can't delete default profile")
sys.exit(6)
import inquirer
question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")]
confirm = inquirer.prompt(question)
if confirm == None or not confirm["delete"]:
+428 -32
View File
@@ -1,6 +1,7 @@
import os
import sys
import yaml
import threading
from rich.rule import Rule
from .. import printer
from ..services.exceptions import ConnpyError
@@ -9,32 +10,139 @@ from .help_text import get_instructions
class RunHandler:
def __init__(self, app):
self.app = app
self.print_lock = threading.Lock()
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]
commands = [" ".join(args.data[1:])]
# 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
# Inline execution with streaming results
def _on_node_complete(unique, node_output, node_status):
nonlocal header_printed
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
if hasattr(args, 'test_expected') and args.test_expected:
# Mode: Test
def _on_node_complete(unique, node_output, node_status, node_result):
nonlocal header_printed
with self.print_lock:
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=matched_nodes,
commands=commands,
expected=args.test_expected,
on_node_complete=_on_node_complete
)
printer.test_summary(results)
else:
# Mode: Normal Run
def _on_node_complete(unique, node_output, node_status):
nonlocal header_printed
with self.print_lock:
if not header_printed:
printer.console.print(Rule("OUTPUT", style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
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()
self.app.services.execution.run_commands(
nodes_filter=nodes_filter,
commands=commands,
on_node_complete=_on_node_complete
)
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))
@@ -55,66 +163,354 @@ class RunHandler:
try:
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", []):
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", []):
self.cli_run(task)
task_res = self.cli_run(task)
if task_res:
results_all.update(task_res)
# If analyze is enabled, run analysis on accumulated results
if getattr(args, "analyze", None) is not None:
printer.console.print()
renderer = printer.BlockMarkdownRenderer()
first_chunk = True
status_context = printer.console.status("[ai_status]Analyzing playbook execution results...[/ai_status]")
def callback(chunk):
nonlocal first_chunk
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
first_chunk = False
renderer.feed(chunk)
query = args.analyze if args.analyze else f"Playbook: {path}"
try:
status_context.start()
self.app.services.ai.analyze_execution_results(
results_all,
query=query,
chunk_callback=callback
)
if first_chunk:
try: status_context.stop()
except: pass
printer.console.print(Rule(title="[architect][bold]Network Architect AI Playbook Analysis[/bold][/architect]", style="architect"))
renderer.flush()
printer.console.print(Rule(style="architect"))
except Exception as e:
printer.error(f"AI Analysis failed: {e}")
except Exception as e:
printer.error(f"Failed to run playbook {path}: {e}")
sys.exit(10)
def cli_run(self, script):
name = script.get("name", "Task")
try:
action = script["action"]
nodelist = script["nodes"]
commands = script["commands"]
variables = script.get("variables")
output_cfg = script["output"]
name = script.get("name", "Task")
options = script.get("options", {})
except KeyError as e:
printer.error(f"'{e.args[0]}' is mandatory in script")
printer.error(f"[{name}] '{e.args[0]}' is mandatory in script")
sys.exit(11)
stdout = (output_cfg == "stdout")
folder = output_cfg if output_cfg not in [None, "stdout"] else None
prompt = options.get("prompt")
printer.header(name.upper())
# 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":
# If stdout is true, we stream results as they arrive
on_complete = printer.node_panel if stdout else None
def _on_run_complete(unique, node_output, node_status):
nonlocal header_printed
if stdout:
with self.print_lock:
if not header_printed:
printer.console.print(Rule(name.upper(), style="header"))
header_printed = True
printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands(
nodes_filter=nodelist,
commands=commands,
variables=variables,
parallel=options.get("parallel", 10),
timeout=options.get("timeout", 10),
timeout=options.get("timeout", 20),
folder=folder,
prompt=prompt,
on_node_complete=on_complete
on_node_complete=_on_run_complete
)
# If not streaming, we could print a summary table here if needed
if not stdout:
for unique, output in results.items():
# Final Summary
if not stdout and not folder:
with self.print_lock:
printer.console.print(Rule(name.upper(), style="header"))
for unique, data in results.items():
output = data["output"] if isinstance(data, dict) else data
printer.node_panel(unique, output, 0)
# ALWAYS show the aggregate execution summary at the end
printer.run_summary(results)
elif action == "test":
expected = script.get("expected", [])
on_complete = printer.test_panel if stdout else None
# Show test_panel per node ONLY if stdout is True
def _on_test_complete(unique, node_output, node_status, node_result):
nonlocal header_printed
if stdout:
with self.print_lock:
if not header_printed:
printer.console.print(Rule(name.upper(), style="header"))
header_printed = True
printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands(
nodes_filter=nodelist,
commands=commands,
expected=expected,
variables=variables,
parallel=options.get("parallel", 10),
timeout=options.get("timeout", 10),
timeout=options.get("timeout", 20),
folder=folder,
prompt=prompt,
on_node_complete=on_complete
on_node_complete=_on_test_complete
)
if not stdout:
printer.test_summary(results)
# 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
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
# Helper to get active theme color
def get_theme_color(style_name, fallback="white"):
try:
style = printer.connpy_theme.styles.get(style_name)
if style and style.color:
if style.color.is_default: return fallback
return style.color.triplet.hex if style.color.triplet else style.color.name
except: pass
return fallback
user_color = get_theme_color("user_prompt", "#00afd7")
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
kb = KeyBindings()
@kb.add('enter')
def _(event):
event.current_buffer.validate_and_handle()
@kb.add('c-j')
@kb.add('escape', 'enter')
def _(event):
event.current_buffer.insert_text('\n')
session = PromptSession(key_bindings=kb)
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*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n"))
printer.console.print(Rule(style="engineer"))
while True:
try:
user_prompt = session.prompt(
HTML(f'<style fg="{user_color}">User (Enter to submit, Ctrl+Enter for newline):</style>\n'),
multiline=True
)
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}")
+55
View File
@@ -0,0 +1,55 @@
import os
import sys
import shutil
import shlex
import socket
from .. import printer
from ..core import node
class ShellHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
shell_config = self.app.config.config.get("shell", {}) if hasattr(self.app.config, "config") else {}
command = getattr(args, 'command_override', None) or shell_config.get("command") or os.environ.get("SHELL", "/bin/bash")
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f"Shell command executable not found: {exe}")
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
"os": node_info["os"],
"prompt": node_info["prompt"]
}
n = node(
unique=node_info["name"],
host=command,
protocol="local",
config=self.app.config,
tags=tags
)
capture_file = getattr(args, 'capture_file', None)
if capture_file:
n.logs = capture_file
elif shell_config.get("logging"):
n.logs = shell_config.get("log_path", os.path.expanduser("~/.config/conn/shell_logs/session.log"))
n.interact(debug=getattr(args, 'debug', False))
def _build_local_identity(self, shell_config):
return {
"name": "local-shell",
"host": socket.gethostname(),
"os": shell_config.get("os", "linux"),
"prompt": shell_config.get("prompt", r'\$\s*$|#\s*$')
}
+163
View File
@@ -0,0 +1,163 @@
import sys
import yaml
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):
import inquirer
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
import inquirer
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)
+565
View File
@@ -0,0 +1,565 @@
import os
import re
import sys
import time
import asyncio
import fcntl
import termios
import tty
from typing import Any, Dict, List, Optional, Callable
from textwrap import dedent
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import has_completions
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import InMemoryHistory
from ..printer import connpy_theme
from connpy.utils import log_cleaner
class CopilotInterface:
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
from ..services.ai_service import AIService
self.config = config
self.history = history or InMemoryHistory()
self.pt_input = pt_input
self.pt_output = pt_output
self.rich_file = rich_file
self.ai_service = AIService(config)
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
self.session_state = session_state if session_state is not None else {}
self.session_state.setdefault('persona', 'engineer')
self.session_state.setdefault('trust_mode', False)
self.session_state.setdefault('memories', [])
self.session_state.setdefault('os', None)
self.session_state.setdefault('prompt', None)
self.session_state.setdefault('context_mode', self.mode_range)
self.session_state.setdefault('context_cmd', 1)
self.session_state.setdefault('context_lines', 50)
self.session_state.setdefault('last_total_cmds', None)
self.session_state.setdefault('last_total_lines', None)
if rich_file:
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
else:
self.console = Console(theme=connpy_theme)
def _sync_session_context(self, state: dict):
"""Persist current context mode, depth, total commands, and total lines into session_state."""
self.session_state['context_mode'] = state['context_mode']
self.session_state['context_cmd'] = state['context_cmd']
self.session_state['context_lines'] = state['context_lines']
self.session_state['last_total_cmds'] = state['total_cmds']
self.session_state['last_total_lines'] = state['total_lines']
def _get_theme_color(self, style_name: str, fallback: str = "white") -> str:
"""Extract Hex or ANSI color name from the active rich theme."""
try:
style = connpy_theme.styles.get(style_name)
if style and style.color:
# If it's a standard color like 'green', Rich might return its hex triplet
if style.color.is_default: return fallback
return style.color.triplet.hex if style.color.triplet else style.color.name
except: pass
return fallback
async def run_session(self,
raw_bytes: bytes,
node_info: dict,
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
"""
from rich.rule import Rule
try:
# Prepare UI state
buffer = log_cleaner(raw_bytes.decode(errors='replace'))
# 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 = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
total_cmds = len(blocks)
total_lines = len(buffer.split('\n'))
saved_mode = self.session_state.get('context_mode', self.mode_range)
saved_cmd = self.session_state.get('context_cmd', 1)
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
last_total_cmds = self.session_state.get('last_total_cmds', None)
last_total_lines = self.session_state.get('last_total_lines', None)
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
if is_range or is_single:
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
state = {
'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
'total_cmds': total_cmds,
'total_lines': total_lines,
'context_lines': min(max(1, initial_lines), max(1, total_lines)),
'context_mode': saved_mode,
'cancelled': False,
'toolbar_msg': '',
'msg_expiry': 0
}
self.session_state['context_mode'] = saved_mode
self.session_state['context_cmd'] = max(1, initial_cmd)
self.session_state['context_lines'] = max(1, initial_lines)
self.session_state['last_total_cmds'] = total_cmds
self.session_state['last_total_lines'] = total_lines
# 1. Visual Separation
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. 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") # Small space before the copilot prompt
bindings = KeyBindings()
@bindings.add('c-up')
def _(event):
if state['context_mode'] == self.mode_lines:
state['context_lines'] = min(state['context_lines'] + 50, state['total_lines'])
else:
state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds'])
self._sync_session_context(state)
event.app.invalidate()
@bindings.add('c-down')
def _(event):
if state['context_mode'] == self.mode_lines:
state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines']))
else:
state['context_cmd'] = max(state['context_cmd'] - 1, 1)
self._sync_session_context(state)
event.app.invalidate()
@bindings.add('tab')
def _(event):
buf = event.current_buffer
# If typing a slash command (no spaces yet), use tab to autocomplete inline
if buf.text.startswith('/') and ' ' not in buf.text:
buf.complete_next()
else:
state['context_mode'] = (state['context_mode'] + 1) % 3
self._sync_session_context(state)
event.app.invalidate()
@bindings.add('escape', eager=True)
@bindings.add('c-c')
def _(event):
state['cancelled'] = True
event.app.exit(result='')
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
@bindings.add('enter', filter=~has_completions)
def _(event):
event.current_buffer.validate_and_handle()
@bindings.add('c-j')
@bindings.add('escape', 'enter')
def _(event):
event.current_buffer.insert_text('\n')
def get_active_buffer():
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, end, preview = blocks[idx]
if state['context_mode'] == self.mode_single:
active_raw = raw_bytes[start:end]
else:
# 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():
import html
# Always use user_prompt color for the Ask prompt
color = self._get_theme_color("user_prompt", "cyan")
if state['context_mode'] == self.mode_lines:
text = html.escape(f"Ask [Ctx: {state['context_lines']}/{state['total_lines']}L]: ")
return HTML(f'<style fg="{color}">{text}</style>')
active = get_active_buffer()
lines_count = len(active.split('\n'))
mode_str = {self.mode_range: "Range", self.mode_single: "Cmd"}[state['context_mode']]
text = html.escape(f"Ask [{mode_str} {state['context_cmd']} ~{lines_count}L]: ")
return HTML(f'<style fg="{color}">{text}</style>')
from prompt_toolkit.application.current import get_app
def get_toolbar():
import html
app = get_app()
c_warning = self._get_theme_color("warning", "yellow")
if app and app.current_buffer:
text = app.current_buffer.text
# 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())]
if matches:
m_text = html.escape(f"Available: {' '.join(matches)}")
return HTML(f'<style fg="{c_warning}">{m_text}</style>' + " " * 20)
m_label = {self.mode_range: "RANGE", self.mode_single: "SINGLE", self.mode_lines: "LINES"}[state['context_mode']]
if state['context_mode'] == self.mode_lines:
base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]'
else:
idx = max(0, state['total_cmds'] - state['context_cmd'])
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
# The fg color will be inherited from bottom-toolbar global style if not specified here
base_html = f'<span>{html.escape(base_str)}</span>'
res_html = base_html
if state.get('toolbar_msg'):
if time.time() < state.get('msg_expiry', 0):
msg = html.escape(state['toolbar_msg'])
res_html = f'<style fg="{c_warning}">⚙️ {msg}</style> | ' + base_html
else:
state['toolbar_msg'] = ''
# Pad with spaces to ensure the line is cleared when the message disappears
return HTML(res_html + " " * 20)
from prompt_toolkit.completion import Completer, Completion
class SlashCommandCompleter(Completer):
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if text.startswith('/'):
parts = text.split()
# Only autocomplete the first word
if len(parts) <= 1 or (len(parts) == 1 and not text.endswith(' ')):
cmd_part = parts[0] if parts else text
commands = [
('/os', 'Set device OS (e.g. cisco_ios)'),
('/prompt', 'Override prompt regex'),
('/architect', 'Switch to Architect persona'),
('/engineer', 'Switch to Engineer persona'),
('/trust', 'Enable auto-execute'),
('/untrust', 'Disable auto-execute'),
('/memorize', 'Add fact to memory'),
('/clear', 'Clear memory')
]
for cmd, desc in commands:
if cmd.startswith(cmd_part.lower()):
yield Completion(cmd, start_position=-len(cmd_part), display_meta=desc)
copilot_completer = SlashCommandCompleter()
while True:
# 2. Ask question
from prompt_toolkit.styles import Style
c_contrast = self._get_theme_color("contrast", "gray")
ui_style = Style.from_dict({
'bottom-toolbar': f'fg:{c_contrast}',
})
session = PromptSession(
history=self.history,
input=self.pt_input,
output=self.pt_output,
completer=copilot_completer,
reserve_space_for_menu=0,
style=ui_style
)
try:
# 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,
bottom_toolbar=get_toolbar,
multiline=True
)
except (KeyboardInterrupt, EOFError):
state['cancelled'] = True
question = ""
if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']:
return "cancel", None, None
# 3. Process Input via AIService
directive = self.ai_service.process_copilot_input(question, self.session_state)
if directive["action"] == "state_update":
msg = directive['message']
state['toolbar_msg'] = msg
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
async def delayed_refresh():
await asyncio.sleep(3.1)
# Only invalidate if the message hasn't been replaced by a newer one
if state.get('toolbar_msg') == msg:
state['toolbar_msg'] = '' # Explicitly clear
try:
from prompt_toolkit.application.current import get_app
app = get_app()
if app: app.invalidate()
except: pass
asyncio.create_task(delayed_refresh())
# 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:
# Clean the toolbar message when a real question is asked
state['toolbar_msg'] = ''
clean_question = directive.get("clean_prompt", question)
overrides = directive.get("overrides", {})
# Merge node_info with session_state and overrides
merged_node_info = node_info.copy()
if self.session_state['os']: merged_node_info['os'] = self.session_state['os']
if self.session_state['prompt']: merged_node_info['prompt'] = self.session_state['prompt']
merged_node_info['persona'] = self.session_state['persona']
merged_node_info['trust'] = self.session_state['trust_mode']
merged_node_info['memories'] = list(self.session_state['memories'])
for k, v in overrides.items():
merged_node_info[k] = v
# Enrich question
past = self.history.get_strings()
if len(past) > 1:
clean_past = [q for q in past[-6:-1] if not q.startswith('/')]
if clean_past:
history_text = "\n".join(f"- {q}" for q in clean_past)
clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}"
# 3. AI Execution
# 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 = ""
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, 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
md_parser.feed(text)
# Check for interruption during AI call
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 first_chunk and result and result.get("error"):
self.console.print(f"[red]Error: {result['error']}[/red]")
return "cancel", None, None
# 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:
self.console.print("")
return "continue", None, None
risk = result.get("risk_level", "low")
risk_style = {"low": "success", "high": "warning", "destructive": "error"}.get(risk, "success")
style_color = self._get_theme_color(risk_style, fallback="green")
cmd_text = "\n".join(f" {i+1}. {c}" for i, c in enumerate(commands))
# Explicitly use 'bold style_color' for both TITLE and BORDER to ensure maximum consistency
self.console.print(Panel(cmd_text, title=f"[bold {style_color}]Suggested Commands [{risk.upper()}][/bold {style_color}]", border_style=f"bold {style_color}"))
if merged_node_info.get('trust', False) and risk != "destructive":
self.console.print(f"[dim]⚙️ Auto-executing (Trust Mode)[/dim]")
return "send_all", commands, None
confirm_session = PromptSession(input=self.pt_input, output=self.pt_output)
c_bindings = KeyBindings()
@c_bindings.add('escape', eager=True)
@c_bindings.add('c-c')
def _(ev): ev.app.exit(result='n')
import html
try:
p_text = html.escape(f"Send? (y/n/e/range) [n]: ")
# Use the EXACT same style_color and force bold="true" for Prompt-Toolkit
action = await confirm_session.prompt_async(HTML(f'<style fg="{style_color}" bold="true">{p_text}</style>'), key_bindings=c_bindings)
except (KeyboardInterrupt, EOFError):
self.console.print("")
return "continue", None, None
def parse_indices(text, max_len):
"""Helper to parse '1-3, 5, 7' into [0, 1, 2, 4, 6]."""
indices = []
# Replace commas with spaces and split
parts = text.replace(',', ' ').split()
for part in parts:
if '-' in part:
try:
start, end = map(int, part.split('-'))
# Ensure inclusive and 0-indexed
indices.extend(range(start-1, end))
except: continue
elif part.isdigit():
indices.append(int(part)-1)
# Filter valid indices and remove duplicates
return [i for i in sorted(set(indices)) if 0 <= i < max_len]
action_l = (action or "n").lower().strip()
if action_l in ('y', 'yes', 'all'):
return "send_all", commands, None
# Check for numeric selection (e.g., "1, 2-4")
if re.match(r'^[0-9,\-\s]+$', action_l):
selected_idxs = parse_indices(action_l, len(commands))
if selected_idxs:
return "send_all", [commands[i] for i in selected_idxs], None
elif action_l.startswith('e'):
# Check if it's a selective edit like 'e1-2'
selection_str = action_l[1:].strip()
if selection_str:
idxs = parse_indices(selection_str, len(commands))
cmds_to_edit = [commands[i] for i in idxs] if idxs else commands
else:
cmds_to_edit = commands
target = "\n".join(cmds_to_edit)
e_bindings = KeyBindings()
@e_bindings.add('c-j')
def _(ev): ev.app.exit(result=ev.app.current_buffer.text)
@e_bindings.add('escape', 'enter')
def _(ev): ev.app.exit(result=ev.app.current_buffer.text)
@e_bindings.add('escape')
def _(ev): ev.app.exit(result='')
c_edit = self._get_theme_color("user_prompt", "cyan")
import html
e_text = html.escape("Edit (Ctrl+Enter or Esc+Enter to submit):\n")
try:
edited = await confirm_session.prompt_async(
HTML(f'<style fg="{c_edit}">{e_text}</style>'),
default=target, multiline=True, key_bindings=e_bindings
)
except (KeyboardInterrupt, EOFError):
self.console.print("")
return "continue", None, None
if edited and edited.strip():
# Split by lines to ensure core.py applies delay between each command
lines = [l.strip() for l in edited.split('\n') if l.strip()]
return "custom", None, lines
self.console.print("")
return "continue", None, None
return "cancel", None, None
finally:
state['cancelled'] = True
+190
View File
@@ -0,0 +1,190 @@
import sys
import os
import getpass
import yaml
from .. import printer
from ..services.exceptions import ConnpyError
class UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == "remote":
printer.error("User management commands are only available in local/server-side mode.")
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, "add", None):
args.action = "add"
args.username = args.add[0]
elif getattr(args, "delete", None):
args.action = "del"
args.username = args.delete[0]
elif getattr(args, "list", False):
args.action = "list"
elif getattr(args, "show", None):
args.action = "show"
args.username = args.show[0]
elif getattr(args, "regen_password", None):
args.action = "regen_password"
args.username = args.regen_password[0]
action = getattr(args, "action", None)
if action == "add":
return self.add_user(args)
elif action == "del":
return self.delete_user(args)
elif action == "list":
return self.list_users(args)
elif action == "show":
return self.show_user(args)
elif action == "regen_password":
return self.regen_password(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def add_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --add <username>")
sys.exit(1)
custom_path = getattr(args, "path", None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass("Enter password for new user: ")
if not password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f"User '{username}' created successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to create user: {e}")
sys.exit(1)
def delete_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --del <username>")
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f"User '{username}' deleted successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to delete user: {e}")
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning("No users registered.")
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get("config_path"):
formatted_u["config_path"] = os.path.join(self.app.services.users.users_dir, formatted_u["username"])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data("Registered Users", yaml_str)
except Exception as e:
printer.error(f"Failed to list users: {e}")
sys.exit(1)
def show_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --show <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != "password_hash"}
if not safe_user.get("config_path"):
safe_user["config_path"] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f"User: {username}", yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
def regen_password(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --regen-password <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
try:
new_password = getpass.getpass("Enter new password: ")
if not new_password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm new password: ")
if new_password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f"Password for user '{username}' regenerated successfully.")
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to regenerate password: {e}")
sys.exit(1)
+28 -25
View File
@@ -1,6 +1,9 @@
import re
import ast
import inquirer
def _raise_val_err(reason):
import inquirer
raise inquirer.errors.ValidationError("", reason=reason)
class Validators:
def __init__(self, app):
@@ -8,61 +11,61 @@ class Validators:
def host_validation(self, answers, current, regex = "^.+$"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
_raise_val_err("Host cannot be empty")
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
return True
def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
return True
def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
return True
def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
try:
port = int(current)
except ValueError:
port = 0
if current != "" and not 1 <= int(port) <= 65535:
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty")
_raise_val_err("Pick a port between 1-65535 or leave empty")
return True
def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty")
_raise_val_err("Pick a port between 1-65535, @profile or leave empty")
try:
port = int(current)
except ValueError:
port = 0
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
elif current != "" and not 1 <= int(port) <= 65535:
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
return True
def pass_validation(self, answers, current, regex = "(^@.+$)"):
profiles = current.split(",")
for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i))
_raise_val_err("Profile {} don't exist".format(i))
return True
def tags_validation(self, answers, current):
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
elif current != "":
isdict = False
try:
@@ -70,7 +73,7 @@ class Validators:
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
_raise_val_err("Tags should be a python dictionary.".format(current))
return True
def profile_tags_validation(self, answers, current):
@@ -81,36 +84,36 @@ class Validators:
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
_raise_val_err("Tags should be a python dictionary.".format(current))
return True
def jumphost_validation(self, answers, current):
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
elif current != "":
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
_raise_val_err("Node {} don't exist.".format(current))
return True
def profile_jumphost_validation(self, answers, current):
if current != "":
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
_raise_val_err("Node {} don't exist.".format(current))
return True
def default_validation(self, answers, current):
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
return True
def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
_raise_val_err("Host cannot be empty")
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
return True
def bulk_folder_validation(self, answers, current):
@@ -123,17 +126,17 @@ class Validators:
matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != "" and len(matches) == 0:
raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current))
_raise_val_err("Location {} don't exist".format(current))
return True
def bulk_host_validation(self, answers, current, regex = "^.+$"):
if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
_raise_val_err("Host cannot be empty")
if current.startswith("@"):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
_raise_val_err("Profile {} don't exist".format(current))
hosts = current.split(",")
nodes = answers["ids"].split(",")
if len(hosts) > 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list")
_raise_val_err("Hosts list should be the same length of nodes list")
return True
+168 -24
View File
@@ -89,9 +89,9 @@ def _get_plugins(which, defaultdir):
if name not in final_all_plugins or preferences.get(name) == "remote":
final_all_plugins[name] = path
# Combine enabled/disabled for the helper commands
enabled_files = list(set(user_enabled + core_enabled + [k for k,v in remote_all_plugins.items() if preferences.get(k) == "remote"]))
disabled_files = list(set(user_disabled + core_disabled))
# Combine enabled/disabled for the helper commands (excluding core plugins from management autocomplete)
enabled_files = list(set(user_enabled + [k for k,v in remote_all_plugins.items() if preferences.get(k) == "remote"]))
disabled_files = list(set(user_disabled))
# Return based on the command
if which == "--disable":
@@ -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.
@@ -147,20 +183,102 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"__extra__": lambda w: get_cwd(w, "import")
})
run_dict = {"--generate": None, "--help": None, "-g": None, "-h": None}
run_dict.update({
"*": run_dict,
"__extra__": lambda w: get_cwd(w, "run") + list(nodes)
# --- Run Loop ---
# After the first positional argument (Node filter or YAML file),
# we stop suggesting nodes and only allow flags or commands.
run_after_node = {"--help": None, "-h": None}
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,
"-h": None,
"__extra__": lambda w: get_cwd(w, "run") + list(nodes),
"*": run_after_node
}
# State Machine Definitions
mcp_dict = {
"list": None,
"add": {"*": {"*": {"*": None}}}, # name url [os]
"remove": {"*": None},
"enable": {"*": None},
"disable": {"*": None},
"--help": None, "-h": None
}
ai_dict = {"__exclude_used__": True, "--help": None, "-h": None}
for opt in ["--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key"]:
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", "--shell-command", "--shell-prompt", "--shell-os"]:
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}
shell_dict = {
"--command": {"*": None},
"-c": {"*": None},
"--capture": {"__extra__": lambda w: get_cwd(w, "--capture")},
"--debug": None,
"-d": None,
"--help": None,
"-h": None
}
_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 = {
@@ -169,9 +287,37 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"folders": None,
}
# --- Connect (default command) ---
# Long flags are offered; short forms (-d/-t) only used for navigation.
# Two states: before node (offer nodes + remaining long flags)
# after node (offer only remaining long flags, no more nodes)
connect_flags_long = ["--debug", "--sftp"]
connect_flags_all = ["--debug", "-d", "--sftp", "-t"]
# Post-node: only offer remaining long flags
connect_after_node = {"__exclude_used__": True}
for f in connect_flags_all:
connect_after_node[f] = connect_after_node
# Pre-node: offer nodes + remaining long flags, consume node → post-node state
connect_dict = {"__exclude_used__": True}
connect_dict["__extra__"] = lambda w: (
list(nodes) + list(folders) + (list(plugins.keys()) if plugins else [])
)
connect_dict["*"] = connect_after_node
for f in connect_flags_all:
connect_dict[f] = connect_dict
# --- Main Tree ---
return {
# Root: offer nodes + long flags; after a node go to post-node state
"__extra__": lambda w: list(nodes) + list(folders) + (list(plugins.keys()) if plugins else []),
"*": connect_after_node,
"--debug": connect_dict,
"-d": connect_dict,
"--sftp": connect_dict,
"-t": connect_dict,
"--add": {"profile": _profile_values},
"--del": {"profile": _profile_values, "__extra__": _nodes_folders},
@@ -219,30 +365,28 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"-a": None, "-r": None, "-s": None, "-e": None, "-h": None,
},
"plugin": {
"--add": lambda w: get_cwd(w, "--add"),
"--update": lambda w: get_cwd(w, "--update"),
"--add": {"*": lambda w: get_cwd(w, "--add")},
"--update": {
"__extra__": lambda w: _get_plugins("--update", configdir),
"*": lambda w: get_cwd(w, "--update")
},
"--del": lambda w: _get_plugins("--del", configdir),
"--enable": lambda w: _get_plugins("--enable", configdir),
"--disable": lambda w: _get_plugins("--disable", 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": {
"--status": None, "-s": None,
"--create-token": None, "--list-tokens": None,
"--revoke-token": None, "--expires-days": None,
"--help": None, "-h": None, "*": None
},
"logout": {"--help": None, "-h": None},
"config": config_dict,
"shell": shell_dict,
"sync": {
"--login": None, "--logout": None,
"--status": None, "--list": None,
+55 -18
View File
@@ -43,7 +43,8 @@ class configfile:
passwords.
'''
def __init__(self, conf = None, key = None):
def __init__(self, conf = None, key = None, shared_config = None):
self._shared_config = shared_config
'''
### Optional Parameters:
@@ -149,6 +150,42 @@ class configfile:
self._generate_nodes_cache()
def get_effective_setting(self, key, default=None):
"""Get config setting with shared fallback for inheritable keys."""
val = self.config.get(key)
if key == "ai":
if val is not None:
if self._shared_config:
import copy
# Deep merge: shared as base, user overrides
base = copy.deepcopy(self._shared_config.config.get(key, {}))
if isinstance(base, dict) and isinstance(val, dict):
# Credential isolation:
# If user defines engineer credentials, discard shared ones
if "engineer_api_key" in val or "engineer_auth" in val:
base.pop("engineer_api_key", None)
base.pop("engineer_auth", None)
# If user defines architect credentials, discard shared ones
if "architect_api_key" in val or "architect_auth" in val:
base.pop("architect_api_key", None)
base.pop("architect_auth", None)
# Recursive update for inner dictionaries (like mcp_servers or model details)
def deep_merge(d1, d2):
for k, v in d2.items():
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
deep_merge(d1[k], v)
else:
d1[k] = copy.deepcopy(v)
deep_merge(base, val)
return base
return val
elif self._shared_config:
return self._shared_config.config.get(key, default)
return val if val is not None else default
def _validate_config(self, data):
"""Verify config data has the required structure."""
if not isinstance(data, dict):
@@ -400,15 +437,7 @@ class configfile:
if isinstance(uniques, str):
uniques = [uniques]
for i in uniques:
if isinstance(i, dict):
name = list(i.keys())[0]
mylist = i[name]
if not self.config["case"]:
name = name.lower()
mylist = [item.lower() for item in mylist]
this = self.getitem(name, mylist, extract = extract)
nodes.update(this)
elif i.startswith("@"):
if i.startswith("@"):
if not self.config["case"]:
i = i.lower()
this = self.getitem(i, extract = extract)
@@ -487,13 +516,18 @@ class configfile:
layer3 = [k + "@" + s + "@" + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"]
nodes.extend(layer3)
if filter:
flat_filter = []
if isinstance(filter, str):
nodes = [item for item in nodes if re.search(filter, item)]
flat_filter = [filter]
elif isinstance(filter, list):
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in filter)]
for item in filter:
if isinstance(item, str):
flat_filter.append(item)
else:
printer.error("Invalid filter: must be a string or a list of strings.")
printer.error("Filter must be a string or a list of strings")
sys.exit(1)
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
@@ -511,15 +545,18 @@ class configfile:
layer3 = {k + "@" + s + "@" + f:v for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"}
nodes.update(layer3)
if filter:
flat_filter = []
if isinstance(filter, str):
filter = "^(?!.*@).+$" if filter == "@" else filter
nodes = {k: v for k, v in nodes.items() if re.search(filter, k)}
flat_filter = [filter]
elif isinstance(filter, list):
filter = ["^(?!.*@).+$" if item == "@" else item for item in filter]
nodes = {k: v for k, v in nodes.items() if any(re.search(pattern, k) for pattern in filter)}
for item in filter:
if isinstance(item, str):
flat_filter.append(item)
else:
printer.error("Invalid filter: must be a string or a list of strings.")
printer.error("Filter must be a string or a list of strings")
sys.exit(1)
flat_filter = ["^(?!.*@).+$" if item == "@" else item for item in flat_filter]
nodes = {k: v for k, v in nodes.items() if any(re.search(pattern, k) for pattern in flat_filter)}
if extract:
for node, keys in nodes.items():
for key, value in keys.items():
+121 -23
View File
@@ -10,15 +10,9 @@ from .core import node,nodes
from ._version import __version__
from . import printer
from .api import start_api,stop_api,debug_api
from .ai import ai
from .plugins import Plugins
from .services import (
NodeService, ProfileService, ConfigService,
PluginService, AIService, SystemService,
ExecutionService, ImportExportService, ConnpyError,
ProfileNotFoundError, ReservedNameError
)
from .services.exceptions import ConnpyError, ProfileNotFoundError, ReservedNameError
from rich_argparse import RichHelpFormatter
# Bridge rich-argparse with our design system
@@ -37,7 +31,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
@@ -46,6 +40,32 @@ console = printer.console
#functions and classes
class DeferredAIProxy:
"""Proxy for connapp.ai that defers importing connpy.ai until ai is actually invoked or accessed."""
def __init__(self):
self._deferred_modifications = []
self._real_ai = None
def _load_real_ai(self):
if self._real_ai is None:
from .ai import ai
self._real_ai = ai
for mod in self._deferred_modifications:
self._real_ai.modify(mod)
return self._real_ai
def modify(self, modification_func):
if self._real_ai is not None:
self._real_ai.modify(modification_func)
else:
self._deferred_modifications.append(modification_func)
def __call__(self, *args, **kwargs):
return self._load_real_ai()(*args, **kwargs)
def __getattr__(self, name):
return getattr(self._load_real_ai(), name)
class connapp:
''' This class starts the connection manager app. It's normally used by connection manager but you can use it on a script to run the connection manager your way and use a different configfile and key.
'''
@@ -77,17 +97,22 @@ class connapp:
self.start_api = start_api
self.stop_api = stop_api # Using SystemService logic eventually
self.debug_api = debug_api
self.ai = ai
# Register context filtering hooks
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)
self.ai = DeferredAIProxy()
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"):
self.services.nodes.list_folders.register_post_hook(self.services.context.filter_node_list)
# 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"):
self.services.nodes.list_folders.register_post_hook(self.services.context.filter_node_list)
# Apply theme from config if exists before remote connection attempts
user_theme = self.config.config.get("theme", {})
self._apply_app_theme(user_theme)
# Populate data via services
try:
@@ -105,7 +130,10 @@ class connapp:
except ConnpyError as e:
# If in remote mode, connectivity issues should be reported
if mode == "remote":
printer.warning(f"Failed to fetch data from remote server: {e}")
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 = []
self.profiles = []
@@ -131,6 +159,10 @@ 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
from .cli.shell_handler import ShellHandler
# Instantiate Handlers
self._node = NodeHandler(self)
@@ -142,7 +174,11 @@ class connapp:
self._plugin = PluginHandler(self)
self._context = ContextHandler(self)
self._import_export = ImportExportHandler(self)
self._shell = ShellHandler(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
@@ -151,10 +187,6 @@ class connapp:
return kwargs.get("result")
configfile._saveconfig.register_post_hook(auto_sync_hook)
# Apply theme from config if exists
user_theme = self.config.config.get("theme", {})
self._apply_app_theme(user_theme)
def _apply_app_theme(self, styles):
"""Unified method to apply theme to printer and help formatter."""
@@ -276,20 +308,28 @@ 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")
aiparser.add_argument("--mcp", nargs='*', metavar=('ACTION', 'NAME'), help="Manage MCP servers. Actions: list, add, remove, enable, disable. Leave empty for interactive wizard.")
aiparser.set_defaults(func=self._ai.dispatch)
#RUNPARSER
runparser = subparsers.add_parser("run", help="Run scripts or commands on nodes", description="Run scripts or commands on nodes", formatter_class=RichHelpFormatter)
runparser.error = self._custom_error
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)
@@ -338,14 +378,64 @@ 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"])
configcrud.add_argument("--shell-command", dest="shell_command", nargs=1, action=self._store_type, help="Set default shell command", metavar="COMMAND")
configcrud.add_argument("--shell-prompt", dest="shell_prompt", nargs=1, action=self._store_type, help="Set shell prompt regex for AI", metavar="REGEX")
configcrud.add_argument("--shell-os", dest="shell_os", nargs=1, action=self._store_type, help="Set shell OS hint for AI", metavar="OS")
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)
#SHELLPARSER
shellparser = subparsers.add_parser("shell", help="Start local interactive shell with copilot", formatter_class=RichHelpFormatter)
shellparser.error = self._custom_error
shellparser.add_argument("--command", "-c", dest="command_override", help="Override shell command")
shellparser.add_argument("--capture", dest="capture_file", help="Capture session to file")
shellparser.add_argument("-d", "--debug", action="store_true", help="Debug mode")
shellparser.set_defaults(func=self._shell.dispatch)
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.add_argument("--create-token", dest="create_token", metavar="NAME", help="Create a permanent API token with the given name")
loginparser.add_argument("--list-tokens", dest="list_tokens", action="store_true", help="List all active API tokens")
loginparser.add_argument("--revoke-token", dest="revoke_token", metavar="TOKEN_ID", help="Revoke an API token by its ID")
loginparser.add_argument("--expires-days", dest="expires_days", type=int, default=0, metavar="DAYS", help="Optional expiration in days for --create-token (default: permanent)")
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)
@@ -469,6 +559,14 @@ class connapp:
# Handle global Ctrl+C gracefully
printer.warning("Operation cancelled by user.")
sys.exit(130)
finally:
# Safely cleanup AI sessions (litellm) if AI was loaded
if "connpy.ai" in sys.modules:
try:
from .ai import cleanup
cleanup()
except (ImportError, Exception):
pass
class _store_type(argparse.Action):
#Custom store type for cli app.
+746 -124
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+588 -43
View File
@@ -1542,11 +1542,6 @@ class ExecutionServiceStub(object):
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.run_yaml_playbook = channel.unary_unary(
'/connpy.ExecutionService/run_yaml_playbook',
request_serializer=connpy__pb2.ScriptRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
class ExecutionServiceServicer(object):
@@ -1570,12 +1565,6 @@ class ExecutionServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def run_yaml_playbook(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_ExecutionServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -1594,11 +1583,6 @@ def add_ExecutionServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
'run_yaml_playbook': grpc.unary_unary_rpc_method_handler(
servicer.run_yaml_playbook,
request_deserializer=connpy__pb2.ScriptRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.ExecutionService', rpc_method_handlers)
@@ -1691,33 +1675,6 @@ class ExecutionService(object):
metadata,
_registered_method=True)
@staticmethod
def run_yaml_playbook(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.ExecutionService/run_yaml_playbook',
connpy__pb2.ScriptRequest.SerializeToString,
connpy__pb2.StructResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class ImportExportServiceStub(object):
"""Missing associated documentation comment in .proto file."""
@@ -1896,6 +1853,11 @@ class AIServiceStub(object):
request_serializer=connpy__pb2.StringRequest.SerializeToString,
response_deserializer=connpy__pb2.BoolResponse.FromString,
_registered_method=True)
self.ask_copilot = channel.unary_unary(
'/connpy.AIService/ask_copilot',
request_serializer=connpy__pb2.CopilotRequest.SerializeToString,
response_deserializer=connpy__pb2.CopilotResponse.FromString,
_registered_method=True)
self.list_sessions = channel.unary_unary(
'/connpy.AIService/list_sessions',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
@@ -1911,11 +1873,36 @@ class AIServiceStub(object):
request_serializer=connpy__pb2.ProviderRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
self.configure_mcp = channel.unary_unary(
'/connpy.AIService/configure_mcp',
request_serializer=connpy__pb2.MCPRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
self.list_mcp_servers = channel.unary_unary(
'/connpy.AIService/list_mcp_servers',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.ValueResponse.FromString,
_registered_method=True)
self.load_session_data = channel.unary_unary(
'/connpy.AIService/load_session_data',
request_serializer=connpy__pb2.StringRequest.SerializeToString,
response_deserializer=connpy__pb2.StructResponse.FromString,
_registered_method=True)
self.build_playbook_chat = channel.stream_stream(
'/connpy.AIService/build_playbook_chat',
request_serializer=connpy__pb2.AskRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.analyze_execution_results = channel.unary_stream(
'/connpy.AIService/analyze_execution_results',
request_serializer=connpy__pb2.AnalyzeRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
self.predict_execution_results = channel.unary_stream(
'/connpy.AIService/predict_execution_results',
request_serializer=connpy__pb2.PreflightRequest.SerializeToString,
response_deserializer=connpy__pb2.AIResponse.FromString,
_registered_method=True)
class AIServiceServicer(object):
@@ -1933,6 +1920,12 @@ class AIServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ask_copilot(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def list_sessions(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -1951,12 +1944,42 @@ class AIServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def configure_mcp(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def list_mcp_servers(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def load_session_data(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def build_playbook_chat(self, request_iterator, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def analyze_execution_results(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def predict_execution_results(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AIServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -1970,6 +1993,11 @@ def add_AIServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.StringRequest.FromString,
response_serializer=connpy__pb2.BoolResponse.SerializeToString,
),
'ask_copilot': grpc.unary_unary_rpc_method_handler(
servicer.ask_copilot,
request_deserializer=connpy__pb2.CopilotRequest.FromString,
response_serializer=connpy__pb2.CopilotResponse.SerializeToString,
),
'list_sessions': grpc.unary_unary_rpc_method_handler(
servicer.list_sessions,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
@@ -1985,11 +2013,36 @@ def add_AIServiceServicer_to_server(servicer, server):
request_deserializer=connpy__pb2.ProviderRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
'configure_mcp': grpc.unary_unary_rpc_method_handler(
servicer.configure_mcp,
request_deserializer=connpy__pb2.MCPRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
'list_mcp_servers': grpc.unary_unary_rpc_method_handler(
servicer.list_mcp_servers,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.ValueResponse.SerializeToString,
),
'load_session_data': grpc.unary_unary_rpc_method_handler(
servicer.load_session_data,
request_deserializer=connpy__pb2.StringRequest.FromString,
response_serializer=connpy__pb2.StructResponse.SerializeToString,
),
'build_playbook_chat': grpc.stream_stream_rpc_method_handler(
servicer.build_playbook_chat,
request_deserializer=connpy__pb2.AskRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
'analyze_execution_results': grpc.unary_stream_rpc_method_handler(
servicer.analyze_execution_results,
request_deserializer=connpy__pb2.AnalyzeRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
'predict_execution_results': grpc.unary_stream_rpc_method_handler(
servicer.predict_execution_results,
request_deserializer=connpy__pb2.PreflightRequest.FromString,
response_serializer=connpy__pb2.AIResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.AIService', rpc_method_handlers)
@@ -2055,6 +2108,33 @@ class AIService(object):
metadata,
_registered_method=True)
@staticmethod
def ask_copilot(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AIService/ask_copilot',
connpy__pb2.CopilotRequest.SerializeToString,
connpy__pb2.CopilotResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def list_sessions(request,
target,
@@ -2136,6 +2216,60 @@ class AIService(object):
metadata,
_registered_method=True)
@staticmethod
def configure_mcp(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AIService/configure_mcp',
connpy__pb2.MCPRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def list_mcp_servers(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AIService/list_mcp_servers',
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.ValueResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def load_session_data(request,
target,
@@ -2163,6 +2297,87 @@ class AIService(object):
metadata,
_registered_method=True)
@staticmethod
def build_playbook_chat(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/connpy.AIService/build_playbook_chat',
connpy__pb2.AskRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def analyze_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/connpy.AIService/analyze_execution_results',
connpy__pb2.AnalyzeRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def predict_execution_results(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/connpy.AIService/predict_execution_results',
connpy__pb2.PreflightRequest.SerializeToString,
connpy__pb2.AIResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
class SystemServiceStub(object):
"""Missing associated documentation comment in .proto file."""
@@ -2406,3 +2621,333 @@ class SystemService(object):
timeout,
metadata,
_registered_method=True)
class AuthServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.login = channel.unary_unary(
'/connpy.AuthService/login',
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.login_sso = channel.unary_unary(
'/connpy.AuthService/login_sso',
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
'/connpy.AuthService/change_password',
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
self.get_sso_providers = channel.unary_unary(
'/connpy.AuthService/get_sso_providers',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True)
self.create_api_token = channel.unary_unary(
'/connpy.AuthService/create_api_token',
request_serializer=connpy__pb2.CreateApiTokenRequest.SerializeToString,
response_deserializer=connpy__pb2.CreateApiTokenResponse.FromString,
_registered_method=True)
self.list_api_tokens = channel.unary_unary(
'/connpy.AuthService/list_api_tokens',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.ListApiTokensResponse.FromString,
_registered_method=True)
self.revoke_api_token = channel.unary_unary(
'/connpy.AuthService/revoke_api_token',
request_serializer=connpy__pb2.RevokeApiTokenRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
class AuthServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def login(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def login_sso(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def change_password(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def get_sso_providers(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def create_api_token(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def list_api_tokens(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def revoke_api_token(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AuthServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'login': grpc.unary_unary_rpc_method_handler(
servicer.login,
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
'login_sso': grpc.unary_unary_rpc_method_handler(
servicer.login_sso,
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
'change_password': grpc.unary_unary_rpc_method_handler(
servicer.change_password,
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
'get_sso_providers': grpc.unary_unary_rpc_method_handler(
servicer.get_sso_providers,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
),
'create_api_token': grpc.unary_unary_rpc_method_handler(
servicer.create_api_token,
request_deserializer=connpy__pb2.CreateApiTokenRequest.FromString,
response_serializer=connpy__pb2.CreateApiTokenResponse.SerializeToString,
),
'list_api_tokens': grpc.unary_unary_rpc_method_handler(
servicer.list_api_tokens,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.ListApiTokensResponse.SerializeToString,
),
'revoke_api_token': grpc.unary_unary_rpc_method_handler(
servicer.revoke_api_token,
request_deserializer=connpy__pb2.RevokeApiTokenRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.AuthService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('connpy.AuthService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class AuthService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def login(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/login',
connpy__pb2.LoginRequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def login_sso(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/login_sso',
connpy__pb2.LoginSSORequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def change_password(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/change_password',
connpy__pb2.ChangePasswordRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def get_sso_providers(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/get_sso_providers',
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.SSOProvidersResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def create_api_token(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/create_api_token',
connpy__pb2.CreateApiTokenRequest.SerializeToString,
connpy__pb2.CreateApiTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def list_api_tokens(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/list_api_tokens',
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.ListApiTokensResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def revoke_api_token(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/revoke_api_token',
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+1025 -139
View File
File diff suppressed because it is too large Load Diff
+560 -77
View File
@@ -9,6 +9,8 @@ from .utils import to_value, from_value, to_struct, from_struct
from ..services.exceptions import ConnpyError
from ..hooks import MethodHook
from .. import printer
from ..cli.terminal_ui import CopilotInterface
from ..utils import log_cleaner
def handle_errors(func):
@wraps(func)
@@ -41,15 +43,100 @@ class NodeStub:
self.remote_host = remote_host
self.config = config
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
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),
session_state=getattr(self, 'copilot_state', None)
)
self.copilot_history = interface.history
self.copilot_state = interface.session_state
async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info):
# Send request to server
request_queue.put(connpy_pb2.InteractRequest(
copilot_question=question,
copilot_context_buffer=active_buffer,
copilot_node_info_json=json.dumps(merged_node_info)
))
# Wait for chunks from server
while True:
try:
chunk_res = response_queue.get(timeout=0.1)
if chunk_res is None: return {"error": "Server disconnected"}
if chunk_res.copilot_stream_chunk:
chunk_callback(chunk_res.copilot_stream_chunk)
elif chunk_res.copilot_response_json:
return json.loads(chunk_res.copilot_response_json)
except queue.Empty:
await asyncio.sleep(0.05)
# Wrap in async loop
async def run_remote_copilot():
while True:
action, commands, custom_cmd = await interface.run_session(
raw_bytes=bytes(client_buffer_bytes),
node_info=node_info,
on_ai_call=on_ai_call_remote,
blocks=blocks
)
if action == "continue":
# Send continue signal to server to loop back for another question
request_queue.put(connpy_pb2.InteractRequest(copilot_action="continue"))
continue
return action, commands, custom_cmd
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:
# In remote mode, send the selected commands as a custom block
# so the server executes exactly what the user picked (e.g., selection '1')
action_sent = f"custom:{chr(10).join(commands)}"
elif action == "custom" and custom_cmd:
action_sent = f"custom:{chr(10).join(custom_cmd)}"
request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent))
resume_generator()
tty.setraw(sys.stdin.fileno())
@handle_errors
def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
import sys
import select
import tty
import termios
import queue
import os
import threading
request_queue = queue.Queue()
client_buffer_bytes = bytearray()
pause_stdin = [False]
wake_r, wake_w = os.pipe()
def pause_generator():
pause_stdin[0] = True
os.write(wake_w, b'\x00')
def resume_generator():
pause_stdin[0] = False
def request_generator():
cols, rows = 80, 24
try:
@@ -63,8 +150,25 @@ class NodeStub:
)
while True:
r, _, _ = select.select([sys.stdin.fileno()], [], [])
if r:
try:
while True:
req = request_queue.get_nowait()
if req is None:
return
yield req
except queue.Empty:
pass
if pause_stdin[0]:
import time
time.sleep(0.05)
continue
r, _, _ = select.select([sys.stdin.fileno(), wake_r], [], [], 0.05)
if wake_r in r:
os.read(wake_r, 1)
continue
if sys.stdin.fileno() in r and not pause_stdin[0]:
try:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
@@ -86,30 +190,74 @@ class NodeStub:
old_tty = termios.tcgetattr(sys.stdin)
try:
import time
tty.setraw(sys.stdin.fileno())
response_iterator = self.stub.interact_node(request_generator())
# First response is connection status
import queue
response_queue = queue.Queue()
def response_consumer():
try:
for r in response_iterator:
response_queue.put(r)
except Exception:
pass
response_queue.put(None)
t_consumer = threading.Thread(target=response_consumer, daemon=True)
t_consumer.start()
# First phase: Wait for connection status, print early data
try:
first_res = next(response_iterator)
if first_res.success:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
tty.setraw(sys.stdin.fileno())
else:
# Connection failed on server
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.error(f"Connection failed: {first_res.error_message}")
return
except StopIteration:
while True:
res = response_queue.get()
if res is None:
return
if res.stdout_data:
data = res.stdout_data
if debug:
data = data.replace(b'\x1b[H\x1b[2J', b'').replace(b'\x1bc', b'').replace(b'\x1b[3J', b'')
os.write(sys.stdout.fileno(), data)
if res.success:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
pause_stdin[0] = False
tty.setraw(sys.stdin.fileno())
break
if res.error_message:
# Connection failed on server
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.error(f"Connection failed: {res.error_message}")
return
except queue.Empty:
return
for res in response_iterator:
# Second phase: Stream active session
# Clear screen filter is only applied before success (Phase 1).
# Once the user has a prompt, Ctrl+L must work normally.
while True:
res = response_queue.get()
if res is None:
break
if res.copilot_prompt:
self._handle_remote_copilot(
res, request_queue, response_queue,
client_buffer_bytes,
pause_generator, resume_generator, old_tty
)
continue
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
os.close(wake_r)
os.close(wake_w)
@handle_errors
def connect_dynamic(self, connection_params, debug=False):
@@ -117,10 +265,22 @@ class NodeStub:
import select
import tty
import termios
import queue
import os
import json
params_json = json.dumps(connection_params)
request_queue = queue.Queue()
client_buffer_bytes = bytearray()
pause_stdin = [False]
wake_r, wake_w = os.pipe()
def pause_generator():
pause_stdin[0] = True
os.write(wake_w, b'\x00')
def resume_generator():
pause_stdin[0] = False
def request_generator():
cols, rows = 80, 24
@@ -136,8 +296,25 @@ class NodeStub:
)
while True:
r, _, _ = select.select([sys.stdin.fileno()], [], [])
if r:
try:
while True:
req = request_queue.get_nowait()
if req is None:
return
yield req
except queue.Empty:
pass
if pause_stdin[0]:
import time
time.sleep(0.05)
continue
r, _, _ = select.select([sys.stdin.fileno(), wake_r], [], [], 0.05)
if wake_r in r:
os.read(wake_r, 1)
continue
if sys.stdin.fileno() in r and not pause_stdin[0]:
try:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
@@ -160,30 +337,72 @@ class NodeStub:
old_tty = termios.tcgetattr(sys.stdin)
try:
import time
tty.setraw(sys.stdin.fileno())
response_iterator = self.stub.interact_node(request_generator())
# First response is connection status
import queue
response_queue = queue.Queue()
def response_consumer():
try:
for r in response_iterator:
response_queue.put(r)
except Exception:
pass
response_queue.put(None)
t_consumer = threading.Thread(target=response_consumer, daemon=True)
t_consumer.start()
# First phase: Wait for connection status, print early data
try:
first_res = next(response_iterator)
if first_res.success:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
tty.setraw(sys.stdin.fileno())
else:
# Connection failed on server
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.error(f"Connection failed: {first_res.error_message}")
return
except StopIteration:
while True:
res = response_queue.get()
if res is None:
return
if res.stdout_data:
data = res.stdout_data
if debug:
data = data.replace(b'\x1b[H\x1b[2J', b'').replace(b'\x1bc', b'').replace(b'\x1b[3J', b'')
os.write(sys.stdout.fileno(), data)
if res.success:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
pause_stdin[0] = False
tty.setraw(sys.stdin.fileno())
break
if res.error_message:
# Connection failed on server
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.error(f"Connection failed: {res.error_message}")
return
except queue.Empty:
return
for res in response_iterator:
# Second phase: Stream active session
while True:
res = response_queue.get()
if res is None:
break
if res.copilot_prompt:
self._handle_remote_copilot(
res, request_queue, response_queue,
client_buffer_bytes,
pause_generator, resume_generator, old_tty
)
continue
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
client_buffer_bytes.extend(res.stdout_data)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
os.close(wake_r)
os.close(wake_w)
@MethodHook
@handle_errors
@@ -243,16 +462,18 @@ 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)
self._trigger_local_cache_sync()
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)
self._trigger_local_cache_sync()
if save:
self._trigger_local_cache_sync()
@handle_errors
def move_node(self, src_id, dst_id, copy=False):
@@ -420,9 +641,9 @@ class ExecutionStub:
folder=folder or "",
prompt=prompt or "",
parallel=parallel,
timeout=timeout,
name=kwargs.get("name", "")
)
# Note: 'timeout', 'on_node_complete', and 'logger' are currently not
# sent over gRPC in the current proto definition.
if variables is not None:
req.vars.CopyFrom(to_struct(variables))
@@ -432,7 +653,10 @@ class ExecutionStub:
for response in self.stub.run_commands(req):
if on_complete:
on_complete(response.unique_id, response.output, response.status)
final_results[response.unique_id] = response.output
final_results[response.unique_id] = {
"output": response.output,
"status": response.status
}
return final_results
@@ -442,10 +666,12 @@ class ExecutionStub:
req = connpy_pb2.TestRequest(
nodes=nodes_list,
commands=commands,
expected=expected,
expected=expected if isinstance(expected, list) else [expected],
folder=kwargs.get("folder", ""),
prompt=prompt or "",
parallel=parallel,
timeout=timeout,
name=kwargs.get("name", "")
)
if variables is not None:
req.vars.CopyFrom(to_struct(variables))
@@ -466,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)
@@ -498,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
@@ -522,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)
@@ -531,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
@@ -579,9 +803,12 @@ 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()
if live_display: live_display.stop()
# Show prompt and wait for answer
prompt_text = Text.from_ansi(response.status_update)
@@ -590,7 +817,6 @@ class AIStub:
if status:
status.update("[ai_status]Agent: Resuming...")
status.start()
if live_display: live_display.start()
req_queue.put(connpy_pb2.AskRequest(confirmation_answer=ans))
continue
@@ -601,41 +827,72 @@ class AIStub:
if response.debug_message:
if debug:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.debug_message))
if status:
try: status.start()
except: pass
continue
if response.important_message:
if status:
try: status.stop()
except: pass
printer.console.print(Text.from_ansi(response.important_message))
if status:
try: status.start()
except: pass
continue
if not response.is_final:
full_content += response.text_chunk
if not live_display and not debug:
if status: status.stop()
live_display = Live(
Panel(Markdown(full_content), title="AI Assistant", expand=False),
console=printer.console,
refresh_per_second=8,
transient=False
)
live_display.start()
elif live_display:
live_display.update(Panel(Markdown(full_content), title="AI Assistant", expand=False))
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 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 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}]"
if live_display:
live_display.update(Panel(Markdown(full_content), title=title, border_style=alias, expand=False))
live_display.stop()
elif full_content:
printer.console.print(Panel(Markdown(full_content), 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
@@ -650,23 +907,144 @@ 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
def configure_mcp(self, name, url=None, enabled=True, auto_load_on_os=None, remove=False):
req = connpy_pb2.MCPRequest(
name=name,
url=url or "",
enabled=enabled,
auto_load_on_os=auto_load_on_os or "",
remove=remove
)
self.stub.configure_mcp(req)
@handle_errors
def list_mcp_servers(self):
res = self.stub.list_mcp_servers(Empty())
return from_value(res.data) or {}
@handle_errors
def load_session_data(self, session_id):
return from_struct(self.stub.load_session_data(connpy_pb2.StringRequest(value=session_id)).data)
@@ -695,3 +1073,108 @@ 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)
@handle_errors
def create_api_token(self, name, expires_in_days=0):
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
resp = self.stub.create_api_token(req)
return {
"token_id": resp.token_id,
"raw_token": resp.raw_token,
"name": resp.name,
}
@handle_errors
def list_api_tokens(self):
resp = self.stub.list_api_tokens(Empty())
return [
{
"token_id": t.token_id,
"name": t.name,
"token_prefix": t.token_prefix,
"created_at": t.created_at,
"last_used_at": t.last_used_at,
"expires_at": t.expires_at,
}
for t in resp.tokens
]
@handle_errors
def revoke_api_token(self, token_id):
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
self.stub.revoke_api_token(req)
+113
View File
@@ -0,0 +1,113 @@
import os
import threading
from connpy.configfile import configfile
from connpy.services.provider import ServiceProvider
from connpy.services.user_service import UserService
class UserRegistry:
"""Holds per-user ServiceProviders in memory, thread-safe with hot-reloading."""
def __init__(self, server_config_dir):
self.server_config_dir = os.path.abspath(server_config_dir)
self.user_service = UserService(self.server_config_dir)
self._providers = {} # username → ServiceProvider
self._mtimes = {} # username → last loaded mtime (float)
self._lock = threading.Lock()
# Load shared/global config
self._shared_conf_file = os.path.join(self.server_config_dir, "config.yaml")
if os.path.exists(self._shared_conf_file):
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = os.path.getmtime(self._shared_conf_file)
else:
self._shared_config = None
self._shared_mtime = 0.0
def _refresh_shared(self):
"""Hot-reload shared config if the file changed on disk."""
if not os.path.exists(self._shared_conf_file):
return
current_mtime = os.path.getmtime(self._shared_conf_file)
if current_mtime > self._shared_mtime:
try:
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = current_mtime
# Clear all user providers so they pick up the new shared config
self._providers.clear()
self._mtimes.clear()
except Exception as e:
from connpy import printer
printer.warning(f"Failed to reload shared config: {e}")
def get_provider(self, username) -> ServiceProvider:
"""Get, lazy-load, or hot-reload a user's full ServiceProvider."""
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user's config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get("config_path")
if config_path:
conf_file = os.path.join(config_path, "config.yaml")
else:
conf_file = os.path.join(self.server_config_dir, "users", username, "config.yaml")
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) < current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode="local")
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, "close"):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f"Failed to hot-reload config for user '{username}' (file may be corrupt/incomplete): {e}")
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]
def has_users(self) -> bool:
"""Check if any users are registered (enables auth enforcement)."""
return bool(self.user_service.list_users())
def get_shared_config(self):
"""Thread-safe access to the hot-reloaded shared configuration."""
with self._lock:
self._refresh_shared()
return self._shared_config
def evict(self, username):
"""Remove and cleanly shut down cached provider (after delete or password change)."""
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, "close"):
try:
provider.close()
except Exception:
pass
+4 -2
View File
@@ -20,7 +20,8 @@ class MethodHook:
try:
args, kwargs = hook(*args, **kwargs)
except Exception as e:
printer.error(f"{self.func.__name__} Pre-hook {hook.__name__} raised an exception: {e}")
hook_name = getattr(hook, "__name__", str(hook))
printer.error(f"{self.func.__name__} Pre-hook {hook_name} raised an exception: {e}")
result = self.func(*args, **kwargs)
@@ -32,7 +33,8 @@ class MethodHook:
try:
result = hook(*args, **kwargs, result=result) # Pass result to hooks
except Exception as e:
printer.error(f"{self.func.__name__} Post-hook {hook.__name__} raised an exception: {e}")
hook_name = getattr(hook, "__name__", str(hook))
printer.error(f"{self.func.__name__} Post-hook {hook_name} raised an exception: {e}")
return result
+174
View File
@@ -0,0 +1,174 @@
import asyncio
import json
import os
import threading
from typing import Any, Dict, List, Optional
import logging
try:
from mcp import ClientSession
from mcp.client.sse import sse_client
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
# Silence noisy MCP and HTTP internal logging
logging.getLogger("mcp").setLevel(logging.CRITICAL)
logging.getLogger("httpx").setLevel(logging.CRITICAL)
logging.getLogger("httpcore").setLevel(logging.CRITICAL)
class MCPClientManager:
"""Manages MCP SSE client connections for connpy."""
_instance = None
_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
with cls._lock:
if cls._instance is None:
cls._instance = super(MCPClientManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, config=None):
if self._initialized:
return
self.config = config
self.sessions: Dict[str, Dict[str, Any]] = {} # name -> {session, stack}
self.tool_cache: Dict[str, List[Dict[str, Any]]] = {}
self._connecting: Dict[str, asyncio.Future] = {}
self._initialized = True
async def get_tools_for_llm(self, os_filter: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Fetches tools from enabled MCP servers that match the OS filter.
"""
if not MCP_AVAILABLE:
return []
all_llm_tools = []
try:
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 []
async def _fetch(name, cfg):
if not cfg.get("enabled", True): return []
# Filter by OS if specified in config (primarily used for copilot strict matching)
auto_os = cfg.get("auto_load_on_os")
if os_filter is not None and auto_os and os_filter.lower() != auto_os.lower():
return []
try:
session = await self._ensure_connected(name, cfg)
if session:
if name in self.tool_cache: return self.tool_cache[name]
llm_tools = await self._fetch_tools_as_openai(name, session)
self.tool_cache[name] = llm_tools
return llm_tools
except Exception:
pass
return []
tasks = [ _fetch(name, cfg) for name, cfg in mcp_config.items() ]
if tasks:
results = await asyncio.gather(*tasks)
for tools in results:
all_llm_tools.extend(tools)
return all_llm_tools
async def _ensure_connected(self, name: str, cfg: Dict[str, Any]) -> Optional[Any]:
if not MCP_AVAILABLE: return None
if name in self.sessions and self.sessions[name].get("session"):
return self.sessions[name]["session"]
url = cfg.get("url")
if not url:
return None
if name in self._connecting:
try:
return await asyncio.wait_for(asyncio.shield(self._connecting[name]), timeout=10.0)
except Exception:
return None
loop = asyncio.get_running_loop()
fut = loop.create_future()
self._connecting[name] = fut
try:
from contextlib import AsyncExitStack
stack = AsyncExitStack()
async def _do_connect():
read, write = await stack.enter_async_context(sse_client(url))
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
return session
session = await asyncio.wait_for(_do_connect(), timeout=15.0)
self.sessions[name] = {"session": session, "stack": stack}
fut.set_result(session)
return session
except Exception:
fut.set_result(None)
return None
finally:
if name in self._connecting:
del self._connecting[name]
async def _fetch_tools_as_openai(self, server_name: str, session: Any) -> List[Dict[str, Any]]:
try:
result = await asyncio.wait_for(session.list_tools(), timeout=5.0)
openai_tools = []
for tool in result.tools:
# Use mcp_ prefix to ensure valid function name for LiteLLM/Gemini
prefixed_name = f"mcp_{server_name}__{tool.name}"
openai_tools.append({
"type": "function",
"function": {
"name": prefixed_name,
"description": f"[{server_name}] {tool.description}",
"parameters": tool.inputSchema
}
})
return openai_tools
except Exception:
return []
async def call_tool(self, full_tool_name: str, arguments: Dict[str, Any]) -> Any:
"""Calls an MCP tool and returns text result."""
if not MCP_AVAILABLE:
return "Error: MCP SDK is not installed."
if "__" not in full_tool_name:
return f"Error: Tool {full_tool_name} is not a valid MCP tool."
clean_name = full_tool_name[4:] if full_tool_name.startswith("mcp_") else full_tool_name
server_name, tool_name = clean_name.split("__", 1)
if server_name not in self.sessions:
return f"Error: MCP server {server_name} is not connected."
session = self.sessions[server_name]["session"]
try:
result = await asyncio.wait_for(session.call_tool(tool_name, arguments), timeout=60.0)
text_outputs = [content.text for content in result.content if hasattr(content, "text")]
return "\n".join(text_outputs) if text_outputs else str(result)
except Exception as e:
return f"Error calling tool {tool_name} on {server_name}: {str(e)}"
async def shutdown(self):
"""Close all SSE connections."""
for name, data in self.sessions.items():
stack = data.get("stack")
if stack:
await stack.aclose()
self.sessions = {}
+221 -40
View File
@@ -15,7 +15,17 @@ class ThreadLocalStream:
def write(self, data):
stream = self._get_stream()
if stream:
stream.write(data)
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()
@@ -46,8 +56,9 @@ def _get_local():
_local.console = None
if not hasattr(_local, 'err_console'):
_local.err_console = None
if not hasattr(_local, 'theme'):
_local.theme = None
if not hasattr(_local, 'theme') or _local.theme is None:
from rich.theme import Theme
_local.theme = Theme(_global_active_styles)
return _local
def set_thread_stream(stream):
@@ -69,23 +80,45 @@ def get_original_stderr():
# Centralized design system
STYLES = {
"info": "cyan",
"warning": "yellow",
"error": "red",
"success": "green",
"debug": "dim",
"header": "bold cyan",
"key": "bold cyan",
"border": "cyan",
"pass": "bold green",
"fail": "bold red",
"engineer": "blue",
"architect": "medium_purple",
"ai_status": "bold green",
"user_prompt": "bold cyan",
"unavailable": "orange3",
"info": "#00ffff", # Cyan
"warning": "#ffff00", # Yellow
"error": "#ff0000", # Red
"success": "#00ff00", # Green
"debug": "#888888",
"header": "bold #00ffff",
"key": "bold #00ffff",
"border": "#00ffff",
"pass": "bold #00ff00",
"fail": "bold #ff0000",
"engineer": "#5fafff", # Sky Blue (lighter than pure blue)
"architect": "#9370db", # Medium Purple
"ai_status": "bold #00ff00",
"user_prompt": "bold #00afd7", # Deep Sky Blue / Soft Cyan
"unavailable": "#d78700",
"contrast": "#bbbbbb",
}
LIGHT_THEME = {
"info": "#00008b", # Navy Blue
"warning": "#d78700", # Orange
"error": "#cd0000", # Dark Red
"success": "#006400", # Dark Green
"debug": "#777777",
"header": "bold #00008b",
"key": "bold #00008b",
"border": "#00008b",
"pass": "bold #006400",
"fail": "bold #cd0000",
"engineer": "#00008b",
"architect": "#8b008b", # Dark Magenta
"ai_status": "bold #006400",
"user_prompt": "bold #00008b",
"unavailable": "#666666",
"contrast": "#777777",
}
_global_active_styles = STYLES.copy()
def _get_console():
local = _get_local()
@@ -171,7 +204,7 @@ def connpy_theme():
local = _get_local()
if local.theme is None:
from rich.theme import Theme
local.theme = Theme(STYLES)
local.theme = Theme(_global_active_styles)
return local.theme
def apply_theme(user_styles=None):
@@ -179,6 +212,7 @@ def apply_theme(user_styles=None):
Updates the global console themes with user-defined styles.
If a style is missing in user_styles, it falls back to the default in STYLES.
"""
global _global_active_styles
local = _get_local()
from rich.theme import Theme
@@ -190,6 +224,7 @@ def apply_theme(user_styles=None):
if key in active_styles:
active_styles[key] = value
_global_active_styles = active_styles
local.theme = Theme(active_styles)
if local.console:
local.console.push_theme(local.theme)
@@ -202,10 +237,15 @@ def _format_multiline(tag, message, style=None):
message = str(message)
lines = message.splitlines()
if not lines:
return f"[{style}]\\[{tag}][/{style}]" if style else f"\\[{tag}]"
if style:
return f"[{style}]\\[{tag}][/{style}]"
return f"\\[{tag}]"
# Apply style to the tag if provided
styled_tag = f"[{style}]\\[{tag}][/{style}]" if style else f"\\[{tag}]"
if style:
# Include brackets in the styling
styled_tag = f"[{style}]\\[{tag}][/{style}]"
formatted = [f"{styled_tag} {lines[0]}"]
# Indent subsequent lines
@@ -317,7 +357,7 @@ def test_panel(unique, output, status, result):
_get_console().print(Panel(Group(Text(), code_block, test_results), title=title_line, width=cols, border_style=border))
def test_summary(results):
"""Print an aggregate summary of multiple test results."""
"""Print an aggregate summary of multiple test results in a single panel."""
from rich.panel import Panel
from rich.text import Text
from rich.console import Group
@@ -328,26 +368,96 @@ def test_summary(results):
except OSError:
cols = 80
for node, test_result in results.items():
status_code = 0 if test_result and all(test_result.values()) else 1
if status_code == 0:
status_str = "[pass]✓ PASS[/pass]"
border = "pass"
else:
status_str = f"[fail]✗ FAIL[/fail]"
border = "fail"
summary_content = Text()
total_passed = 0
total_failed = 0
total_partial = 0
if not results:
summary_content.append(" No test results found.\n", style="error")
else:
for node, test_result in results.items():
summary_content.append(f"", style="border")
summary_content.append(f"{node.ljust(40)}", style="bold")
title_line = f"[bold]{node}[/bold] — {status_str}"
test_output = Text()
test_output.append("TEST RESULTS:\n", style="header")
max_key_len = max(len(k) for k in test_result.keys()) if test_result else 0
for k, v in (test_result.items() if test_result else []):
mark = "" if v else ""
style = "success" if v else "error"
test_output.append(f" {k.ljust(max_key_len)} {mark}\n", style=style)
if test_result:
passed_count = sum(1 for v in test_result.values() if v)
total_count = len(test_result)
if passed_count == total_count:
total_passed += 1
node_style = "success"
mark = "✓ PASS"
elif passed_count > 0:
total_partial += 1
node_style = "warning"
mark = f"⚠ PARTIAL ({passed_count}/{total_count})"
else:
total_failed += 1
node_style = "error"
mark = "✗ FAIL"
summary_content.append(f" {mark}\n", style=node_style)
for k, v in test_result.items():
res_mark = "" if v else ""
res_style = "success" if v else "error"
summary_content.append(f" {k.ljust(38)} {res_mark}\n", style=res_style)
else:
total_failed += 1
summary_content.append(" ✗ FAIL\n", style="error")
summary_content.append(" No results (execution failed)\n", style="error")
status_parts = []
if total_passed: status_parts.append(f"[pass]{total_passed} PASSED[/pass]")
if total_partial: status_parts.append(f"[warning]{total_partial} PARTIAL[/warning]")
if total_failed: status_parts.append(f"[fail]{total_failed} FAILED[/fail]")
status_str = " | ".join(status_parts) if status_parts else "[error]NO RESULTS[/error]"
title_line = f"AGGREGATE TEST SUMMARY — {status_str}"
_get_console().print(Panel(Group(Text(), summary_content), title=title_line, width=cols, border_style="border"))
def run_summary(results):
"""Print an aggregate summary of multiple execution results in a single panel."""
from rich.panel import Panel
from rich.text import Text
from rich.console import Group
import os
try:
cols, _ = os.get_terminal_size()
except OSError:
cols = 80
summary_content = Text()
total_ok = 0
total_err = 0
if not results:
summary_content.append(" No execution results found.\n", style="error")
else:
for node, data in results.items():
summary_content.append(f"", style="border")
summary_content.append(f"{node.ljust(40)}", style="bold")
_get_console().print(Panel(Group(Text(), test_output), title=title_line, width=cols, border_style=border))
# Check if we have a status dict or just output (for backward compatibility)
status = data.get("status", 0) if isinstance(data, dict) else 0
if status == 0:
total_ok += 1
summary_content.append(f" ✓ DONE\n", style="success")
else:
total_err += 1
summary_content.append(f" ✗ FAIL({status})\n", style="error")
status_parts = []
if total_ok: status_parts.append(f"[success]{total_ok} DONE[/success]")
if total_err: status_parts.append(f"[error]{total_err} FAILED[/error]")
status_str = " | ".join(status_parts) if status_parts else "[error]NO RESULTS[/error]"
title_line = f"AGGREGATE EXECUTION SUMMARY — {status_str}"
_get_console().print(Panel(Group(Text(), summary_content), title=title_line, width=cols, border_style="border"))
def header(text):
"""Print a section header."""
@@ -392,7 +502,78 @@ class _ThemeProxy:
local = _get_local()
if local.theme is None:
from rich.theme import Theme
local.theme = Theme(STYLES)
local.theme = Theme(_global_active_styles)
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
+121 -2
View File
@@ -53,7 +53,6 @@ service ExecutionService {
rpc run_commands (RunRequest) returns (stream NodeRunResult) {}
rpc test_commands (TestRequest) returns (stream NodeRunResult) {}
rpc run_cli_script (ScriptRequest) returns (StructResponse) {}
rpc run_yaml_playbook (ScriptRequest) returns (StructResponse) {}
}
service ImportExportService {
@@ -65,10 +64,16 @@ service ImportExportService {
service AIService {
rpc ask (stream AskRequest) returns (stream AIResponse) {}
rpc confirm (StringRequest) returns (BoolResponse) {}
rpc ask_copilot (CopilotRequest) returns (CopilotResponse) {}
rpc list_sessions (google.protobuf.Empty) returns (ValueResponse) {}
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 {
@@ -89,12 +94,24 @@ message InteractRequest {
int32 cols = 5;
int32 rows = 6;
string connection_params_json = 7;
// Copilot fields
string copilot_question = 8;
string copilot_action = 9;
string copilot_context_buffer = 10;
string copilot_node_info_json = 13;
}
message InteractResponse {
bytes stdout_data = 1;
bool success = 2;
string error_message = 3;
// Copilot fields
bool copilot_prompt = 4;
string copilot_buffer_preview = 5;
string copilot_response_json = 6;
string copilot_node_info_json = 7;
string copilot_stream_chunk = 8;
string copilot_injected_command = 9;
}
message FilterRequest {
@@ -176,16 +193,20 @@ message RunRequest {
string prompt = 4;
int32 parallel = 5;
google.protobuf.Struct vars = 6;
int32 timeout = 7;
string name = 8;
}
message TestRequest {
repeated string nodes = 1;
repeated string commands = 2;
string expected = 3;
repeated string expected = 3;
string folder = 4;
string prompt = 5;
int32 parallel = 6;
google.protobuf.Struct vars = 7;
int32 timeout = 8;
string name = 9;
}
message ScriptRequest {
@@ -216,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 {
@@ -236,6 +259,7 @@ message ProviderRequest {
string provider = 1;
string model = 2;
string api_key = 3;
google.protobuf.Struct auth = 4;
}
message IntRequest {
@@ -253,3 +277,98 @@ message FullReplaceRequest {
google.protobuf.Struct connections = 1;
google.protobuf.Struct profiles = 2;
}
message CopilotRequest {
string terminal_buffer = 1;
string user_question = 2;
string node_info_json = 3;
}
message CopilotResponse {
repeated string commands = 1;
string guide = 2;
string risk_level = 3;
string error = 4;
}
message MCPRequest {
string name = 1;
string url = 2;
bool enabled = 3;
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) {}
rpc create_api_token (CreateApiTokenRequest) returns (CreateApiTokenResponse) {}
rpc list_api_tokens (google.protobuf.Empty) returns (ListApiTokensResponse) {}
rpc revoke_api_token (RevokeApiTokenRequest) returns (google.protobuf.Empty) {}
}
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 CreateApiTokenRequest {
string name = 1;
int32 expires_in_days = 2;
}
message CreateApiTokenResponse {
string token_id = 1;
string raw_token = 2;
string name = 3;
}
message ApiTokenInfo {
string token_id = 1;
string name = 2;
string token_prefix = 3;
string created_at = 4;
string last_used_at = 5;
string expires_at = 6;
}
message ListApiTokensResponse {
repeated ApiTokenInfo tokens = 1;
}
message RevokeApiTokenRequest {
string token_id = 1;
}
message AnalyzeRequest {
google.protobuf.Struct results = 1;
string query = 2;
}
message PreflightRequest {
repeated string target_nodes = 1;
repeated string commands = 2;
}
+29 -4
View File
@@ -1,12 +1,35 @@
from .exceptions import *
from .node_service import NodeService
from .profile_service import ProfileService
from .execution_service import ExecutionService
from .import_export_service import ImportExportService
from .ai_service import AIService
from .plugin_service import PluginService
from .config_service import ConfigService
from .system_service import SystemService
def __getattr__(name: str):
if name == "ExecutionService":
from .execution_service import ExecutionService
globals()["ExecutionService"] = ExecutionService
return ExecutionService
elif name == "ImportExportService":
from .import_export_service import ImportExportService
globals()["ImportExportService"] = ImportExportService
return ImportExportService
elif name == "SystemService":
from .system_service import SystemService
globals()["SystemService"] = SystemService
return SystemService
elif name == "SyncService":
from .sync_service import SyncService
globals()["SyncService"] = SyncService
return SyncService
elif name == "UserService":
from .user_service import UserService
globals()["UserService"] = UserService
return UserService
elif name == "AIService":
from .ai_service import AIService
globals()["AIService"] = AIService
return AIService
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = [
'NodeService',
@@ -17,6 +40,8 @@ __all__ = [
'PluginService',
'ConfigService',
'SystemService',
'SyncService',
'UserService',
'ConnpyError',
'NodeNotFoundError',
'NodeAlreadyExistsError',
+306 -4
View File
@@ -1,9 +1,215 @@
import re
from .base import BaseService
from .exceptions import InvalidConfigurationError
from connpy.utils import log_cleaner
class AIService(BaseService):
"""Business logic for interacting with AI agents and LLM configurations."""
def _clean_cisco_scrolling(self, text: str) -> str:
"""Resolves horizontal scrolling artifacts (backspaces, \r, ANSI) by merging overlapping segments."""
def merge_overlapping(s1, s2):
s2_clean = s2.lstrip(' $')
max_overlap = min(len(s1), len(s2_clean))
for i in range(max_overlap, 0, -1):
if s1[-i:] == s2_clean[:i]:
return s1 + s2_clean[i:]
return s1 + s2_clean
scroll_re = re.compile(r'(\x08{5,}\s*\$?|\$\r|\x1b\[\d+[GD]\s*\$?)')
parts = scroll_re.split(text)
merged = ""
for part in parts:
if scroll_re.match(part):
continue
cleaned = log_cleaner(part)
if not merged:
merged = cleaned
else:
merged_lines = merged.split('\n')
cleaned_lines = cleaned.split('\n')
merged_lines[-1] = merge_overlapping(merged_lines[-1], cleaned_lines[0])
merged_lines.extend(cleaned_lines[1:])
merged = "\n".join(merged_lines)
return merged
def build_context_blocks(self, raw_bytes: bytes, cmd_byte_positions: list, node_info: dict, last_line: str = "") -> list:
"""Identifies command blocks in the terminal history."""
blocks = []
if not raw_bytes:
return blocks
default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
device_prompt = node_info.get("prompt", default_prompt) if isinstance(node_info, dict) else default_prompt
prompt_re_str = re.sub(r'(?<!\\)\$', '', device_prompt)
try:
prompt_re = re.compile(prompt_re_str)
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 = 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
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 = 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:
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:
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:
"""Parses slash commands and manages session state. Returns directive dict."""
text = input_text.strip()
if not text.startswith('/'):
return {"action": "execute", "clean_prompt": text, "overrides": {}}
parts = text.split(maxsplit=1)
cmd = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
# 1. State Commands (Persistent)
if cmd == "/os":
if args:
session_state['os'] = args
return {"action": "state_update", "message": f"OS context changed to {args}"}
elif cmd == "/prompt":
if args:
session_state['prompt'] = args
return {"action": "state_update", "message": f"Prompt regex changed to {args}"}
elif cmd == "/memorize":
if args:
session_state['memories'].append(args)
return {"action": "state_update", "message": f"Memory added: {args}"}
elif cmd == "/clear":
session_state['memories'] = []
return {"action": "state_update", "message": "Memory cleared"}
# 2. Hybrid Commands
elif cmd == "/architect":
if not args:
session_state['persona'] = 'architect'
return {"action": "state_update", "message": "Persona set to Architect"}
else:
return {"action": "execute", "clean_prompt": args, "overrides": {"persona": "architect"}}
elif cmd == "/engineer":
if not args:
session_state['persona'] = 'engineer'
return {"action": "state_update", "message": "Persona set to Engineer"}
else:
return {"action": "execute", "clean_prompt": args, "overrides": {"persona": "engineer"}}
elif cmd == "/trust":
if not args:
session_state['trust_mode'] = True
return {"action": "state_update", "message": "Auto-execute (trust) enabled for session"}
else:
return {"action": "execute", "clean_prompt": args, "overrides": {"trust": True}}
elif cmd == "/untrust":
if not args:
session_state['trust_mode'] = False
return {"action": "state_update", "message": "Auto-execute (trust) disabled for session"}
else:
return {"action": "execute", "clean_prompt": args, "overrides": {"trust": False}}
# Unknown command, execute normally
return {"action": "execute", "clean_prompt": text, "overrides": {}}
def ask(self, input_text, dryrun=False, chat_history=None, status=None, debug=False, session_id=None, console=None, chunk_callback=None, confirm_handler=None, trust=False, **overrides):
"""Send a prompt to the AI agent."""
from connpy.ai import ai
@@ -17,12 +223,30 @@ class AIService(BaseService):
agent = ai(self.config, console=console)
return agent.confirm(input_text)
def ask_copilot(self, terminal_buffer, user_question, node_info=None, chunk_callback=None):
"""Ask the AI copilot for terminal assistance."""
from connpy.ai import ai, run_ai_async
agent = ai(self.config)
future = run_ai_async(agent.aask_copilot(terminal_buffer, user_question, node_info, chunk_callback=chunk_callback))
return future.result()
def list_sessions(self):
"""Return a list of all saved AI sessions."""
async def aask_copilot(self, terminal_buffer, user_question, node_info=None, chunk_callback=None):
"""Ask the AI copilot for terminal assistance asynchronously."""
from connpy.ai import ai, run_ai_async
import asyncio
agent = ai(self.config)
future = run_ai_async(agent.aask_copilot(terminal_buffer, user_question, node_info, chunk_callback=chunk_callback))
return await asyncio.wrap_future(future)
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."""
@@ -34,20 +258,98 @@ 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)
def configure_mcp(self, name, url=None, enabled=None, auto_load_on_os=None, remove=False):
"""Update MCP server settings in the configuration with smart merging."""
ai_settings = self.config.config.get("ai", {})
mcp_servers = ai_settings.get("mcp_servers", {})
if remove:
if name in mcp_servers:
del mcp_servers[name]
else:
# Get existing or new
server_cfg = mcp_servers.get(name, {})
# Partial updates
if url is not None:
server_cfg["url"] = url
if enabled is not None:
server_cfg["enabled"] = bool(enabled)
elif "enabled" not in server_cfg:
server_cfg["enabled"] = True # Default for new entries
if auto_load_on_os is not None:
if auto_load_on_os == "": # Explicit clear
if "auto_load_on_os" in server_cfg:
del server_cfg["auto_load_on_os"]
else:
server_cfg["auto_load_on_os"] = auto_load_on_os
mcp_servers[name] = server_cfg
ai_settings["mcp_servers"] = mcp_servers
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)
+4
View File
@@ -70,6 +70,10 @@ class ConfigService(BaseService):
if not isinstance(user_styles, dict):
raise InvalidConfigurationError("Theme file must be a YAML dictionary.")
# Support both direct styles and nested under 'theme' key
if "theme" in user_styles and isinstance(user_styles["theme"], dict):
user_styles = user_styles["theme"]
# Filter for valid styles only (prevent junk in config)
valid_styles = {k: v for k, v in user_styles.items() if k in STYLES}
+17 -40
View File
@@ -1,6 +1,5 @@
from typing import List, Dict, Any, Callable, Optional
import os
import yaml
from .base import BaseService
from connpy.core import nodes as Nodes
from .exceptions import ConnpyError
@@ -14,11 +13,12 @@ class ExecutionService(BaseService):
commands: List[str],
variables: Optional[Dict[str, Any]] = None,
parallel: int = 10,
timeout: int = 10,
timeout: int = 20,
folder: Optional[str] = None,
prompt: Optional[str] = None,
on_node_complete: Optional[Callable] = None,
logger: Optional[Callable] = None
logger: Optional[Callable] = None,
name: Optional[str] = None
) -> Dict[str, str]:
"""Execute commands on a set of nodes."""
@@ -42,7 +42,15 @@ class ExecutionService(BaseService):
logger=logger
)
return results
# Combine output and status for the caller
full_results = {}
for unique in results:
full_results[unique] = {
"output": results[unique],
"status": executor.status.get(unique, 1)
}
return full_results
except Exception as e:
raise ConnpyError(f"Execution failed: {e}")
@@ -53,10 +61,12 @@ class ExecutionService(BaseService):
expected: List[str],
variables: Optional[Dict[str, Any]] = None,
parallel: int = 10,
timeout: int = 10,
timeout: int = 20,
folder: Optional[str] = None,
prompt: Optional[str] = None,
on_node_complete: Optional[Callable] = None,
logger: Optional[Callable] = None
logger: Optional[Callable] = None,
name: Optional[str] = None
) -> Dict[str, Dict[str, bool]]:
"""Run commands and verify expected output on a set of nodes."""
@@ -75,6 +85,7 @@ class ExecutionService(BaseService):
vars=variables,
parallel=parallel,
timeout=timeout,
folder=folder,
prompt=prompt,
on_complete=on_node_complete,
logger=logger
@@ -96,37 +107,3 @@ class ExecutionService(BaseService):
return self.run_commands(nodes_filter, commands, parallel=parallel)
def run_yaml_playbook(self, playbook_path: str, parallel: int = 10) -> Dict[str, Any]:
"""Run a structured Connpy YAML automation playbook."""
if not os.path.exists(playbook_path):
raise ConnpyError(f"Playbook file not found: {playbook_path}")
try:
with open(playbook_path, "r") as f:
playbook = yaml.load(f, Loader=yaml.FullLoader)
except Exception as e:
raise ConnpyError(f"Failed to load playbook {playbook_path}: {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")
if action == "run":
return self.run_commands(
nodes_filter=playbook["nodes"],
commands=playbook["commands"],
parallel=parallel,
timeout=playbook.get("timeout", 10)
)
elif action == "test":
return self.test_commands(
nodes_filter=playbook["nodes"],
commands=playbook["commands"],
expected=playbook.get("expected", []),
parallel=parallel,
timeout=playbook.get("timeout", 10)
)
else:
raise ConnpyError(f"Unsupported playbook action: {action}")
+66 -24
View File
@@ -1,6 +1,7 @@
from .base import BaseService
import yaml
import os
from copy import deepcopy
from .exceptions import InvalidConfigurationError, NodeNotFoundError, ReservedNameError
from ..configfile import NoAliasDumper
@@ -23,13 +24,45 @@ class ImportExportService(BaseService):
def export_to_dict(self, folders=None):
"""Export nodes/folders to a dictionary."""
if not folders:
return self.config._getallnodesfull(extract=False)
return deepcopy(self.config.connections)
else:
# Validate folders exist
for f in folders:
if f != "@" and f not in self.config._getallfolders():
raise NodeNotFoundError(f"Folder '{f}' not found.")
return self.config._getallnodesfull(folders, extract=False)
flat = self.config._getallnodesfull(folders, extract=False)
nested = {}
for k, v in flat.items():
uniques = self.config._explode_unique(k)
if not uniques:
continue
if "folder" in uniques and "subfolder" in uniques:
f_name = uniques["folder"]
s_name = uniques["subfolder"]
i_name = uniques["id"]
if f_name not in nested:
nested[f_name] = {"type": "folder"}
if s_name not in nested[f_name]:
nested[f_name][s_name] = {"type": "subfolder"}
nested[f_name][s_name][i_name] = v
elif "folder" in uniques:
f_name = uniques["folder"]
i_name = uniques["id"]
if f_name not in nested:
nested[f_name] = {"type": "folder"}
nested[f_name][i_name] = v
else:
i_name = uniques["id"]
nested[i_name] = v
return nested
def import_from_file(self, file_path):
"""Import nodes/folders from a YAML file."""
@@ -48,26 +81,35 @@ class ImportExportService(BaseService):
if not isinstance(data, dict):
raise InvalidConfigurationError("Invalid import data format: expected a dictionary of nodes.")
# Process imports
for k, v in data.items():
uniques = self.config._explode_unique(k)
# Ensure folders exist
if "folder" in uniques:
folder_name = f"@{uniques['folder']}"
if folder_name not in self.config._getallfolders():
folder_uniques = self.config._explode_unique(folder_name)
self.config._folder_add(**folder_uniques)
if "subfolder" in uniques:
sub_name = f"@{uniques['subfolder']}@{uniques['folder']}"
if sub_name not in self.config._getallfolders():
sub_uniques = self.config._explode_unique(sub_name)
self.config._folder_add(**sub_uniques)
# Add node/connection
v.update(uniques)
self._validate_node_name(k)
self.config._connections_add(**v)
def _traverse_import(node_data, current_folder='', current_subfolder=''):
for k, v in node_data.items():
if k == "type":
continue
if isinstance(v, dict):
node_type = v.get("type", "connection")
if node_type == "folder":
self.config._folder_add(folder=k)
_traverse_import(v, current_folder=k, current_subfolder='')
elif node_type == "subfolder":
self.config._folder_add(folder=current_folder, subfolder=k)
_traverse_import(v, current_folder=current_folder, current_subfolder=k)
elif node_type == "connection":
unique_id = k
if current_subfolder:
unique_id = f"{k}@{current_subfolder}@{current_folder}"
elif current_folder:
unique_id = f"{k}@{current_folder}"
self._validate_node_name(unique_id)
kwargs = deepcopy(v)
kwargs['id'] = k
kwargs['folder'] = current_folder
kwargs['subfolder'] = current_subfolder
self.config._connections_add(**kwargs)
else:
# Invalid format skip
pass
_traverse_import(data)
self.config._saveconfig(self.config.file)
+29 -14
View File
@@ -67,8 +67,14 @@ class NodeService(BaseService):
case_sensitive = self.config.config.get("case", False)
if filter_str:
flags = re.IGNORECASE if not case_sensitive else 0
folders = [f for f in folders if re.search(filter_str, f, flags)]
if filter_str.startswith("@"):
if not case_sensitive:
folders = [f for f in folders if f.lower() == filter_str.lower()]
else:
folders = [f for f in folders if f == filter_str]
else:
flags = re.IGNORECASE if not case_sensitive else 0
folders = [f for f in folders if re.search(filter_str, f, flags)]
return folders
def get_node_details(self, unique_id):
@@ -89,13 +95,20 @@ class NodeService(BaseService):
"""Generate and update the internal nodes cache."""
self.config._generate_nodes_cache(nodes=nodes, folders=folders, profiles=profiles)
def validate_parent_folder(self, unique_id):
def validate_parent_folder(self, unique_id, is_folder=False):
"""Check if parent folder exists for a given node unique ID."""
node_folder = unique_id.partition("@")[2]
if node_folder:
parent_folder = f"@{node_folder}"
if parent_folder not in self.config._getallfolders():
raise NodeNotFoundError(f"Folder '{parent_folder}' not found.")
if is_folder:
uniques = self.config._explode_unique(unique_id)
if uniques and "subfolder" in uniques and "folder" in uniques:
parent_folder = f"@{uniques['folder']}"
if parent_folder not in self.config._getallfolders():
raise NodeNotFoundError(f"Folder '{parent_folder}' not found.")
else:
node_folder = unique_id.partition("@")[2]
if node_folder:
parent_folder = f"@{node_folder}"
if parent_folder not in self.config._getallfolders():
raise NodeNotFoundError(f"Folder '{parent_folder}' not found.")
def add_node(self, unique_id, data, is_folder=False):
@@ -115,7 +128,7 @@ class NodeService(BaseService):
# Check if parent folder exists when creating a subfolder
if "subfolder" in uniques:
self.validate_parent_folder(unique_id)
self.validate_parent_folder(unique_id, is_folder=True)
self.config._folder_add(**uniques)
self.config._saveconfig(self.config.file)
@@ -135,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:
@@ -149,9 +162,10 @@ class NodeService(BaseService):
# config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data)
self.config._saveconfig(self.config.file)
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)
@@ -164,7 +178,8 @@ class NodeService(BaseService):
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file)
if save:
self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
"""Interact with a node directly."""
@@ -182,7 +197,7 @@ class NodeService(BaseService):
n = node(unique_id, **resolved_data, config=self.config)
if sftp:
n.protocol = "sftp"
n.interact(debug=debug, logger=logger)
def move_node(self, src_id, dst_id, copy=False):
+165 -63
View File
@@ -7,16 +7,47 @@ from .exceptions import InvalidConfigurationError, NodeNotFoundError
class PluginService(BaseService):
"""Business logic for enabling, disabling, and listing plugins."""
def _get_plugin_path(self, name, include_disabled=True):
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "user", True
if include_disabled:
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "user", False
# 2. Shared/Global directory
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "shared", True
if include_disabled:
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "shared", False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
p_file = os.path.join(core_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "core", True
return None, None, False
def list_plugins(self):
"""List all core and user-defined plugins with their status and hash."""
import os
import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
all_plugin_info = {}
def get_hash(path):
@@ -26,19 +57,43 @@ 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)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
path = os.path.join(core_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
# 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), "origin": "shared"}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
# 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), "origin": "user"}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
all_plugin_info[name] = {"enabled": False, "origin": "user"}
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,17 +186,38 @@ 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(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 not os.path.exists(disabled_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
# 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
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def disable_plugin(self, name):
"""Deactivate a plugin by renaming it to a backup file."""
@@ -145,33 +225,41 @@ 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):
# 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
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
# 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):
@@ -183,13 +271,13 @@ class PluginService(BaseService):
is_mock = True
def __init__(self, config):
from ..core import node, nodes
from ..ai import ai
from ..connapp import DeferredAIProxy
from ..services.provider import ServiceProvider
self.config = config
self.node = node
self.nodes = nodes
self.ai = ai
self.ai = DeferredAIProxy()
self.services = ServiceProvider(config, mode="local")
@@ -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__"]):
@@ -233,25 +316,44 @@ class PluginService(BaseService):
from rich.console import Console
from rich.console import Console
buf = io.StringIO()
import queue
import threading
q = queue.Queue()
class QueueIO(io.StringIO):
def write(self, s):
q.put(s)
return len(s)
def flush(self):
pass
buf = QueueIO()
old_console = printer._get_console()
old_err_console = printer._get_err_console()
printer.set_thread_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_err_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_stream(buf)
def run_plugin():
printer.set_thread_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_err_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_stream(buf)
try:
if hasattr(module, "Entrypoint"):
module.Entrypoint(args, parser, app)
except BaseException as e:
if not isinstance(e, SystemExit):
import traceback
printer.err_console.print(traceback.format_exc())
finally:
printer.set_thread_console(old_console)
printer.set_thread_err_console(old_err_console)
printer.set_thread_stream(None)
q.put(None)
t = threading.Thread(target=run_plugin, daemon=True)
t.start()
try:
if hasattr(module, "Entrypoint"):
module.Entrypoint(args, parser, app)
except BaseException as e:
if not isinstance(e, SystemExit):
import traceback
printer.err_console.print(traceback.format_exc())
finally:
printer.set_thread_console(old_console)
printer.set_thread_err_console(old_err_console)
printer.set_thread_stream(None)
for line in buf.getvalue().splitlines(keepends=True):
yield line
while True:
item = q.get()
if item is None:
break
yield item
+99 -13
View File
@@ -14,6 +14,12 @@ class ServiceProvider:
self.mode = mode
self.config = config
self.remote_host = remote_host
self._system = None
self._execution = None
self._import_export = None
self._sync = None
self._users = None
self._ai = None
if mode == "local":
self._init_local()
@@ -27,41 +33,54 @@ class ServiceProvider:
from .profile_service import ProfileService
from .config_service import ConfigService
from .plugin_service import PluginService
from .ai_service import AIService
from .system_service import SystemService
from .execution_service import ExecutionService
from .import_export_service import ImportExportService
from .context_service import ContextService
from .sync_service import SyncService
self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config)
self.config_svc = ConfigService(self.config)
self.plugins = PluginService(self.config)
self.ai = AIService(self.config)
self.system = SystemService(self.config)
self.execution = ExecutionService(self.config)
self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config)
self.sync = SyncService(self.config)
def _init_remote(self):
# Allow ConfigService to work locally so the user can revert the mode
from .config_service import ConfigService
from .context_service import ContextService
from .sync_service import SyncService
self.config_svc = ConfigService(self.config)
self.context = ContextService(self.config)
self.sync = SyncService(self.config)
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():
env_token = os.environ.get("CONNPY_TOKEN")
if env_token:
return env_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)
self.plugins = PluginStub(channel, remote_host=self.remote_host)
@@ -69,3 +88,70 @@ 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)
@property
def system(self):
if self._system is None and self.mode == "local":
from .system_service import SystemService
self._system = SystemService(self.config)
return self._system
@system.setter
def system(self, value):
self._system = value
@property
def execution(self):
if self._execution is None and self.mode == "local":
from .execution_service import ExecutionService
self._execution = ExecutionService(self.config)
return self._execution
@execution.setter
def execution(self, value):
self._execution = value
@property
def import_export(self):
if self._import_export is None and self.mode == "local":
from .import_export_service import ImportExportService
self._import_export = ImportExportService(self.config)
return self._import_export
@import_export.setter
def import_export(self, value):
self._import_export = value
@property
def sync(self):
if self._sync is None:
from .sync_service import SyncService
self._sync = SyncService(self.config)
return self._sync
@sync.setter
def sync(self, value):
self._sync = value
@property
def users(self):
if self._users is None and self.mode == "local":
from .user_service import UserService
self._users = UserService(self.config.defaultdir)
return self._users
@users.setter
def users(self, value):
self._users = value
@property
def ai(self):
if self._ai is None and self.mode == "local":
from .ai_service import AIService
self._ai = AIService(self.config)
return self._ai
@ai.setter
def ai(self, value):
self._ai = value
+48 -7
View File
@@ -1,3 +1,4 @@
import sys
import os
import time
import zipfile
@@ -6,13 +7,46 @@ import io
import yaml
import threading
from datetime import datetime
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from google.auth.exceptions import RefreshError
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
from googleapiclient.errors import HttpError
def __getattr__(name: str):
if name == "Credentials":
from google.oauth2.credentials import Credentials
return Credentials
elif name == "Request":
from google.auth.transport.requests import Request
return Request
elif name == "build":
from googleapiclient.discovery import build
return build
elif name == "RefreshError":
from google.auth.exceptions import RefreshError
return RefreshError
elif name == "InstalledAppFlow":
from google_auth_oauthlib.flow import InstalledAppFlow
return InstalledAppFlow
elif name == "MediaFileUpload":
from googleapiclient.http import MediaFileUpload
return MediaFileUpload
elif name == "MediaIoBaseDownload":
from googleapiclient.http import MediaIoBaseDownload
return MediaIoBaseDownload
elif name == "HttpError":
from googleapiclient.errors import HttpError
return HttpError
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def _get_google_libs():
mod = sys.modules[__name__]
return (
getattr(mod, "Credentials"),
getattr(mod, "Request"),
getattr(mod, "build"),
getattr(mod, "RefreshError"),
getattr(mod, "InstalledAppFlow"),
getattr(mod, "MediaFileUpload"),
getattr(mod, "MediaIoBaseDownload"),
getattr(mod, "HttpError"),
)
from .base import BaseService
from .. import printer
@@ -44,6 +78,7 @@ class SyncService(BaseService):
def login(self):
"""Authenticate with Google Drive."""
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
creds = None
if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
@@ -81,6 +116,7 @@ class SyncService(BaseService):
def get_credentials(self):
"""Get valid credentials, refreshing if necessary."""
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
else:
@@ -98,6 +134,7 @@ class SyncService(BaseService):
def check_login_status(self):
"""Check if logged in to Google Drive."""
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file)
if creds and creds.expired and creds.refresh_token:
@@ -110,6 +147,7 @@ class SyncService(BaseService):
def list_backups(self):
"""List files in Google Drive appDataFolder."""
_, _, build, _, _, _, _, HttpError = _get_google_libs()
creds = self.get_credentials()
if not creds:
printer.error("Not logged in to Google Drive.")
@@ -168,6 +206,7 @@ class SyncService(BaseService):
def upload_file(self, file_path, timestamp):
"""Internal method to upload to Drive."""
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
creds = self.get_credentials()
if not creds: return False
@@ -193,6 +232,7 @@ class SyncService(BaseService):
def delete_backup(self, file_id):
"""Delete a backup from Drive."""
_, _, build, _, _, _, _, _ = _get_google_libs()
creds = self.get_credentials()
if not creds: return False
try:
@@ -226,6 +266,7 @@ class SyncService(BaseService):
def download_file(self, file_id, dest):
"""Internal method to download from Drive."""
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
creds = self.get_credentials()
if not creds: return False
try:
+375
View File
@@ -0,0 +1,375 @@
import os
import hashlib
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)
# Reverse index cache: token_hash -> (username, token_id)
self._token_index: dict[str, tuple[str, str]] = {}
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 _build_token_index(self, registry: dict) -> dict[str, tuple[str, str]]:
"""Builds a reverse index of token_hash -> (username, token_id) for O(1) PAT lookup."""
index = {}
for username, user_data in registry.get("users", {}).items():
for token_id, token_meta in user_data.get("api_tokens", {}).items():
token_hash = token_meta.get("token_hash")
if token_hash:
index[token_hash] = (username, token_id)
return index
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
# --- Personal Access Token (PAT) Management ---
def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -> dict:
"""Creates a Personal Access Token for the user.
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
"""
if not name or not isinstance(name, str):
raise ValueError("Token name 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 "api_tokens" not in user_data:
user_data["api_tokens"] = {}
# Generate cryptographically secure token with recognizable prefix
raw_secret = secrets.token_hex(32)
raw_token = f"cnp_pat_{raw_secret}"
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
token_id = f"tok_{secrets.token_hex(4)}"
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = None
if expires_in_days and expires_in_days > 0:
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
user_data["api_tokens"][token_id] = {
"name": name,
"token_hash": token_hash,
"token_prefix": raw_token[:16],
"created_at": now.isoformat(),
"last_used_at": None,
"expires_at": expires_at,
}
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return {
"token_id": token_id,
"raw_token": raw_token,
"name": name,
}
def list_api_tokens(self, username: str) -> list[dict]:
"""Lists all active API tokens for a user (without sensitive data)."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
tokens = registry["users"][username].get("api_tokens", {})
return [
{
"token_id": tid,
"name": meta.get("name"),
"token_prefix": meta.get("token_prefix"),
"created_at": meta.get("created_at"),
"last_used_at": meta.get("last_used_at"),
"expires_at": meta.get("expires_at"),
}
for tid, meta in tokens.items()
]
def revoke_api_token(self, username: str, token_id: str) -> bool:
"""Revokes (deletes) a specific API token. Returns True if found and removed."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
tokens = registry["users"][username].get("api_tokens", {})
if token_id not in tokens:
return False
del tokens[token_id]
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return True
def verify_api_token(self, raw_token: str) -> str | None:
"""Validates a PAT by hashing it and looking up the reverse index.
Returns username if valid and not expired, None otherwise.
"""
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
# Rebuild index if empty (cold start or after process restart)
if not self._token_index:
registry = self._load_registry()
self._token_index = self._build_token_index(registry)
match = self._token_index.get(token_hash)
if not match:
return None
username, token_id = match
# Validate token still exists and check expiration
registry = self._load_registry()
user_data = registry.get("users", {}).get(username, {})
token_meta = user_data.get("api_tokens", {}).get(token_id)
if not token_meta:
# Token was revoked between index build and now
self._token_index = self._build_token_index(registry)
return None
# Check expiration
expires_at = token_meta.get("expires_at")
if expires_at:
exp_dt = datetime.datetime.fromisoformat(expires_at)
if datetime.datetime.now(datetime.timezone.utc) > exp_dt:
return None
# Update last_used_at
token_meta["last_used_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
self._save_registry(registry)
return username
+85 -3
View File
@@ -23,7 +23,7 @@ class TestAIInit:
myai = ai(config)
with pytest.raises(ValueError) as exc:
myai.ask("hello")
assert "Engineer API key not configured" in str(exc.value)
assert "Engineer API key or authentication not configured" in str(exc.value)
def test_init_missing_architect_key_warns(self, ai_config, capsys, mock_litellm):
"""Warns if architect key is missing but doesn't crash."""
@@ -58,6 +58,77 @@ class TestAIInit:
pass # May fail on other file opens, that's ok
# =========================================================================
# AI Auth Dict tests
# =========================================================================
class TestAIAuthDict:
def test_init_with_auth_dict(self, ai_config):
"""Initializes correctly when auth dicts are configured."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = None
ai_config.config["ai"]["architect_api_key"] = None
ai_config.config["ai"]["engineer_auth"] = {"my_key": "my_val"}
ai_config.config["ai"]["architect_auth"] = {"another_key": "another_val"}
myai = ai(ai_config)
assert myai.engineer_auth == {"my_key": "my_val"}
assert myai.architect_auth == {"another_key": "another_val"}
def test_compat_key_injection(self, ai_config):
"""Injects API key into auth dict if auth is empty or doesn't have it."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = "compat-eng-key"
ai_config.config["ai"]["architect_api_key"] = "compat-arch-key"
ai_config.config["ai"]["engineer_auth"] = {}
ai_config.config["ai"]["architect_auth"] = {}
myai = ai(ai_config)
assert myai.engineer_auth == {"api_key": "compat-eng-key"}
assert myai.architect_auth == {"api_key": "compat-arch-key"}
def test_has_architect_keyless(self, ai_config):
"""Evaluates has_architect correctly for keyless models and auth configs."""
from connpy.ai import ai
# 1. Keyless model (Vertex)
ai_config.config["ai"]["architect_api_key"] = None
ai_config.config["ai"]["architect_auth"] = {}
ai_config.config["ai"]["architect_model"] = "vertex/gemini-pro"
myai = ai(ai_config)
assert myai.has_architect is True
# 2. Architect auth dict is set
ai_config.config["ai"]["architect_model"] = "custom-model"
ai_config.config["ai"]["architect_auth"] = {"vertex_project": "proj-1"}
myai = ai(ai_config)
assert myai.has_architect is True
def test_ask_unpacks_auth_dict(self, ai_config, mock_litellm):
"""Verifies that ask unpacks engineer_auth when calling completion."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = None
ai_config.config["ai"]["engineer_auth"] = {"vertex_project": "my-project", "vertex_location": "us-east1"}
myai = ai(ai_config)
myai.ask("test query", stream=False)
# Check mock_litellm completion call
mock_litellm["completion"].assert_called()
kwargs = mock_litellm["completion"].call_args.kwargs
assert kwargs.get("vertex_project") == "my-project"
assert kwargs.get("vertex_location") == "us-east1"
assert "api_key" not in kwargs
def test_auth_precedence_no_api_key_injection(self, ai_config):
"""Verifies that api_key is not injected into the auth dict when auth is already set (non-empty)."""
from connpy.ai import ai
ai_config.config["ai"]["engineer_api_key"] = "legacy-eng-key"
ai_config.config["ai"]["architect_api_key"] = "legacy-arch-key"
ai_config.config["ai"]["engineer_auth"] = {"vertex_project": "proj-eng"}
ai_config.config["ai"]["architect_auth"] = {"vertex_project": "proj-arch"}
myai = ai(ai_config)
assert myai.engineer_auth == {"vertex_project": "proj-eng"}
assert "api_key" not in myai.engineer_auth
assert myai.architect_auth == {"vertex_project": "proj-arch"}
assert "api_key" not in myai.architect_auth
# =========================================================================
# register_ai_tool tests
# =========================================================================
@@ -409,6 +480,15 @@ class TestToolDefinitions:
names = [t["function"]["name"] for t in tools]
assert "arch_tool" in names
def test_architect_tools_one_shot(self, ai_config):
from connpy.ai import ai
one_shot_ai = ai(ai_config, one_shot=True)
tools = one_shot_ai._get_architect_tools()
names = [t["function"]["name"] for t in tools]
assert "delegate_to_engineer" not in names
assert "return_to_engineer" not in names
assert "manage_memory_tool" in names
# =========================================================================
# AI Session Management tests
@@ -427,12 +507,14 @@ class TestAISessions:
def test_generate_session_id(self, myai):
session_id = myai._generate_session_id("Any query")
# Format: YYYYMMDD-HHMMSS
assert len(session_id) == 15
# Format: YYYYMMDD-HHMMSS-suffix
assert len(session_id) == 20
assert "-" in session_id
parts = session_id.split("-")
assert len(parts) == 3
assert len(parts[0]) == 8 # YYYYMMDD
assert len(parts[1]) == 6 # HHMMSS
assert len(parts[2]) == 4 # suffix
def test_save_and_load_session(self, myai):
history = [
+563
View File
@@ -0,0 +1,563 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
import json
import asyncio
from connpy.ai import ai
from connpy.core import node
class DummyConfig:
def __init__(self):
self.config = {"ai": {"engineer_api_key": "test_key", "engineer_model": "test_model"}}
self.defaultdir = "/tmp"
class MockAsyncIterator:
def __init__(self, items):
self.items = items
def __aiter__(self):
return self
async def __anext__(self):
if not self.items:
raise StopAsyncIteration
return self.items.pop(0)
@pytest.fixture
def mock_acompletion():
# Patch acompletion inside connpy.ai.aask_copilot
with patch('litellm.acompletion') as mock:
yield mock
def test_aask_copilot_tool_call(mock_acompletion):
agent = ai(DummyConfig())
# Setup mock response for streaming
class MockDelta:
def __init__(self, content):
self.content = content
class MockChoice:
def __init__(self, content):
self.delta = MockDelta(content)
class MockChunk:
def __init__(self, content):
self.choices = [MockChoice(content)]
# acompletion is awaited and returns an async iterator
async def mock_ac(*args, **kwargs):
return MockAsyncIterator([
MockChunk("<guide>Check the interfaces and running config.</guide>"),
MockChunk("<commands>\nshow ip int br\nshow run\n</commands>"),
MockChunk("<risk>low</risk>")
])
mock_acompletion.side_effect = mock_ac
async def run_test():
return await agent.aask_copilot("Router#", "What do I do?")
result = asyncio.run(run_test())
if result["error"]:
print(f"ERROR OCCURRED: {result['error']}")
assert result["error"] is None
assert result["guide"] == "Check the interfaces and running config."
assert result["risk_level"] == "low"
assert result["commands"] == ["show ip int br", "show run"]
def test_aask_copilot_fallback(mock_acompletion):
agent = ai(DummyConfig())
# Setup mock response for streaming
class MockDelta:
def __init__(self, content):
self.content = content
class MockChoice:
def __init__(self, content):
self.delta = MockDelta(content)
class MockChunk:
def __init__(self, content):
self.choices = [MockChoice(content)]
async def mock_ac(*args, **kwargs):
return MockAsyncIterator([
MockChunk("Here is some text response instead of tool call.")
])
mock_acompletion.side_effect = mock_ac
async def run_test():
return await agent.aask_copilot("Router#", "What do I do?")
result = asyncio.run(run_test())
if result["error"]:
print(f"ERROR OCCURRED: {result['error']}")
assert result["error"] is None
assert result["guide"] == "Here is some text response instead of tool call."
assert result["risk_level"] == "low"
def test_logclean_ansi():
c = node("test_node", "1.2.3.4")
raw = "Router#\x1b[K\x1b[m show ip"
clean = c._logclean(raw, var=True)
assert "\x1b" not in clean
def test_ingress_task_interception():
async def run_test():
c = node("test_node", "1.2.3.4")
c.mylog = MagicMock()
c.mylog.getvalue.return_value = b"Some session log"
c.unique = "test_node"
c.host = "1.2.3.4"
c.tags = {"os": "cisco_ios"}
class MockStream:
def __init__(self):
self.data = [b"a", b"b", b"\x00", b"c", b""]
async def read(self):
if self.data:
return self.data.pop(0)
return b""
def setup(self, resize_callback):
pass
stream = MockStream()
called_copilot = False
async def mock_handler(buffer, node_info, s, child_fd):
nonlocal called_copilot
called_copilot = True
assert buffer == "Some session log"
assert node_info["os"] == "cisco_ios"
c.child = MagicMock()
c.child.child_fd = 123
c.child.after = b""
c.child.buffer = b""
async def mock_ingress():
while True:
data = await stream.read()
if not data:
break
if mock_handler and b'\x00' in data:
buffer = c.mylog.getvalue().decode()
node_info = {"name": getattr(c, 'unique', 'unknown'), "host": getattr(c, 'host', 'unknown')}
if isinstance(getattr(c, 'tags', None), dict):
node_info["os"] = c.tags.get("os", "unknown")
await mock_handler(buffer, node_info, stream, c.child.child_fd)
continue
await mock_ingress()
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]
def test_copilot_context_state_persistence():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
session_state = {}
interface = CopilotInterface(MockConfig(), session_state=session_state)
raw_bytes = b"router# show ip\r\nrouter# show run\r\nrouter# "
blocks = [
(0, 15, "router# show ip"),
(15, 30, "router# show run"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
kb = kwargs.get('key_bindings')
if kb:
class DummyApp:
def invalidate(self): pass
class DummyEvent:
app = DummyApp()
current_buffer = type('Buf', (), {'text': ''})()
# Trigger TAB key ('c-i' or 'tab') to switch mode from RANGE (0) to SINGLE (1)
for b in kb.bindings:
if any(k in ('c-i', 'tab') or 'tab' in str(k).lower() or 'c-i' in str(k).lower() for k in b.keys):
b.handler(DummyEvent())
break
return "test question"
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface.run_session(
raw_bytes=raw_bytes,
node_info={"name": "test"},
on_ai_call=mock_ai_call,
blocks=blocks
))
assert interface.session_state.get('context_mode') == interface.mode_single
assert interface.session_state.get('last_total_cmds') == len(blocks)
def test_copilot_range_mode_accumulation():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
blocks = [
(0, 10, "router# cmd1"),
(10, 20, "router# cmd2"),
(20, 30, "router# cmd3"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: RANGE mode at default (saved_cmd = 1) -> stays 1 (does not expand)
session_state_default = {'context_mode': 0, 'context_cmd': 1, 'last_total_cmds': 2}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_cmd') == 1
# Test 2: RANGE mode at expanded (saved_cmd = 2 > 1) -> expands to 2 + 2 = 4
session_state_expanded = {'context_mode': 0, 'context_cmd': 2, 'last_total_cmds': 2}
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_expanded.session_state.get('context_cmd') == 4
def test_copilot_lines_mode_accumulation():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = ("line\n" * 130).encode()
blocks = [(0, 10, "router#")]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: LINES mode at default 50 lines -> stays 50
session_state_default = {'context_mode': 2, 'context_lines': 50, 'last_total_lines': 100}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_lines') == 50
# Test 2: LINES mode at expanded 100 lines -> expands beyond 100
session_state_expanded = {'context_mode': 2, 'context_lines': 100, 'last_total_lines': 100}
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_expanded.session_state.get('context_lines') > 100
def test_copilot_single_mode_retains_command_block():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
blocks = [
(0, 10, "router# cmd1"),
(10, 20, "router# cmd2"),
(20, 30, "router# cmd3"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: In SINGLE mode at default (context_cmd = 1), stays at 1
session_state_default = {'context_mode': 1, 'context_cmd': 1, 'last_total_cmds': 2}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_cmd') == 1
# Test 2: In SINGLE mode at past command (context_cmd = 2 > 1), becomes 2 + 2 = 4 to stay locked on past command
session_state_custom = {'context_mode': 1, 'context_cmd': 2, 'last_total_cmds': 2}
interface_custom = CopilotInterface(MockConfig(), session_state=session_state_custom)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_custom.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_custom.session_state.get('context_cmd') == 4
+239
View File
@@ -0,0 +1,239 @@
import os
import pytest
import grpc
import argparse
from unittest.mock import MagicMock, patch
from connpy.connapp import connapp
from connpy.services.provider import ServiceProvider
from connpy.cli.user_handler import UserHandler
from connpy.cli.login_handler import LoginHandler
from connpy.grpc_layer.stubs import AuthClientInterceptor, AuthStub
@pytest.fixture
def mock_config():
config = MagicMock()
config.config = {"service_mode": "local", "remote_host": "localhost:8048"}
config.defaultdir = "/mock/default/dir"
return config
@pytest.fixture
def app_instance(mock_config):
with patch("connpy.services.provider.ServiceProvider") as mock_provider_cls:
mock_provider = MagicMock()
mock_provider.context = MagicMock()
mock_provider.nodes = MagicMock()
mock_provider.profiles = MagicMock()
mock_provider.config_svc = MagicMock()
mock_provider.plugins = MagicMock()
mock_provider.sync = MagicMock()
mock_provider.mode = "local"
mock_provider.remote_host = "localhost:8048"
mock_provider_cls.return_value = mock_provider
app = connapp(mock_config)
# Mock UserService on app services
app.services.users = MagicMock()
return app
class TestCLIMultiUserParsing:
def test_parser_contains_user_login_logout(self, app_instance):
parser, _ = app_instance.get_parser()
# Verify subcommands exist by finding the _SubParsersAction
subparsers_action = None
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
subparsers_action = action
break
assert subparsers_action is not None
subcommands = subparsers_action.choices.keys()
assert "user" in subcommands
assert "login" in subcommands
assert "logout" in subcommands
def test_user_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
# Parse add user
args = parser.parse_args(["user", "--add", "newguy"])
assert args.add == ["newguy"]
assert args.func == app_instance._user.dispatch
# Parse delete user
args = parser.parse_args(["user", "--del", "oldguy"])
assert args.delete == ["oldguy"]
# Parse list users
args = parser.parse_args(["user", "--list"])
assert args.list is True
# Parse show user
args = parser.parse_args(["user", "--show", "someguy"])
assert args.show == ["someguy"]
# Parse regen-password
args = parser.parse_args(["user", "--regen-password", "someguy"])
assert args.regen_password == ["someguy"]
# Parse path
args = parser.parse_args(["user", "--add", "newguy", "--path", "/some/path"])
assert args.add == ["newguy"]
assert args.path == ["/some/path"]
def test_login_logout_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
args = parser.parse_args(["login", "someuser"])
assert args.username == "someuser"
assert args.status is False
assert args.func == app_instance._login.dispatch
args = parser.parse_args(["login", "--status"])
assert args.status is True
args = parser.parse_args(["login", "-s"])
assert args.status is True
args = parser.parse_args(["logout"])
assert args.func == app_instance._login.dispatch
class TestUserHandlerDispatch:
def test_user_handler_fails_in_remote_mode(self, app_instance):
app_instance.services.mode = "remote"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["testuser"]
with pytest.raises(SystemExit) as excinfo:
handler.dispatch(args)
assert excinfo.value.code == 1
def test_user_handler_routes_add_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["newuser"]
args.delete = None
args.list = False
args.show = None
args.regen_password = None
with patch.object(handler, "add_user") as mock_add:
handler.dispatch(args)
assert args.action == "add"
assert args.username == "newuser"
mock_add.assert_called_once_with(args)
def test_user_handler_routes_list_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = None
args.delete = None
args.list = True
args.show = None
args.regen_password = None
with patch.object(handler, "list_users") as mock_list:
handler.dispatch(args)
assert args.action == "list"
mock_list.assert_called_once_with(args)
class TestAuthClientInterceptor:
def test_auth_client_interceptor_adds_bearer_token(self):
# Mock token provider
token_provider = MagicMock(return_value="my-super-secret-token")
interceptor = AuthClientInterceptor(token_provider)
# Mock ClientCallDetails using namedtuple
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata was injected
metadata_dict = dict(intercepted_details.metadata)
assert "authorization" in metadata_dict
assert metadata_dict["authorization"] == "Bearer my-super-secret-token"
def test_auth_client_interceptor_no_token(self):
token_provider = MagicMock(return_value=None)
interceptor = AuthClientInterceptor(token_provider)
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata remains empty
assert len(intercepted_details.metadata) == 0
class TestLoginHandlerStatus:
def test_status_no_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=False):
with patch("connpy.printer.warning") as mock_warning:
handler.show_status()
mock_warning.assert_called_once_with("No active session found. You can log in using 'connpy login'.")
def test_status_invalid_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data="invalid-token")):
with patch("connpy.printer.error") as mock_error:
handler.show_status()
mock_error.assert_called_once_with("Invalid local session token format.")
def test_status_valid_token(self, app_instance):
handler = LoginHandler(app_instance)
# Mock token payload: {"sub": "testuser", "exp": 1780007003}
# Part 1 (header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# Part 2 (payload): eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30
# Part 3 (sig): signature
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30.signature"
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data=token)):
with patch("connpy.printer.success") as mock_success:
with patch("connpy.printer.info") as mock_info:
# Patch time so exp is in the future
with patch("datetime.datetime") as mock_dt:
mock_dt.now.return_value.timestamp.return_value = 1780000000
# Mock fromtimestamp for expiration display
mock_dt.fromtimestamp.return_value.strftime.return_value = "2026-05-28 19:23:23 UTC"
handler.show_status()
mock_success.assert_called_once_with("Logged in as 'testuser'")
def mock_open(*args, **kwargs):
from unittest.mock import mock_open as unittest_mock_open
return unittest_mock_open(*args, **kwargs)
+138
View File
@@ -0,0 +1,138 @@
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 prompt_toolkit PromptSession.prompt for user input, and Prompt.ask for save confirmation
with patch("prompt_toolkit.PromptSession.prompt", return_value="create a basic task"):
with patch("rich.prompt.Prompt.ask", return_value="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("prompt_toolkit.PromptSession.prompt", return_value="create task"):
with patch("rich.prompt.Prompt.ask", return_value="run"):
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
app.start(["run", "--generate-ai", str(dest_yaml)])
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
assert os.path.exists(dest_yaml)
with open(dest_yaml) as f:
content = f.read()
assert "tasks:" in content
mock_yaml_run.assert_called_once()
args = mock_yaml_run.call_args[0][0]
assert args.data == [str(dest_yaml)]
+67
View File
@@ -0,0 +1,67 @@
import pytest
from unittest.mock import MagicMock, patch
from connpy.cli.sso_handler import SSOHandler
def test_sso_handler_add_provider_with_allowed_domains():
# 1. Setup mock app structure
app_mock = MagicMock()
app_mock.services.mode = "local"
app_mock.config.config = {"sso": {"providers": {}}}
handler = SSOHandler(app_mock)
# Mock inquirer prompts
mock_answers = {
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
"secret": "my-secret-key",
"username_claim": "email",
"algorithms": "RS256, HS256",
"allowed_domains": "yyy.com, company.org"
}
args_mock = MagicMock()
args_mock.provider = "google"
with patch("inquirer.prompt", return_value=mock_answers):
handler.add_provider(args_mock)
# Verify update_setting was called with the correct data structure
app_mock.services.config_svc.update_setting.assert_called_once()
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
assert saved_key == "sso"
assert "providers" in saved_sso_config
assert "google" in saved_sso_config["providers"]
google_config = saved_sso_config["providers"]["google"]
assert google_config["jwks_url"] == "https://accounts.google.com/.well-known/jwks.json"
assert google_config["secret"] == "my-secret-key"
assert google_config["username_claim"] == "email"
assert google_config["algorithms"] == ["RS256", "HS256"]
assert google_config["allowed_domains"] == ["yyy.com", "company.org"]
def test_sso_handler_add_provider_allowed_domains_empty():
app_mock = MagicMock()
app_mock.services.mode = "local"
app_mock.config.config = {"sso": {"providers": {}}}
handler = SSOHandler(app_mock)
mock_answers = {
"jwks_url": "https://accounts.google.com/.well-known/jwks.json",
"secret": "",
"username_claim": "sub",
"algorithms": "RS256",
"allowed_domains": " " # empty input
}
args_mock = MagicMock()
args_mock.provider = "google"
with patch("inquirer.prompt", return_value=mock_answers):
handler.add_provider(args_mock)
saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0]
google_config = saved_sso_config["providers"]["google"]
assert "allowed_domains" not in google_config
+217
View File
@@ -65,4 +65,221 @@ 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
assert "--shell-command" in config_completions
assert "--shell-prompt" in config_completions
assert "--shell-os" 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
assert "--shell-command" in loop_back_comp
def test_shell_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
shell_completions = resolve_completion(["shell", ""], tree)
assert "--command" in shell_completions
assert "--capture" in shell_completions
assert "--debug" in shell_completions
assert "--help" in shell_completions
# Short flags must NOT be recommended
assert "-c" not in shell_completions
assert "-d" not in shell_completions
assert "-h" not in shell_completions
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
class TestPluginUpdateCompletion:
def test_plugin_update_first_arg_suggests_plugins(self, tmp_path):
from connpy.completion import _build_tree, resolve_completion
# Create a mock user plugin
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir(parents=True, exist_ok=True)
(plugins_dir / "my_custom_plugin.py").touch()
# Build tree with plugin mock dict
plugins = {"my_custom_plugin": str(plugins_dir / "my_custom_plugin.py")}
tree = _build_tree([], [], [], plugins, str(tmp_path))
# First argument of --update should suggest my_custom_plugin
completions = resolve_completion(["plugin", "--update", ""], tree)
assert "my_custom_plugin" in completions
# Second argument should suggest file paths (calling get_cwd)
# Type "plugin --update my_custom_plugin "
file_completions = resolve_completion(["plugin", "--update", "my_custom_plugin", ""], tree)
assert isinstance(file_completions, list)
+66 -4
View File
@@ -40,7 +40,7 @@ def test_node_del(mock_prompt, mock_delete_node, mock_list_nodes, app):
mock_list_nodes.return_value = ["router1"]
mock_prompt.return_value = {"delete": True}
app.start(["node", "-r", "router1"])
mock_delete_node.assert_called_once_with("router1", is_folder=False)
mock_delete_node.assert_called_once_with("router1", is_folder=False, save=True)
@patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.get_node_details")
@@ -165,9 +165,9 @@ def test_ai(mock_status, mock_ask, app):
@patch("connpy.services.execution_service.ExecutionService.run_commands")
def test_run(mock_run_commands, app):
app.start(["run", "node1", "command1", "command2"])
app.start(["run", "router1", "command1", "command2"])
mock_run_commands.assert_called_once()
assert mock_run_commands.call_args[1]["nodes_filter"] == "node1"
assert mock_run_commands.call_args[1]["nodes_filter"] == ["router1"]
assert mock_run_commands.call_args[1]["commands"] == ["command1 command2"]
@patch("os.path.exists")
@@ -246,7 +246,7 @@ def test_plugin_disable(mock_disable, app):
@patch("connpy.services.ai_service.AIService.list_sessions")
def test_ai_list(mock_list_sessions, app):
mock_list_sessions.return_value = [{"id": "1", "title": "t", "created_at": "now", "model": "m"}]
mock_list_sessions.return_value = ([{"id": "1", "title": "t", "created_at": "now", "model": "m"}], 1)
app.start(["ai", "--list"])
mock_list_sessions.assert_called_once()
@@ -262,3 +262,65 @@ def test_type_node_reserved_word(app):
with pytest.raises(SystemExit) as exc:
app._type_node("bulk")
assert exc.value.code == 2
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_inline_json(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {}}
app.start(["config", "--engineer-auth", '{"vertex_project": "test-123"}'])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["engineer_auth"] == {"vertex_project": "test-123"}
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_inline_yaml(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {}}
app.start(["config", "--architect-auth", 'project: test-yaml'])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["architect_auth"] == {"project": "test-yaml"}
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_clear_auth(mock_get_settings, mock_update_setting, app):
mock_get_settings.return_value = {"ai": {"engineer_auth": {"project": "123"}, "engineer_api_key": "some-key"}}
app.start(["config", "--engineer-auth", "clear"])
args, kwargs = mock_update_setting.call_args
assert "engineer_auth" not in args[1]
app.start(["config", "--engineer-api-key", "none"])
args, kwargs = mock_update_setting.call_args
assert "engineer_api_key" not in args[1]
@patch("os.path.exists")
@patch("builtins.open")
@patch("connpy.services.config_service.ConfigService.update_setting")
@patch("connpy.services.config_service.ConfigService.get_settings")
def test_config_auth_file_path(mock_get_settings, mock_update_setting, mock_open, mock_exists, app):
mock_get_settings.return_value = {"ai": {}}
mock_exists.side_effect = lambda p: True if p == "/path/to/creds.json" else False
mock_file = MagicMock()
mock_file.read.return_value = '{"vertex_project": "file-project"}'
mock_open.return_value.__enter__.return_value = mock_file
app.start(["config", "--engineer-auth", "/path/to/creds.json"])
mock_update_setting.assert_called_once()
args, kwargs = mock_update_setting.call_args
assert args[0] == "ai"
assert args[1]["engineer_auth"] == {"vertex_project": "file-project"}
@patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.connect_node")
def test_node_connect_exact_match_priority(mock_connect_node, mock_list_nodes, app):
"""Test that exact matches are prioritized over partial/regex matches when connecting."""
mock_list_nodes.return_value = ["pe1@ctx", "qro1pe1@ctx"]
app.start(["node", "pe1@ctx"])
mock_connect_node.assert_called_once_with("pe1@ctx", sftp=False, debug=False, logger=app._service_logger)
+313
View File
@@ -338,6 +338,58 @@ class TestNodeTest:
assert isinstance(result, dict)
assert result.get("1.1.1.1") == False
def test_test_expected_regex(self, mock_pexpect):
"""Regex in expected matches correctly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12.5")
with patch.object(n, '_logclean', return_value="Debian version 12.5"):
result = n.test(["cat /etc/debian_version"], "version \\d+\\.\\d+")
assert isinstance(result, dict)
assert result.get("version \\d+\\.\\d+") == True
def test_test_expected_invalid_regex(self, mock_pexpect):
"""Malformed regex defaults to literal matching safely."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
# (invalid is a malformed regex (missing closing paren), but matches literally
n.mylog = io.BytesIO(b"some (invalid text")
with patch.object(n, '_logclean', return_value="some (invalid text"):
result = n.test(["echo"], "(invalid")
assert isinstance(result, dict)
assert result.get("(invalid") == True
def test_test_expected_with_vars(self, mock_pexpect):
"""Expected output formats variables properly."""
child = mock_pexpect["child"]
child.expect.return_value = 0
from connpy.core import node
n = node("router1", "10.0.0.1", user="admin", password="")
with patch.object(n, '_connect', return_value=True):
n.child = child
n.mylog = io.BytesIO(b"Debian version 12")
with patch.object(n, '_logclean', return_value="Debian version 12"):
result = n.test(["echo"], "version {version_num}", vars={"version_num": "12"})
assert isinstance(result, dict)
assert result.get("version 12") == True
# =========================================================================
# nodes (parallel) tests
@@ -435,3 +487,264 @@ class TestNodes:
mynodes.run(["show version"], on_complete=on_done)
assert "r1" in completed
# =========================================================================
# Jumphost chain tests
# =========================================================================
class TestJumphostChain:
"""Tests for chained jumphost (multi-hop ProxyCommand) support."""
def _make_config_with_nodes(self, tmp_config_dir, nodes, profiles=None):
"""Helper to create a config with custom nodes."""
import yaml
from connpy.configfile import configfile
if profiles is None:
profiles = {
"default": {
"host": "", "protocol": "ssh", "port": "", "user": "",
"password": "", "options": "", "logs": "", "tags": "", "jumphost": ""
}
}
data = {
"config": {"case": False, "idletime": 30, "fzf": False},
"connections": nodes,
"profiles": profiles
}
config_file = tmp_config_dir / "config.yaml"
config_file.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
import os
os.chmod(str(config_file), 0o600)
return configfile(conf=str(config_file), key=str(tmp_config_dir / ".osk"))
def test_single_jumphost(self, tmp_config_dir):
"""Regression: single jumphost produces correct ProxyCommand."""
from connpy.core import node
nodes = {
"bastion": {
"host": "10.0.0.1", "protocol": "ssh", "port": "2222",
"user": "admin", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "bastion", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", jumphost="bastion", config=config)
assert 'ProxyCommand=' in n.jumphost
assert '-W %h:%p' in n.jumphost
assert '-p 2222' in n.jumphost
assert 'admin@10.0.0.1' in n.jumphost
def test_two_hop_chain(self, tmp_config_dir):
"""Two-hop SSH chain: dest -> bastionA -> bastionB."""
from connpy.core import node
nodes = {
"bastionB": {
"host": "10.0.0.1", "protocol": "ssh", "port": "",
"user": "userB", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"bastionA": {
"host": "10.0.0.2", "protocol": "ssh", "port": "",
"user": "userA", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", jumphost="bastionA", config=config)
# Should contain nested ProxyCommand
assert 'ProxyCommand=' in n.jumphost
assert 'userA@10.0.0.2' in n.jumphost
assert 'userB@10.0.0.1' in n.jumphost
# Inner proxy should be escaped
assert 'ProxyCommand=\\"' in n.jumphost or 'ProxyCommand=\\\\' in n.jumphost
def test_three_hop_chain(self, tmp_config_dir):
"""Three-hop SSH chain: dest -> A -> B -> C."""
from connpy.core import node
nodes = {
"hopC": {
"host": "10.0.0.3", "protocol": "ssh", "port": "",
"user": "uc", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"hopB": {
"host": "10.0.0.2", "protocol": "ssh", "port": "",
"user": "ub", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopC", "type": "connection"
},
"hopA": {
"host": "10.0.0.1", "protocol": "ssh", "port": "",
"user": "ua", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopB", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", jumphost="hopA", config=config)
# All three hosts should appear in the command
assert 'uc@10.0.0.3' in n.jumphost
assert 'ub@10.0.0.2' in n.jumphost
assert 'ua@10.0.0.1' in n.jumphost
def test_circular_detection(self, tmp_config_dir):
"""Circular jumphost reference raises ValueError."""
from connpy.core import node
nodes = {
"hopA": {
"host": "10.0.0.1", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopB", "type": "connection"
},
"hopB": {
"host": "10.0.0.2", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
with pytest.raises(ValueError, match="Circular jumphost reference"):
node("dest", "10.0.1.1", jumphost="hopA", config=config)
def test_max_depth(self, tmp_config_dir):
"""Chain exceeding 5 hops raises ValueError."""
from connpy.core import node
nodes = {}
# Build chain of 6 hops: hop0 -> hop1 -> ... -> hop5
for i in range(6):
jh = f"hop{i+1}" if i < 5 else ""
nodes[f"hop{i}"] = {
"host": f"10.0.0.{i}", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": jh, "type": "connection"
}
nodes["dest"] = {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "hop0", "type": "connection"
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
with pytest.raises(ValueError, match="maximum depth of 5"):
node("dest", "10.0.1.1", jumphost="hop0", config=config)
def test_kubectl_with_jumphost_error(self, tmp_config_dir):
"""kubectl jumphost with its own jumphost raises ValueError."""
from connpy.core import node
nodes = {
"sshhost": {
"host": "10.0.0.1", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"kubejump": {
"host": "my-pod", "protocol": "kubectl", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "sshhost", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "kubejump", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
with pytest.raises(ValueError, match="does not support chained jumphosts"):
node("dest", "10.0.1.1", jumphost="kubejump", config=config)
def test_password_chain_order(self, tmp_config_dir):
"""Passwords are collected innermost-first: B, A, dest."""
from connpy.core import node
nodes = {
"bastionB": {
"host": "10.0.0.1", "protocol": "ssh", "port": "",
"user": "ub", "password": "passB", "options": "",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"bastionA": {
"host": "10.0.0.2", "protocol": "ssh", "port": "",
"user": "ua", "password": "passA", "options": "",
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "passDest", "options": "",
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", password="passDest",
jumphost="bastionA", config=config)
# Order: innermost (B) -> outer (A) -> destination
assert n.password == ["passB", "passA", "passDest"]
def test_chain_with_options_and_port(self, tmp_config_dir):
"""Options and ports are preserved for each hop in the chain."""
from connpy.core import node
nodes = {
"bastionB": {
"host": "10.0.0.1", "protocol": "ssh", "port": "2222",
"user": "ub", "password": "", "options": "-o StrictHostKeyChecking=no",
"logs": "", "tags": "", "jumphost": "", "type": "connection"
},
"bastionA": {
"host": "10.0.0.2", "protocol": "ssh", "port": "3333",
"user": "ua", "password": "", "options": "-i /tmp/key.pem",
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", jumphost="bastionA", config=config)
assert '-p 2222' in n.jumphost
assert '-p 3333' in n.jumphost
assert 'StrictHostKeyChecking=no' in n.jumphost
assert '/tmp/key.pem' in n.jumphost
def test_chain_with_ssm_inner(self, tmp_config_dir):
"""SSH outer jumphost with SSM inner jumphost works."""
from connpy.core import node
nodes = {
"ssm_bastion": {
"host": "i-12345", "protocol": "ssm", "port": "",
"user": "ec2-user", "password": "", "options": "",
"logs": "", "tags": {"region": "us-east-1"}, "jumphost": "", "type": "connection"
},
"ssh_jump": {
"host": "10.0.0.2", "protocol": "ssh", "port": "",
"user": "admin", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "ssm_bastion", "type": "connection"
},
"dest": {
"host": "10.0.1.1", "protocol": "ssh", "port": "",
"user": "root", "password": "", "options": "",
"logs": "", "tags": "", "jumphost": "ssh_jump", "type": "connection"
}
}
config = self._make_config_with_nodes(tmp_config_dir, nodes)
n = node("dest", "10.0.1.1", user="root", jumphost="ssh_jump", config=config)
assert 'aws ssm start-session' in n.jumphost
assert 'admin@10.0.0.2' in n.jumphost
assert '--region us-east-1' in n.jumphost
+136
View File
@@ -0,0 +1,136 @@
"""
Tests for gRPC auth serialization/deserialization (engineer_auth, architect_auth, provider auth).
These tests verify that:
1. to_struct/from_struct round-trips correctly for auth dicts.
2. AIStub.ask() correctly serializes engineer_auth and architect_auth into AskRequest.
3. AIServicer.ask() correctly deserializes them and passes them to the service.
4. AIStub.configure_provider() serializes auth into ProviderRequest.
5. AIServicer.configure_provider() deserializes auth and forwards it to the service.
"""
import pytest
from unittest.mock import MagicMock, patch, call
from connpy.grpc_layer import connpy_pb2
from connpy.grpc_layer.utils import to_struct, from_struct
# --- Unit: Struct round-trip ---
class TestStructRoundTrip:
def test_simple_dict(self):
d = {"api_key": "secret", "region": "us-east-1"}
assert from_struct(to_struct(d)) == d
def test_nested_dict(self):
d = {"vertex_project": "my-project", "vertex_location": "us-central1", "nested": {"key": "val"}}
assert from_struct(to_struct(d)) == d
def test_empty_dict(self):
assert from_struct(to_struct({})) == {}
def test_none_returns_empty(self):
assert from_struct(to_struct(None)) == {}
# --- Unit: AskRequest Struct fields ---
class TestAskRequestStructFields:
def test_engineer_auth_round_trip(self):
auth = {"vertex_project": "proj", "vertex_location": "us-central1"}
req = connpy_pb2.AskRequest(input_text="hi")
req.engineer_auth.CopyFrom(to_struct(auth))
assert from_struct(req.engineer_auth) == auth
def test_architect_auth_round_trip(self):
auth = {"api_key": "sk-abc", "base_url": "https://custom.api/v1"}
req = connpy_pb2.AskRequest(input_text="hi")
req.architect_auth.CopyFrom(to_struct(auth))
assert from_struct(req.architect_auth) == auth
def test_has_field_false_when_unset(self):
req = connpy_pb2.AskRequest(input_text="hi")
assert not req.HasField("engineer_auth")
assert not req.HasField("architect_auth")
def test_has_field_true_when_set(self):
req = connpy_pb2.AskRequest(input_text="hi")
req.engineer_auth.CopyFrom(to_struct({"k": "v"}))
assert req.HasField("engineer_auth")
# --- Unit: ProviderRequest Struct field ---
class TestProviderRequestStructField:
def test_auth_round_trip(self):
auth = {"vertex_project": "proj", "vertex_location": "eu-west1"}
req = connpy_pb2.ProviderRequest(provider="vertex", model="gemini-pro")
req.auth.CopyFrom(to_struct(auth))
assert from_struct(req.auth) == auth
def test_has_field_false_when_unset(self):
req = connpy_pb2.ProviderRequest(provider="openai", model="gpt-4o")
assert not req.HasField("auth")
def test_has_field_true_when_set(self):
req = connpy_pb2.ProviderRequest(provider="vertex")
req.auth.CopyFrom(to_struct({"vertex_project": "p"}))
assert req.HasField("auth")
# --- Integration: Server deserializes auth and passes to service ---
class TestAIServicerAuthDeserialization:
@pytest.fixture
def servicer(self, populated_config):
from connpy.grpc_layer.server import AIServicer
return AIServicer(populated_config)
def test_configure_provider_passes_auth_to_service(self, servicer):
auth = {"vertex_project": "my-proj", "vertex_location": "us-central1"}
req = connpy_pb2.ProviderRequest(provider="vertex", model="gemini/gemini-pro", api_key="")
req.auth.CopyFrom(to_struct(auth))
with patch.object(servicer.service, "configure_provider") as mock_cp:
mock_context = MagicMock()
servicer.configure_provider(req, mock_context)
mock_cp.assert_called_once_with("vertex", "gemini/gemini-pro", "", auth=auth)
def test_configure_provider_no_auth(self, servicer):
req = connpy_pb2.ProviderRequest(provider="openai", model="gpt-4o", api_key="sk-test")
with patch.object(servicer.service, "configure_provider") as mock_cp:
mock_context = MagicMock()
servicer.configure_provider(req, mock_context)
mock_cp.assert_called_once_with("openai", "gpt-4o", "sk-test", auth=None)
# --- Integration: Stub serializes auth into request ---
class TestAIStubAuthSerialization:
@pytest.fixture
def ai_stub(self):
from connpy.grpc_layer.stubs import AIStub
mock_channel = MagicMock()
stub = AIStub(mock_channel, "localhost:8048")
return stub
def test_configure_provider_with_auth_serializes_struct(self, ai_stub):
auth = {"vertex_project": "proj", "vertex_location": "us-central1"}
ai_stub.stub.configure_provider = MagicMock()
ai_stub.configure_provider("vertex", model="gemini/gemini-pro", auth=auth)
ai_stub.stub.configure_provider.assert_called_once()
sent_req = ai_stub.stub.configure_provider.call_args[0][0]
assert sent_req.provider == "vertex"
assert sent_req.model == "gemini/gemini-pro"
assert sent_req.HasField("auth")
assert from_struct(sent_req.auth) == auth
def test_configure_provider_without_auth_no_struct(self, ai_stub):
ai_stub.stub.configure_provider = MagicMock()
ai_stub.configure_provider("openai", model="gpt-4o", api_key="sk-x")
sent_req = ai_stub.stub.configure_provider.call_args[0][0]
assert not sent_req.HasField("auth")
+15 -4
View File
@@ -78,15 +78,15 @@ class TestStubsMessageFormatting:
@patch("select.select")
def test_connect_dynamic_msg_formatting_ssm(self, mock_select, mock_read, mock_setraw, mock_getattr, mock_setattr):
from connpy.grpc_layer.stubs import NodeStub
mock_getattr.return_value = [0, 0, 0, 0, 0, 0, [0] * 32]
mock_channel = MagicMock()
stub = NodeStub(mock_channel, "localhost:8048")
mock_resp = MagicMock()
mock_resp.success = True
stub.stub.interact_node.return_value = iter([mock_resp])
mock_resp.stdout_data = b''
stub.stub.interact_node.return_value = iter([mock_resp])
with patch("connpy.printer.success") as mock_success:
with patch("sys.stdin.fileno", return_value=0):
mock_select.return_value = ([], [], [])
@@ -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()
+80
View File
@@ -0,0 +1,80 @@
import os
import sys
import pytest
from unittest.mock import patch, MagicMock
from connpy.core import node
from connpy.cli.shell_handler import ShellHandler
from connpy.cli.validators import Validators
def test_local_protocol_get_cmd_and_connect():
"""Test protocol=local in node returns command string and spawns process."""
n = node(unique="test_local", host="echo hello", protocol="local")
assert n._get_cmd() == "echo hello"
with patch("pexpect.spawn") as mock_spawn:
mock_child = MagicMock()
mock_child.child_fd = 10
mock_spawn.return_value = mock_child
with patch("pexpect.fdpexpect.fdspawn") as mock_fdspawn:
res = n._connect()
assert res is True
mock_spawn.assert_called_once()
def test_shell_handler_dispatch(tmp_path):
"""Test ShellHandler builds transient node and calls interact."""
app_mock = MagicMock()
app_mock.config.config = {"shell": {"os": "ubuntu", "prompt": r"\$\s*$"}}
handler = ShellHandler(app_mock)
args = MagicMock()
args.command_override = None
args.capture_file = str(tmp_path / "session.log")
args.debug = False
with patch("connpy.cli.shell_handler.node") as mock_node_cls:
mock_node = MagicMock()
mock_node_cls.return_value = mock_node
handler.dispatch(args)
mock_node_cls.assert_called_once()
kwargs = mock_node_cls.call_args.kwargs
assert kwargs["protocol"] == "local"
assert kwargs["unique"] == "local-shell"
assert mock_node.interact.called
def test_validator_excludes_local_protocol():
"""Ensure protocol_validation does NOT accept 'local' for inventory forms."""
validators = Validators(MagicMock())
with pytest.raises(Exception):
validators.protocol_validation({}, "local")
def test_is_child_connpy_active_non_local_protocol():
"""Ensure non-local protocols (e.g. ssh) never check for sub-child processes."""
n = node(unique="ssh_node", host="10.0.0.1", protocol="ssh")
with patch("os.tcgetpgrp") as mock_tcgetpgrp:
assert n._is_child_connpy_active(10) is False
mock_tcgetpgrp.assert_not_called()
def test_is_child_connpy_active_local_protocol():
"""Test detection of child connpy process when protocol=local."""
n = node(unique="local_node", host="/bin/bash", protocol="local")
# Case 1: child is connpy
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"python3\x00/usr/local/bin/connpy\x00connect\x00r1")))):
assert n._is_child_connpy_active(10) is True
# Case 2: child is regular bash
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/bin/bash\x00")))):
assert n._is_child_connpy_active(10) is False
# Case 3: child is conn entry point (e.g. /home/fluzzi32/.local/bin/conn xr)
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/usr/bin/python3\x00/home/fluzzi32/.local/bin/conn\x00xr")))):
assert n._is_child_connpy_active(10) is True
+360
View File
@@ -0,0 +1,360 @@
import os
import pytest
import grpc
from concurrent import futures
from google.protobuf.empty_pb2 import Empty
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
from connpy.configfile import configfile
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing gRPC auth."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
# Initialize basic config file inside it
from connpy.configfile import configfile
conf_file = os.path.join(str(config_dir), "config.yaml")
configfile(conf=conf_file)
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry."""
return UserRegistry(str(test_config_dir))
@pytest.fixture
def auth_grpc_server(test_config_dir, registry):
"""Starts an authenticated local gRPC server for integration testing."""
srv = grpc.server(
futures.ThreadPoolExecutor(max_workers=5),
interceptors=[server.AuthInterceptor(registry)]
)
fallback_provider = ServiceProvider(configfile(conf=os.path.join(str(test_config_dir), "config.yaml")), mode="local")
# Register services
connpy_pb2_grpc.add_NodeServiceServicer_to_server(server.NodeServicer(fallback_provider, registry=registry), srv)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(server.AuthServicer(registry), srv)
port = srv.add_insecure_port('127.0.0.1:0')
srv.start()
yield f"127.0.0.1:{port}"
srv.stop(0)
@pytest.fixture
def channel(auth_grpc_server):
with grpc.insecure_channel(auth_grpc_server) as channel:
yield channel
class TestGRPCAuthentication:
def test_backward_compatibility_no_users(self, channel, registry):
"""Verifies that if no users are registered, gRPC calls proceed without authentication."""
assert registry.has_users() is False
# Calling NodeService list_nodes should succeed without any authorization metadata
stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
res = stub.list_nodes(req)
assert res is not None
def test_login_and_authenticated_calls(self, channel, registry):
"""Tests user creation, login to retrieve JWT, and using JWT to access protected endpoints."""
username = "alice"
password = "alicepassword"
# 1. Register a user in the registry
registry.user_service.create_user(username, password)
assert registry.has_users() is True
# 2. Try unauthenticated call - must fail with UNAUTHENTICATED
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
with pytest.raises(grpc.RpcError) as exc:
node_stub.list_nodes(req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Authorization token is missing" in exc.value.details()
# 3. Call login endpoint (open method) - must succeed
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username=username, password=password)
login_res = auth_stub.login(login_req)
assert login_res.username == username
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 4. Make authenticated call using Bearer token - must succeed
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_login_invalid_credentials(self, channel, registry):
"""Verifies login fails and returns UNAUTHENTICATED for incorrect credentials."""
registry.user_service.create_user("bob", "bobpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username="bob", password="wrongpassword")
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Invalid username or password" in exc.value.details()
def test_change_password(self, channel, registry):
"""Tests changing password via gRPC and verifying old password no longer works."""
username = "charlie"
registry.user_service.create_user(username, "oldpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
# 1. Login with old password to get token
login_res = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
token = login_res.token
# 2. Change password via gRPC using the token
metadata = [("authorization", f"Bearer {token}")]
change_req = connpy_pb2.ChangePasswordRequest(old_password="oldpass", new_password="newpass")
auth_stub.change_password(change_req, metadata=metadata)
# 3. Logging in with old password must fail
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
# 4. Logging in with new password must succeed
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
assert login_res_new.token is not None
def test_sso_login_success_and_auto_provision(self, channel, registry):
"""Tests that a valid SSO token successfully logs the user in and auto-provisions their account."""
import jwt
# 1. Setup SSO configuration in the registry's shared config
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "preferred_username",
"algorithms": ["HS256"]
}
}
}
# 2. Check that the user 'ssoalice' does not exist yet
assert not any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 3. Generate a valid SSO token signed with Authelia's secret
sso_token = jwt.encode(
{"preferred_username": "ssoalice"},
"sso-shared-secret",
algorithm="HS256"
)
# 4. Call login_sso
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="ssoalice",
id_token=sso_token,
provider="authelia"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "ssoalice"
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 5. Verify user 'ssoalice' was auto-created/provisioned
assert any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 6. Make an authenticated call to NodeService list_nodes with the returned token
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_sso_login_invalid_signature(self, channel, registry):
"""Verifies that an SSO token with an invalid signature fails with UNAUTHENTICATED."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
# Token signed with a WRONG key
wrong_token = jwt.encode({"sub": "bob"}, "wrong-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="bob",
id_token=wrong_token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO Token validation failed" in exc.value.details()
def test_sso_login_mismatched_username(self, channel, registry):
"""Verifies that if the requested username doesn't match the token claim, it fails."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "charlie"}, "sso-shared-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="different_user",
id_token=token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Mismatched username" in exc.value.details()
def test_sso_login_allowed_domains_success(self, channel, registry):
"""Verifies that SSO login succeeds if email matches allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com", "other.org"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_sso_login_allowed_domains_failed(self, channel, registry):
"""Verifies that SSO login fails if email does not match allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@attacker.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO user domain 'attacker.com' not allowed" in exc.value.details()
def test_sso_login_allowed_domains_fallback_to_username(self, channel, registry):
"""Verifies allowed_domains validation falls back to username claim if email is not present."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_login_and_login_sso_expiration_time(self, channel, registry):
"""Verifies expires_at is set to 12 hours in both login and login_sso."""
import jwt
import datetime
# 1. Test standard login expiration
registry.user_service.create_user("exp_user", "password123")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_res = auth_stub.login(connpy_pb2.LoginRequest(username="exp_user", password="password123"))
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
expected_expires_12h = now + 12 * 3600
# Allow a 10s buffer for execution lag
assert abs(login_res.expires_at - expected_expires_12h) < 10
# 2. Test SSO login expiration
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "sso_exp_user"}, "sso-secret", algorithm="HS256")
login_sso_res = auth_stub.login_sso(connpy_pb2.LoginSSORequest(
username="sso_exp_user",
id_token=token,
provider="authelia"
))
assert abs(login_sso_res.expires_at - expected_expires_12h) < 10
+67
View File
@@ -0,0 +1,67 @@
import os
import pytest
from connpy.grpc_layer.server import NodeServicer, _current_user
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
def test_dynamic_routing_isolation(test_config_dir, registry):
"""Verifies that NodeServicer routes list_nodes to the correct user configuration based on _current_user ContextVar."""
# Setup fallback provider
from connpy.configfile import configfile
conf_file = os.path.join(registry.user_service.config_dir, "config.yaml")
config = configfile(conf=conf_file)
fallback_provider = ServiceProvider(config, mode="local")
# Create servicer with fallback and registry
servicer = NodeServicer(fallback_provider, registry=registry)
# Register two users
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
# Add nodes to each user's provider
p1.nodes.add_node("node-for-user-1", {"host": "1.1.1.1"})
p2.nodes.add_node("node-for-user-2", {"host": "2.2.2.2"})
# Verify fallback is empty
fallback_res = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
from connpy.grpc_layer.utils import from_value
assert "node-for-user-1" not in from_value(fallback_res.data)
assert "node-for-user-2" not in from_value(fallback_res.data)
# Set context to User 1
t1 = _current_user.set(u1)
try:
res1 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes1 = from_value(res1.data)
assert "node-for-user-1" in nodes1
assert "node-for-user-2" not in nodes1
finally:
_current_user.reset(t1)
# Set context to User 2
t2 = _current_user.set(u2)
try:
res2 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes2 = from_value(res2.data)
assert "node-for-user-2" in nodes2
assert "node-for-user-1" not in nodes2
finally:
_current_user.reset(t2)
+198
View File
@@ -0,0 +1,198 @@
import os
import shutil
import pytest
from connpy.configfile import configfile
from connpy.services.plugin_service import PluginService
from connpy.services.exceptions import InvalidConfigurationError
@pytest.fixture
def temp_plugins_env(tmp_path):
"""Creates a temporary isolated environment for core, shared, and user plugins."""
base_dir = tmp_path / "plugins_test_env"
base_dir.mkdir()
# Paths for shared config and user config folders
shared_dir = base_dir / "shared"
user_dir = base_dir / "user"
shared_dir.mkdir()
user_dir.mkdir()
# Create plugins subdirectories
(shared_dir / "plugins").mkdir()
(user_dir / "plugins").mkdir()
# Mock core_plugins path by creating a sibling folder
core_dir = base_dir / "core_plugins"
core_dir.mkdir()
# Config file paths
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write empty config templates
import yaml
empty_conf = {"config": {}, "connections": {}, "profiles": {}}
with open(shared_path, "w") as f:
yaml.safe_dump(empty_conf, f)
with open(user_path, "w") as f:
yaml.safe_dump(empty_conf, f)
return {
"shared_dir": shared_dir,
"user_dir": user_dir,
"core_dir": core_dir,
"shared_path": shared_path,
"user_path": user_path
}
def test_plugin_resolution_priority_merge(temp_plugins_env, monkeypatch):
"""Test that list_plugins correctly merges core, shared, and user plugins with overrides."""
env = temp_plugins_env
# 1. Create a core plugin: 'coreplug'
core_file = env["core_dir"] / "coreplug.py"
with open(core_file, "w") as f:
f.write("# core plugin content")
# 2. Create a shared plugin: 'sharedplug'
shared_file = env["shared_dir"] / "plugins" / "sharedplug.py"
with open(shared_file, "w") as f:
f.write("# shared plugin content")
# 3. Create a user plugin: 'userplug'
user_file = env["user_dir"] / "plugins" / "userplug.py"
with open(user_file, "w") as f:
f.write("# user plugin content")
# 4. Create an override plugin: 'overrideplug' in all three directories
with open(env["core_dir"] / "overrideplug.py", "w") as f:
f.write("# core override version")
with open(env["shared_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# shared override version")
with open(env["user_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# user override version")
# Initialize configs
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
# Initialize service
plugin_svc = PluginService(user_cfg)
# Monkeypatch the core plugins folder path inside list_plugins
# in order to use our mock core folder instead of the real one.
# Note: real path is computed via __file__, so we'll mock the internal core path
monkeypatch.setattr(
"os.path.realpath",
lambda path: os.path.join(str(env["core_dir"]), "dummy")
)
plugins_list = plugin_svc.list_plugins()
# Verify all plugins are registered
assert "coreplug" in plugins_list
assert "sharedplug" in plugins_list
assert "userplug" in plugins_list
assert "overrideplug" in plugins_list
# Verify status is Active (enabled=True)
assert plugins_list["coreplug"]["enabled"] is True
assert plugins_list["sharedplug"]["enabled"] is True
assert plugins_list["userplug"]["enabled"] is True
assert plugins_list["overrideplug"]["enabled"] is True
# Verify hashes differ matching user overrides
import hashlib
user_override_hash = hashlib.md5(b"# user override version").hexdigest()
assert plugins_list["overrideplug"]["hash"] == user_override_hash
def test_get_plugin_source_override(temp_plugins_env, monkeypatch):
"""Test that get_plugin_source resolves the highest priority plugin version."""
env = temp_plugins_env
# Create override in shared and user
with open(env["shared_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("shared content")
with open(env["user_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("user override")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Fetch source
source = plugin_svc.get_plugin_source("myplug")
assert source == "user override"
def test_delete_plugin_restrictions(temp_plugins_env):
"""Test that deleting shared plugins is rejected, but deleting user overrides works."""
env = temp_plugins_env
# Create shared plugin
with open(env["shared_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("global content")
# Create user plugin override
with open(env["user_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("user content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# 1. Delete plugin (should delete the user override first)
plugin_svc.delete_plugin("globalplug")
# Verify user override is gone, but shared plugin remains
assert not os.path.exists(env["user_dir"] / "plugins" / "globalplug.py")
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
# 2. Try to delete again (now only exists in shared/global folder)
with pytest.raises(InvalidConfigurationError) as exc:
plugin_svc.delete_plugin("globalplug")
assert "Global and core plugins are read-only" in str(exc.value)
# Verify shared plugin is still present
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
def test_shadow_disable_and_enable_mechanisms(temp_plugins_env):
"""Test that disabling a shared plugin creates a shadow backup file and enabling it removes it."""
env = temp_plugins_env
# Create a shared plugin
with open(env["shared_dir"] / "plugins" / "sharedplug.py", "w") as f:
f.write("shared content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Ensure it's active initially
list_initial = plugin_svc.list_plugins()
assert list_initial["sharedplug"]["enabled"] is True
# 1. Disable the shared plugin (should shadow-disable it in user dir)
res = plugin_svc.disable_plugin("sharedplug")
assert res is True
# Verify shadow bkp file exists in user plugins and has 0 bytes
shadow_bkp = env["user_dir"] / "plugins" / "sharedplug.py.bkp"
assert os.path.exists(shadow_bkp)
assert os.path.getsize(shadow_bkp) == 0
# Verify list_plugins lists it as disabled
list_disabled = plugin_svc.list_plugins()
assert list_disabled["sharedplug"]["enabled"] is False
# 2. Re-enable the shadow-disabled plugin (should delete the user shadow file)
res_enable = plugin_svc.enable_plugin("sharedplug")
assert res_enable is True
# Verify shadow file is deleted
assert not os.path.exists(shadow_bkp)
# Verify list_plugins lists it as active again
list_active = plugin_svc.list_plugins()
assert list_active["sharedplug"]["enabled"] is True
+296
View File
@@ -0,0 +1,296 @@
import pytest
import json
from unittest.mock import patch, MagicMock
from connpy.ai import PlaybookBuilderAgent
from connpy.services.ai_service import AIService
# =========================================================================
# PlaybookBuilderAgent validation tests
# =========================================================================
def test_validate_playbook_valid(ai_config):
"""Verifies that a valid canonical tasks[] playbook passes validation."""
agent = PlaybookBuilderAgent(ai_config)
valid_yaml = """
tasks:
- name: "Apply standard config"
action: "run"
nodes: "router1"
commands:
- "conf t"
- "end"
output: "stdout"
- name: "Verify connectivity"
action: "test"
nodes: "router1"
commands:
- "ping 10.0.0.1"
expected: "!"
output: "stdout"
"""
res = agent.validate_playbook(valid_yaml)
assert res["valid"] is True
assert "valid" in res["message"].lower()
def test_validate_playbook_invalid_yaml(ai_config):
"""Verifies that syntax errors in YAML are caught and reported."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Broken task"
action: "run
nodes: "router1"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "syntax error" in res["error"].lower()
def test_validate_playbook_missing_tasks_key(ai_config):
"""Verifies that a playbook without tasks root key is invalid."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
not_tasks:
- name: "Apply standard config"
action: "run"
nodes: "router1"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing mandatory root 'tasks' key" in res["error"].lower()
def test_validate_playbook_missing_mandatory_fields(ai_config):
"""Verifies that missing name, action, nodes, commands, or output triggers a validation failure."""
agent = PlaybookBuilderAgent(ai_config)
# Missing nodes
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "run"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing mandatory fields" in res["error"].lower()
assert "nodes" in res["error"]
def test_validate_playbook_invalid_action(ai_config):
"""Verifies that an unsupported action type is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "delete_everything"
nodes: "router1"
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "invalid action" in res["error"].lower()
def test_validate_playbook_missing_expected_in_test(ai_config):
"""Verifies that action 'test' requires the expected field."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply standard config"
action: "test"
nodes: "router1"
commands:
- "ping 10.0.0.1"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "missing the mandatory 'expected' key" in res["error"].lower()
def test_validate_playbook_invalid_nodes_type(ai_config):
"""Verifies that nodes of invalid type (e.g. integer) is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply config"
action: "run"
nodes: 12345
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "nodes' must be a string (regex) or a list of strings (regexes)" in res["error"]
def test_validate_playbook_invalid_nodes_list_item(ai_config):
"""Verifies that nodes list containing non-string items is caught."""
agent = PlaybookBuilderAgent(ai_config)
invalid_yaml = """
tasks:
- name: "Apply config"
action: "run"
nodes:
- "router1"
- 9999
commands:
- "conf t"
output: "stdout"
"""
res = agent.validate_playbook(invalid_yaml)
assert res["valid"] is False
assert "list contains a non-string value" in res["error"]
# =========================================================================
# AIService new methods delegation tests
# =========================================================================
def test_build_playbook_chat_delegation(ai_config):
"""Verifies that build_playbook_chat instantiates PlaybookBuilderAgent and delegates ask."""
service = AIService(ai_config)
with patch("connpy.ai.PlaybookBuilderAgent") as MockAgentClass:
mock_agent = MockAgentClass.return_value
mock_agent.ask.return_value = {"response": "Mock response", "chat_history": []}
history = [{"role": "user", "content": "build playbook"}]
res = service.build_playbook_chat("help me", chat_history=history)
MockAgentClass.assert_called_once_with(ai_config)
mock_agent.ask.assert_called_once_with("help me", chat_history=history, status=None, chunk_callback=None)
assert res["response"] == "Mock response"
def test_analyze_execution_results_delegation(ai_config):
"""Verifies that analyze_execution_results formats prompt with @architect and delegates to self.ask."""
service = AIService(ai_config)
service.ask = MagicMock()
results = {"router1": {"output": "success", "status": 0}}
service.analyze_execution_results(results, query="diagnose border")
service.ask.assert_called_once()
args, kwargs = service.ask.call_args
prompt = args[0]
assert prompt.startswith("@architect:")
assert "diagnose border" in prompt
assert "Results Data:" in prompt
assert "router1" in prompt
assert kwargs.get("one_shot") is True
def test_predict_execution_results_delegation(ai_config):
"""Verifies that predict_execution_results formats prompt with @engineer and delegates to self.ask."""
service = AIService(ai_config)
service.ask = MagicMock()
nodes = ["router1", "router2"]
commands = ["conf t", "interface lo0"]
service.predict_execution_results(nodes, commands)
service.ask.assert_called_once()
args, kwargs = service.ask.call_args
prompt = args[0]
assert prompt.startswith("@engineer:")
assert "Preflight Simulation Agent" in prompt
assert "router1, router2" in prompt
assert "conf t" in prompt
assert "interface lo0" in prompt
# =========================================================================
# gRPC Integration Tests for AIService
# =========================================================================
import grpc
from concurrent import futures
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
class TestGRPCAIIntegration:
@pytest.fixture
def grpc_server(self, populated_config):
"""Starts a local gRPC server for IA integration testing."""
srv = grpc.server(futures.ThreadPoolExecutor(max_workers=5))
connpy_pb2_grpc.add_AIServiceServicer_to_server(server.ServerServicer(populated_config).ai if hasattr(server, 'ServerServicer') else server.AIServicer(populated_config), srv)
port = srv.add_insecure_port('127.0.0.1:0')
srv.start()
yield f"127.0.0.1:{port}"
srv.stop(0)
@pytest.fixture
def channel(self, grpc_server):
with grpc.insecure_channel(grpc_server) as channel:
yield channel
@pytest.fixture
def ai_stub(self, channel):
return stubs.AIStub(channel, "localhost")
def test_build_playbook_chat_grpc(self, ai_stub, populated_config):
"""Verifies that build_playbook_chat gRPC stream functions correctly."""
# Mock PlaybookBuilderAgent.ask to simulate agent response stream
def mock_ask(user_input, chat_history=None, status=None, debug=False, chunk_callback=None):
if chunk_callback:
chunk_callback("Generated Tasks:\n- name: config")
return {"response": "Done", "playbook_yaml": "tasks:\n- name: config"}
with patch("connpy.ai.PlaybookBuilderAgent.ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.build_playbook_chat("make playbook", chunk_callback=callback)
assert "tasks:" in res["playbook_yaml"]
assert len(chunks) > 0
assert "Generated Tasks:" in chunks[0]
def test_analyze_execution_results_grpc(self, ai_stub, populated_config):
"""Verifies that analyze_execution_results gRPC stream functions correctly."""
# Mock AIService.ask to simulate response stream
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
if chunk_callback:
chunk_callback("Results are optimal.")
return {"response": "Done"}
with patch.object(AIService, "ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.analyze_execution_results({"r1": "ok"}, query="test query", chunk_callback=callback)
assert res is not None
assert len(chunks) > 0
assert "optimal" in chunks[0]
def test_predict_execution_results_grpc(self, ai_stub, populated_config):
"""Verifies that predict_execution_results gRPC stream functions correctly."""
# Mock AIService.ask to simulate response stream
def mock_ask(prompt, status=None, debug=False, chunk_callback=None, **kwargs):
if chunk_callback:
chunk_callback("Commands are safe.")
return {"response": "Done"}
with patch.object(AIService, "ask", side_effect=mock_ask):
chunks = []
def callback(chunk):
chunks.append(chunk)
res = ai_stub.predict_execution_results(["r1"], ["show version"], chunk_callback=callback)
assert res is not None
assert len(chunks) > 0
assert "safe" in chunks[0]
+217
View File
@@ -0,0 +1,217 @@
import os
import time
import pytest
import yaml
from connpy.configfile import configfile
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def temp_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
config_dir = tmp_path / "conn_shared_test"
config_dir.mkdir()
return config_dir
def test_shared_ai_deep_merge(temp_config_dir):
"""Test get_effective_setting deep merge logic for 'ai' settings."""
shared_dir = os.path.join(temp_config_dir, "shared")
user_dir = os.path.join(temp_config_dir, "user")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write shared configuration
shared_data = {
"config": {
"theme": "dark",
"case": False,
"ai": {
"engineer_model": "shared-eng-model",
"architect_model": "shared-arch-model",
"engineer_api_key": "shared-key",
"mcp_servers": {
"global-server": {
"url": "http://global-server/sse",
"enabled": True
},
"override-server": {
"url": "http://override-shared/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# Write user configuration with overrides
user_data = {
"config": {
"case": True,
"ai": {
"engineer_model": "user-custom-eng-model",
"mcp_servers": {
"override-server": {
"enabled": False
},
"user-server": {
"url": "http://user-server/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
# Initialize configfile instances
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
# Verify non-inheritable settings (theme, case)
assert user_config.get_effective_setting("case") is True
assert user_config.get_effective_setting("theme") is None # Should NOT inherit "theme"
# Verify AI setting deep merge
effective_ai = user_config.get_effective_setting("ai")
# Model override
assert effective_ai.get("engineer_model") == "user-custom-eng-model"
# Model inheritance
assert effective_ai.get("architect_model") == "shared-arch-model"
# API key inheritance
assert effective_ai.get("engineer_api_key") == "shared-key"
# MCP Servers merge
mcp = effective_ai.get("mcp_servers", {})
# Inherited server
assert "global-server" in mcp
assert mcp["global-server"]["url"] == "http://global-server/sse"
assert mcp["global-server"]["enabled"] is True
# Merged & overridden server
assert "override-server" in mcp
assert mcp["override-server"]["url"] == "http://override-shared/sse" # inherited
assert mcp["override-server"]["enabled"] is False # overridden
# User-only server
assert "user-server" in mcp
assert mcp["user-server"]["url"] == "http://user-server/sse"
def test_registry_injection_and_hot_reload(temp_config_dir):
"""Test that UserRegistry correctly injects shared config and hot-reloads it when it changes on disk."""
registry = UserRegistry(str(temp_config_dir))
# Define paths
shared_path = os.path.join(temp_config_dir, "config.yaml")
# 1. Create a global config file
global_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_model": "global-model"
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Re-init registry to pick up the newly created shared config file
registry = UserRegistry(str(temp_config_dir))
# Register user
username = "testuser"
registry.user_service.create_user(username, "testpassword")
# Check initial injection
provider = registry.get_provider(username)
ai_settings = provider.config.get_effective_setting("ai")
assert ai_settings.get("engineer_api_key") == "global-initial-key"
assert ai_settings.get("engineer_model") == "global-model"
# 2. Modify global config on disk
global_data["config"]["ai"]["engineer_api_key"] = "global-updated-key"
# Sleep briefly to ensure mtime change is detectable
time.sleep(0.1)
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Set the mtime forward explicitly to avoid filesystem resolution limits
new_mtime = os.path.getmtime(shared_path) + 10.0
os.utime(shared_path, (new_mtime, new_mtime))
# Retrieve provider again - should trigger hot-reload of shared config
provider2 = registry.get_provider(username)
ai_settings_updated = provider2.config.get_effective_setting("ai")
assert ai_settings_updated.get("engineer_api_key") == "global-updated-key"
assert ai_settings_updated.get("engineer_model") == "global-model"
def test_shared_ai_credential_isolation(temp_config_dir):
"""Test that setting user engineer/architect credentials discards corresponding shared credentials."""
shared_dir = os.path.join(temp_config_dir, "shared_isolation")
user_dir = os.path.join(temp_config_dir, "user_isolation")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Shared has both api_key and auth
shared_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_auth": {"vertex_project": "shared-project", "api_key": "shared-auth-key"},
"architect_api_key": "global-arch-key",
"architect_auth": {"project": "arch-project"}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# User configures ONLY engineer_api_key (expects engineer_auth to be discarded)
# and ONLY architect_auth (expects architect_api_key to be discarded)
user_data = {
"config": {
"ai": {
"engineer_api_key": "user-custom-key",
"architect_auth": {"project": "user-project", "api_key": "user-auth-key"}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
effective_ai = user_config.get_effective_setting("ai")
# 1. Engineer: local api_key is present, so shared engineer_auth must be completely discarded
assert effective_ai.get("engineer_api_key") == "user-custom-key"
assert "engineer_auth" not in effective_ai
# 2. Architect: local auth is present, so shared architect_api_key must be completely discarded
assert effective_ai.get("architect_auth") == {"project": "user-project", "api_key": "user-auth-key"}
assert "architect_api_key" not in effective_ai
+216
View File
@@ -0,0 +1,216 @@
import os
import datetime
import hashlib
import pytest
import yaml
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
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))
@pytest.fixture
def user_with_token(user_service):
"""Creates a user and returns (user_service, username, token_result)."""
username = "tokenuser"
user_service.create_user(username, "password123")
result = user_service.create_api_token(username, "Test Token")
return user_service, username, result
class TestApiTokenCreation:
def test_create_api_token_returns_raw_token(self, user_service):
"""Verifies that create_api_token returns a raw token with the correct prefix."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "CI Pipeline")
assert "raw_token" in result
assert result["raw_token"].startswith("cnp_pat_")
assert len(result["raw_token"]) > 16
assert "token_id" in result
assert result["token_id"].startswith("tok_")
assert result["name"] == "CI Pipeline"
def test_create_api_token_stores_hash_not_plaintext(self, user_service):
"""Ensures only the SHA-256 hash is persisted, never the raw token."""
user_service.create_user("bob", "pass")
result = user_service.create_api_token("bob", "My App")
registry = user_service._load_registry()
tokens = registry["users"]["bob"]["api_tokens"]
assert len(tokens) == 1
token_meta = list(tokens.values())[0]
expected_hash = hashlib.sha256(result["raw_token"].encode("utf-8")).hexdigest()
assert token_meta["token_hash"] == expected_hash
# Raw token must NOT be stored
assert result["raw_token"] not in str(token_meta)
def test_create_api_token_with_expiration(self, user_service):
"""Verifies that expires_at is set correctly when expires_in_days is provided."""
user_service.create_user("charlie", "pass")
user_service.create_api_token("charlie", "Temp Token", expires_in_days=30)
registry = user_service._load_registry()
token_meta = list(registry["users"]["charlie"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is not None
exp_dt = datetime.datetime.fromisoformat(token_meta["expires_at"])
now = datetime.datetime.now(datetime.timezone.utc)
delta = exp_dt - now
assert 29 <= delta.days <= 30
def test_create_api_token_permanent_by_default(self, user_service):
"""Verifies that expires_at is None when no expiration is specified."""
user_service.create_user("dave", "pass")
user_service.create_api_token("dave", "Permanent Token")
registry = user_service._load_registry()
token_meta = list(registry["users"]["dave"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is None
def test_create_api_token_nonexistent_user(self, user_service):
"""Ensures creating a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.create_api_token("ghost", "Token")
def test_create_api_token_empty_name(self, user_service):
"""Ensures empty token names are rejected."""
user_service.create_user("eve", "pass")
with pytest.raises(ValueError, match="cannot be empty"):
user_service.create_api_token("eve", "")
def test_create_multiple_tokens(self, user_service):
"""Verifies a user can have multiple tokens."""
user_service.create_user("frank", "pass")
t1 = user_service.create_api_token("frank", "Token 1")
t2 = user_service.create_api_token("frank", "Token 2")
assert t1["token_id"] != t2["token_id"]
assert t1["raw_token"] != t2["raw_token"]
tokens = user_service.list_api_tokens("frank")
assert len(tokens) == 2
class TestApiTokenVerification:
def test_verify_valid_token(self, user_with_token):
"""Verifies that a valid raw token authenticates correctly."""
svc, username, result = user_with_token
verified = svc.verify_api_token(result["raw_token"])
assert verified == username
def test_verify_invalid_token(self, user_service):
"""Verifies that a random/invalid token returns None."""
user_service.create_user("alice", "pass")
assert user_service.verify_api_token("cnp_pat_invalid_token_here") is None
def test_verify_expired_token(self, user_service):
"""Verifies that an expired token returns None."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "Expiring", expires_in_days=1)
# Manually set expires_at to the past
registry = user_service._load_registry()
token_meta = list(registry["users"]["alice"]["api_tokens"].values())[0]
token_meta["expires_at"] = (
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
).isoformat()
user_service._save_registry(registry)
# Invalidate cache so verify_api_token re-reads
user_service._token_index = {}
assert user_service.verify_api_token(result["raw_token"]) is None
def test_verify_updates_last_used_at(self, user_with_token):
"""Verifies that last_used_at is updated upon successful verification."""
svc, username, result = user_with_token
# Initially last_used_at should be None
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is None
# Verify the token
svc.verify_api_token(result["raw_token"])
# Now last_used_at should be set
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is not None
class TestApiTokenListing:
def test_list_tokens_returns_metadata(self, user_with_token):
"""Verifies list returns metadata without sensitive data."""
svc, username, result = user_with_token
tokens = svc.list_api_tokens(username)
assert len(tokens) == 1
t = tokens[0]
assert t["token_id"] == result["token_id"]
assert t["name"] == "Test Token"
assert t["token_prefix"].startswith("cnp_pat_")
assert "created_at" in t
# Must NOT expose token_hash or raw_token
assert "token_hash" not in t
assert "raw_token" not in t
def test_list_tokens_empty(self, user_service):
"""Verifies listing tokens for a user with none returns empty list."""
user_service.create_user("alice", "pass")
assert user_service.list_api_tokens("alice") == []
def test_list_tokens_nonexistent_user(self, user_service):
"""Ensures listing tokens for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.list_api_tokens("ghost")
class TestApiTokenRevocation:
def test_revoke_token(self, user_with_token):
"""Verifies that a revoked token is immediately invalid."""
svc, username, result = user_with_token
# Token works before revocation
assert svc.verify_api_token(result["raw_token"]) == username
# Revoke
removed = svc.revoke_api_token(username, result["token_id"])
assert removed is True
# Token must fail after revocation
assert svc.verify_api_token(result["raw_token"]) is None
# List should be empty
assert svc.list_api_tokens(username) == []
def test_revoke_nonexistent_token(self, user_service):
"""Verifies revoking a non-existent token returns False."""
user_service.create_user("alice", "pass")
assert user_service.revoke_api_token("alice", "tok_nonexistent") is False
def test_revoke_nonexistent_user(self, user_service):
"""Ensures revoking a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.revoke_api_token("ghost", "tok_abc")
class TestJwtUnchanged:
def test_jwt_still_works(self, user_service):
"""Confirms that existing JWT session tokens still authenticate correctly."""
user_service.create_user("jwtuser", "pass")
token = user_service.generate_jwt("jwtuser")
verified = user_service.verify_jwt(token)
assert verified == "jwtuser"
+134
View File
@@ -0,0 +1,134 @@
import os
import pytest
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
class TestUserRegistry:
def test_has_users_empty(self, registry):
"""Verifies has_users is False when no users exist."""
assert registry.has_users() is False
def test_get_provider_returns_service_provider(self, registry):
"""Tests that get_provider lazy-loads a valid ServiceProvider instance."""
username = "alice"
registry.user_service.create_user(username, "password")
assert registry.has_users() is True
provider = registry.get_provider(username)
assert isinstance(provider, ServiceProvider)
assert provider.mode == "local"
def test_get_provider_cached(self, registry):
"""Verifies that subsequent calls return the cached singleton instance."""
username = "bob"
registry.user_service.create_user(username, "password")
p1 = registry.get_provider(username)
p2 = registry.get_provider(username)
assert p1 is p2 # must be exact same object reference
def test_two_users_isolated(self, registry):
"""Ensures different users get completely separate ServiceProviders and configs."""
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
assert p1 is not p2
assert p1.config is not p2.config
# Add a node for user1 and verify user2 is unaffected
p1.nodes.add_node("node1", {"host": "1.1.1.1"})
assert "node1" in p1.nodes.list_nodes()
assert "node1" not in p2.nodes.list_nodes()
def test_evict_clears_cache(self, registry):
"""Verifies that eviction deletes the cached provider from memory."""
username = "evictuser"
registry.user_service.create_user(username, "pass")
p1 = registry.get_provider(username)
assert username in registry._providers
registry.evict(username)
assert username not in registry._providers
# Calling get_provider again spawns a new instance
p2 = registry.get_provider(username)
assert p1 is not p2
def test_provider_hot_reload_on_external_change(self, registry):
"""Verifies that UserRegistry hot-reloads the provider if config.yaml is updated externally."""
username = "charlie"
registry.user_service.create_user(username, "password")
# Initial load (no nodes)
p1 = registry.get_provider(username)
assert len(p1.nodes.list_nodes()) == 0
# Resolve config.yaml file path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Modify the config file physically on disk by appending a node
from connpy.configfile import configfile
cfg = configfile(conf=conf_file)
cfg._connections_add(id="testnode", host="8.8.8.8")
cfg._saveconfig(cfg.file)
# Artificially increase mtime to force reload
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetch provider again
p2 = registry.get_provider(username)
# Verify it hot-reloaded and the new node is immediately visible
assert p1 is not p2
assert "testnode" in p2.nodes.list_nodes()
def test_provider_hot_reload_fails_on_corrupt_file_keeps_old_provider(self, registry):
"""Verifies that UserRegistry keeps serving the old provider if disk config is corrupt."""
username = "danny"
registry.user_service.create_user(username, "password")
# Initial load
p1 = registry.get_provider(username)
p1.nodes.add_node("nodeA", {"host": "2.2.2.2"})
assert "nodeA" in p1.nodes.list_nodes()
# Resolve config.yaml path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Write corrupted content directly to config.yaml
with open(conf_file, "w") as f:
f.write("corrupt yaml content ::: invalid syntax :::")
# Artificially increase mtime to force reload attempt
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetching provider again should fallback to old_provider instead of failing completely
p2 = registry.get_provider(username)
# Verify fallback
assert p1 is p2
assert "nodeA" in p2.nodes.list_nodes()
+217
View File
@@ -0,0 +1,217 @@
import os
import shutil
import pytest
import datetime
import jwt
import yaml
from pathlib import Path
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def user_service(test_config_dir):
"""Initializes UserService pointing to a temporary directory."""
return UserService(str(test_config_dir))
class TestUserService:
def test_no_users(self, user_service):
"""Verifies that a new registry is empty by default."""
users = user_service.list_users()
assert users == []
def test_create_user_default(self, user_service):
"""Tests Mode A: fresh user config and key creation."""
username = "testuser"
res = user_service.create_user(username, "mypassword")
assert res["username"] == username
assert res["config_path"] is None
assert "created" in res
# Verify folder, config.yaml and .osk key are created
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
assert os.path.isdir(os.path.join(user_dir, "plugins"))
assert os.path.isdir(os.path.join(user_dir, "ai_sessions"))
assert os.path.isfile(os.path.join(user_dir, "config.yaml"))
assert os.path.isfile(os.path.join(user_dir, ".osk"))
def test_create_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: using an existing valid config path."""
# Setup existing custom config directory
custom_dir = tmp_path / "custom_user_conn"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
# Write basic config.yaml
config_data = {
"config": {"case": False, "idletime": 30, "fzf": False},
"connections": {},
"profiles": {}
}
with open(config_file, "w") as f:
yaml.dump(config_data, f)
res = user_service.create_user("fluzzi", "fluzzipass", config_path=str(custom_dir))
assert res["username"] == "fluzzi"
assert res["config_path"] == str(custom_dir)
# Verify no directory is created under the server's user folder
user_dir = os.path.join(user_service.users_dir, "fluzzi")
assert not os.path.exists(user_dir)
def test_create_user_custom_path_auto_init(self, user_service, tmp_path):
"""Ensures create_user automatically initializes a missing directory and default config.yaml."""
custom_dir = tmp_path / "new_custom_config"
# Test creation where the directory does not exist yet
res = user_service.create_user("john", "pass", config_path=str(custom_dir))
assert res["username"] == "john"
assert res["config_path"] == str(custom_dir)
# Verify custom path and subdirs/configs were created
assert os.path.isdir(custom_dir)
assert os.path.exists(os.path.join(custom_dir, "config.yaml"))
assert os.path.isdir(os.path.join(custom_dir, "plugins"))
assert os.path.isdir(os.path.join(custom_dir, "ai_sessions"))
def test_create_duplicate_user(self, user_service):
"""Ensures duplicate usernames are rejected."""
user_service.create_user("dupuser", "password")
with pytest.raises(ValueError, match="already exists"):
user_service.create_user("dupuser", "anotherpass")
def test_delete_user_default(self, user_service):
"""Tests Mode A: deleting a server-managed user cleans up directories."""
username = "deluser"
user_service.create_user(username, "password")
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
user_service.delete_user(username)
# Directory should be cleaned up
assert not os.path.exists(user_dir)
# Registry should be updated
assert len(user_service.list_users()) == 0
def test_delete_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: deleting a custom-path user leaves files untouched."""
custom_dir = tmp_path / "fluzzi_custom"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
with open(config_file, "w") as f:
yaml.dump({"config": {}, "connections": {}, "profiles": {}}, f)
username = "fluzzi"
user_service.create_user(username, "pass", config_path=str(custom_dir))
user_service.delete_user(username)
# Registry cleared
assert len(user_service.list_users()) == 0
# Files remain untouched
assert os.path.isdir(str(custom_dir))
assert os.path.isfile(str(config_file))
def test_list_users(self, user_service):
"""Tests listing all registered users with their metadata."""
user_service.create_user("user1", "pass1")
user_service.create_user("user2", "pass2")
users = user_service.list_users()
assert len(users) == 2
usernames = [u["username"] for u in users]
assert "user1" in usernames
assert "user2" in usernames
def test_get_user(self, user_service):
"""Tests retrieving a single user's configuration metadata."""
user_service.create_user("user1", "pass1")
user = user_service.get_user("user1")
assert user["username"] == "user1"
assert user["config_path"] is None
assert "created" in user
with pytest.raises(ValueError, match="not found"):
user_service.get_user("nonexistent")
def test_authenticate_valid(self, user_service):
"""Verifies successful authentication."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "my-secure-password") is True
def test_authenticate_invalid(self, user_service):
"""Verifies unsuccessful authentication on incorrect or missing credentials."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "wrong-password") is False
assert user_service.authenticate("nonexistent", "my-secure-password") is False
def test_jwt_roundtrip(self, user_service):
"""Tests generating a JWT token and verifying it back to the username."""
username = "jwttester"
user_service.create_user(username, "pass")
token = user_service.generate_jwt(username)
assert isinstance(token, str)
verified_user = user_service.verify_jwt(token)
assert verified_user == username
def test_jwt_expired(self, user_service):
"""Tests that expired JWT tokens are rejected and return None."""
username = "jwttester"
user_service.create_user(username, "pass")
# Manually generate an expired token by setting exp to the past
registry = user_service._load_registry()
expired_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=10)
payload = {
"sub": username,
"exp": expired_time
}
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
verified_user = user_service.verify_jwt(token)
assert verified_user is None
def test_change_password(self, user_service):
"""Tests changing password for a user."""
username = "passchanger"
user_service.create_user(username, "oldpass")
# Old credentials authenticate
assert user_service.authenticate(username, "oldpass") is True
# Change password
user_service.change_password(username, "oldpass", "newpass")
# Old password fails, new password works
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
# Change with invalid old password should fail
with pytest.raises(ValueError, match="Invalid credentials"):
user_service.change_password(username, "wrongold", "evennewer")
def test_admin_change_password(self, user_service):
"""Tests administrative password change (no old password required)."""
username = "adminpasschanger"
user_service.create_user(username, "oldpass")
# Admin changes password directly
user_service.admin_change_password(username, "newpass")
# Verify credentials
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
+32
View File
@@ -0,0 +1,32 @@
import pytest
from connpy.utils import log_cleaner
def test_log_cleaner_empty():
assert log_cleaner("") == ""
assert log_cleaner(None) == ""
def test_log_cleaner_plain_text():
assert log_cleaner("hello world") == "hello world"
def test_log_cleaner_ansi_colors():
# \x1b[31m is red, \x1b[0m is reset
assert log_cleaner("\x1b[31mhello\x1b[0m world") == "hello world"
def test_log_cleaner_osc_window_title():
# Set window title OSC: \x1b]0;my title\x07 followed by prompt
sample = "\x1b]0;fluzzi32@norman: ~\x07fluzzi32@norman:~$"
assert log_cleaner(sample) == "fluzzi32@norman:~$"
def test_log_cleaner_osc_with_st_terminator():
# OSC can also be terminated by \x1b\\ (ST)
sample = "\x1b]0;some title\x1b\\my_prompt>"
assert log_cleaner(sample) == "my_prompt>"
def test_log_cleaner_mixed_ansi_and_osc():
sample = "\x1b]0;title\x07\x1b[32muser@host\x1b[0m:\x1b[34m/path\x1b[0m$ "
assert log_cleaner(sample) == "user@host:/path$"
def test_log_cleaner_carriage_return_and_backspace():
# Test that standard control sequences like \r and \b still work as expected
assert log_cleaner("hello\rworld") == "world"
assert log_cleaner("hell\bo") == "helo"
+205
View File
@@ -0,0 +1,205 @@
import asyncio
import os
import sys
import termios
import tty
import signal
import struct
import fcntl
class LocalStream:
"""
Asynchronous stream wrapper for local stdin/stdout.
Handles terminal raw mode, async I/O, and SIGWINCH signals.
"""
def __init__(self):
self.stdin_fd = sys.stdin.fileno()
self.stdout_fd = sys.stdout.fileno()
self.original_tty_settings = None
self.resize_callback = None
self._reader_queue = asyncio.Queue()
self._loop = None
def setup(self, resize_callback=None):
self._loop = asyncio.get_running_loop()
self.resize_callback = resize_callback
# Save original terminal settings
try:
self.original_tty_settings = termios.tcgetattr(self.stdin_fd)
tty.setraw(self.stdin_fd)
except termios.error:
# Not a TTY, maybe piped or redirected
pass
# Set stdin non-blocking
flags = fcntl.fcntl(self.stdin_fd, fcntl.F_GETFL)
fcntl.fcntl(self.stdin_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Setup read callback
self._loop.add_reader(self.stdin_fd, self._read_ready)
# Register SIGWINCH
if resize_callback:
try:
self._loop.add_signal_handler(signal.SIGWINCH, self._handle_winch)
except (NotImplementedError, RuntimeError):
# signal handling not supported on some loops (e.g., Windows Proactor)
pass
def stop_reading(self):
"""Temporarily stop reading from stdin."""
if self._loop and self.stdin_fd is not None:
try:
self._loop.remove_reader(self.stdin_fd)
except Exception:
pass
def start_reading(self):
"""Resume reading from stdin."""
if self._loop and self.stdin_fd is not None:
try:
# Ensure we don't add it twice
self._loop.remove_reader(self.stdin_fd)
except Exception:
pass
self._loop.add_reader(self.stdin_fd, self._read_ready)
def teardown(self):
if self._loop:
try:
self._loop.remove_reader(self.stdin_fd)
except Exception:
pass
if self.resize_callback:
try:
self._loop.remove_signal_handler(signal.SIGWINCH)
except Exception:
pass
# Restore terminal settings
if self.original_tty_settings is not None:
try:
termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.original_tty_settings)
except termios.error:
pass
# Restore blocking mode for stdin
try:
flags = fcntl.fcntl(self.stdin_fd, fcntl.F_GETFL)
fcntl.fcntl(self.stdin_fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
except Exception:
pass
def _read_ready(self):
try:
# Read whatever is available
data = os.read(self.stdin_fd, 4096)
if data:
self._reader_queue.put_nowait(data)
else:
self._reader_queue.put_nowait(b'') # EOF
except BlockingIOError:
pass
except OSError:
self._reader_queue.put_nowait(b'') # EOF on error
async def read(self) -> bytes:
"""Asynchronously read bytes from stdin."""
return await self._reader_queue.get()
async def write(self, data: bytes):
"""Asynchronously write bytes to stdout."""
if not data:
return
try:
os.write(self.stdout_fd, data)
except OSError:
pass
def _handle_winch(self):
if self.resize_callback:
try:
# Use ioctl to get the current window size
s = struct.pack("HHHH", 0, 0, 0, 0)
a = fcntl.ioctl(self.stdout_fd, termios.TIOCGWINSZ, s)
rows, cols, _, _ = struct.unpack("HHHH", a)
# We schedule the callback safely inside the asyncio loop
# instead of running it raw in the signal handler
self._loop.call_soon(self.resize_callback, rows, cols)
except Exception:
pass
import threading
class RemoteStream:
"""
Asynchronous stream wrapper for gRPC remote connections.
Bridges the blocking gRPC iterators with the async _async_interact_loop.
"""
def __init__(self, request_iterator, response_queue):
self.request_iterator = request_iterator
self.response_queue = response_queue
self.running = True
self._reader_queue = asyncio.Queue()
self.copilot_queue = asyncio.Queue()
self.resize_callback = None
self._loop = None
self.t = None
def setup(self, resize_callback=None):
self._loop = asyncio.get_running_loop()
self.resize_callback = resize_callback
def read_requests():
try:
for req in self.request_iterator:
if not self.running:
break
if req.cols > 0 and req.rows > 0:
if self.resize_callback:
self._loop.call_soon_threadsafe(self.resize_callback, req.rows, req.cols)
# Copilot dispatching
copilot_msg = {}
if getattr(req, "copilot_question", ""):
copilot_msg.update({
"question": req.copilot_question,
"context_buffer": getattr(req, "copilot_context_buffer", ""),
"node_info_json": getattr(req, "copilot_node_info_json", "")
})
if getattr(req, "copilot_action", ""):
copilot_msg["action"] = req.copilot_action
if getattr(req, "copilot_node_info_json", ""):
copilot_msg["node_info_json"] = req.copilot_node_info_json
if copilot_msg:
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
if req.stdin_data:
self._loop.call_soon_threadsafe(self._reader_queue.put_nowait, req.stdin_data)
except Exception:
pass
finally:
if self._loop and not self._loop.is_closed():
try:
self._loop.call_soon_threadsafe(self._reader_queue.put_nowait, b'')
except RuntimeError:
pass
self.t = threading.Thread(target=read_requests, daemon=True)
self.t.start()
def teardown(self):
self.running = False
self.response_queue.put(None) # Signal EOF
async def read(self) -> bytes:
"""Asynchronously read bytes from the gRPC iterator queue."""
return await self._reader_queue.get()
async def write(self, data: bytes):
"""Asynchronously write bytes to the gRPC response queue."""
if data:
self.response_queue.put(data)
+70
View File
@@ -0,0 +1,70 @@
import re
def log_cleaner(data: str) -> str:
"""
Stateless utility to remove ANSI sequences and process cursor movements.
"""
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(?:[\x30-\x5A\x5C-\x7E]|\[[0-?]*[ -/ ]*[@-~])|\r|\b|\x7f|[\x00-\x1F]|[^\x1B\r\b\x7f\x00-\x1F]+)')
for line in lines:
buffer = []
cursor = 0
for token in token_re.findall(line):
if token == '\r':
cursor = 0
elif token in ('\b', '\x7f'):
if cursor > 0:
cursor -= 1
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:
continue
else:
for char in token:
if cursor == len(buffer):
buffer.append(char)
else:
buffer[cursor] = char
cursor += 1
cleaned_lines.append("".join(buffer))
return "\n".join(cleaned_lines).replace('\n\n', '\n').strip()
+13 -5
View File
@@ -1,9 +1,17 @@
version: "3.8"
services:
connpy-app:
build: .
image: connpy-app
image: connpy:latest
container_name: connpy
# Fundamental para la interactividad de la terminal
stdin_open: true
tty: true
environment:
- TERM=xterm-256color
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./docker/connpy/:/app
- ./docker/logs/:/logs
- ./docker/ssh/:/root/.ssh/
- ./docker/config:/config
- ./docker/ssh:/root/.ssh
- /var/run/docker.sock:/var/run/docker.sock
# No definimos comando por defecto para que 'run' sea más natural
+58 -14
View File
@@ -1,21 +1,65 @@
# Use the official python image
# connpy v6.0.0b8 - Modern Network Automation Environment (Local Build)
FROM python:3.11-slim
FROM python:3.11-alpine as connpy-app
LABEL description="Connpy: AI-Driven Network Automation & Intelligence Platform"
# Configuración de Terminal y Python
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
TERM=xterm-256color
# Set the entrypoint
# Set the working directory
WORKDIR /app
# Install any additional dependencies
RUN apk update && apk add --no-cache openssh fzf fzf-tmux ncurses bash
RUN pip3 install connpy
RUN connpy config --configfolder /app
# 1. Herramientas base del sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
openssh-client \
fzf \
ncurses-bin \
bash \
procps \
unzip \
ca-certificates \
gnupg \
iputils-ping \
telnet \
&& rm -rf /var/lib/apt/lists/*
#AUTH
RUN ssh-keygen -A
RUN mkdir /root/.ssh && \
chmod 700 /root/.ssh
# 2. Instalar Docker CLI (para el plugin de docker de connpy)
RUN install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && apt-get install -y docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
# 3. Instalar Kubectl (para el plugin de k8s de connpy)
RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/$(dpkg --print-architecture)/kubectl" && \
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \
rm kubectl
#Set the entrypoint
ENTRYPOINT ["connpy"]
# 4. Instalar AWS CLI y Session Manager Plugin (Universal x86_64/ARM64)
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then AWS_ARCH="x86_64"; else AWS_ARCH="aarch64"; fi && \
curl "https://awscli.amazonaws.com/awscli-exe-linux-$AWS_ARCH.zip" -o "awscliv2.zip" && \
unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws/ && \
if [ "$ARCH" = "x86_64" ]; then \
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "ssm.deb"; \
else \
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_arm64/session-manager-plugin.deb" -o "ssm.deb"; \
fi && \
dpkg -i ssm.deb && rm ssm.deb
# 5. Copiar código local e instalar dependencias
COPY . .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir .
# 6. Configuración de persistencia
# Creamos la carpeta y el puntero .folder para que connpy use /config
RUN mkdir -p /config /root/.ssh /root/.config/conn && chmod 700 /root/.ssh && \
echo -n "/config" > /root/.config/conn/.folder
# Punto de entrada directo a connpy
ENTRYPOINT ["conn"]
+3190
View File
File diff suppressed because it is too large Load Diff
+339 -44
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.ai_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>
@@ -61,13 +61,22 @@ el.replaceWith(d);
def dispatch(self, args):
if args.list_sessions:
sessions = self.app.services.ai.list_sessions()
limit = 20 if not getattr(args, &#34;all&#34;, False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;)
return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)
title = &#34;AI Persisted Sessions&#34;
if limit and total &gt; limit:
title += f&#34; (Showing last {limit} of {total})&#34;
printer.table(title, columns, rows)
if limit and total &gt; limit:
printer.info(f&#34;Use &#39;--list --all&#39; to see all {total} sessions.&#34;)
return
if args.delete_session:
@@ -77,19 +86,22 @@ el.replaceWith(d);
except Exception as e:
printer.error(str(e))
return
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
if args.resume:
sessions = self.app.services.ai.list_sessions()
sessions, _ = self.app.services.ai.list_sessions()
session_id = sessions[0][&#34;id&#34;] if sessions else None
if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;)
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -99,18 +111,25 @@ el.replaceWith(d);
arguments[key] = cli_val[0]
elif settings.get(key):
arguments[key] = settings.get(key)
for key in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
cli_val = getattr(args, key, None)
if cli_val:
arguments[key] = self._parse_auth_value(cli_val[0])
elif settings.get(key):
arguments[key] = settings.get(key)
# Check keys only if running in local mode (not remote)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;):
printer.error(&#34;Engineer API key not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; to set it.&#34;)
if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#34;):
printer.error(&#34;Engineer API key/auth not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;):
printer.warning(&#34;Architect API key not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; to enable it.&#34;)
if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#34;):
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# El resto de la interacción el CLI la maneja con el agente subyacente
# The rest of the interaction is handled by the CLI with the underlying agent
self.app.myai = self.app.services.ai
self.ai_overrides = arguments
@@ -121,7 +140,7 @@ el.replaceWith(d);
def single_question(self, args, session_id):
query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) as status:
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
@@ -134,7 +153,6 @@ el.replaceWith(d);
if &#34;usage&#34; in result:
u = result[&#34;usage&#34;]
console.print(f&#34;[debug]Tokens: {u[&#39;total&#39;]} (Input: {u[&#39;input&#39;]}, Output: {u[&#39;output&#39;]})[/debug]&#34;)
console.print()
def interactive_chat(self, args, session_id):
history = None
@@ -146,7 +164,7 @@ el.replaceWith(d);
if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
else:
printer.error(f&#34;Could not load session {session_id}. Starting clean.&#34;)
printer.info(f&#34;Session &#39;{session_id}&#39; not found. Starting clean.&#34;)
if not history:
mdprint(Rule(style=&#34;engineer&#34;))
@@ -157,10 +175,10 @@ el.replaceWith(d);
try:
user_query = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;]: break
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **self.ai_overrides)
with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
if new_history is not None:
@@ -178,14 +196,273 @@ el.replaceWith(d);
if &#34;usage&#34; in result:
u = result[&#34;usage&#34;]
console.print(f&#34;[debug]Tokens: {u[&#39;total&#39;]} (Input: {u[&#39;input&#39;]}, Output: {u[&#39;output&#39;]})[/debug]&#34;)
console.print()
except (KeyboardInterrupt, EOFError):
console.print(&#34;\n[dim]Session closed.[/dim]&#34;)
break</code></pre>
break
def configure_mcp(self, args):
&#34;&#34;&#34;Handle MCP server configuration via CLI tokens or interactive wizard.&#34;&#34;&#34;
mcp_args = args.mcp
# 1. Non-interactive CLI Mode (if arguments are provided)
if mcp_args:
action = mcp_args[0].lower()
if action == &#34;list&#34;:
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;)
else:
columns = [&#34;Name&#34;, &#34;URL&#34;, &#34;Enabled&#34;, &#34;Auto-load OS&#34;]
rows = []
for name, cfg in mcp_servers.items():
rows.append([
name,
cfg.get(&#34;url&#34;, &#34;&#34;),
&#34;[green]Yes[/green]&#34; if cfg.get(&#34;enabled&#34;, True) else &#34;[red]No[/red]&#34;,
cfg.get(&#34;auto_load_on_os&#34;, &#34;Any&#34;)
])
printer.table(&#34;Configured MCP Servers&#34;, columns, rows)
return
elif action == &#34;add&#34;:
if len(mcp_args) &lt; 3:
printer.error(&#34;Usage: connpy ai --mcp add &lt;name&gt; &lt;url&gt; [os_filter]&#34;)
return
name, url = mcp_args[1], mcp_args[2]
os_filter = mcp_args[3] if len(mcp_args) &gt; 3 else None
try:
self.app.services.ai.configure_mcp(name, url=url, auto_load_on_os=os_filter)
printer.success(f&#34;MCP server &#39;{name}&#39; added/updated.&#34;)
except Exception as e:
printer.error(str(e))
return
elif action == &#34;remove&#34;:
if len(mcp_args) &lt; 2:
printer.error(&#34;Usage: connpy ai --mcp remove &lt;name&gt;&#34;)
return
name = mcp_args[1]
try:
self.app.services.ai.configure_mcp(name, remove=True)
printer.success(f&#34;MCP server &#39;{name}&#39; removed.&#34;)
except Exception as e:
printer.error(str(e))
return
elif action in [&#34;enable&#34;, &#34;disable&#34;]:
if len(mcp_args) &lt; 2:
printer.error(f&#34;Usage: connpy ai --mcp {action} &lt;name&gt;&#34;)
return
name = mcp_args[1]
enabled = (action == &#34;enable&#34;)
try:
self.app.services.ai.configure_mcp(name, enabled=enabled)
printer.success(f&#34;MCP server &#39;{name}&#39; {&#39;enabled&#39; if enabled else &#39;disabled&#39;}.&#34;)
except Exception as e:
printer.error(str(e))
return
else:
printer.error(f&#34;Unknown MCP action: {action}&#34;)
printer.info(&#34;Available actions: list, add, remove, enable, disable&#34;)
return
# 2. Interactive Wizard Mode (if no arguments provided)
# Import forms dynamically to avoid circular dependencies if any
if not hasattr(self.app, &#34;cli_forms&#34;):
from .forms import Forms
self.app.cli_forms = Forms(self.app)
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
return
action = result[&#34;action&#34;]
try:
if action == &#34;list&#34;:
# Recursive call to the non-interactive list logic
args.mcp = [&#34;list&#34;]
return self.configure_mcp(args)
elif action == &#34;add&#34;:
self.app.services.ai.configure_mcp(
result[&#34;name&#34;],
url=result[&#34;url&#34;],
enabled=result[&#34;enabled&#34;],
auto_load_on_os=result[&#34;os&#34;]
)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; saved.&#34;)
elif action == &#34;update&#34;: # Used for toggle
self.app.services.ai.configure_mcp(
result[&#34;name&#34;],
enabled=result[&#34;enabled&#34;]
)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; updated.&#34;)
elif action == &#34;remove&#34;:
self.app.services.ai.configure_mcp(result[&#34;name&#34;], remove=True)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; removed.&#34;)
except Exception as e:
printer.error(str(e))
def _parse_auth_value(self, value):
if not value or value.lower() in [&#34;none&#34;, &#34;clear&#34;]:
return None
import os
import yaml
import json
if os.path.exists(value):
try:
with open(value, &#34;r&#34;) as f:
content = f.read()
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
printer.error(f&#34;Failed to read/parse auth file &#39;{value}&#39;: {e}&#34;)
sys.exit(1)
try:
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
printer.error(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.ai_handler.AIHandler.configure_mcp"><code class="name flex">
<span>def <span class="ident">configure_mcp</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def configure_mcp(self, args):
&#34;&#34;&#34;Handle MCP server configuration via CLI tokens or interactive wizard.&#34;&#34;&#34;
mcp_args = args.mcp
# 1. Non-interactive CLI Mode (if arguments are provided)
if mcp_args:
action = mcp_args[0].lower()
if action == &#34;list&#34;:
mcp_servers = self.app.services.ai.list_mcp_servers()
if not mcp_servers:
printer.info(&#34;No MCP servers configured.&#34;)
else:
columns = [&#34;Name&#34;, &#34;URL&#34;, &#34;Enabled&#34;, &#34;Auto-load OS&#34;]
rows = []
for name, cfg in mcp_servers.items():
rows.append([
name,
cfg.get(&#34;url&#34;, &#34;&#34;),
&#34;[green]Yes[/green]&#34; if cfg.get(&#34;enabled&#34;, True) else &#34;[red]No[/red]&#34;,
cfg.get(&#34;auto_load_on_os&#34;, &#34;Any&#34;)
])
printer.table(&#34;Configured MCP Servers&#34;, columns, rows)
return
elif action == &#34;add&#34;:
if len(mcp_args) &lt; 3:
printer.error(&#34;Usage: connpy ai --mcp add &lt;name&gt; &lt;url&gt; [os_filter]&#34;)
return
name, url = mcp_args[1], mcp_args[2]
os_filter = mcp_args[3] if len(mcp_args) &gt; 3 else None
try:
self.app.services.ai.configure_mcp(name, url=url, auto_load_on_os=os_filter)
printer.success(f&#34;MCP server &#39;{name}&#39; added/updated.&#34;)
except Exception as e:
printer.error(str(e))
return
elif action == &#34;remove&#34;:
if len(mcp_args) &lt; 2:
printer.error(&#34;Usage: connpy ai --mcp remove &lt;name&gt;&#34;)
return
name = mcp_args[1]
try:
self.app.services.ai.configure_mcp(name, remove=True)
printer.success(f&#34;MCP server &#39;{name}&#39; removed.&#34;)
except Exception as e:
printer.error(str(e))
return
elif action in [&#34;enable&#34;, &#34;disable&#34;]:
if len(mcp_args) &lt; 2:
printer.error(f&#34;Usage: connpy ai --mcp {action} &lt;name&gt;&#34;)
return
name = mcp_args[1]
enabled = (action == &#34;enable&#34;)
try:
self.app.services.ai.configure_mcp(name, enabled=enabled)
printer.success(f&#34;MCP server &#39;{name}&#39; {&#39;enabled&#39; if enabled else &#39;disabled&#39;}.&#34;)
except Exception as e:
printer.error(str(e))
return
else:
printer.error(f&#34;Unknown MCP action: {action}&#34;)
printer.info(&#34;Available actions: list, add, remove, enable, disable&#34;)
return
# 2. Interactive Wizard Mode (if no arguments provided)
# Import forms dynamically to avoid circular dependencies if any
if not hasattr(self.app, &#34;cli_forms&#34;):
from .forms import Forms
self.app.cli_forms = Forms(self.app)
mcp_servers = self.app.services.ai.list_mcp_servers()
result = self.app.cli_forms.mcp_wizard(mcp_servers)
if not result:
return
action = result[&#34;action&#34;]
try:
if action == &#34;list&#34;:
# Recursive call to the non-interactive list logic
args.mcp = [&#34;list&#34;]
return self.configure_mcp(args)
elif action == &#34;add&#34;:
self.app.services.ai.configure_mcp(
result[&#34;name&#34;],
url=result[&#34;url&#34;],
enabled=result[&#34;enabled&#34;],
auto_load_on_os=result[&#34;os&#34;]
)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; saved.&#34;)
elif action == &#34;update&#34;: # Used for toggle
self.app.services.ai.configure_mcp(
result[&#34;name&#34;],
enabled=result[&#34;enabled&#34;]
)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; updated.&#34;)
elif action == &#34;remove&#34;:
self.app.services.ai.configure_mcp(result[&#34;name&#34;], remove=True)
printer.success(f&#34;MCP server &#39;{result[&#39;name&#39;]}&#39; removed.&#34;)
except Exception as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"><p>Handle MCP server configuration via CLI tokens or interactive wizard.</p></div>
</dd>
<dt id="connpy.cli.ai_handler.AIHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
@@ -196,13 +473,22 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def dispatch(self, args):
if args.list_sessions:
sessions = self.app.services.ai.list_sessions()
limit = 20 if not getattr(args, &#34;all&#34;, False) else None
sessions, total = self.app.services.ai.list_sessions(limit=limit)
if not sessions:
printer.info(&#34;No saved AI sessions found.&#34;)
return
columns = [&#34;ID&#34;, &#34;Title&#34;, &#34;Created At&#34;, &#34;Model&#34;]
rows = [[s[&#34;id&#34;], s[&#34;title&#34;], s[&#34;created_at&#34;], s[&#34;model&#34;]] for s in sessions]
printer.table(&#34;AI Persisted Sessions&#34;, columns, rows)
title = &#34;AI Persisted Sessions&#34;
if limit and total &gt; limit:
title += f&#34; (Showing last {limit} of {total})&#34;
printer.table(title, columns, rows)
if limit and total &gt; limit:
printer.info(f&#34;Use &#39;--list --all&#39; to see all {total} sessions.&#34;)
return
if args.delete_session:
@@ -212,19 +498,22 @@ el.replaceWith(d);
except Exception as e:
printer.error(str(e))
return
if args.mcp is not None:
return self.configure_mcp(args)
# Determinar session_id para retomar
# Determine session_id to resume
session_id = None
if args.resume:
sessions = self.app.services.ai.list_sessions()
sessions, _ = self.app.services.ai.list_sessions()
session_id = sessions[0][&#34;id&#34;] if sessions else None
if not session_id:
printer.warning(&#34;No previous session found to resume.&#34;)
elif args.session:
session_id = args.session[0]
# Configurar argumentos adicionales para el servicio de AI
# Prioridad: CLI Args &gt; Configuración Local
# Configure additional arguments for the AI service
# Priority: CLI Args &gt; Local Config
settings = self.app.services.config_svc.get_settings().get(&#34;ai&#34;, {})
arguments = {}
@@ -234,18 +523,25 @@ el.replaceWith(d);
arguments[key] = cli_val[0]
elif settings.get(key):
arguments[key] = settings.get(key)
for key in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
cli_val = getattr(args, key, None)
if cli_val:
arguments[key] = self._parse_auth_value(cli_val[0])
elif settings.get(key):
arguments[key] = settings.get(key)
# Check keys only if running in local mode (not remote)
if getattr(self.app.services, &#34;mode&#34;, &#34;local&#34;) == &#34;local&#34;:
if not arguments.get(&#34;engineer_api_key&#34;):
printer.error(&#34;Engineer API key not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; to set it.&#34;)
if not arguments.get(&#34;engineer_api_key&#34;) and not arguments.get(&#34;engineer_auth&#34;):
printer.error(&#34;Engineer API key/auth not configured. The chat cannot start.&#34;)
printer.info(&#34;Use &#39;connpy config --engineer-api-key &lt;key&gt;&#39; or &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
sys.exit(1)
if not arguments.get(&#34;architect_api_key&#34;):
printer.warning(&#34;Architect API key not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; to enable it.&#34;)
if not arguments.get(&#34;architect_api_key&#34;) and not arguments.get(&#34;architect_auth&#34;):
printer.warning(&#34;Architect API key/auth not configured. Architect will be unavailable.&#34;)
printer.info(&#34;Use &#39;connpy config --architect-api-key &lt;key&gt;&#39; or &#39;connpy config --architect-auth &lt;auth&gt;&#39; to enable it.&#34;)
# El resto de la interacción el CLI la maneja con el agente subyacente
# The rest of the interaction is handled by the CLI with the underlying agent
self.app.myai = self.app.services.ai
self.ai_overrides = arguments
@@ -274,7 +570,7 @@ el.replaceWith(d);
if history:
mdprint(f&#34;[debug]Analyzing {len(history)} previous messages...[/debug]\n&#34;)
else:
printer.error(f&#34;Could not load session {session_id}. Starting clean.&#34;)
printer.info(f&#34;Session &#39;{session_id}&#39; not found. Starting clean.&#34;)
if not history:
mdprint(Rule(style=&#34;engineer&#34;))
@@ -285,10 +581,10 @@ el.replaceWith(d);
try:
user_query = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;)
if not user_query.strip(): continue
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;]: break
if user_query.lower() in [&#39;exit&#39;, &#39;quit&#39;, &#39;bye&#39;, &#39;cancel&#39;]: break
with console.status(&#34;[ai_status]Agent is thinking...&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, **self.ai_overrides)
with console.status(&#34;[ai_status]Agent is thinking...[/ai_status]&#34;) as status:
result = self.app.myai.ask(user_query, chat_history=history, status=status, debug=args.debug, trust=args.trust, session_id=session_id, **self.ai_overrides)
new_history = result.get(&#34;chat_history&#34;)
if new_history is not None:
@@ -306,7 +602,6 @@ el.replaceWith(d);
if &#34;usage&#34; in result:
u = result[&#34;usage&#34;]
console.print(f&#34;[debug]Tokens: {u[&#39;total&#39;]} (Input: {u[&#39;input&#39;]}, Output: {u[&#39;output&#39;]})[/debug]&#34;)
console.print()
except (KeyboardInterrupt, EOFError):
console.print(&#34;\n[dim]Session closed.[/dim]&#34;)
break</code></pre>
@@ -323,7 +618,7 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def single_question(self, args, session_id):
query = &#34; &#34;.join(args.ask)
with console.status(&#34;[ai_status]Agent is thinking and analyzing...&#34;) as status:
with console.status(&#34;[ai_status]Agent is thinking and analyzing...[/ai_status]&#34;) as status:
result = self.app.myai.ask(query, status=status, debug=args.debug, session_id=session_id, trust=args.trust, **self.ai_overrides)
responder = result.get(&#34;responder&#34;, &#34;engineer&#34;)
@@ -335,8 +630,7 @@ el.replaceWith(d);
if &#34;usage&#34; in result:
u = result[&#34;usage&#34;]
console.print(f&#34;[debug]Tokens: {u[&#39;total&#39;]} (Input: {u[&#39;input&#39;]}, Output: {u[&#39;output&#39;]})[/debug]&#34;)
console.print()</code></pre>
console.print(f&#34;[debug]Tokens: {u[&#39;total&#39;]} (Input: {u[&#39;input&#39;]}, Output: {u[&#39;output&#39;]})[/debug]&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
@@ -360,6 +654,7 @@ el.replaceWith(d);
<li>
<h4><code><a title="connpy.cli.ai_handler.AIHandler" href="#connpy.cli.ai_handler.AIHandler">AIHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.ai_handler.AIHandler.configure_mcp" href="#connpy.cli.ai_handler.AIHandler.configure_mcp">configure_mcp</a></code></li>
<li><code><a title="connpy.cli.ai_handler.AIHandler.dispatch" href="#connpy.cli.ai_handler.AIHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.ai_handler.AIHandler.interactive_chat" href="#connpy.cli.ai_handler.AIHandler.interactive_chat">interactive_chat</a></code></li>
<li><code><a title="connpy.cli.ai_handler.AIHandler.single_question" href="#connpy.cli.ai_handler.AIHandler.single_question">single_question</a></code></li>
@@ -371,7 +666,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+2 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.api_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>
@@ -193,7 +193,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+126 -8
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.config_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>
@@ -70,12 +70,17 @@ el.replaceWith(d);
&#34;theme&#34;: self.set_theme,
&#34;engineer_model&#34;: self.set_ai_config,
&#34;engineer_api_key&#34;: self.set_ai_config,
&#34;engineer_auth&#34;: self.set_ai_config,
&#34;architect_model&#34;: self.set_ai_config,
&#34;architect_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host,
&#34;sync_remote&#34;: self.set_sync_remote
&#34;sync_remote&#34;: self.set_sync_remote,
&#34;shell_command&#34;: self.set_shell_config,
&#34;shell_prompt&#34;: self.set_shell_config,
&#34;shell_os&#34;: self.set_shell_config
}
handler = actions.get(getattr(args, &#34;command&#34;, None))
if handler:
@@ -178,10 +183,74 @@ el.replaceWith(d);
try:
settings = self.app.services.config_svc.get_settings()
aiconfig = settings.get(&#34;ai&#34;, {})
aiconfig[args.command] = args.data[0]
val = args.data[0]
# Check for unset/clear request
if val.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if args.command in aiconfig:
del aiconfig[args.command]
else:
# If configuring auth, parse as dictionary (JSON/YAML or file path)
if args.command in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
parsed_val = self._parse_auth_value(val)
if parsed_val is not None:
aiconfig[args.command] = parsed_val
else:
if args.command in aiconfig:
del aiconfig[args.command]
else:
aiconfig[args.command] = val
self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig)
printer.success(&#34;Config saved&#34;)
except ConnpyError as e:
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))
def _parse_auth_value(self, value):
if value.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
return None
# Check if it&#39;s a file path
import os
if os.path.exists(value):
try:
with open(value, &#34;r&#34;) as f:
content = f.read()
import json
try:
return json.loads(content)
except ValueError:
return yaml.safe_load(content)
except Exception as e:
raise InvalidConfigurationError(f&#34;Failed to read/parse auth file &#39;{value}&#39;: {e}&#34;)
# Try parsing as inline JSON/YAML
try:
import json
return json.loads(value)
except ValueError:
try:
parsed = yaml.safe_load(value)
if isinstance(parsed, dict):
return parsed
raise ValueError()
except Exception:
raise InvalidConfigurationError(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)
def set_shell_config(self, args):
key = args.command.replace(&#34;shell_&#34;, &#34;&#34;)
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get(&#34;shell&#34;, {}) if isinstance(settings.get(&#34;shell&#34;), dict) else {}
if str(val).lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting(&#34;shell&#34;, shell_cfg)
printer.success(&#34;Config saved&#34;)
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"></div>
@@ -206,12 +275,17 @@ el.replaceWith(d);
&#34;theme&#34;: self.set_theme,
&#34;engineer_model&#34;: self.set_ai_config,
&#34;engineer_api_key&#34;: self.set_ai_config,
&#34;engineer_auth&#34;: self.set_ai_config,
&#34;architect_model&#34;: self.set_ai_config,
&#34;architect_api_key&#34;: self.set_ai_config,
&#34;architect_auth&#34;: self.set_ai_config,
&#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host,
&#34;sync_remote&#34;: self.set_sync_remote
&#34;sync_remote&#34;: self.set_sync_remote,
&#34;shell_command&#34;: self.set_shell_config,
&#34;shell_prompt&#34;: self.set_shell_config,
&#34;shell_os&#34;: self.set_shell_config
}
handler = actions.get(getattr(args, &#34;command&#34;, None))
if handler:
@@ -234,10 +308,27 @@ el.replaceWith(d);
try:
settings = self.app.services.config_svc.get_settings()
aiconfig = settings.get(&#34;ai&#34;, {})
aiconfig[args.command] = args.data[0]
val = args.data[0]
# Check for unset/clear request
if val.lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if args.command in aiconfig:
del aiconfig[args.command]
else:
# If configuring auth, parse as dictionary (JSON/YAML or file path)
if args.command in [&#34;engineer_auth&#34;, &#34;architect_auth&#34;]:
parsed_val = self._parse_auth_value(val)
if parsed_val is not None:
aiconfig[args.command] = parsed_val
else:
if args.command in aiconfig:
del aiconfig[args.command]
else:
aiconfig[args.command] = val
self.app.services.config_svc.update_setting(&#34;ai&#34;, aiconfig)
printer.success(&#34;Config saved&#34;)
except ConnpyError as e:
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"></div>
@@ -365,6 +456,32 @@ el.replaceWith(d);
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.config_handler.ConfigHandler.set_shell_config"><code class="name flex">
<span>def <span class="ident">set_shell_config</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def set_shell_config(self, args):
key = args.command.replace(&#34;shell_&#34;, &#34;&#34;)
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get(&#34;shell&#34;, {}) if isinstance(settings.get(&#34;shell&#34;), dict) else {}
if str(val).lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting(&#34;shell&#34;, shell_cfg)
printer.success(&#34;Config saved&#34;)
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.config_handler.ConfigHandler.set_sync_remote"><code class="name flex">
<span>def <span class="ident">set_sync_remote</span></span>(<span>self, args)</span>
</code></dt>
@@ -469,6 +586,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_idletime" href="#connpy.cli.config_handler.ConfigHandler.set_idletime">set_idletime</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_remote_host" href="#connpy.cli.config_handler.ConfigHandler.set_remote_host">set_remote_host</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_service_mode" href="#connpy.cli.config_handler.ConfigHandler.set_service_mode">set_service_mode</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_shell_config" href="#connpy.cli.config_handler.ConfigHandler.set_shell_config">set_shell_config</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_sync_remote" href="#connpy.cli.config_handler.ConfigHandler.set_sync_remote">set_sync_remote</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_theme" href="#connpy.cli.config_handler.ConfigHandler.set_theme">set_theme</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.show_completion" href="#connpy.cli.config_handler.ConfigHandler.show_completion">show_completion</a></code></li>
@@ -482,7 +600,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+2 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.context_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>
@@ -249,7 +249,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+186 -3
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.forms 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>
@@ -61,6 +61,7 @@ el.replaceWith(d);
self.validators = Validators(app)
def questions_edit(self):
import inquirer
questions = []
questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;))
questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;))
@@ -74,6 +75,7 @@ el.replaceWith(d);
return inquirer.prompt(questions)
def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try:
defaults = self.app.services.nodes.get_node_details(unique)
if &#34;tags&#34; not in defaults:
@@ -151,6 +153,7 @@ el.replaceWith(d);
return result
def questions_profiles(self, unique, edit=None):
import inquirer
try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if &#34;tags&#34; not in defaults:
@@ -216,6 +219,7 @@ el.replaceWith(d);
return result
def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;):
import inquirer
questions = []
questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation))
@@ -249,11 +253,185 @@ el.replaceWith(d);
if &#34;tags&#34; in answer and not answer[&#34;tags&#34;].startswith(&#34;@&#34;) and answer[&#34;tags&#34;]:
answer[&#34;tags&#34;] = ast.literal_eval(answer[&#34;tags&#34;])
return answer</code></pre>
return answer
def mcp_wizard(self, mcp_servers):
&#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34;
import inquirer
from .helpers import theme
while True:
options = [
(&#34;List Configured Servers&#34;, &#34;list&#34;),
(&#34;Add/Update Server&#34;, &#34;add&#34;),
(&#34;Enable/Disable Server&#34;, &#34;toggle&#34;),
(&#34;Remove Server&#34;, &#34;remove&#34;),
(&#34;Back&#34;, &#34;exit&#34;)
]
questions = [
inquirer.List(&#34;action&#34;, message=&#34;MCP Configuration&#34;, choices=options)
]
answers = inquirer.prompt(questions, theme=theme)
if not answers or answers[&#34;action&#34;] == &#34;exit&#34;:
return None
action = answers[&#34;action&#34;]
if action == &#34;list&#34;:
if not mcp_servers:
print(&#34;\nNo MCP servers configured.\n&#34;)
else:
return {&#34;action&#34;: &#34;list&#34;}
elif action == &#34;add&#34;:
questions = [
inquirer.Text(&#34;name&#34;, message=&#34;Server Name (identifier)&#34;),
inquirer.Text(&#34;url&#34;, message=&#34;SSE URL (e.g., http://localhost:8000/sse)&#34;),
inquirer.Confirm(&#34;enabled&#34;, message=&#34;Enabled?&#34;, default=True),
inquirer.Text(&#34;auto_load_os&#34;, message=&#34;Auto-load on specific OS (blank for any)&#34;)
]
answers = inquirer.prompt(questions, theme=theme)
if answers:
return {
&#34;action&#34;: &#34;add&#34;,
&#34;name&#34;: answers[&#34;name&#34;],
&#34;url&#34;: answers[&#34;url&#34;],
&#34;enabled&#34;: answers[&#34;enabled&#34;],
&#34;os&#34;: answers[&#34;auto_load_os&#34;]
}
elif action == &#34;toggle&#34;:
if not mcp_servers:
print(&#34;\nNo servers to toggle.\n&#34;)
continue
choices = []
for name, cfg in mcp_servers.items():
status = &#34;[Enabled]&#34; if cfg.get(&#34;enabled&#34;, True) else &#34;[Disabled]&#34;
choices.append((f&#34;{name} {status}&#34;, name))
questions = [
inquirer.List(&#34;name&#34;, message=&#34;Select server to toggle&#34;, choices=choices + [(&#34;Cancel&#34;, None)])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers[&#34;name&#34;]:
current = mcp_servers[answers[&#34;name&#34;]].get(&#34;enabled&#34;, True)
return {
&#34;action&#34;: &#34;update&#34;,
&#34;name&#34;: answers[&#34;name&#34;],
&#34;enabled&#34;: not current
}
elif action == &#34;remove&#34;:
if not mcp_servers:
print(&#34;\nNo servers to remove.\n&#34;)
continue
questions = [
inquirer.List(&#34;name&#34;, message=&#34;Select server to remove&#34;, choices=list(mcp_servers.keys()) + [&#34;Cancel&#34;])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers[&#34;name&#34;] != &#34;Cancel&#34;:
return {&#34;action&#34;: &#34;remove&#34;, &#34;name&#34;: answers[&#34;name&#34;]}
return None</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.forms.Forms.mcp_wizard"><code class="name flex">
<span>def <span class="ident">mcp_wizard</span></span>(<span>self, mcp_servers)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def mcp_wizard(self, mcp_servers):
&#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34;
import inquirer
from .helpers import theme
while True:
options = [
(&#34;List Configured Servers&#34;, &#34;list&#34;),
(&#34;Add/Update Server&#34;, &#34;add&#34;),
(&#34;Enable/Disable Server&#34;, &#34;toggle&#34;),
(&#34;Remove Server&#34;, &#34;remove&#34;),
(&#34;Back&#34;, &#34;exit&#34;)
]
questions = [
inquirer.List(&#34;action&#34;, message=&#34;MCP Configuration&#34;, choices=options)
]
answers = inquirer.prompt(questions, theme=theme)
if not answers or answers[&#34;action&#34;] == &#34;exit&#34;:
return None
action = answers[&#34;action&#34;]
if action == &#34;list&#34;:
if not mcp_servers:
print(&#34;\nNo MCP servers configured.\n&#34;)
else:
return {&#34;action&#34;: &#34;list&#34;}
elif action == &#34;add&#34;:
questions = [
inquirer.Text(&#34;name&#34;, message=&#34;Server Name (identifier)&#34;),
inquirer.Text(&#34;url&#34;, message=&#34;SSE URL (e.g., http://localhost:8000/sse)&#34;),
inquirer.Confirm(&#34;enabled&#34;, message=&#34;Enabled?&#34;, default=True),
inquirer.Text(&#34;auto_load_os&#34;, message=&#34;Auto-load on specific OS (blank for any)&#34;)
]
answers = inquirer.prompt(questions, theme=theme)
if answers:
return {
&#34;action&#34;: &#34;add&#34;,
&#34;name&#34;: answers[&#34;name&#34;],
&#34;url&#34;: answers[&#34;url&#34;],
&#34;enabled&#34;: answers[&#34;enabled&#34;],
&#34;os&#34;: answers[&#34;auto_load_os&#34;]
}
elif action == &#34;toggle&#34;:
if not mcp_servers:
print(&#34;\nNo servers to toggle.\n&#34;)
continue
choices = []
for name, cfg in mcp_servers.items():
status = &#34;[Enabled]&#34; if cfg.get(&#34;enabled&#34;, True) else &#34;[Disabled]&#34;
choices.append((f&#34;{name} {status}&#34;, name))
questions = [
inquirer.List(&#34;name&#34;, message=&#34;Select server to toggle&#34;, choices=choices + [(&#34;Cancel&#34;, None)])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers[&#34;name&#34;]:
current = mcp_servers[answers[&#34;name&#34;]].get(&#34;enabled&#34;, True)
return {
&#34;action&#34;: &#34;update&#34;,
&#34;name&#34;: answers[&#34;name&#34;],
&#34;enabled&#34;: not current
}
elif action == &#34;remove&#34;:
if not mcp_servers:
print(&#34;\nNo servers to remove.\n&#34;)
continue
questions = [
inquirer.List(&#34;name&#34;, message=&#34;Select server to remove&#34;, choices=list(mcp_servers.keys()) + [&#34;Cancel&#34;])
]
answers = inquirer.prompt(questions, theme=theme)
if answers and answers[&#34;name&#34;] != &#34;Cancel&#34;:
return {&#34;action&#34;: &#34;remove&#34;, &#34;name&#34;: answers[&#34;name&#34;]}
return None</code></pre>
</details>
<div class="desc"><p>Interactive wizard to manage MCP servers.</p></div>
</dd>
<dt id="connpy.cli.forms.Forms.questions_bulk"><code class="name flex">
<span>def <span class="ident">questions_bulk</span></span>(<span>self, nodes='', hosts='')</span>
</code></dt>
@@ -263,6 +441,7 @@ el.replaceWith(d);
<span>Expand source code</span>
</summary>
<pre><code class="python">def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;):
import inquirer
questions = []
questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation))
@@ -309,6 +488,7 @@ el.replaceWith(d);
<span>Expand source code</span>
</summary>
<pre><code class="python">def questions_edit(self):
import inquirer
questions = []
questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;))
questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;))
@@ -332,6 +512,7 @@ el.replaceWith(d);
<span>Expand source code</span>
</summary>
<pre><code class="python">def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try:
defaults = self.app.services.nodes.get_node_details(unique)
if &#34;tags&#34; not in defaults:
@@ -419,6 +600,7 @@ el.replaceWith(d);
<span>Expand source code</span>
</summary>
<pre><code class="python">def questions_profiles(self, unique, edit=None):
import inquirer
try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if &#34;tags&#34; not in defaults:
@@ -505,6 +687,7 @@ el.replaceWith(d);
<li>
<h4><code><a title="connpy.cli.forms.Forms" href="#connpy.cli.forms.Forms">Forms</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.forms.Forms.mcp_wizard" href="#connpy.cli.forms.Forms.mcp_wizard">mcp_wizard</a></code></li>
<li><code><a title="connpy.cli.forms.Forms.questions_bulk" href="#connpy.cli.forms.Forms.questions_bulk">questions_bulk</a></code></li>
<li><code><a title="connpy.cli.forms.Forms.questions_edit" href="#connpy.cli.forms.Forms.questions_edit">questions_edit</a></code></li>
<li><code><a title="connpy.cli.forms.Forms.questions_nodes" href="#connpy.cli.forms.Forms.questions_nodes">questions_nodes</a></code></li>
@@ -517,7 +700,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+8 -9
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.help_text 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>
@@ -117,6 +117,10 @@ Here are some important instructions and tips for configuring your new node:
- `prompt`: Replaces default app prompt to identify the end of output or where the user can start inputting commands.
- `kube_command`: Replaces the default command (`/bin/bash`) for `kubectl exec`.
- `docker_command`: Replaces the default command for `docker exec`.
- `region`: AWS Region used for `aws ssm start-session`.
- `profile`: AWS Profile used for `aws ssm start-session`.
- `ssh_options`: Additional SSH options injected when an SSM node is used as a jumphost (e.g., `-i ~/.ssh/key.pem`).
- `nc_command`: Replaces the default `nc` command used when bridging connections through Docker or Kubernetes (e.g., `ip netns exec global-vrf nc`).
&#34;&#34;&#34;
if type == &#34;bashcompletion&#34;:
return &#39;&#39;&#39;
@@ -215,9 +219,7 @@ tasks:
nodes: #List of nodes to work on. Mandatory
- &#39;router1@office&#39; #You can add specific nodes
- &#39;@aws&#39; #entire folders or subfolders
- &#39;@office&#39;: #or filter inside a folder or subfolder
- &#39;router2&#39;
- &#39;router7&#39;
- &#39;router.*@office&#39; #or use regex to filter inside a folder
commands: #List of commands to send, use {name} to pass variables
- &#39;term len 0&#39;
@@ -243,7 +245,7 @@ tasks:
vrouterN@aws:
id: 5
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path only works on &#39;run&#39; action.
output: /home/user/logs #Type of output, if null you only get Connection and test result. Choices are: null,stdout,/path/to/folder. Folder path works on both &#39;run&#39; and &#39;test&#39; actions.
options:
prompt: r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39; #Optional prompt to check on your devices, default should work on most devices.
@@ -255,9 +257,6 @@ tasks:
nodes:
- &#39;router1@office&#39;
- &#39;@aws&#39;
- &#39;@office&#39;:
- &#39;router2&#39;
- &#39;router7&#39;
commands:
- &#39;ping 10.100.100.{id}&#39;
expected: &#39;!&#39; #Expected text to find when running test action. Mandatory for &#39;test&#39;
@@ -304,7 +303,7 @@ tasks:
</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.6</a>.</p>
<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>
+115 -3
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.helpers 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>
@@ -68,8 +68,9 @@ el.replaceWith(d);
else:
return answer[0]
else:
import inquirer
questions = [inquirer.List(name, message=&#34;Pick {} to {}:&#34;.format(name,action), choices=list_, carousel=True)]
answer = inquirer.prompt(questions)
answer = inquirer.prompt(questions, theme=theme)
if answer == None:
return None
else:
@@ -115,6 +116,86 @@ el.replaceWith(d);
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.helpers.get_theme"><code class="name flex">
<span>def <span class="ident">get_theme</span></span>(<span>)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_theme():
&#34;&#34;&#34;Returns a fresh instance of the theme with current colors.&#34;&#34;&#34;
from inquirer.themes import Default, term
class ConnpyTheme(Default):
def __init__(self):
super().__init__()
try:
from ..printer import _global_active_styles
# Use user_prompt as primary accent, fallback to info/cyan
accent = _global_active_styles.get(&#34;user_prompt&#34;, _global_active_styles.get(&#34;info&#34;, &#34;cyan&#34;))
accent_color = hex_to_blessed(accent)
self.Question.mark_color = accent_color
self.List.selection_color = accent_color
self.List.selection_cursor = &#34;&gt;&#34;
except:
# Absolute fallback to standard cyan
self.Question.mark_color = term.cyan
self.List.selection_color = term.bold_cyan
self.List.selection_cursor = &#34;&gt;&#34;
return ConnpyTheme()</code></pre>
</details>
<div class="desc"><p>Returns a fresh instance of the theme with current colors.</p></div>
</dd>
<dt id="connpy.cli.helpers.hex_to_blessed"><code class="name flex">
<span>def <span class="ident">hex_to_blessed</span></span>(<span>hex_str)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def hex_to_blessed(hex_str):
&#34;&#34;&#34;Convert hex color string to blessed/ansi format.&#34;&#34;&#34;
from inquirer.themes import term
if not hex_str or not isinstance(hex_str, str):
return term.normal
# Check for bold prefix
prefix = &#34;&#34;
if hex_str.startswith(&#39;bold &#39;):
prefix = term.bold
hex_str = hex_str.replace(&#39;bold &#39;, &#39;&#39;).strip()
# If it&#39;s a standard color name
if not hex_str.startswith(&#39;#&#39;):
return prefix + getattr(term, hex_str, term.normal)
# Parse hex
try:
h = hex_str.lstrip(&#39;#&#39;)
if len(h) == 3:
h = &#39;&#39;.join([c*2 for c in h])
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
# Try RGB, fallback to standard cyan if it fails or returns empty
try:
c = term.color_rgb(r, g, b)
if not c: # Some terms return empty for RGB
return prefix + term.cyan
return prefix + c
except:
return prefix + term.cyan
except:
return prefix + term.normal</code></pre>
</details>
<div class="desc"><p>Convert hex color string to blessed/ansi format.</p></div>
</dd>
<dt id="connpy.cli.helpers.nodes_completer"><code class="name flex">
<span>def <span class="ident">nodes_completer</span></span>(<span>prefix, parsed_args, **kwargs)</span>
</code></dt>
@@ -181,6 +262,28 @@ el.replaceWith(d);
</dl>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.helpers.ThemeProxy"><code class="flex name class">
<span>class <span class="ident">ThemeProxy</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class ThemeProxy:
&#34;&#34;&#34;Proxy to ensure theme colors are resolved at runtime.&#34;&#34;&#34;
def __getattr__(self, name):
return getattr(get_theme(), name)
def __iter__(self):
return iter(get_theme())
def __getitem__(self, item):
return get_theme()[item]</code></pre>
</details>
<div class="desc"><p>Proxy to ensure theme colors are resolved at runtime.</p></div>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
@@ -198,16 +301,25 @@ 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.ThemeProxy" href="#connpy.cli.helpers.ThemeProxy">ThemeProxy</a></code></h4>
</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.6</a>.</p>
<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>
+35 -3
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.import_export_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>
@@ -58,12 +58,24 @@ el.replaceWith(d);
<pre><code class="python">class ImportExportHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch_import(self, args):
file_path = args.data[0]
try:
printer.warning(&#34;This could overwrite your current configuration!&#34;)
import inquirer
question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;import&#34;]:
@@ -135,6 +147,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.import_export_handler.ImportExportHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.import_export_handler.ImportExportHandler.bulk"><code class="name flex">
@@ -228,6 +258,7 @@ el.replaceWith(d);
file_path = args.data[0]
try:
printer.warning(&#34;This could overwrite your current configuration!&#34;)
import inquirer
question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;import&#34;]:
@@ -264,6 +295,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.bulk" href="#connpy.cli.import_export_handler.ImportExportHandler.bulk">bulk</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_export" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_export">dispatch_export</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_import" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_import">dispatch_import</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.forms" href="#connpy.cli.import_export_handler.ImportExportHandler.forms">forms</a></code></li>
</ul>
</li>
</ul>
@@ -272,7 +304,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+27 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli 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>
@@ -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,10 +92,26 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.shell_handler" href="shell_handler.html">connpy.cli.shell_handler</a></code></dt>
<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>
</dd>
<dt><code class="name"><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></dt>
<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>
@@ -125,11 +145,16 @@ 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.shell_handler" href="shell_handler.html">connpy.cli.shell_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>
@@ -137,7 +162,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+617
View File
@@ -0,0 +1,617 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.login_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.login_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.login_handler.LoginHandler"><code class="flex name class">
<span>class <span class="ident">LoginHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class LoginHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def login(self, args):
# Handle token management actions first
if getattr(args, &#34;create_token&#34;, None):
return self.create_token(args)
if getattr(args, &#34;list_tokens&#34;, False):
return self.list_tokens(args)
if getattr(args, &#34;revoke_token&#34;, None):
return self.revoke_token(args)
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
auth_service = getattr(self.app.services, &#34;auth&#34;, None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)
def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)
def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)
def _get_auth_service(self):
&#34;&#34;&#34;Gets an authenticated auth service stub, reusing existing or creating one.&#34;&#34;&#34;
auth_service = getattr(self.app.services, &#34;auth&#34;, None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
# Load existing session token for authentication
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.error(&#34;No active session. Please log in first using &#39;connpy login&#39;.&#34;)
sys.exit(1)
with open(token_path, &#34;r&#34;) as f:
session_token = f.read().strip()
from ..grpc_layer.stubs import AuthClientInterceptor
interceptor = AuthClientInterceptor(lambda: session_token)
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server: {e}&#34;)
sys.exit(1)
return auth_service
def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, &#34;expires_days&#34;, 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f&#34;API token &#39;{name}&#39; created successfully.&#34;)
printer.warning(&#34;⚠ Copy this token now. It will NOT be shown again:&#34;)
printer.data(&#34;Token&#34;, result[&#34;raw_token&#34;])
printer.info(f&#34;Token ID: {result[&#39;token_id&#39;]}&#34;)
if expires_days &gt; 0:
printer.info(f&#34;Expires in: {expires_days} days&#34;)
else:
printer.info(&#34;Expires: Never (permanent)&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
def list_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info(&#34;No API tokens found.&#34;)
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
&#34;token_id&#34;: t[&#34;token_id&#34;],
&#34;name&#34;: t[&#34;name&#34;],
&#34;prefix&#34;: t[&#34;token_prefix&#34;],
&#34;created&#34;: t[&#34;created_at&#34;] or &#34;N/A&#34;,
&#34;last_used&#34;: t[&#34;last_used_at&#34;] or &#34;Never&#34;,
&#34;expires&#34;: t[&#34;expires_at&#34;] or &#34;Never&#34;,
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data(&#34;API Tokens&#34;, yaml_str)
except ConnpyError as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f&#34;Token &#39;{token_id}&#39; revoked successfully.&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.login_handler.LoginHandler.create_token"><code class="name flex">
<span>def <span class="ident">create_token</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, &#34;expires_days&#34;, 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f&#34;API token &#39;{name}&#39; created successfully.&#34;)
printer.warning(&#34;⚠ Copy this token now. It will NOT be shown again:&#34;)
printer.data(&#34;Token&#34;, result[&#34;raw_token&#34;])
printer.info(f&#34;Token ID: {result[&#39;token_id&#39;]}&#34;)
if expires_days &gt; 0:
printer.info(f&#34;Expires in: {expires_days} days&#34;)
else:
printer.info(&#34;Expires: Never (permanent)&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.list_tokens"><code class="name flex">
<span>def <span class="ident">list_tokens</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_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info(&#34;No API tokens found.&#34;)
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
&#34;token_id&#34;: t[&#34;token_id&#34;],
&#34;name&#34;: t[&#34;name&#34;],
&#34;prefix&#34;: t[&#34;token_prefix&#34;],
&#34;created&#34;: t[&#34;created_at&#34;] or &#34;N/A&#34;,
&#34;last_used&#34;: t[&#34;last_used_at&#34;] or &#34;Never&#34;,
&#34;expires&#34;: t[&#34;expires_at&#34;] or &#34;Never&#34;,
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data(&#34;API Tokens&#34;, yaml_str)
except ConnpyError as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def login(self, args):
# Handle token management actions first
if getattr(args, &#34;create_token&#34;, None):
return self.create_token(args)
if getattr(args, &#34;list_tokens&#34;, False):
return self.list_tokens(args)
if getattr(args, &#34;revoke_token&#34;, None):
return self.revoke_token(args)
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
auth_service = getattr(self.app.services, &#34;auth&#34;, None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.logout"><code class="name flex">
<span>def <span class="ident">logout</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.revoke_token"><code class="name flex">
<span>def <span class="ident">revoke_token</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f&#34;Token &#39;{token_id}&#39; revoked successfully.&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.show_status"><code class="name flex">
<span>def <span class="ident">show_status</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4>
<ul class="two-column">
<li><code><a title="connpy.cli.login_handler.LoginHandler.create_token" href="#connpy.cli.login_handler.LoginHandler.create_token">create_token</a></code></li>
<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.list_tokens" href="#connpy.cli.login_handler.LoginHandler.list_tokens">list_tokens</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.revoke_token" href="#connpy.cli.login_handler.LoginHandler.revoke_token">revoke_token</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>
+82 -15
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.node_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>
@@ -58,7 +58,35 @@ el.replaceWith(d);
<pre><code class="python">class NodeHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def _filter_exact_match(self, matches, query):
if not query or len(matches) &lt;= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args):
if not self.app.case and args.data != None:
@@ -85,6 +113,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 = []
@@ -104,7 +133,7 @@ el.replaceWith(d);
debug=args.debug,
logger=self.app._service_logger
)
except ConnpyError as e:
except (ConnpyError, ValueError) as e:
printer.error(str(e))
sys.exit(1)
@@ -119,6 +148,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 = []
@@ -127,14 +157,16 @@ el.replaceWith(d);
sys.exit(2)
printer.info(f&#34;Removing: {matches}&#34;)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]:
sys.exit(7)
try:
for item in matches:
self.app.services.nodes.delete_node(item, is_folder=is_folder)
for i, item in enumerate(matches):
save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -190,6 +222,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 +250,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 +289,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;)
else:
editcount = 0
changed_items = []
for k in matches:
updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -268,8 +302,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key]
if this_item_changed:
editcount += 1
self.app.services.nodes.update_node(k, updated_item)
changed_items.append((k, updated_item))
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0:
printer.info(&#34;Nothing to do here&#34;)
@@ -280,6 +318,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.node_handler.NodeHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.node_handler.NodeHandler.add"><code class="name flex">
@@ -354,6 +410,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 = []
@@ -373,7 +430,7 @@ el.replaceWith(d);
debug=args.debug,
logger=self.app._service_logger
)
except ConnpyError as e:
except (ConnpyError, ValueError) as e:
printer.error(str(e))
sys.exit(1)</code></pre>
</details>
@@ -398,6 +455,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 = []
@@ -406,14 +464,16 @@ el.replaceWith(d);
sys.exit(2)
printer.info(f&#34;Removing: {matches}&#34;)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]:
sys.exit(7)
try:
for item in matches:
self.app.services.nodes.delete_node(item, is_folder=is_folder)
for i, item in enumerate(matches):
save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -456,6 +516,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 +555,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;)
else:
editcount = 0
changed_items = []
for k in matches:
updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -507,8 +568,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key]
if this_item_changed:
editcount += 1
self.app.services.nodes.update_node(k, updated_item)
changed_items.append((k, updated_item))
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0:
printer.info(&#34;Nothing to do here&#34;)
@@ -535,6 +600,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 = []
@@ -595,6 +661,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.node_handler.NodeHandler.connect" href="#connpy.cli.node_handler.NodeHandler.connect">connect</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.delete" href="#connpy.cli.node_handler.NodeHandler.delete">delete</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.dispatch" href="#connpy.cli.node_handler.NodeHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.forms" href="#connpy.cli.node_handler.NodeHandler.forms">forms</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.modify" href="#connpy.cli.node_handler.NodeHandler.modify">modify</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.show" href="#connpy.cli.node_handler.NodeHandler.show">show</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.version" href="#connpy.cli.node_handler.NodeHandler.version">version</a></code></li>
@@ -606,7 +673,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+18 -6
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.plugin_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>
@@ -167,6 +167,8 @@ el.replaceWith(d);
# Populate local plugins
for name, details in local_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -175,11 +177,14 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Remote)&#34;
color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Local&#34;)
origin = details.get(&#34;origin&#34;, &#34;Local&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
# Populate remote plugins
if self.app.services.mode == &#34;remote&#34;:
for name, details in remote_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -190,7 +195,8 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Local)&#34;
color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Remote&#34;)
origin = details.get(&#34;origin&#34;, &#34;Remote&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
if not local_plugins and not remote_plugins:
printer.console.print(&#34; No plugins found.&#34;)
@@ -320,6 +326,8 @@ el.replaceWith(d);
# Populate local plugins
for name, details in local_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -328,11 +336,14 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Remote)&#34;
color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Local&#34;)
origin = details.get(&#34;origin&#34;, &#34;Local&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
# Populate remote plugins
if self.app.services.mode == &#34;remote&#34;:
for name, details in remote_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -343,7 +354,8 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Local)&#34;
color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Remote&#34;)
origin = details.get(&#34;origin&#34;, &#34;Remote&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
if not local_plugins and not remote_plugins:
printer.console.print(&#34; No plugins found.&#34;)
@@ -385,7 +397,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+36 -4
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.profile_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>
@@ -58,7 +58,18 @@ el.replaceWith(d);
<pre><code class="python">class ProfileHandler:
def __init__(self, app):
self.app = app
self.forms = Forms(app)
self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch(self, args):
if not self.app.case:
@@ -78,6 +89,7 @@ el.replaceWith(d);
printer.error(&#34;Can&#39;t delete default profile&#34;)
sys.exit(6)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]:
@@ -145,6 +157,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.profile_handler.ProfileHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.profile_handler.ProfileHandler.add"><code class="name flex">
@@ -194,6 +224,7 @@ el.replaceWith(d);
printer.error(&#34;Can&#39;t delete default profile&#34;)
sys.exit(6)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)]
confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]:
@@ -300,10 +331,11 @@ el.replaceWith(d);
<ul>
<li>
<h4><code><a title="connpy.cli.profile_handler.ProfileHandler" href="#connpy.cli.profile_handler.ProfileHandler">ProfileHandler</a></code></h4>
<ul class="">
<ul class="two-column">
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.add" href="#connpy.cli.profile_handler.ProfileHandler.add">add</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.delete" href="#connpy.cli.profile_handler.ProfileHandler.delete">delete</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.dispatch" href="#connpy.cli.profile_handler.ProfileHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.forms" href="#connpy.cli.profile_handler.ProfileHandler.forms">forms</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.modify" href="#connpy.cli.profile_handler.ProfileHandler.modify">modify</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.show" href="#connpy.cli.profile_handler.ProfileHandler.show">show</a></code></li>
</ul>
@@ -314,7 +346,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
<!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.shell_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.shell_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.shell_handler.ShellHandler"><code class="flex name class">
<span>class <span class="ident">ShellHandler</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 ShellHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
shell_config = self.app.config.config.get(&#34;shell&#34;, {}) if hasattr(self.app.config, &#34;config&#34;) else {}
command = getattr(args, &#39;command_override&#39;, None) or shell_config.get(&#34;command&#34;) or os.environ.get(&#34;SHELL&#34;, &#34;/bin/bash&#34;)
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f&#34;Shell command executable not found: {exe}&#34;)
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
&#34;os&#34;: node_info[&#34;os&#34;],
&#34;prompt&#34;: node_info[&#34;prompt&#34;]
}
n = node(
unique=node_info[&#34;name&#34;],
host=command,
protocol=&#34;local&#34;,
config=self.app.config,
tags=tags
)
capture_file = getattr(args, &#39;capture_file&#39;, None)
if capture_file:
n.logs = capture_file
elif shell_config.get(&#34;logging&#34;):
n.logs = shell_config.get(&#34;log_path&#34;, os.path.expanduser(&#34;~/.config/conn/shell_logs/session.log&#34;))
n.interact(debug=getattr(args, &#39;debug&#39;, False))
def _build_local_identity(self, shell_config):
return {
&#34;name&#34;: &#34;local-shell&#34;,
&#34;host&#34;: socket.gethostname(),
&#34;os&#34;: shell_config.get(&#34;os&#34;, &#34;linux&#34;),
&#34;prompt&#34;: shell_config.get(&#34;prompt&#34;, r&#39;\$\s*$|#\s*$&#39;)
}</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.shell_handler.ShellHandler.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):
shell_config = self.app.config.config.get(&#34;shell&#34;, {}) if hasattr(self.app.config, &#34;config&#34;) else {}
command = getattr(args, &#39;command_override&#39;, None) or shell_config.get(&#34;command&#34;) or os.environ.get(&#34;SHELL&#34;, &#34;/bin/bash&#34;)
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f&#34;Shell command executable not found: {exe}&#34;)
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
&#34;os&#34;: node_info[&#34;os&#34;],
&#34;prompt&#34;: node_info[&#34;prompt&#34;]
}
n = node(
unique=node_info[&#34;name&#34;],
host=command,
protocol=&#34;local&#34;,
config=self.app.config,
tags=tags
)
capture_file = getattr(args, &#39;capture_file&#39;, None)
if capture_file:
n.logs = capture_file
elif shell_config.get(&#34;logging&#34;):
n.logs = shell_config.get(&#34;log_path&#34;, os.path.expanduser(&#34;~/.config/conn/shell_logs/session.log&#34;))
n.interact(debug=getattr(args, &#39;debug&#39;, False))</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.shell_handler.ShellHandler" href="#connpy.cli.shell_handler.ShellHandler">ShellHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.shell_handler.ShellHandler.dispatch" href="#connpy.cli.shell_handler.ShellHandler.dispatch">dispatch</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>
+463
View File
@@ -0,0 +1,463 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.sso_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.sso_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler"><code class="flex name class">
<span>class <span class="ident">SSOHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class SSOHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_provider(self, args):
import inquirer
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
import inquirer
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
return
# Print list in YAML format
providers_list = list(providers.keys())
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
printer.data(&#34;Configured SSO Providers&#34;, yaml_str)
def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler.add_provider"><code class="name flex">
<span>def <span class="ident">add_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_provider(self, args):
import inquirer
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.delete_provider"><code class="name flex">
<span>def <span class="ident">delete_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
import inquirer
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.list_providers"><code class="name flex">
<span>def <span class="ident">list_providers</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
return
# Print list in YAML format
providers_list = list(providers.keys())
yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
printer.data(&#34;Configured SSO Providers&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.show_provider"><code class="name flex">
<span>def <span class="ident">show_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.sso_handler.SSOHandler" href="#connpy.cli.sso_handler.SSOHandler">SSOHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.sso_handler.SSOHandler.add_provider" href="#connpy.cli.sso_handler.SSOHandler.add_provider">add_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.delete_provider" href="#connpy.cli.sso_handler.SSOHandler.delete_provider">delete_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.dispatch" href="#connpy.cli.sso_handler.SSOHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.list_providers" href="#connpy.cli.sso_handler.SSOHandler.list_providers">list_providers</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.show_provider" href="#connpy.cli.sso_handler.SSOHandler.show_provider">show_provider</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer>
</body>
</html>
+2 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.sync_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>
@@ -427,7 +427,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
File diff suppressed because it is too large Load Diff
+522
View File
@@ -0,0 +1,522 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.user_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.user_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.user_handler.UserHandler"><code class="flex name class">
<span>class <span class="ident">UserHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)
def delete_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)
def show_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.user_handler.UserHandler.add_user"><code class="name flex">
<span>def <span class="ident">add_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.delete_user"><code class="name flex">
<span>def <span class="ident">delete_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.list_users"><code class="name flex">
<span>def <span class="ident">list_users</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.regen_password"><code class="name flex">
<span>def <span class="ident">regen_password</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.show_user"><code class="name flex">
<span>def <span class="ident">show_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.user_handler.UserHandler" href="#connpy.cli.user_handler.UserHandler">UserHandler</a></code></h4>
<ul class="two-column">
<li><code><a title="connpy.cli.user_handler.UserHandler.add_user" href="#connpy.cli.user_handler.UserHandler.add_user">add_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.delete_user" href="#connpy.cli.user_handler.UserHandler.delete_user">delete_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.dispatch" href="#connpy.cli.user_handler.UserHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.list_users" href="#connpy.cli.user_handler.UserHandler.list_users">list_users</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.regen_password" href="#connpy.cli.user_handler.UserHandler.regen_password">regen_password</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.show_user" href="#connpy.cli.user_handler.UserHandler.show_user">show_user</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer>
</body>
</html>
+50 -50
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.validators 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>
@@ -61,61 +61,61 @@ el.replaceWith(d);
def host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True
def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
_raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
return True
def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
_raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True
def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
try:
port = int(current)
except ValueError:
port = 0
if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535 or leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535 or leave empty&#34;)
return True
def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
try:
port = int(current)
except ValueError:
port = 0
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
return True
def pass_validation(self, answers, current, regex = &#34;(^@.+$)&#34;):
profiles = current.split(&#34;,&#34;)
for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(i))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(i))
return True
def tags_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;:
isdict = False
try:
@@ -123,7 +123,7 @@ el.replaceWith(d);
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current))
_raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True
def profile_tags_validation(self, answers, current):
@@ -134,36 +134,36 @@ el.replaceWith(d);
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current))
_raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True
def jumphost_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;:
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current))
_raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True
def profile_jumphost_validation(self, answers, current):
if current != &#34;&#34;:
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current))
_raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True
def default_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True
def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True
def bulk_folder_validation(self, answers, current):
@@ -176,19 +176,19 @@ el.replaceWith(d);
matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != &#34;&#34; and len(matches) == 0:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Location {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Location {} don&#39;t exist&#34;.format(current))
return True
def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
hosts = current.split(&#34;,&#34;)
nodes = answers[&#34;ids&#34;].split(&#34;,&#34;)
if len(hosts) &gt; 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Hosts list should be the same length of nodes list&#34;)
_raise_val_err(&#34;Hosts list should be the same length of nodes list&#34;)
return True</code></pre>
</details>
<div class="desc"></div>
@@ -212,7 +212,7 @@ el.replaceWith(d);
matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != &#34;&#34; and len(matches) == 0:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Location {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Location {} don&#39;t exist&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -227,14 +227,14 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
hosts = current.split(&#34;,&#34;)
nodes = answers[&#34;ids&#34;].split(&#34;,&#34;)
if len(hosts) &gt; 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Hosts list should be the same length of nodes list&#34;)
_raise_val_err(&#34;Hosts list should be the same length of nodes list&#34;)
return True</code></pre>
</details>
<div class="desc"></div>
@@ -249,10 +249,10 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -268,7 +268,7 @@ el.replaceWith(d);
<pre><code class="python">def default_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -283,10 +283,10 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;)
_raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -302,10 +302,10 @@ el.replaceWith(d);
<pre><code class="python">def jumphost_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;:
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current))
_raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -322,7 +322,7 @@ el.replaceWith(d);
profiles = current.split(&#34;,&#34;)
for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(i))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(i))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -337,16 +337,16 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
try:
port = int(current)
except ValueError:
port = 0
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
return True</code></pre>
</details>
<div class="desc"></div>
@@ -362,7 +362,7 @@ el.replaceWith(d);
<pre><code class="python">def profile_jumphost_validation(self, answers, current):
if current != &#34;&#34;:
if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current))
_raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -377,13 +377,13 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
try:
port = int(current)
except ValueError:
port = 0
if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535 or leave empty&#34;)
_raise_val_err(&#34;Pick a port between 1-65535 or leave empty&#34;)
return True</code></pre>
</details>
<div class="desc"></div>
@@ -398,7 +398,7 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
_raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
return True</code></pre>
</details>
<div class="desc"></div>
@@ -419,7 +419,7 @@ el.replaceWith(d);
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current))
_raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -434,10 +434,10 @@ el.replaceWith(d);
</summary>
<pre><code class="python">def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;):
if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
_raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -453,7 +453,7 @@ el.replaceWith(d);
<pre><code class="python">def tags_validation(self, answers, current):
if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current))
_raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;:
isdict = False
try:
@@ -461,7 +461,7 @@ el.replaceWith(d);
except Exception:
pass
if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current))
_raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True</code></pre>
</details>
<div class="desc"></div>
@@ -508,7 +508,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
+2 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.connpy_pb2 API documentation</title>
<meta name="description" content="Generated protocol buffer code.">
<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>
@@ -61,7 +61,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer 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>
@@ -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>
@@ -102,7 +107,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>
@@ -3,7 +3,7 @@
<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.6">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.remote_plugin_pb2 API documentation</title>
<meta name="description" content="Generated protocol buffer code.">
<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>
@@ -62,7 +62,7 @@ el.replaceWith(d);
<dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.IdRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"><p>The type of the None singleton.</p></div>
<div class="desc"></div>
</dd>
</dl>
</dd>
@@ -81,7 +81,7 @@ el.replaceWith(d);
<dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.OutputChunk.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"><p>The type of the None singleton.</p></div>
<div class="desc"></div>
</dd>
</dl>
</dd>
@@ -100,7 +100,7 @@ el.replaceWith(d);
<dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.PluginInvokeRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"><p>The type of the None singleton.</p></div>
<div class="desc"></div>
</dd>
</dl>
</dd>
@@ -119,7 +119,7 @@ el.replaceWith(d);
<dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.StringResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd>
<div class="desc"><p>The type of the None singleton.</p></div>
<div class="desc"></div>
</dd>
</dl>
</dd>
@@ -168,7 +168,7 @@ el.replaceWith(d);
</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.6</a>.</p>
<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>

Some files were not shown because too many files have changed in this diff Show More