feat(cli): add multiline input support for AI prompts, update docs and bump to v6.0.5
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = "6.0.4"
|
__version__ = "6.0.5"
|
||||||
|
|||||||
@@ -379,6 +379,35 @@ class RunHandler:
|
|||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.syntax import Syntax
|
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]
|
dest_file = args.data[0]
|
||||||
if os.path.exists(dest_file):
|
if os.path.exists(dest_file):
|
||||||
@@ -390,12 +419,15 @@ class RunHandler:
|
|||||||
# Consistent layout opening matching global AI (engineer style)
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
printer.console.print(Rule(style="engineer"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
printer.console.print(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"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
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):
|
except (KeyboardInterrupt, EOFError):
|
||||||
printer.console.print()
|
printer.console.print()
|
||||||
printer.warning("Operation cancelled by user.")
|
printer.warning("Operation cancelled by user.")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from rich.panel import Panel
|
|||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
from prompt_toolkit.filters import has_completions
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
from prompt_toolkit.history import InMemoryHistory
|
from prompt_toolkit.history import InMemoryHistory
|
||||||
|
|
||||||
@@ -126,6 +128,16 @@ class CopilotInterface:
|
|||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
event.app.exit(result='')
|
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():
|
def get_active_buffer():
|
||||||
if state['context_mode'] == self.mode_lines:
|
if state['context_mode'] == self.mode_lines:
|
||||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||||
@@ -271,7 +283,8 @@ class CopilotInterface:
|
|||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
bottom_toolbar=get_toolbar
|
bottom_toolbar=get_toolbar,
|
||||||
|
multiline=True
|
||||||
)
|
)
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
@@ -284,13 +297,14 @@ class CopilotInterface:
|
|||||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||||
|
|
||||||
if directive["action"] == "state_update":
|
if directive["action"] == "state_update":
|
||||||
state['toolbar_msg'] = directive['message']
|
msg = directive['message']
|
||||||
|
state['toolbar_msg'] = msg
|
||||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||||
|
|
||||||
async def delayed_refresh():
|
async def delayed_refresh():
|
||||||
await asyncio.sleep(3.1)
|
await asyncio.sleep(3.1)
|
||||||
# Only invalidate if the message hasn't been replaced by a newer one
|
# Only invalidate if the message hasn't been replaced by a newer one
|
||||||
if state.get('toolbar_msg') == directive['message']:
|
if state.get('toolbar_msg') == msg:
|
||||||
state['toolbar_msg'] = '' # Explicitly clear
|
state['toolbar_msg'] = '' # Explicitly clear
|
||||||
try:
|
try:
|
||||||
from prompt_toolkit.application.current import get_app
|
from prompt_toolkit.application.current import get_app
|
||||||
|
|||||||
@@ -100,15 +100,16 @@ def test_ai_generate_wizard_save(app, tmp_path):
|
|||||||
})
|
})
|
||||||
app.services.ai.build_playbook_chat = mock_chat
|
app.services.ai.build_playbook_chat = mock_chat
|
||||||
|
|
||||||
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save
|
# Mock prompt_toolkit PromptSession.prompt for user input, and Prompt.ask for save confirmation
|
||||||
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]):
|
with patch("prompt_toolkit.PromptSession.prompt", return_value="create a basic task"):
|
||||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
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)
|
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
|
||||||
assert os.path.exists(dest_yaml)
|
assert os.path.exists(dest_yaml)
|
||||||
with open(dest_yaml) as f:
|
with open(dest_yaml) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
assert "tasks:" in content
|
assert "tasks:" in content
|
||||||
|
|
||||||
def test_ai_generate_wizard_run(app, tmp_path):
|
def test_ai_generate_wizard_run(app, tmp_path):
|
||||||
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
|
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
|
||||||
@@ -121,16 +122,17 @@ def test_ai_generate_wizard_run(app, tmp_path):
|
|||||||
})
|
})
|
||||||
app.services.ai.build_playbook_chat = mock_chat
|
app.services.ai.build_playbook_chat = mock_chat
|
||||||
|
|
||||||
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]):
|
with patch("prompt_toolkit.PromptSession.prompt", return_value="create task"):
|
||||||
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
|
with patch("rich.prompt.Prompt.ask", return_value="run"):
|
||||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
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)
|
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
|
||||||
assert os.path.exists(dest_yaml)
|
assert os.path.exists(dest_yaml)
|
||||||
with open(dest_yaml) as f:
|
with open(dest_yaml) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
assert "tasks:" in content
|
assert "tasks:" in content
|
||||||
|
|
||||||
mock_yaml_run.assert_called_once()
|
mock_yaml_run.assert_called_once()
|
||||||
args = mock_yaml_run.call_args[0][0]
|
args = mock_yaml_run.call_args[0][0]
|
||||||
assert args.data == [str(dest_yaml)]
|
assert args.data == [str(dest_yaml)]
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ el.replaceWith(d);
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
logger=self.app._service_logger
|
logger=self.app._service_logger
|
||||||
)
|
)
|
||||||
except ConnpyError as e:
|
except (ConnpyError, ValueError) as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ el.replaceWith(d);
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
logger=self.app._service_logger
|
logger=self.app._service_logger
|
||||||
)
|
)
|
||||||
except ConnpyError as e:
|
except (ConnpyError, ValueError) as e:
|
||||||
printer.error(str(e))
|
printer.error(str(e))
|
||||||
sys.exit(1)</code></pre>
|
sys.exit(1)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
# Populate local plugins
|
# Populate local plugins
|
||||||
for name, details in local_plugins.items():
|
for name, details in local_plugins.items():
|
||||||
|
if details.get("origin") == "core":
|
||||||
|
continue
|
||||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||||
color = "red" if state == "Disabled" else "green"
|
color = "red" if state == "Disabled" else "green"
|
||||||
|
|
||||||
@@ -175,11 +177,14 @@ el.replaceWith(d);
|
|||||||
state = "Shadowed (Override by Remote)"
|
state = "Shadowed (Override by Remote)"
|
||||||
color = "yellow"
|
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
|
# Populate remote plugins
|
||||||
if self.app.services.mode == "remote":
|
if self.app.services.mode == "remote":
|
||||||
for name, details in remote_plugins.items():
|
for name, details in remote_plugins.items():
|
||||||
|
if details.get("origin") == "core":
|
||||||
|
continue
|
||||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||||
color = "red" if state == "Disabled" else "green"
|
color = "red" if state == "Disabled" else "green"
|
||||||
|
|
||||||
@@ -190,7 +195,8 @@ el.replaceWith(d);
|
|||||||
state = "Shadowed (Override by Local)"
|
state = "Shadowed (Override by Local)"
|
||||||
color = "yellow"
|
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:
|
if not local_plugins and not remote_plugins:
|
||||||
printer.console.print(" No plugins found.")
|
printer.console.print(" No plugins found.")
|
||||||
@@ -320,6 +326,8 @@ el.replaceWith(d);
|
|||||||
|
|
||||||
# Populate local plugins
|
# Populate local plugins
|
||||||
for name, details in local_plugins.items():
|
for name, details in local_plugins.items():
|
||||||
|
if details.get("origin") == "core":
|
||||||
|
continue
|
||||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||||
color = "red" if state == "Disabled" else "green"
|
color = "red" if state == "Disabled" else "green"
|
||||||
|
|
||||||
@@ -328,11 +336,14 @@ el.replaceWith(d);
|
|||||||
state = "Shadowed (Override by Remote)"
|
state = "Shadowed (Override by Remote)"
|
||||||
color = "yellow"
|
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
|
# Populate remote plugins
|
||||||
if self.app.services.mode == "remote":
|
if self.app.services.mode == "remote":
|
||||||
for name, details in remote_plugins.items():
|
for name, details in remote_plugins.items():
|
||||||
|
if details.get("origin") == "core":
|
||||||
|
continue
|
||||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||||
color = "red" if state == "Disabled" else "green"
|
color = "red" if state == "Disabled" else "green"
|
||||||
|
|
||||||
@@ -343,7 +354,8 @@ el.replaceWith(d);
|
|||||||
state = "Shadowed (Override by Local)"
|
state = "Shadowed (Override by Local)"
|
||||||
color = "yellow"
|
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:
|
if not local_plugins and not remote_plugins:
|
||||||
printer.console.print(" No plugins found.")
|
printer.console.print(" No plugins found.")
|
||||||
|
|||||||
@@ -427,6 +427,35 @@ el.replaceWith(d);
|
|||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.syntax import Syntax
|
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]
|
dest_file = args.data[0]
|
||||||
if os.path.exists(dest_file):
|
if os.path.exists(dest_file):
|
||||||
@@ -438,12 +467,15 @@ el.replaceWith(d);
|
|||||||
# Consistent layout opening matching global AI (engineer style)
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
printer.console.print(Rule(style="engineer"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
printer.console.print(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"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
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):
|
except (KeyboardInterrupt, EOFError):
|
||||||
printer.console.print()
|
printer.console.print()
|
||||||
printer.warning("Operation cancelled by user.")
|
printer.warning("Operation cancelled by user.")
|
||||||
@@ -547,6 +579,35 @@ el.replaceWith(d);
|
|||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.syntax import Syntax
|
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]
|
dest_file = args.data[0]
|
||||||
if os.path.exists(dest_file):
|
if os.path.exists(dest_file):
|
||||||
@@ -558,12 +619,15 @@ el.replaceWith(d);
|
|||||||
# Consistent layout opening matching global AI (engineer style)
|
# Consistent layout opening matching global AI (engineer style)
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
printer.console.print(Rule(style="engineer"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
printer.console.print(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"))
|
printer.console.print(Rule(style="engineer"))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
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):
|
except (KeyboardInterrupt, EOFError):
|
||||||
printer.console.print()
|
printer.console.print()
|
||||||
printer.warning("Operation cancelled by user.")
|
printer.warning("Operation cancelled by user.")
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ el.replaceWith(d);
|
|||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
event.app.exit(result='')
|
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():
|
def get_active_buffer():
|
||||||
if state['context_mode'] == self.mode_lines:
|
if state['context_mode'] == self.mode_lines:
|
||||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||||
@@ -305,7 +315,8 @@ el.replaceWith(d);
|
|||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
bottom_toolbar=get_toolbar
|
bottom_toolbar=get_toolbar,
|
||||||
|
multiline=True
|
||||||
)
|
)
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
@@ -318,13 +329,14 @@ el.replaceWith(d);
|
|||||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||||
|
|
||||||
if directive["action"] == "state_update":
|
if directive["action"] == "state_update":
|
||||||
state['toolbar_msg'] = directive['message']
|
msg = directive['message']
|
||||||
|
state['toolbar_msg'] = msg
|
||||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||||
|
|
||||||
async def delayed_refresh():
|
async def delayed_refresh():
|
||||||
await asyncio.sleep(3.1)
|
await asyncio.sleep(3.1)
|
||||||
# Only invalidate if the message hasn't been replaced by a newer one
|
# Only invalidate if the message hasn't been replaced by a newer one
|
||||||
if state.get('toolbar_msg') == directive['message']:
|
if state.get('toolbar_msg') == msg:
|
||||||
state['toolbar_msg'] = '' # Explicitly clear
|
state['toolbar_msg'] = '' # Explicitly clear
|
||||||
try:
|
try:
|
||||||
from prompt_toolkit.application.current import get_app
|
from prompt_toolkit.application.current import get_app
|
||||||
@@ -614,6 +626,16 @@ el.replaceWith(d);
|
|||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
event.app.exit(result='')
|
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():
|
def get_active_buffer():
|
||||||
if state['context_mode'] == self.mode_lines:
|
if state['context_mode'] == self.mode_lines:
|
||||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||||
@@ -759,7 +781,8 @@ el.replaceWith(d);
|
|||||||
question = await session.prompt_async(
|
question = await session.prompt_async(
|
||||||
get_prompt_text,
|
get_prompt_text,
|
||||||
key_bindings=bindings,
|
key_bindings=bindings,
|
||||||
bottom_toolbar=get_toolbar
|
bottom_toolbar=get_toolbar,
|
||||||
|
multiline=True
|
||||||
)
|
)
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
@@ -772,13 +795,14 @@ el.replaceWith(d);
|
|||||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||||
|
|
||||||
if directive["action"] == "state_update":
|
if directive["action"] == "state_update":
|
||||||
state['toolbar_msg'] = directive['message']
|
msg = directive['message']
|
||||||
|
state['toolbar_msg'] = msg
|
||||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||||
|
|
||||||
async def delayed_refresh():
|
async def delayed_refresh():
|
||||||
await asyncio.sleep(3.1)
|
await asyncio.sleep(3.1)
|
||||||
# Only invalidate if the message hasn't been replaced by a newer one
|
# Only invalidate if the message hasn't been replaced by a newer one
|
||||||
if state.get('toolbar_msg') == directive['message']:
|
if state.get('toolbar_msg') == msg:
|
||||||
state['toolbar_msg'] = '' # Explicitly clear
|
state['toolbar_msg'] = '' # Explicitly clear
|
||||||
try:
|
try:
|
||||||
from prompt_toolkit.application.current import get_app
|
from prompt_toolkit.application.current import get_app
|
||||||
|
|||||||
+333
-238
@@ -41,169 +41,196 @@ el.replaceWith(d);
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
|
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
|
||||||
</p>
|
</p>
|
||||||
<h1 id="connpy">Connpy</h1>
|
<h1 id="connpy-v603">Connpy (v6.0.3)</h1>
|
||||||
<p><a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/v/connpy.svg?style=flat-square"></a>
|
<p><a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/v/connpy.svg?style=flat-square"></a>
|
||||||
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square"></a>
|
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square"></a>
|
||||||
<a href="https://github.com/fluzzi/connpy/blob/main/LICENSE"><img alt="" src="https://img.shields.io/pypi/l/connpy.svg?style=flat-square"></a>
|
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400"></a>
|
||||||
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/dm/connpy.svg?style=flat-square"></a></p>
|
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square"></a>
|
||||||
|
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/backend-gRPC-blue?style=flat-square"></a>
|
||||||
|
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square"></a>
|
||||||
|
<a href="https://modelcontextprotocol.io"><img alt="" src="https://img.shields.io/badge/MCP-compatible-orange?style=flat-square"></a>
|
||||||
|
<a href="https://github.com/fluzzi/connpy/blob/main/LICENSE"><img alt="" src="https://img.shields.io/pypi/l/connpy.svg?style=flat-square"></a></p>
|
||||||
<p><strong>Connpy</strong> is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for <strong>SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM</strong>.</p>
|
<p><strong>Connpy</strong> is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for <strong>SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM</strong>.</p>
|
||||||
<p>The v6 release introduces the <strong>AI Copilot</strong>, an interactive terminal assistant that understands your network context and helps you manage your infrastructure more intelligently.</p>
|
<p>The v6 release introduces a comprehensive <strong>AI Copilot</strong> and <strong>AI Playbook Engine</strong>, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.</p>
|
||||||
<h2 id="ai-copilot-new-in-v6">🤖 AI Copilot (New in v6)</h2>
|
<hr>
|
||||||
<p>The AI Copilot is deeply integrated into your terminal workflow:
|
<h2 id="1-ai-system">1. 🤖 AI System</h2>
|
||||||
- <strong>Terminal Context Awareness</strong>: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
|
<h3 id="1a-terminal-copilot-ctrlspace">1a. Terminal Copilot (Ctrl+Space)</h3>
|
||||||
- <strong>Dynamic Context Selection</strong>: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
|
<p>Invoke the context-aware AI Copilot directly inside any active terminal session by pressing <strong><code>Ctrl + Space</code></strong>.
|
||||||
- <strong>Hybrid Multi-Agent System</strong>: Automatically escalates complex tasks between the <strong>Network Engineer</strong> (execution) and the <strong>Network Architect</strong> (strategy).
|
* <strong>Context Modes</strong>: Cycles through <code>LINES</code> (sends raw scroll buffer), <code>SINGLE</code> (captures exactly one command + output block), and <code>RANGE</code> (logical group of recent commands) using <strong><code>Ctrl+Up/Down</code></strong>.
|
||||||
- <strong>MCP Integration</strong>: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
|
* <strong>Slash Commands (<code>/</code>)</strong>: Control the AI persona and safety settings:
|
||||||
- <strong>Flexible Auth & Keyless AI</strong>: Support for advanced LiteLLM credentials (<code>--engineer-auth</code> / <code>--architect-auth</code>) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
|
* <code>/architect</code> / <code>/engineer</code>: Swaps the agent between high-level strategist and technical executor.
|
||||||
- <strong>Enhanced Session Management</strong>: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
|
* <code>/trust</code> / <code>/untrust</code>: Configures auto-run behavior for suggested non-destructive commands.
|
||||||
- <strong>Semantic Prompt Integration</strong>: Emit standard OSC prompt sequences (<code>]133;B</code>) for real-time remote/web front-end command tracking.
|
* <code>/os [system]</code>: Manually overrides target OS parsing rules (e.g. <code>/os cisco_ios</code>).
|
||||||
- <strong>Interactive Chat</strong>: Launch with <code>conn <a title="connpy.ai" href="#connpy.ai">ai</a></code> for a collaborative troubleshooting session.</p>
|
* <code>/prompt [regex]</code>: Overrides command prompt detection bounds.
|
||||||
<h2 id="core-features">Core Features</h2>
|
* <code>/clear</code>: Clear context history.</p>
|
||||||
|
<h3 id="1b-ai-chat-conn-ai">1b. AI Chat (conn ai)</h3>
|
||||||
|
<p>Start a standalone persistent session with the AI Copilot. Manage sessions using <code>--list</code>, <code>--resume</code>, <code>--session <id></code> (to restore a specific history), <code>--delete <id></code>, or send a quick single-shot question directly from the terminal prompt:</p>
|
||||||
|
<pre><code class="language-bash">conn ai "how do i check bgp summary on cisco?"
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="1c-mcp-integration">1c. MCP Integration</h3>
|
||||||
|
<p>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:</p>
|
||||||
|
<pre><code class="language-bash">conn ai --mcp
|
||||||
|
</code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="2-automation-playbooks">2. ⚙️ Automation & Playbooks</h2>
|
||||||
|
<h3 id="2a-quick-run-conn-run">2a. Quick Run (conn run)</h3>
|
||||||
|
<p>Run commands in parallel directly on target nodes or folder structures:</p>
|
||||||
|
<pre><code class="language-bash">conn run router1 "show interface"
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="2b-yaml-playbook-engine">2b. YAML Playbook Engine</h3>
|
||||||
|
<p>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.</p>
|
||||||
|
<pre><code class="language-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"
|
||||||
|
</code></pre>
|
||||||
|
<p>Execute using the playbooks runner:</p>
|
||||||
|
<pre><code class="language-bash">conn run example_playbook.yaml
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="2c-ai-assisted-automation">2c. AI-Assisted Automation</h3>
|
||||||
|
<p>Leverage AI to generate playbook templates (<code>--generate-ai</code>), simulate command changes before execution (<code>--preflight-ai</code>), or analyze consolidated execution logs post-run (<code>--analyze</code>). Use <code>--test "expected text1" "expected text2"</code> to specify assert-style output validations.
|
||||||
|
* <em>To generate an empty template:</em> <code>conn run --generate</code></p>
|
||||||
|
<hr>
|
||||||
|
<h2 id="3-inventory-management">3. 📂 Inventory Management</h2>
|
||||||
|
<h3 id="3a-nodes">3a. Nodes</h3>
|
||||||
|
<p>Manage connections using standard commands: add (<code>conn --add node1</code>), edit (<code>conn --mod node1</code>), delete (<code>conn --del node1</code>), show configuration (<code>conn --show node1</code>), or connect (<code>conn node1</code>).</p>
|
||||||
|
<h3 id="3b-profiles">3b. Profiles</h3>
|
||||||
|
<p>Define credentials and templates globally and reference them inside node fields using the <code>@profile_name</code> placeholder. Manage profiles interactively or via commands:</p>
|
||||||
|
<pre><code class="language-bash">conn profile -a profile_name
|
||||||
|
# Or equivalently:
|
||||||
|
conn -a profile profile_name
|
||||||
|
</code></pre>
|
||||||
|
<p>During the interactive <code>conn --add</code> prompt, you can input <code>@profile_name</code> in the <strong>username</strong> or <strong>password</strong> fields to reference it.</p>
|
||||||
|
<h3 id="3c-folders-move-copy-list">3c. Folders, Move, Copy, List</h3>
|
||||||
|
<p>Organize nodes into logical folder hierarchies (<code>@office</code>, <code>@datacenter@office</code>). Move items (<code>conn move [src] [dst]</code>), copy (<code>conn copy [src] [dst]</code>), or list items with custom filters and formatting:</p>
|
||||||
|
<pre><code class="language-bash">conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="3d-bulk-export-import">3d. Bulk, Export, Import</h3>
|
||||||
|
<p>Bulk import connections from formatted text files (<code>conn bulk -f nodes.txt</code>), or export/import connection folders using YAML configurations (<code>conn export @folder > backup.yaml</code> / <code>conn import backup.yaml</code>).</p>
|
||||||
|
<h3 id="3e-tags-system">3e. Tags System</h3>
|
||||||
|
<p>Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (<code>os</code>), prompt regex rules (<code>prompt</code>), and page length triggers (<code>screen_length_command</code>).</p>
|
||||||
|
<pre><code class="language-yaml"># Custom tags dictionary (YANG / VSR context)
|
||||||
|
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
|
||||||
|
</code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="4-protocols-connection-features">4. 🔌 Protocols & Connection Features</h2>
|
||||||
|
<h3 id="4a-ssh-sftp-telnet-kubectl-docker-aws-ssm">4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM</h3>
|
||||||
|
<p>Connect to various architectures using native protocols:
|
||||||
|
* <strong>SSH / Telnet</strong>: Standard CLI protocols.
|
||||||
|
* <strong>SFTP</strong>: Transfer files securely (<code>conn --sftp node</code>).
|
||||||
|
* <strong>Docker</strong>: Connect directly to local container names (host set to container name/ID).
|
||||||
|
* <strong>Kubernetes (kubectl)</strong>: Connect to pods (namespace customizable via options).
|
||||||
|
* <strong>AWS SSM</strong>: Connect to EC2 instances using Instance IDs as hosts.</p>
|
||||||
|
<h3 id="4b-jumphosts">4b. Jumphosts</h3>
|
||||||
|
<p>Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.</p>
|
||||||
|
<h3 id="4c-debug-mode-keepalive-logging">4c. Debug Mode, Keepalive, Logging</h3>
|
||||||
|
<p>Track connection steps (<code>conn --debug node</code>), set idle keepalive intervals (<code>conn config --keepalive <seconds></code>), or define dynamic output log files using variables like <code>${unique}</code>, <code>${host}</code>, <code>${port}</code>, <code>${user}</code>, <code>${protocol}</code>, or <code>${date 'format'}</code>.</p>
|
||||||
|
<hr>
|
||||||
|
<h2 id="5-remote-capture-conn-capture-core-plugin">5. 🖥️ Remote Capture (conn capture - Core Plugin)</h2>
|
||||||
|
<p>Perform remote packet capture (<code>tcpdump</code>) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:</p>
|
||||||
|
<pre><code class="language-bash">conn capture router1 eth0 -w -f "port 80"
|
||||||
|
</code></pre>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Multi-Protocol</strong>: Native support for SSH, SFTP, Telnet, kubectl, Docker exec, and AWS SSM.</li>
|
<li><strong>Requirements</strong>: Local installation of Wireshark or <code>tshark</code> is required for live piping (<code>-w</code>).</li>
|
||||||
<li><strong>Context Management</strong>: Set regex-based contexts to manage specific nodes across different environments (work, home, clients).</li>
|
<li><strong>Advanced flags</strong>: Specify network namespaces (<code>--ns <name></code>), custom filters (<code>-f <filter></code>), or configure the Wireshark local path (<code>--set-wireshark-path</code>).</li>
|
||||||
<li><strong>Advanced Inventory</strong>:<ul>
|
|
||||||
<li>Organize nodes in folders (<code>@folder</code>) and subfolders (<code>@subfolder@folder</code>).</li>
|
|
||||||
<li>Use Global Profiles (<code>@profilename</code>) to manage shared credentials easily.</li>
|
|
||||||
<li>Bulk creation, copying, moving, and export/import of nodes.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
<hr>
|
||||||
<li><strong>Modern UI</strong>: High-performance terminal experience with <code>prompt-toolkit</code>, including:<ul>
|
<h2 id="6-context-filtering">6. 🛡️ Context Filtering</h2>
|
||||||
<li>Fuzzy search integration with <code>fzf</code>.</li>
|
<p>Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:</p>
|
||||||
<li>Advanced tab completion.</li>
|
<pre><code class="language-bash">conn context production -a --regex ".*-prod"
|
||||||
<li>Syntax highlighting and customizable themes.</li>
|
conn context production --set
|
||||||
|
</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Manage Contexts</strong>: List defined filters (<code>conn context --ls</code>), show context details (<code>conn context production -s</code>), or delete contexts (<code>conn context production -r</code>).</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
<hr>
|
||||||
<li><strong>Automation Engine</strong>: Run parallel tasks and playbooks on multiple devices with variable support.</li>
|
<h2 id="7-plugin-system">7. 🔌 Plugin System</h2>
|
||||||
<li><strong>Plugin System</strong>: Build and execute custom Python scripts locally or on a remote gRPC server.</li>
|
<p>Extend <code><a title="connpy" href="#connpy">connpy</a></code> 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:</p>
|
||||||
<li><strong>gRPC Architecture</strong>: Fully decoupled Client/Server model for distributed management.</li>
|
<pre><code class="language-bash">conn plugin --add my_plugin script.py
|
||||||
<li><strong>Privacy & Sync</strong>: Local-first encrypted storage (RSA/OAEP) with optional Google Drive backup.</li>
|
conn plugin --update my_plugin script.py
|
||||||
</ul>
|
conn plugin --remote --sync
|
||||||
<h2 id="installation">Installation</h2>
|
</code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="8-grpc-client-server-architecture">8. ⚙️ gRPC Client-Server Architecture</h2>
|
||||||
|
<h3 id="8a-server-startstoprestartdebug">8a. Server (start/stop/restart/debug)</h3>
|
||||||
|
<p>Execute tasks on a centralized remote host. Start gRPC server (<code>conn api -s 50051</code>), stop (<code>conn api -x</code>), restart (<code>conn api -r</code>), or debug in the foreground (<code>conn api -d</code>).</p>
|
||||||
|
<h3 id="8b-client-config">8b. Client Config</h3>
|
||||||
|
<p>Shift the local CLI to communicate with a remote server instance:</p>
|
||||||
|
<pre><code class="language-bash">conn config --service-mode remote
|
||||||
|
conn config --remote localhost:50051
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="8c-user-management">8c. User Management</h3>
|
||||||
|
<p>Manage server-side user credentials for distributed setups:</p>
|
||||||
|
<pre><code class="language-bash">conn user --add username
|
||||||
|
conn user --list
|
||||||
|
conn user --regen-password username
|
||||||
|
</code></pre>
|
||||||
|
<p>Use <code>--path</code> to specify custom configuration folders in server Mode B.</p>
|
||||||
|
<h3 id="8d-sso-oidc">8d. SSO / OIDC</h3>
|
||||||
|
<p>Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:</p>
|
||||||
|
<pre><code class="language-bash">conn sso --add provider_name
|
||||||
|
</code></pre>
|
||||||
|
<h3 id="8e-login-logout">8e. Login / Logout</h3>
|
||||||
|
<p>Authenticate client sessions (<code>conn login [username]</code>), check connection status (<code>conn login --status</code>), or close sessions (<code>conn logout</code>).</p>
|
||||||
|
<hr>
|
||||||
|
<h2 id="9-installation-configuration">9. ⚡ Installation & Configuration</h2>
|
||||||
|
<h3 id="9a-pip-install">9a. pip install</h3>
|
||||||
<pre><code class="language-bash">pip install connpy
|
<pre><code class="language-bash">pip install connpy
|
||||||
</code></pre>
|
</code></pre>
|
||||||
<h3 id="run-it-in-windowslinux-using-docker">Run it in Windows/Linux using Docker</h3>
|
<h3 id="9b-shell-completion-fzf">9b. Shell Completion + FZF</h3>
|
||||||
<pre><code class="language-bash">git clone https://github.com/fluzzi/connpy
|
<p>Install autocompletions and fuzzy-search wrappers into your shell profile:</p>
|
||||||
cd connpy
|
<pre><code class="language-bash">eval "$(conn config --completion bash)"
|
||||||
docker compose build
|
eval "$(conn config --fzf-wrapper bash)"
|
||||||
|
</code></pre>
|
||||||
# Run it like a native app (completely silent)
|
<h3 id="9c-conn-config-options">9c. conn config options</h3>
|
||||||
docker compose run --rm --remove-orphans connpy-app [command]
|
<p>View configuration details (<code>conn config</code>) or customize variables like case sensitivity (<code>--allow-uppercase</code>), FZF list picker (<code>--fzf true</code>), configurations directory (<code>--configfolder</code>), or persistent AI API keys and models (<code>--engineer-model</code>).</p>
|
||||||
|
<h3 id="9d-theming">9d. Theming</h3>
|
||||||
# Pro Tip: Add this alias for a 100% native experience from any folder
|
<p>Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:</p>
|
||||||
alias conn='docker compose -f /path/to/connpy/docker-compose.yml run --rm --remove-orphans connpy-app'
|
<pre><code class="language-bash">conn config --theme /path/to/theme.yaml
|
||||||
</code></pre>
|
</code></pre>
|
||||||
<hr>
|
<hr>
|
||||||
<h2 id="privacy-integration">🔒 Privacy & Integration</h2>
|
<h2 id="10-privacy-security-synchronization-conn-sync">10. 🔒 Privacy, Security & Synchronization (conn sync)</h2>
|
||||||
<h3 id="privacy-policy">Privacy Policy</h3>
|
<p>Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (<code>conn sync --once</code>, <code>--list</code>, <code>--restore</code>) or schedule auto-sync. Segregate restores (<code>--nodes</code> / <code>--config</code>) or sync remote nodes with <code>--sync-remote</code>.</p>
|
||||||
<p>Connpy is committed to protecting your privacy:
|
|
||||||
- <strong>Local Storage</strong>: All server addresses, usernames, and passwords are encrypted and stored <strong>only</strong> on your machine. No data is transmitted to our servers.
|
|
||||||
- <strong>Data Access</strong>: Data is used solely for managing and automating your connections.</p>
|
|
||||||
<h3 id="google-integration">Google Integration</h3>
|
|
||||||
<p>Used strictly for backup:
|
|
||||||
- <strong>Backup</strong>: Sync your encrypted configuration with your Google Drive account.
|
|
||||||
- <strong>Scoped Access</strong>: Connpy only accesses its own backup files.</p>
|
|
||||||
<hr>
|
<hr>
|
||||||
<h2 id="usage">Usage</h2>
|
<h2 id="11-python-api">11. 🐍 Python API</h2>
|
||||||
<pre><code class="language-bash">usage: conn [-h] [--add | --del | --mod | --show | --debug] [node|folder] [--sftp]
|
<p>Embed connection and automation routines programmatically in Python:</p>
|
||||||
conn {profile,move,copy,list,bulk,export,import,ai,run,api,plugin,config,sync,context} ...
|
|
||||||
</code></pre>
|
|
||||||
<h3 id="basic-examples">Basic Examples:</h3>
|
|
||||||
<pre><code class="language-bash"># Add a folder and subfolder
|
|
||||||
conn --add @office
|
|
||||||
conn --add @datacenter@office
|
|
||||||
|
|
||||||
# Add a node with a profile
|
|
||||||
conn --add server1@datacenter@office --profile @myuser
|
|
||||||
|
|
||||||
# Connect to a node (fuzzy match)
|
|
||||||
conn server1
|
|
||||||
|
|
||||||
# Start the AI Copilot
|
|
||||||
conn ai
|
|
||||||
|
|
||||||
# Run a command on all nodes in a folder
|
|
||||||
conn run @office "uptime"
|
|
||||||
</code></pre>
|
|
||||||
<h3 id="sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</h3>
|
|
||||||
<p>In remote mode, <code><a title="connpy" href="#connpy">connpy</a></code> supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the <code>conn sso</code> command suite:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>List configured providers</strong>:
|
|
||||||
<code>bash
|
|
||||||
conn sso --list</code></li>
|
|
||||||
<li><strong>Show provider details</strong> (sensitive credentials like secrets are masked):
|
|
||||||
<code>bash
|
|
||||||
conn sso --show <provider_name></code></li>
|
|
||||||
<li><strong>Add or update a provider</strong> (opens an interactive configuration wizard):
|
|
||||||
<code>bash
|
|
||||||
conn sso --add <provider_name></code></li>
|
|
||||||
<li><strong>Delete a provider</strong>:
|
|
||||||
<code>bash
|
|
||||||
conn sso --del <provider_name></code></li>
|
|
||||||
</ul>
|
|
||||||
<h4 id="security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</h4>
|
|
||||||
<p>To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a <code>$</code> instead of the literal secret during the <code>conn sso --add</code> prompts (e.g., <code>$CONN_SSO_MYPROVIDER_SECRET</code>). The backend gRPC server will dynamically resolve the value from its environment variables at runtime.</p>
|
|
||||||
<hr>
|
|
||||||
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
|
|
||||||
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
|
|
||||||
<p>When Connpy operates in remote mode, plugins are executed <strong>transparently on the server</strong>:
|
|
||||||
- The client automatically downloads the plugin source code (<code>Parser</code> class context) to generate the local <code>argparse</code> structure and provide autocompletion.
|
|
||||||
- The execution phase (<code>Entrypoint</code> class) is redirected via gRPC streams to execute in the server's memory.
|
|
||||||
- You can manage remote plugins using the <code>--remote</code> flag.</p>
|
|
||||||
<h3 id="general-structure">General Structure</h3>
|
|
||||||
<ul>
|
|
||||||
<li>The plugin script must define specific classes:</li>
|
|
||||||
<li><strong>Class <code>Parser</code></strong>: Handles <code>argparse.ArgumentParser</code> initialization.</li>
|
|
||||||
<li><strong>Class <code>Entrypoint</code></strong>: Main execution logic (receives <code>args</code>, <code>parser</code>, and <code>connapp</code>).</li>
|
|
||||||
<li><strong>Class <code>Preload</code></strong>: (Optional) For modifying core app behavior or registering hooks.</li>
|
|
||||||
</ul>
|
|
||||||
<h3 id="preload-modifications-and-hooks">Preload Modifications and Hooks</h3>
|
|
||||||
<p>You can customize the behavior of core classes using hooks:
|
|
||||||
- <strong><code>modify(method)</code></strong>: Alter class instances (e.g., <code>connapp.config</code>, <code>connapp.ai</code>).
|
|
||||||
- <strong><code>register_pre_hook(method)</code></strong>: Logic to run before a method execution.
|
|
||||||
- <strong><code>register_post_hook(method)</code></strong>: Logic to run after a method execution.</p>
|
|
||||||
<h3 id="command-completion-support">Command Completion Support</h3>
|
|
||||||
<p>Plugins can provide intelligent tab completion:
|
|
||||||
1. <strong>Tree-based Completion (Recommended)</strong>: Define <code>_connpy_tree(info)</code> returning a navigation dictionary.
|
|
||||||
2. <strong>Legacy Completion</strong>: Define <code>_connpy_completion(wordsnumber, words, info)</code>.</p>
|
|
||||||
<hr>
|
|
||||||
<h2 id="grpc-service-architecture">⚙️ gRPC Service Architecture</h2>
|
|
||||||
<p>Connpy can operate in a decoupled mode:
|
|
||||||
1. <strong>Start the API (Server)</strong>: <code>conn api -s 50051</code>
|
|
||||||
2. <strong>Configure the Client</strong>:
|
|
||||||
<code>bash
|
|
||||||
conn config --service-mode remote
|
|
||||||
conn config --remote-host localhost:50051</code>
|
|
||||||
All inventory management and execution will now happen on the server.</p>
|
|
||||||
<hr>
|
|
||||||
<h2 id="automation-module-api">🐍 Automation Module (API)</h2>
|
|
||||||
<p>You can use <code><a title="connpy" href="#connpy">connpy</a></code> as a Python library for your own scripts.</p>
|
|
||||||
<h3 id="basic-execution">Basic Execution</h3>
|
|
||||||
<pre><code class="language-python">import connpy
|
<pre><code class="language-python">import connpy
|
||||||
router = connpy.node("uniqueName", "1.1.1.1", user="admin")
|
|
||||||
|
# 1. Direct single node interaction
|
||||||
|
router = connpy.node("router1", "1.1.1.1", user="admin")
|
||||||
router.run(["show ip int brief"])
|
router.run(["show ip int brief"])
|
||||||
print(router.output)
|
print(router.output)
|
||||||
</code></pre>
|
|
||||||
<h3 id="parallel-tasks-with-variables">Parallel Tasks with Variables</h3>
|
|
||||||
<pre><code class="language-python">import connpy
|
|
||||||
config = connpy.configfile()
|
|
||||||
nodes = config.getitem("@office", ["router1", "router2"])
|
|
||||||
routers = connpy.nodes(nodes, config=config)
|
|
||||||
|
|
||||||
|
# 2. Parallel nodes execution with variables
|
||||||
|
config = connpy.configfile()
|
||||||
|
nodes_info = config.getitem("@office", ["router1", "router2"])
|
||||||
|
routers = connpy.nodes(nodes_info, config=config)
|
||||||
variables = {
|
variables = {
|
||||||
"router1@office": {"id": "1"},
|
"router1@office": {"id": "1"},
|
||||||
"__global__": {"mask": "255.255.255.0"}
|
"__global__": {"mask": "255.255.255.0"}
|
||||||
}
|
}
|
||||||
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
||||||
</code></pre>
|
|
||||||
<h3 id="ai-programmatic-use">AI Programmatic Use</h3>
|
# 3. AI Copilot prompts
|
||||||
<pre><code class="language-python">import connpy
|
|
||||||
myai = connpy.ai(connpy.configfile())
|
myai = connpy.ai(connpy.configfile())
|
||||||
response = myai.ask("What is the status of the BGP neighbors in the office?")
|
response = myai.ask("Show BGP status.")
|
||||||
|
print(response)
|
||||||
</code></pre>
|
</code></pre>
|
||||||
|
<p><em>Supports additional programmatic features like <code><a title="connpy.node.test" href="#connpy.node.test">node.test()</a></code>, <code><a title="connpy.node.interact" href="#connpy.node.interact">node.interact()</a></code>, <code><a title="connpy.configfile.encrypt" href="#connpy.configfile.encrypt">configfile.encrypt()</a></code>, <code>connapp</code> embeds, and <code>ClassHook</code> / <code>MethodHook</code> plugin hooks.</em></p>
|
||||||
<hr>
|
<hr>
|
||||||
<p><em>For detailed developer notes and plugin hooks documentation, see the <a href="https://fluzzi.github.io/connpy/">Documentation</a>.</em></p>
|
<h2 id="12-docker-deployment">12. 🐳 Docker Deployment</h2>
|
||||||
<h2 id="license">📜 License</h2>
|
<p>Run <code><a title="connpy" href="#connpy">connpy</a></code> containerized and silent:</p>
|
||||||
|
<pre><code class="language-bash">docker compose run --rm connpy-app [command]
|
||||||
|
</code></pre>
|
||||||
|
<p>Add <code>alias conn='docker compose run --rm connpy-app'</code> for a transparent container experience.</p>
|
||||||
|
<hr>
|
||||||
|
<h2 id="13-license">13. 📜 License</h2>
|
||||||
<p><a href="LICENSE">PolyForm Noncommercial 1.0.0</a></p>
|
<p><a href="LICENSE">PolyForm Noncommercial 1.0.0</a></p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -4241,80 +4268,12 @@ class node:
|
|||||||
else:
|
else:
|
||||||
self.password = [password]
|
self.password = [password]
|
||||||
if self.jumphost != "" and config != '':
|
if self.jumphost != "" and config != '':
|
||||||
self.jumphost = config.getitem(self.jumphost)
|
raw_cmd, jh_passwords = self._build_jumphost_chain(self.jumphost, config)
|
||||||
for key in self.jumphost:
|
if jh_passwords:
|
||||||
profile = re.search("^@(.*)", str(self.jumphost[key]))
|
self.password = jh_passwords + self.password
|
||||||
if profile:
|
if raw_cmd:
|
||||||
try:
|
escaped = raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
self.jumphost[key] = config.profiles[profile.group(1)][key]
|
self.jumphost = f'-o ProxyCommand="{escaped}"'
|
||||||
except KeyError:
|
|
||||||
self.jumphost[key] = ""
|
|
||||||
elif self.jumphost[key] == '' and key == "protocol":
|
|
||||||
try:
|
|
||||||
self.jumphost[key] = config.profiles["default"][key]
|
|
||||||
except KeyError:
|
|
||||||
self.jumphost[key] = "ssh"
|
|
||||||
if isinstance(self.jumphost["password"],list):
|
|
||||||
jumphost_password = []
|
|
||||||
for i, s in enumerate(self.jumphost["password"]):
|
|
||||||
profile = re.search("^@(.*)", self.jumphost["password"][i])
|
|
||||||
if profile:
|
|
||||||
jumphost_password.append(config.profiles[profile.group(1)]["password"])
|
|
||||||
else:
|
|
||||||
jumphost_password.append(self.jumphost["password"][i])
|
|
||||||
self.jumphost["password"] = jumphost_password
|
|
||||||
else:
|
|
||||||
self.jumphost["password"] = [self.jumphost["password"]]
|
|
||||||
if self.jumphost["password"] != [""]:
|
|
||||||
self.password = self.jumphost["password"] + self.password
|
|
||||||
|
|
||||||
if self.jumphost["protocol"] == "ssh":
|
|
||||||
jumphost_cmd = self.jumphost["protocol"] + " -W %h:%p"
|
|
||||||
if self.jumphost["port"] != '':
|
|
||||||
jumphost_cmd = jumphost_cmd + " -p " + self.jumphost["port"]
|
|
||||||
if self.jumphost["options"] != '':
|
|
||||||
jumphost_cmd = jumphost_cmd + " " + self.jumphost["options"]
|
|
||||||
if self.jumphost["user"] == '':
|
|
||||||
jumphost_cmd = jumphost_cmd + " {}".format(self.jumphost["host"])
|
|
||||||
else:
|
|
||||||
jumphost_cmd = jumphost_cmd + " {}".format("@".join([self.jumphost["user"],self.jumphost["host"]]))
|
|
||||||
self.jumphost = f"-o ProxyCommand=\"{jumphost_cmd}\""
|
|
||||||
elif self.jumphost["protocol"] == "ssm":
|
|
||||||
ssm_target = self.jumphost["host"]
|
|
||||||
ssm_cmd = f"aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters 'portNumber=22'"
|
|
||||||
if isinstance(self.jumphost.get("tags"), dict):
|
|
||||||
if "profile" in self.jumphost["tags"]:
|
|
||||||
ssm_cmd += f" --profile {self.jumphost['tags']['profile']}"
|
|
||||||
if "region" in self.jumphost["tags"]:
|
|
||||||
ssm_cmd += f" --region {self.jumphost['tags']['region']}"
|
|
||||||
if self.jumphost["options"] != '':
|
|
||||||
ssm_cmd += f" {self.jumphost['options']}"
|
|
||||||
|
|
||||||
bastion_user_part = f"{self.jumphost['user']}@{ssm_target}" if self.jumphost['user'] else ssm_target
|
|
||||||
|
|
||||||
ssh_opts = ""
|
|
||||||
if isinstance(self.jumphost.get("tags"), dict) and "ssh_options" in self.jumphost["tags"]:
|
|
||||||
ssh_opts = f" {self.jumphost['tags']['ssh_options']}"
|
|
||||||
|
|
||||||
inner_ssh = f"ssh{ssh_opts} -o ProxyCommand='{ssm_cmd}' -W %h:%p {bastion_user_part}"
|
|
||||||
self.jumphost = f"-o ProxyCommand=\"{inner_ssh}\""
|
|
||||||
elif self.jumphost["protocol"] in ["kubectl", "docker"]:
|
|
||||||
nc_cmd = "nc"
|
|
||||||
if isinstance(self.jumphost.get("tags"), dict) and "nc_command" in self.jumphost["tags"]:
|
|
||||||
nc_cmd = self.jumphost["tags"]["nc_command"]
|
|
||||||
|
|
||||||
if self.jumphost["protocol"] == "kubectl":
|
|
||||||
proxy_cmd = f"kubectl exec "
|
|
||||||
if self.jumphost["options"] != '':
|
|
||||||
proxy_cmd += f"{self.jumphost['options']} "
|
|
||||||
proxy_cmd += f"{self.jumphost['host']} -i -- {nc_cmd} %h %p"
|
|
||||||
else:
|
|
||||||
proxy_cmd = f"docker "
|
|
||||||
if self.jumphost["options"] != '':
|
|
||||||
proxy_cmd += f"{self.jumphost['options']} "
|
|
||||||
proxy_cmd += f"exec -i {self.jumphost['host']} {nc_cmd} %h %p"
|
|
||||||
|
|
||||||
self.jumphost = f"-o ProxyCommand=\"{proxy_cmd}\""
|
|
||||||
else:
|
else:
|
||||||
self.jumphost = ""
|
self.jumphost = ""
|
||||||
|
|
||||||
@@ -4323,6 +4282,127 @@ class node:
|
|||||||
self.result = {}
|
self.result = {}
|
||||||
self.cmd_byte_positions = [(0, None)]
|
self.cmd_byte_positions = [(0, None)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_jumphost_data(jh_dict, config):
|
||||||
|
'''Resolve @profile references and normalize passwords in a jumphost dict.'''
|
||||||
|
for key in jh_dict:
|
||||||
|
profile = re.search("^@(.*)", str(jh_dict[key]))
|
||||||
|
if profile:
|
||||||
|
try:
|
||||||
|
jh_dict[key] = config.profiles[profile.group(1)][key]
|
||||||
|
except KeyError:
|
||||||
|
jh_dict[key] = ""
|
||||||
|
elif jh_dict[key] == '' and key == "protocol":
|
||||||
|
try:
|
||||||
|
jh_dict[key] = config.profiles["default"][key]
|
||||||
|
except KeyError:
|
||||||
|
jh_dict[key] = "ssh"
|
||||||
|
if isinstance(jh_dict["password"], list):
|
||||||
|
resolved = []
|
||||||
|
for p in jh_dict["password"]:
|
||||||
|
profile = re.search("^@(.*)", p)
|
||||||
|
if profile:
|
||||||
|
resolved.append(config.profiles[profile.group(1)]["password"])
|
||||||
|
else:
|
||||||
|
resolved.append(p)
|
||||||
|
jh_dict["password"] = resolved
|
||||||
|
else:
|
||||||
|
jh_dict["password"] = [jh_dict["password"]]
|
||||||
|
return jh_dict
|
||||||
|
|
||||||
|
def _build_jumphost_chain(self, jumphost_name, config, visited=None, depth=0, target_host="%h", target_port="%p"):
|
||||||
|
'''Recursively build ProxyCommand for chained jumphosts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (raw_proxy_command, passwords_list)
|
||||||
|
- raw_proxy_command: Command string to embed in ProxyCommand
|
||||||
|
- passwords_list: Ordered passwords (innermost first)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On circular references or exceeding max depth (5).
|
||||||
|
'''
|
||||||
|
if depth >= 5:
|
||||||
|
raise ValueError("Jumphost chain exceeds maximum depth of 5 hops")
|
||||||
|
if visited is None:
|
||||||
|
visited = []
|
||||||
|
if jumphost_name in visited:
|
||||||
|
cycle = " -> ".join(visited + [jumphost_name])
|
||||||
|
raise ValueError(f"Circular jumphost reference detected: {cycle}")
|
||||||
|
visited = visited + [jumphost_name]
|
||||||
|
|
||||||
|
jh = config.getitem(jumphost_name)
|
||||||
|
jh = self._resolve_jumphost_data(jh, config)
|
||||||
|
|
||||||
|
passwords = []
|
||||||
|
inner_proxy_opt = ""
|
||||||
|
|
||||||
|
# Recursively resolve inner jumphost
|
||||||
|
if jh.get("jumphost", "") != "":
|
||||||
|
if jh["protocol"] not in ["ssh"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Jumphost '{jumphost_name}' uses protocol '{jh['protocol']}' "
|
||||||
|
f"which does not support chained jumphosts. "
|
||||||
|
f"Only SSH jumphosts can have their own jumphosts."
|
||||||
|
)
|
||||||
|
parent_port = jh["port"] if jh["port"] != "" else "22"
|
||||||
|
inner_raw_cmd, inner_passwords = self._build_jumphost_chain(
|
||||||
|
jh["jumphost"], config, visited, depth + 1, target_host=jh["host"], target_port=parent_port
|
||||||
|
)
|
||||||
|
passwords = inner_passwords
|
||||||
|
escaped = inner_raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
|
inner_proxy_opt = f'-o ProxyCommand="{escaped}"'
|
||||||
|
|
||||||
|
# Collect this hop's passwords
|
||||||
|
if jh["password"] != [""]:
|
||||||
|
passwords = passwords + jh["password"]
|
||||||
|
|
||||||
|
t_port = target_port if target_port != "" else "22"
|
||||||
|
|
||||||
|
# Build raw command based on protocol
|
||||||
|
if jh["protocol"] == "ssh":
|
||||||
|
cmd = f"ssh -W {target_host}:{t_port}"
|
||||||
|
if inner_proxy_opt:
|
||||||
|
cmd += f" {inner_proxy_opt}"
|
||||||
|
if jh["port"] != '':
|
||||||
|
cmd += f" -p {jh['port']}"
|
||||||
|
if jh["options"] != '':
|
||||||
|
cmd += f" {jh['options']}"
|
||||||
|
user_host = f"{jh['user']}@{jh['host']}" if jh['user'] != '' else jh['host']
|
||||||
|
cmd += f" {user_host}"
|
||||||
|
elif jh["protocol"] == "ssm":
|
||||||
|
ssm_target = jh["host"]
|
||||||
|
ssm_cmd = f"aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters 'portNumber=22'"
|
||||||
|
if isinstance(jh.get("tags"), dict):
|
||||||
|
if "profile" in jh["tags"]:
|
||||||
|
ssm_cmd += f" --profile {jh['tags']['profile']}"
|
||||||
|
if "region" in jh["tags"]:
|
||||||
|
ssm_cmd += f" --region {jh['tags']['region']}"
|
||||||
|
if jh["options"] != '':
|
||||||
|
ssm_cmd += f" {jh['options']}"
|
||||||
|
bastion_user_part = f"{jh['user']}@{ssm_target}" if jh['user'] else ssm_target
|
||||||
|
ssh_opts = ""
|
||||||
|
if isinstance(jh.get("tags"), dict) and "ssh_options" in jh["tags"]:
|
||||||
|
ssh_opts = f" {jh['tags']['ssh_options']}"
|
||||||
|
cmd = f"ssh{ssh_opts} -o ProxyCommand='{ssm_cmd}' -W {target_host}:{t_port} {bastion_user_part}"
|
||||||
|
elif jh["protocol"] in ["kubectl", "docker"]:
|
||||||
|
nc_cmd = "nc"
|
||||||
|
if isinstance(jh.get("tags"), dict) and "nc_command" in jh["tags"]:
|
||||||
|
nc_cmd = jh["tags"]["nc_command"]
|
||||||
|
if jh["protocol"] == "kubectl":
|
||||||
|
cmd = "kubectl exec "
|
||||||
|
if jh["options"] != '':
|
||||||
|
cmd += f"{jh['options']} "
|
||||||
|
cmd += f"{jh['host']} -i -- {nc_cmd} {target_host} {t_port}"
|
||||||
|
else:
|
||||||
|
cmd = "docker "
|
||||||
|
if jh["options"] != '':
|
||||||
|
cmd += f"{jh['options']} "
|
||||||
|
cmd += f"exec -i {jh['host']} {nc_cmd} {target_host} {t_port}"
|
||||||
|
else:
|
||||||
|
return "", passwords
|
||||||
|
|
||||||
|
return cmd, passwords
|
||||||
|
|
||||||
@MethodHook
|
@MethodHook
|
||||||
def _passtx(self, passwords, *, keyfile=None):
|
def _passtx(self, passwords, *, keyfile=None):
|
||||||
# decrypts passwords, used by other methdos.
|
# decrypts passwords, used by other methdos.
|
||||||
@@ -5214,7 +5294,8 @@ class node:
|
|||||||
|
|
||||||
attempts = 1
|
attempts = 1
|
||||||
while attempts <= max_attempts:
|
while attempts <= max_attempts:
|
||||||
child = pexpect.spawn(cmd)
|
args = shlex.split(cmd)
|
||||||
|
child = pexpect.spawn(args[0], args[1:])
|
||||||
if isinstance(self.tags, dict) and self.tags.get("console"):
|
if isinstance(self.tags, dict) and self.tags.get("console"):
|
||||||
child.sendline()
|
child.sendline()
|
||||||
if debug:
|
if debug:
|
||||||
@@ -6437,41 +6518,55 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
|
|||||||
<nav id="sidebar">
|
<nav id="sidebar">
|
||||||
<div class="toc">
|
<div class="toc">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="#connpy">Connpy</a><ul>
|
<li><a href="#connpy-v603">Connpy (v6.0.3)</a><ul>
|
||||||
<li><a href="#ai-copilot-new-in-v6">🤖 AI Copilot (New in v6)</a></li>
|
<li><a href="#1-ai-system">1. 🤖 AI System</a><ul>
|
||||||
<li><a href="#core-features">Core Features</a></li>
|
<li><a href="#1a-terminal-copilot-ctrlspace">1a. Terminal Copilot (Ctrl+Space)</a></li>
|
||||||
<li><a href="#installation">Installation</a><ul>
|
<li><a href="#1b-ai-chat-conn-ai">1b. AI Chat (conn ai)</a></li>
|
||||||
<li><a href="#run-it-in-windowslinux-using-docker">Run it in Windows/Linux using Docker</a></li>
|
<li><a href="#1c-mcp-integration">1c. MCP Integration</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#privacy-integration">🔒 Privacy & Integration</a><ul>
|
<li><a href="#2-automation-playbooks">2. ⚙️ Automation & Playbooks</a><ul>
|
||||||
<li><a href="#privacy-policy">Privacy Policy</a></li>
|
<li><a href="#2a-quick-run-conn-run">2a. Quick Run (conn run)</a></li>
|
||||||
<li><a href="#google-integration">Google Integration</a></li>
|
<li><a href="#2b-yaml-playbook-engine">2b. YAML Playbook Engine</a></li>
|
||||||
|
<li><a href="#2c-ai-assisted-automation">2c. AI-Assisted Automation</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#usage">Usage</a><ul>
|
<li><a href="#3-inventory-management">3. 📂 Inventory Management</a><ul>
|
||||||
<li><a href="#basic-examples">Basic Examples:</a></li>
|
<li><a href="#3a-nodes">3a. Nodes</a></li>
|
||||||
<li><a href="#sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</a><ul>
|
<li><a href="#3b-profiles">3b. Profiles</a></li>
|
||||||
<li><a href="#security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</a></li>
|
<li><a href="#3c-folders-move-copy-list">3c. Folders, Move, Copy, List</a></li>
|
||||||
|
<li><a href="#3d-bulk-export-import">3d. Bulk, Export, Import</a></li>
|
||||||
|
<li><a href="#3e-tags-system">3e. Tags System</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a href="#4-protocols-connection-features">4. 🔌 Protocols & Connection Features</a><ul>
|
||||||
|
<li><a href="#4a-ssh-sftp-telnet-kubectl-docker-aws-ssm">4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM</a></li>
|
||||||
|
<li><a href="#4b-jumphosts">4b. Jumphosts</a></li>
|
||||||
|
<li><a href="#4c-debug-mode-keepalive-logging">4c. Debug Mode, Keepalive, Logging</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
|
<li><a href="#5-remote-capture-conn-capture-core-plugin">5. 🖥️ Remote Capture (conn capture - Core Plugin)</a></li>
|
||||||
<li><a href="#remote-plugin-execution">Remote Plugin Execution</a></li>
|
<li><a href="#6-context-filtering">6. 🛡️ Context Filtering</a></li>
|
||||||
<li><a href="#general-structure">General Structure</a></li>
|
<li><a href="#7-plugin-system">7. 🔌 Plugin System</a></li>
|
||||||
<li><a href="#preload-modifications-and-hooks">Preload Modifications and Hooks</a></li>
|
<li><a href="#8-grpc-client-server-architecture">8. ⚙️ gRPC Client-Server Architecture</a><ul>
|
||||||
<li><a href="#command-completion-support">Command Completion Support</a></li>
|
<li><a href="#8a-server-startstoprestartdebug">8a. Server (start/stop/restart/debug)</a></li>
|
||||||
|
<li><a href="#8b-client-config">8b. Client Config</a></li>
|
||||||
|
<li><a href="#8c-user-management">8c. User Management</a></li>
|
||||||
|
<li><a href="#8d-sso-oidc">8d. SSO / OIDC</a></li>
|
||||||
|
<li><a href="#8e-login-logout">8e. Login / Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#grpc-service-architecture">⚙️ gRPC Service Architecture</a></li>
|
<li><a href="#9-installation-configuration">9. ⚡ Installation & Configuration</a><ul>
|
||||||
<li><a href="#automation-module-api">🐍 Automation Module (API)</a><ul>
|
<li><a href="#9a-pip-install">9a. pip install</a></li>
|
||||||
<li><a href="#basic-execution">Basic Execution</a></li>
|
<li><a href="#9b-shell-completion-fzf">9b. Shell Completion + FZF</a></li>
|
||||||
<li><a href="#parallel-tasks-with-variables">Parallel Tasks with Variables</a></li>
|
<li><a href="#9c-conn-config-options">9c. conn config options</a></li>
|
||||||
<li><a href="#ai-programmatic-use">AI Programmatic Use</a></li>
|
<li><a href="#9d-theming">9d. Theming</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#license">📜 License</a></li>
|
<li><a href="#10-privacy-security-synchronization-conn-sync">10. 🔒 Privacy, Security & Synchronization (conn sync)</a></li>
|
||||||
|
<li><a href="#11-python-api">11. 🐍 Python API</a></li>
|
||||||
|
<li><a href="#12-docker-deployment">12. 🐳 Docker Deployment</a></li>
|
||||||
|
<li><a href="#13-license">13. 📜 License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -2629,7 +2629,7 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(core_dir, f)
|
path = os.path.join(core_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||||
|
|
||||||
# 2. Scan shared plugins (medium priority)
|
# 2. Scan shared plugins (medium priority)
|
||||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||||
@@ -2639,10 +2639,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(shared_dir, f)
|
path = os.path.join(shared_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||||
|
|
||||||
# 3. Scan user plugins (highest priority)
|
# 3. Scan user plugins (highest priority)
|
||||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||||
@@ -2651,10 +2651,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(user_dir, f)
|
path = os.path.join(user_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||||
|
|
||||||
return all_plugin_info
|
return all_plugin_info
|
||||||
|
|
||||||
@@ -3277,7 +3277,7 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(core_dir, f)
|
path = os.path.join(core_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||||
|
|
||||||
# 2. Scan shared plugins (medium priority)
|
# 2. Scan shared plugins (medium priority)
|
||||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||||
@@ -3287,10 +3287,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(shared_dir, f)
|
path = os.path.join(shared_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||||
|
|
||||||
# 3. Scan user plugins (highest priority)
|
# 3. Scan user plugins (highest priority)
|
||||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||||
@@ -3299,10 +3299,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(user_dir, f)
|
path = os.path.join(user_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||||
|
|
||||||
return all_plugin_info</code></pre>
|
return all_plugin_info</code></pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(core_dir, f)
|
path = os.path.join(core_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||||
|
|
||||||
# 2. Scan shared plugins (medium priority)
|
# 2. Scan shared plugins (medium priority)
|
||||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||||
@@ -125,10 +125,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(shared_dir, f)
|
path = os.path.join(shared_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||||
|
|
||||||
# 3. Scan user plugins (highest priority)
|
# 3. Scan user plugins (highest priority)
|
||||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||||
@@ -137,10 +137,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(user_dir, f)
|
path = os.path.join(user_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||||
|
|
||||||
return all_plugin_info
|
return all_plugin_info
|
||||||
|
|
||||||
@@ -763,7 +763,7 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(core_dir, f)
|
path = os.path.join(core_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||||
|
|
||||||
# 2. Scan shared plugins (medium priority)
|
# 2. Scan shared plugins (medium priority)
|
||||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||||
@@ -773,10 +773,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(shared_dir, f)
|
path = os.path.join(shared_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||||
|
|
||||||
# 3. Scan user plugins (highest priority)
|
# 3. Scan user plugins (highest priority)
|
||||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||||
@@ -785,10 +785,10 @@ el.replaceWith(d);
|
|||||||
if f.endswith(".py"):
|
if f.endswith(".py"):
|
||||||
name = f[:-3]
|
name = f[:-3]
|
||||||
path = os.path.join(user_dir, f)
|
path = os.path.join(user_dir, f)
|
||||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||||
elif f.endswith(".py.bkp"):
|
elif f.endswith(".py.bkp"):
|
||||||
name = f[:-7]
|
name = f[:-7]
|
||||||
all_plugin_info[name] = {"enabled": False}
|
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||||
|
|
||||||
return all_plugin_info</code></pre>
|
return all_plugin_info</code></pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Reference in New Issue
Block a user