a4e 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a4e/__init__.py +0 -0
- a4e/cli.py +47 -0
- a4e/cli_commands/__init__.py +5 -0
- a4e/cli_commands/add.py +376 -0
- a4e/cli_commands/deploy.py +149 -0
- a4e/cli_commands/dev.py +162 -0
- a4e/cli_commands/info.py +206 -0
- a4e/cli_commands/init.py +211 -0
- a4e/cli_commands/list.py +227 -0
- a4e/cli_commands/mcp.py +504 -0
- a4e/cli_commands/remove.py +197 -0
- a4e/cli_commands/update.py +285 -0
- a4e/cli_commands/validate.py +117 -0
- a4e/core.py +109 -0
- a4e/dev_runner.py +425 -0
- a4e/server.py +86 -0
- a4e/templates/agent.md.j2 +168 -0
- a4e/templates/agent.py.j2 +15 -0
- a4e/templates/agents.md.j2 +99 -0
- a4e/templates/metadata.json.j2 +20 -0
- a4e/templates/prompt.md.j2 +20 -0
- a4e/templates/prompts/agent.md.j2 +206 -0
- a4e/templates/skills/agents.md.j2 +110 -0
- a4e/templates/skills/skill.md.j2 +120 -0
- a4e/templates/support_module.py.j2 +84 -0
- a4e/templates/tool.py.j2 +60 -0
- a4e/templates/tools/agent.md.j2 +192 -0
- a4e/templates/view.tsx.j2 +21 -0
- a4e/templates/views/agent.md.j2 +219 -0
- a4e/tools/__init__.py +70 -0
- a4e/tools/agent_tools/__init__.py +12 -0
- a4e/tools/agent_tools/add_support_module.py +95 -0
- a4e/tools/agent_tools/add_tool.py +115 -0
- a4e/tools/agent_tools/list_tools.py +28 -0
- a4e/tools/agent_tools/remove_tool.py +69 -0
- a4e/tools/agent_tools/update_tool.py +123 -0
- a4e/tools/deploy/__init__.py +8 -0
- a4e/tools/deploy/deploy.py +59 -0
- a4e/tools/dev/__init__.py +10 -0
- a4e/tools/dev/check_environment.py +79 -0
- a4e/tools/dev/dev_start.py +30 -0
- a4e/tools/dev/dev_stop.py +26 -0
- a4e/tools/project/__init__.py +10 -0
- a4e/tools/project/get_agent_info.py +66 -0
- a4e/tools/project/get_instructions.py +216 -0
- a4e/tools/project/initialize_project.py +231 -0
- a4e/tools/schemas/__init__.py +8 -0
- a4e/tools/schemas/generate_schemas.py +278 -0
- a4e/tools/skills/__init__.py +12 -0
- a4e/tools/skills/add_skill.py +105 -0
- a4e/tools/skills/helpers.py +137 -0
- a4e/tools/skills/list_skills.py +54 -0
- a4e/tools/skills/remove_skill.py +74 -0
- a4e/tools/skills/update_skill.py +150 -0
- a4e/tools/validation/__init__.py +8 -0
- a4e/tools/validation/validate.py +389 -0
- a4e/tools/views/__init__.py +12 -0
- a4e/tools/views/add_view.py +40 -0
- a4e/tools/views/helpers.py +91 -0
- a4e/tools/views/list_views.py +27 -0
- a4e/tools/views/remove_view.py +73 -0
- a4e/tools/views/update_view.py +124 -0
- a4e/utils/dev_manager.py +253 -0
- a4e/utils/schema_generator.py +255 -0
- a4e-0.1.5.dist-info/METADATA +427 -0
- a4e-0.1.5.dist-info/RECORD +70 -0
- a4e-0.1.5.dist-info/WHEEL +5 -0
- a4e-0.1.5.dist-info/entry_points.txt +2 -0
- a4e-0.1.5.dist-info/licenses/LICENSE +21 -0
- a4e-0.1.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
List views tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ...core import mcp, get_project_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def list_views(agent_name: Optional[str] = None) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
List all views available in the current agent project
|
|
14
|
+
"""
|
|
15
|
+
project_dir = get_project_dir(agent_name)
|
|
16
|
+
views_dir = project_dir / "views"
|
|
17
|
+
|
|
18
|
+
if not views_dir.exists():
|
|
19
|
+
return {"views": [], "count": 0}
|
|
20
|
+
|
|
21
|
+
views = []
|
|
22
|
+
for view_dir in views_dir.iterdir():
|
|
23
|
+
if view_dir.is_dir() and (view_dir / "view.tsx").exists():
|
|
24
|
+
views.append(view_dir.name)
|
|
25
|
+
|
|
26
|
+
return {"views": sorted(views), "count": len(views)}
|
|
27
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remove view tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
from ...core import mcp, get_project_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
def remove_view(
|
|
15
|
+
view_id: str,
|
|
16
|
+
agent_name: Optional[str] = None,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""
|
|
19
|
+
Remove a view from the agent
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
view_id: ID of the view to remove (the folder name)
|
|
23
|
+
agent_name: Optional agent ID if not in agent directory
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Result with success status and removed folder path
|
|
27
|
+
"""
|
|
28
|
+
project_dir = get_project_dir(agent_name)
|
|
29
|
+
views_dir = project_dir / "views"
|
|
30
|
+
|
|
31
|
+
if not views_dir.exists():
|
|
32
|
+
return {
|
|
33
|
+
"success": False,
|
|
34
|
+
"error": f"views/ directory not found at {views_dir}. Are you in an agent project?",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
view_folder = views_dir / view_id
|
|
38
|
+
|
|
39
|
+
if not view_folder.exists():
|
|
40
|
+
return {
|
|
41
|
+
"success": False,
|
|
42
|
+
"error": f"View '{view_id}' not found at {view_folder}",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Prevent removing the mandatory welcome view
|
|
46
|
+
if view_id == "welcome":
|
|
47
|
+
return {
|
|
48
|
+
"success": False,
|
|
49
|
+
"error": "Cannot remove the 'welcome' view - it is required for all agents",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Remove the view folder and all its contents
|
|
54
|
+
shutil.rmtree(view_folder)
|
|
55
|
+
|
|
56
|
+
# Update schemas.json if it exists
|
|
57
|
+
schemas_file = views_dir / "schemas.json"
|
|
58
|
+
if schemas_file.exists():
|
|
59
|
+
try:
|
|
60
|
+
schemas = json.loads(schemas_file.read_text())
|
|
61
|
+
if view_id in schemas:
|
|
62
|
+
del schemas[view_id]
|
|
63
|
+
schemas_file.write_text(json.dumps(schemas, indent=2))
|
|
64
|
+
except (json.JSONDecodeError, KeyError):
|
|
65
|
+
pass # Ignore schema update errors
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"success": True,
|
|
69
|
+
"message": f"Removed view '{view_id}'",
|
|
70
|
+
"removed_folder": str(view_folder),
|
|
71
|
+
}
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {"success": False, "error": str(e)}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Update view - modify existing view's description or props.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ...core import mcp, jinja_env, get_project_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@mcp.tool()
|
|
12
|
+
def update_view(
|
|
13
|
+
view_id: str,
|
|
14
|
+
description: Optional[str] = None,
|
|
15
|
+
props: Optional[dict] = None,
|
|
16
|
+
agent_name: Optional[str] = None,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""
|
|
19
|
+
Update an existing view's description or props.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
view_id: ID of the view to update
|
|
23
|
+
description: New description (optional)
|
|
24
|
+
props: New props dict (optional, replaces all props)
|
|
25
|
+
agent_name: Optional agent ID if not in agent directory
|
|
26
|
+
"""
|
|
27
|
+
project_dir = get_project_dir(agent_name)
|
|
28
|
+
views_dir = project_dir / "views"
|
|
29
|
+
|
|
30
|
+
if not views_dir.exists():
|
|
31
|
+
return {
|
|
32
|
+
"success": False,
|
|
33
|
+
"error": f"views/ directory not found at {views_dir}",
|
|
34
|
+
"fix": "Make sure you're in an agent project directory or specify agent_name",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
view_dir = views_dir / view_id
|
|
38
|
+
if not view_dir.exists():
|
|
39
|
+
# List available views for helpful error
|
|
40
|
+
available = [d.name for d in views_dir.iterdir() if d.is_dir() and not d.name.startswith("_")]
|
|
41
|
+
return {
|
|
42
|
+
"success": False,
|
|
43
|
+
"error": f"View '{view_id}' not found",
|
|
44
|
+
"fix": f"Available views: {', '.join(available) if available else 'none'}",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if description is None and props is None:
|
|
48
|
+
return {
|
|
49
|
+
"success": False,
|
|
50
|
+
"error": "Nothing to update",
|
|
51
|
+
"fix": "Provide at least one of: description, props",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Read current schema to get existing values
|
|
56
|
+
schema_file = view_dir / "view.schema.json"
|
|
57
|
+
if schema_file.exists():
|
|
58
|
+
current_schema = json.loads(schema_file.read_text())
|
|
59
|
+
if description is None:
|
|
60
|
+
description = current_schema.get("description", f"View: {view_id}")
|
|
61
|
+
if props is None:
|
|
62
|
+
# Extract props from schema
|
|
63
|
+
props = {}
|
|
64
|
+
for prop_name, prop_info in current_schema.get("props", {}).get("properties", {}).items():
|
|
65
|
+
props[prop_name] = prop_info.get("type", "string")
|
|
66
|
+
else:
|
|
67
|
+
if description is None:
|
|
68
|
+
description = f"View: {view_id}"
|
|
69
|
+
if props is None:
|
|
70
|
+
return {
|
|
71
|
+
"success": False,
|
|
72
|
+
"error": "Props must be specified when no schema exists",
|
|
73
|
+
"fix": "Provide the props dict with all props for the view",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Convert snake_case to PascalCase for component name
|
|
77
|
+
view_name = "".join(word.title() for word in view_id.split("_"))
|
|
78
|
+
|
|
79
|
+
# Regenerate view.tsx
|
|
80
|
+
template = jinja_env.get_template("view.tsx.j2")
|
|
81
|
+
code = template.render(view_name=view_name, description=description, props=props)
|
|
82
|
+
(view_dir / "view.tsx").write_text(code)
|
|
83
|
+
|
|
84
|
+
# Regenerate view.schema.json
|
|
85
|
+
schema_properties = {}
|
|
86
|
+
required_props = []
|
|
87
|
+
for prop_name, prop_type in props.items():
|
|
88
|
+
if isinstance(prop_type, dict):
|
|
89
|
+
schema_properties[prop_name] = {
|
|
90
|
+
"type": prop_type.get("type", "string"),
|
|
91
|
+
"description": prop_type.get("description", f"The {prop_name} prop"),
|
|
92
|
+
}
|
|
93
|
+
if prop_type.get("required", True):
|
|
94
|
+
required_props.append(prop_name)
|
|
95
|
+
else:
|
|
96
|
+
schema_properties[prop_name] = {
|
|
97
|
+
"type": prop_type,
|
|
98
|
+
"description": f"The {prop_name} prop",
|
|
99
|
+
}
|
|
100
|
+
required_props.append(prop_name)
|
|
101
|
+
|
|
102
|
+
view_schema = {
|
|
103
|
+
"name": view_id,
|
|
104
|
+
"description": description,
|
|
105
|
+
"props": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": schema_properties,
|
|
108
|
+
"required": required_props,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
(view_dir / "view.schema.json").write_text(json.dumps(view_schema, indent=2))
|
|
112
|
+
|
|
113
|
+
# Regenerate schemas
|
|
114
|
+
from ..schemas import generate_schemas
|
|
115
|
+
generate_schemas(force=True, agent_name=agent_name)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"success": True,
|
|
119
|
+
"message": f"Updated view '{view_id}'",
|
|
120
|
+
"path": str(view_dir),
|
|
121
|
+
"files": ["view.tsx", "view.schema.json"],
|
|
122
|
+
}
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return {"success": False, "error": str(e)}
|
a4e/utils/dev_manager.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import shutil
|
|
3
|
+
import time
|
|
4
|
+
import sys
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
HUB_URL = "https://dev-a4e.global.simetrik.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DevManager:
|
|
14
|
+
"""Manages the development server and ngrok tunnel lifecycle."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def stop_dev_server(port: int = 5000) -> Dict[str, Any]:
|
|
18
|
+
"""
|
|
19
|
+
Stop development server and cleanup tunnels.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
port: Port to cleanup.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Dictionary with cleanup status.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
DevManager._cleanup_port(port)
|
|
29
|
+
return {
|
|
30
|
+
"success": True,
|
|
31
|
+
"message": f"Dev server stopped and port {port} cleaned up.",
|
|
32
|
+
}
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return {"success": False, "error": f"Failed to stop dev server: {str(e)}"}
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _cleanup_port(port: int):
|
|
38
|
+
"""Kill any process using the specified port and any ngrok process."""
|
|
39
|
+
try:
|
|
40
|
+
# 1. Kill process on port (lsof on Mac/Linux)
|
|
41
|
+
# Find PID using port
|
|
42
|
+
cmd = f"lsof -t -i:{port}"
|
|
43
|
+
try:
|
|
44
|
+
pid = subprocess.check_output(cmd, shell=True).decode().strip()
|
|
45
|
+
if pid:
|
|
46
|
+
print(f"Killing process {pid} on port {port}")
|
|
47
|
+
subprocess.run(f"kill -9 {pid}", shell=True)
|
|
48
|
+
except subprocess.CalledProcessError:
|
|
49
|
+
pass # No process found
|
|
50
|
+
|
|
51
|
+
# 2. Kill orphan ngrok processes
|
|
52
|
+
# This is a bit aggressive but ensures clean state as requested
|
|
53
|
+
try:
|
|
54
|
+
subprocess.run("pkill -f ngrok", shell=True)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"Warning during cleanup: {e}")
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def start_dev_server(
|
|
63
|
+
project_dir: Path, port: int = 5000, auth_token: Optional[str] = None
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
Start the agent runner and ngrok tunnel.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
project_dir: Path to the agent project directory.
|
|
70
|
+
port: Port to run the server on.
|
|
71
|
+
auth_token: Optional ngrok auth token.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dictionary with success status and connection details.
|
|
75
|
+
"""
|
|
76
|
+
if not project_dir.exists():
|
|
77
|
+
return {
|
|
78
|
+
"success": False,
|
|
79
|
+
"error": f"Project directory {project_dir} does not exist",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Perform cleanup before starting
|
|
83
|
+
DevManager._cleanup_port(port)
|
|
84
|
+
|
|
85
|
+
# 1. Start the Agent Server (Dev Runner)
|
|
86
|
+
# We assume dev_runner.py is in the parent directory of this file's parent (a4e/dev_runner.py)
|
|
87
|
+
# utils/dev_manager.py -> a4e/utils/dev_manager.py
|
|
88
|
+
# We need a4e/dev_runner.py
|
|
89
|
+
|
|
90
|
+
# Get the package root (a4e)
|
|
91
|
+
package_root = Path(__file__).parent.parent
|
|
92
|
+
runner_script = package_root / "dev_runner.py"
|
|
93
|
+
|
|
94
|
+
if not runner_script.exists():
|
|
95
|
+
return {
|
|
96
|
+
"success": False,
|
|
97
|
+
"error": f"Runner script not found at {runner_script}",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
print(f"Starting agent server on port {port}...")
|
|
101
|
+
server_process = subprocess.Popen(
|
|
102
|
+
[
|
|
103
|
+
sys.executable,
|
|
104
|
+
str(runner_script),
|
|
105
|
+
"--agent-path",
|
|
106
|
+
str(project_dir),
|
|
107
|
+
"--port",
|
|
108
|
+
str(port),
|
|
109
|
+
],
|
|
110
|
+
stdout=subprocess.PIPE,
|
|
111
|
+
stderr=subprocess.PIPE,
|
|
112
|
+
text=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Give it a moment to start
|
|
116
|
+
time.sleep(2)
|
|
117
|
+
if server_process.poll() is not None:
|
|
118
|
+
stdout, stderr = server_process.communicate()
|
|
119
|
+
error_details = ""
|
|
120
|
+
if stdout:
|
|
121
|
+
error_details += f"STDOUT:\n{stdout}\n"
|
|
122
|
+
if stderr:
|
|
123
|
+
error_details += f"STDERR:\n{stderr}\n"
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"success": False,
|
|
127
|
+
"error": "Agent server failed to start (likely port already in use)",
|
|
128
|
+
"details": error_details or "No output captured",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
agent_id = project_dir.name
|
|
132
|
+
|
|
133
|
+
# 2. Start ngrok Tunnel
|
|
134
|
+
return DevManager._start_ngrok(agent_id, port, auth_token, server_process)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _start_ngrok(
|
|
138
|
+
agent_id: str,
|
|
139
|
+
port: int,
|
|
140
|
+
auth_token: Optional[str],
|
|
141
|
+
server_process: subprocess.Popen,
|
|
142
|
+
) -> Dict[str, Any]:
|
|
143
|
+
"""Internal helper to start ngrok via library or CLI."""
|
|
144
|
+
public_url = None
|
|
145
|
+
method = "unknown"
|
|
146
|
+
|
|
147
|
+
# Try pyngrok first
|
|
148
|
+
try:
|
|
149
|
+
from pyngrok import ngrok, conf # type: ignore
|
|
150
|
+
|
|
151
|
+
if auth_token:
|
|
152
|
+
conf.get_default().auth_token = auth_token
|
|
153
|
+
|
|
154
|
+
# Smartly manage tunnels instead of killing all
|
|
155
|
+
try:
|
|
156
|
+
tunnels = ngrok.get_tunnels()
|
|
157
|
+
for t in tunnels:
|
|
158
|
+
# Check if tunnel matches our port
|
|
159
|
+
# t.config is a dict with 'addr', e.g., 'http://localhost:5000' or just '5000'
|
|
160
|
+
addr = str(t.config.get("addr", ""))
|
|
161
|
+
# Match exact port at end of addr or as standalone value
|
|
162
|
+
if addr.endswith(f":{port}") or addr == str(port):
|
|
163
|
+
print(
|
|
164
|
+
f"Disconnecting existing tunnel for port {port}: {t.public_url}"
|
|
165
|
+
)
|
|
166
|
+
ngrok.disconnect(t.public_url)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"Warning: Failed to enumerate/disconnect tunnels: {e}")
|
|
169
|
+
# We don't kill() here to be safe, just proceed and hope for the best
|
|
170
|
+
|
|
171
|
+
tunnel = ngrok.connect(port)
|
|
172
|
+
public_url = tunnel.public_url
|
|
173
|
+
method = "pyngrok"
|
|
174
|
+
except ImportError:
|
|
175
|
+
# Fallback to ngrok CLI
|
|
176
|
+
ngrok_path = shutil.which("ngrok")
|
|
177
|
+
if not ngrok_path:
|
|
178
|
+
server_process.kill()
|
|
179
|
+
return {
|
|
180
|
+
"success": False,
|
|
181
|
+
"error": "Neither 'pyngrok' library nor 'ngrok' CLI found.",
|
|
182
|
+
"fix": "Run 'uv add pyngrok' OR install ngrok CLI.",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Start ngrok CLI process
|
|
186
|
+
try:
|
|
187
|
+
if auth_token:
|
|
188
|
+
subprocess.run(
|
|
189
|
+
[ngrok_path, "config", "add-authtoken", auth_token], check=True
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
ngrok_process = subprocess.Popen(
|
|
193
|
+
[ngrok_path, "http", str(port), "--log=stdout"],
|
|
194
|
+
stdout=subprocess.PIPE,
|
|
195
|
+
stderr=subprocess.PIPE,
|
|
196
|
+
text=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Read stdout to find the URL
|
|
200
|
+
start_time = time.time()
|
|
201
|
+
while time.time() - start_time < 10:
|
|
202
|
+
line = ngrok_process.stdout.readline() # type: ignore
|
|
203
|
+
if not line:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
# Look for "url=https://..." with support for all ngrok domains
|
|
207
|
+
match = re.search(
|
|
208
|
+
r"url=(https://[a-zA-Z0-9-]+\.(?:ngrok\.app|ngrok\.dev|ngrok\.pizza|ngrok-free\.app|ngrok-free\.dev|ngrok-free\.pizza|ngrok\.io|ngrok-free\.io))",
|
|
209
|
+
line,
|
|
210
|
+
)
|
|
211
|
+
if match:
|
|
212
|
+
public_url = match.group(1)
|
|
213
|
+
method = "ngrok_cli"
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if not public_url:
|
|
217
|
+
server_process.kill()
|
|
218
|
+
ngrok_process.kill()
|
|
219
|
+
return {
|
|
220
|
+
"success": False,
|
|
221
|
+
"error": "Failed to get public URL from ngrok CLI",
|
|
222
|
+
"details": "Timeout waiting for URL",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
server_process.kill()
|
|
227
|
+
return {
|
|
228
|
+
"success": False,
|
|
229
|
+
"error": f"Failed to start ngrok CLI: {str(e)}",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
server_process.kill()
|
|
234
|
+
return {"success": False, "error": f"Failed to start tunnel: {str(e)}"}
|
|
235
|
+
|
|
236
|
+
if public_url:
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"success": True,
|
|
240
|
+
"message": f"Dev mode started successfully via {method}",
|
|
241
|
+
"public_url": public_url,
|
|
242
|
+
"hub_url": f"{HUB_URL}/builder/playground?{urlencode({'url': public_url, 'agent': agent_id} )}",
|
|
243
|
+
"instructions": [
|
|
244
|
+
"1. Copy the Hub URL above",
|
|
245
|
+
"2. Open it in your browser",
|
|
246
|
+
"3. Your local agent is now connected to the Hub!",
|
|
247
|
+
"4. Press Ctrl+C to stop the server",
|
|
248
|
+
],
|
|
249
|
+
"pid": server_process.pid,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
server_process.kill()
|
|
253
|
+
return {"success": False, "error": "Unknown error starting tunnel"}
|