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.
Files changed (70) hide show
  1. a4e/__init__.py +0 -0
  2. a4e/cli.py +47 -0
  3. a4e/cli_commands/__init__.py +5 -0
  4. a4e/cli_commands/add.py +376 -0
  5. a4e/cli_commands/deploy.py +149 -0
  6. a4e/cli_commands/dev.py +162 -0
  7. a4e/cli_commands/info.py +206 -0
  8. a4e/cli_commands/init.py +211 -0
  9. a4e/cli_commands/list.py +227 -0
  10. a4e/cli_commands/mcp.py +504 -0
  11. a4e/cli_commands/remove.py +197 -0
  12. a4e/cli_commands/update.py +285 -0
  13. a4e/cli_commands/validate.py +117 -0
  14. a4e/core.py +109 -0
  15. a4e/dev_runner.py +425 -0
  16. a4e/server.py +86 -0
  17. a4e/templates/agent.md.j2 +168 -0
  18. a4e/templates/agent.py.j2 +15 -0
  19. a4e/templates/agents.md.j2 +99 -0
  20. a4e/templates/metadata.json.j2 +20 -0
  21. a4e/templates/prompt.md.j2 +20 -0
  22. a4e/templates/prompts/agent.md.j2 +206 -0
  23. a4e/templates/skills/agents.md.j2 +110 -0
  24. a4e/templates/skills/skill.md.j2 +120 -0
  25. a4e/templates/support_module.py.j2 +84 -0
  26. a4e/templates/tool.py.j2 +60 -0
  27. a4e/templates/tools/agent.md.j2 +192 -0
  28. a4e/templates/view.tsx.j2 +21 -0
  29. a4e/templates/views/agent.md.j2 +219 -0
  30. a4e/tools/__init__.py +70 -0
  31. a4e/tools/agent_tools/__init__.py +12 -0
  32. a4e/tools/agent_tools/add_support_module.py +95 -0
  33. a4e/tools/agent_tools/add_tool.py +115 -0
  34. a4e/tools/agent_tools/list_tools.py +28 -0
  35. a4e/tools/agent_tools/remove_tool.py +69 -0
  36. a4e/tools/agent_tools/update_tool.py +123 -0
  37. a4e/tools/deploy/__init__.py +8 -0
  38. a4e/tools/deploy/deploy.py +59 -0
  39. a4e/tools/dev/__init__.py +10 -0
  40. a4e/tools/dev/check_environment.py +79 -0
  41. a4e/tools/dev/dev_start.py +30 -0
  42. a4e/tools/dev/dev_stop.py +26 -0
  43. a4e/tools/project/__init__.py +10 -0
  44. a4e/tools/project/get_agent_info.py +66 -0
  45. a4e/tools/project/get_instructions.py +216 -0
  46. a4e/tools/project/initialize_project.py +231 -0
  47. a4e/tools/schemas/__init__.py +8 -0
  48. a4e/tools/schemas/generate_schemas.py +278 -0
  49. a4e/tools/skills/__init__.py +12 -0
  50. a4e/tools/skills/add_skill.py +105 -0
  51. a4e/tools/skills/helpers.py +137 -0
  52. a4e/tools/skills/list_skills.py +54 -0
  53. a4e/tools/skills/remove_skill.py +74 -0
  54. a4e/tools/skills/update_skill.py +150 -0
  55. a4e/tools/validation/__init__.py +8 -0
  56. a4e/tools/validation/validate.py +389 -0
  57. a4e/tools/views/__init__.py +12 -0
  58. a4e/tools/views/add_view.py +40 -0
  59. a4e/tools/views/helpers.py +91 -0
  60. a4e/tools/views/list_views.py +27 -0
  61. a4e/tools/views/remove_view.py +73 -0
  62. a4e/tools/views/update_view.py +124 -0
  63. a4e/utils/dev_manager.py +253 -0
  64. a4e/utils/schema_generator.py +255 -0
  65. a4e-0.1.5.dist-info/METADATA +427 -0
  66. a4e-0.1.5.dist-info/RECORD +70 -0
  67. a4e-0.1.5.dist-info/WHEEL +5 -0
  68. a4e-0.1.5.dist-info/entry_points.txt +2 -0
  69. a4e-0.1.5.dist-info/licenses/LICENSE +21 -0
  70. 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)}
@@ -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"}