signalwire-agents 0.1.13__py3-none-any.whl → 1.0.17.dev4__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 (143) hide show
  1. signalwire_agents/__init__.py +99 -15
  2. signalwire_agents/agent_server.py +248 -60
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +9 -0
  5. signalwire_agents/cli/build_search.py +951 -41
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/dokku.py +2320 -0
  13. signalwire_agents/cli/execution/__init__.py +10 -0
  14. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  15. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  16. signalwire_agents/cli/init_project.py +2636 -0
  17. signalwire_agents/cli/output/__init__.py +10 -0
  18. signalwire_agents/cli/output/output_formatter.py +255 -0
  19. signalwire_agents/cli/output/swml_dump.py +186 -0
  20. signalwire_agents/cli/simulation/__init__.py +10 -0
  21. signalwire_agents/cli/simulation/data_generation.py +374 -0
  22. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  23. signalwire_agents/cli/simulation/mock_env.py +282 -0
  24. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  25. signalwire_agents/cli/test_swaig.py +566 -2366
  26. signalwire_agents/cli/types.py +81 -0
  27. signalwire_agents/core/__init__.py +2 -2
  28. signalwire_agents/core/agent/__init__.py +12 -0
  29. signalwire_agents/core/agent/config/__init__.py +12 -0
  30. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  31. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  32. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  33. signalwire_agents/core/agent/prompt/manager.py +306 -0
  34. signalwire_agents/core/agent/routing/__init__.py +9 -0
  35. signalwire_agents/core/agent/security/__init__.py +9 -0
  36. signalwire_agents/core/agent/swml/__init__.py +9 -0
  37. signalwire_agents/core/agent/tools/__init__.py +15 -0
  38. signalwire_agents/core/agent/tools/decorator.py +97 -0
  39. signalwire_agents/core/agent/tools/registry.py +210 -0
  40. signalwire_agents/core/agent_base.py +845 -2916
  41. signalwire_agents/core/auth_handler.py +233 -0
  42. signalwire_agents/core/config_loader.py +259 -0
  43. signalwire_agents/core/contexts.py +418 -0
  44. signalwire_agents/core/data_map.py +3 -15
  45. signalwire_agents/core/function_result.py +116 -44
  46. signalwire_agents/core/logging_config.py +162 -18
  47. signalwire_agents/core/mixins/__init__.py +28 -0
  48. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  49. signalwire_agents/core/mixins/auth_mixin.py +280 -0
  50. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  51. signalwire_agents/core/mixins/serverless_mixin.py +460 -0
  52. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  53. signalwire_agents/core/mixins/state_mixin.py +153 -0
  54. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  55. signalwire_agents/core/mixins/web_mixin.py +1142 -0
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +84 -1
  58. signalwire_agents/core/skill_manager.py +62 -20
  59. signalwire_agents/core/swaig_function.py +18 -5
  60. signalwire_agents/core/swml_builder.py +207 -11
  61. signalwire_agents/core/swml_handler.py +27 -21
  62. signalwire_agents/core/swml_renderer.py +123 -312
  63. signalwire_agents/core/swml_service.py +171 -203
  64. signalwire_agents/mcp_gateway/__init__.py +29 -0
  65. signalwire_agents/mcp_gateway/gateway_service.py +564 -0
  66. signalwire_agents/mcp_gateway/mcp_manager.py +513 -0
  67. signalwire_agents/mcp_gateway/session_manager.py +218 -0
  68. signalwire_agents/prefabs/concierge.py +0 -3
  69. signalwire_agents/prefabs/faq_bot.py +0 -3
  70. signalwire_agents/prefabs/info_gatherer.py +0 -3
  71. signalwire_agents/prefabs/receptionist.py +0 -3
  72. signalwire_agents/prefabs/survey.py +0 -3
  73. signalwire_agents/schema.json +9218 -5489
  74. signalwire_agents/search/__init__.py +7 -1
  75. signalwire_agents/search/document_processor.py +490 -31
  76. signalwire_agents/search/index_builder.py +307 -37
  77. signalwire_agents/search/migration.py +418 -0
  78. signalwire_agents/search/models.py +30 -0
  79. signalwire_agents/search/pgvector_backend.py +748 -0
  80. signalwire_agents/search/query_processor.py +162 -31
  81. signalwire_agents/search/search_engine.py +916 -35
  82. signalwire_agents/search/search_service.py +376 -53
  83. signalwire_agents/skills/README.md +452 -0
  84. signalwire_agents/skills/__init__.py +14 -2
  85. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  86. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  87. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  88. signalwire_agents/skills/datasphere/README.md +210 -0
  89. signalwire_agents/skills/datasphere/skill.py +84 -3
  90. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  91. signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
  92. signalwire_agents/skills/datasphere_serverless/skill.py +82 -1
  93. signalwire_agents/skills/datetime/README.md +132 -0
  94. signalwire_agents/skills/datetime/__init__.py +9 -0
  95. signalwire_agents/skills/datetime/skill.py +20 -7
  96. signalwire_agents/skills/joke/README.md +149 -0
  97. signalwire_agents/skills/joke/__init__.py +9 -0
  98. signalwire_agents/skills/joke/skill.py +21 -0
  99. signalwire_agents/skills/math/README.md +161 -0
  100. signalwire_agents/skills/math/__init__.py +9 -0
  101. signalwire_agents/skills/math/skill.py +18 -4
  102. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  103. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  104. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  105. signalwire_agents/skills/native_vector_search/README.md +210 -0
  106. signalwire_agents/skills/native_vector_search/__init__.py +9 -0
  107. signalwire_agents/skills/native_vector_search/skill.py +569 -101
  108. signalwire_agents/skills/play_background_file/README.md +218 -0
  109. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  110. signalwire_agents/skills/play_background_file/skill.py +242 -0
  111. signalwire_agents/skills/registry.py +395 -40
  112. signalwire_agents/skills/spider/README.md +236 -0
  113. signalwire_agents/skills/spider/__init__.py +13 -0
  114. signalwire_agents/skills/spider/skill.py +598 -0
  115. signalwire_agents/skills/swml_transfer/README.md +395 -0
  116. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  117. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  118. signalwire_agents/skills/weather_api/README.md +178 -0
  119. signalwire_agents/skills/weather_api/__init__.py +12 -0
  120. signalwire_agents/skills/weather_api/skill.py +191 -0
  121. signalwire_agents/skills/web_search/README.md +163 -0
  122. signalwire_agents/skills/web_search/__init__.py +9 -0
  123. signalwire_agents/skills/web_search/skill.py +586 -112
  124. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  125. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  126. signalwire_agents/skills/{wikipedia → wikipedia_search}/skill.py +33 -3
  127. signalwire_agents/web/__init__.py +17 -0
  128. signalwire_agents/web/web_service.py +559 -0
  129. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-agent-init.1 +400 -0
  130. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-search.1 +483 -0
  131. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/swaig-test.1 +308 -0
  132. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/METADATA +347 -215
  133. signalwire_agents-1.0.17.dev4.dist-info/RECORD +147 -0
  134. signalwire_agents-1.0.17.dev4.dist-info/entry_points.txt +6 -0
  135. signalwire_agents/core/state/file_state_manager.py +0 -219
  136. signalwire_agents/core/state/state_manager.py +0 -101
  137. signalwire_agents/skills/wikipedia/__init__.py +0 -9
  138. signalwire_agents-0.1.13.data/data/schema.json +0 -5611
  139. signalwire_agents-0.1.13.dist-info/RECORD +0 -67
  140. signalwire_agents-0.1.13.dist-info/entry_points.txt +0 -3
  141. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/WHEEL +0 -0
  142. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/licenses/LICENSE +0 -0
  143. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2320 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SignalWire Agent Dokku Deployment Tool
4
+
5
+ CLI tool for deploying SignalWire agents to Dokku with support for:
6
+ - Simple git push deployment
7
+ - Full CI/CD with GitHub Actions
8
+ - Service provisioning (PostgreSQL, Redis)
9
+ - Preview environments for PRs
10
+
11
+ Usage:
12
+ sw-agent-dokku init myagent # Simple mode
13
+ sw-agent-dokku init myagent --cicd # With GitHub Actions CI/CD
14
+ sw-agent-dokku deploy # Deploy current directory
15
+ sw-agent-dokku logs # Tail logs
16
+ sw-agent-dokku config set KEY=value # Set environment variables
17
+ sw-agent-dokku scale web=2 # Scale processes
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import subprocess
23
+ import secrets
24
+ import argparse
25
+ import shutil
26
+ from pathlib import Path
27
+ from typing import Optional, Dict, List, Any
28
+
29
+
30
+ # =============================================================================
31
+ # ANSI Colors
32
+ # =============================================================================
33
+
34
+ class Colors:
35
+ RED = '\033[0;31m'
36
+ GREEN = '\033[0;32m'
37
+ YELLOW = '\033[1;33m'
38
+ BLUE = '\033[0;34m'
39
+ CYAN = '\033[0;36m'
40
+ MAGENTA = '\033[0;35m'
41
+ BOLD = '\033[1m'
42
+ DIM = '\033[2m'
43
+ NC = '\033[0m'
44
+
45
+
46
+ def print_step(msg: str):
47
+ print(f"{Colors.BLUE}==>{Colors.NC} {msg}")
48
+
49
+
50
+ def print_success(msg: str):
51
+ print(f"{Colors.GREEN}✓{Colors.NC} {msg}")
52
+
53
+
54
+ def print_warning(msg: str):
55
+ print(f"{Colors.YELLOW}!{Colors.NC} {msg}")
56
+
57
+
58
+ def print_error(msg: str):
59
+ print(f"{Colors.RED}✗{Colors.NC} {msg}")
60
+
61
+
62
+ def print_header(msg: str):
63
+ print(f"\n{Colors.BOLD}{Colors.CYAN}{msg}{Colors.NC}")
64
+
65
+
66
+ def prompt(question: str, default: str = "") -> str:
67
+ if default:
68
+ result = input(f"{question} [{default}]: ").strip()
69
+ return result if result else default
70
+ return input(f"{question}: ").strip()
71
+
72
+
73
+ def prompt_yes_no(question: str, default: bool = True) -> bool:
74
+ hint = "Y/n" if default else "y/N"
75
+ result = input(f"{question} [{hint}]: ").strip().lower()
76
+ if not result:
77
+ return default
78
+ return result in ('y', 'yes')
79
+
80
+
81
+ def generate_password(length: int = 32) -> str:
82
+ return secrets.token_urlsafe(length)[:length]
83
+
84
+
85
+ # =============================================================================
86
+ # Templates - Core Files
87
+ # =============================================================================
88
+
89
+ PROCFILE_TEMPLATE = """web: gunicorn app:app --bind 0.0.0.0:$PORT --workers 2 --worker-class uvicorn.workers.UvicornWorker
90
+ """
91
+
92
+ RUNTIME_TEMPLATE = """python-3.11
93
+ """
94
+
95
+ REQUIREMENTS_TEMPLATE = """signalwire-agents>=1.0.16
96
+ gunicorn>=21.0.0
97
+ uvicorn>=0.24.0
98
+ python-dotenv>=1.0.0
99
+ requests>=2.28.0
100
+ """
101
+
102
+ CHECKS_TEMPLATE = """WAIT=5
103
+ TIMEOUT=30
104
+ ATTEMPTS=5
105
+
106
+ /health
107
+ """
108
+
109
+ GITIGNORE_TEMPLATE = """# Environment
110
+ .env
111
+ .venv/
112
+ venv/
113
+ __pycache__/
114
+ *.pyc
115
+ *.pyo
116
+
117
+ # IDE
118
+ .vscode/
119
+ .idea/
120
+ *.swp
121
+ *.swo
122
+
123
+ # Testing
124
+ .pytest_cache/
125
+ .coverage
126
+ htmlcov/
127
+
128
+ # Build
129
+ dist/
130
+ build/
131
+ *.egg-info/
132
+
133
+ # Logs
134
+ *.log
135
+
136
+ # OS
137
+ .DS_Store
138
+ Thumbs.db
139
+ """
140
+
141
+ ENV_EXAMPLE_TEMPLATE = """# SignalWire Agent Configuration
142
+ # =============================================================================
143
+
144
+ # SignalWire Credentials (required for WebRTC calling)
145
+ SIGNALWIRE_SPACE_NAME=your-space
146
+ SIGNALWIRE_PROJECT_ID=your-project-id
147
+ SIGNALWIRE_TOKEN=your-api-token
148
+
149
+ # Public URL for SWML callbacks (required for WebRTC calling)
150
+ # This should be your publicly accessible URL (e.g., ngrok, dokku domain)
151
+ SWML_PROXY_URL_BASE=https://your-app.example.com
152
+
153
+ # Basic Auth for SWML endpoints (password required, user defaults to 'signalwire')
154
+ # SWML_BASIC_AUTH_USER=signalwire
155
+ SWML_BASIC_AUTH_PASSWORD=your-secure-password
156
+
157
+ # Agent Configuration
158
+ AGENT_NAME={app_name}
159
+
160
+ # App Configuration
161
+ APP_ENV=production
162
+ APP_NAME={app_name}
163
+
164
+ # Optional: External Services
165
+ # DATABASE_URL=postgres://user:pass@host:5432/db
166
+ # REDIS_URL=redis://host:6379
167
+ """
168
+
169
+ APP_TEMPLATE = '''#!/usr/bin/env python3
170
+ """
171
+ {agent_name} - SignalWire AI Agent
172
+
173
+ Deployed to Dokku with automatic health checks and SWAIG support.
174
+ """
175
+
176
+ import os
177
+ from dotenv import load_dotenv
178
+ from signalwire_agents import AgentBase, SwaigFunctionResult
179
+
180
+ # Load environment variables from .env file
181
+ load_dotenv()
182
+
183
+
184
+ class {agent_class}(AgentBase):
185
+ """{agent_name} agent for Dokku deployment."""
186
+
187
+ def __init__(self):
188
+ super().__init__(name="{agent_slug}")
189
+
190
+ self._configure_prompts()
191
+ self.add_language("English", "en-US", "rime.spore")
192
+ self._setup_functions()
193
+
194
+ def _configure_prompts(self):
195
+ self.prompt_add_section(
196
+ "Role",
197
+ "You are a helpful AI assistant deployed on Dokku."
198
+ )
199
+
200
+ self.prompt_add_section(
201
+ "Guidelines",
202
+ bullets=[
203
+ "Be professional and courteous",
204
+ "Ask clarifying questions when needed",
205
+ "Keep responses concise and helpful"
206
+ ]
207
+ )
208
+
209
+ def _setup_functions(self):
210
+ @self.tool(
211
+ description="Get information about a topic",
212
+ parameters={{
213
+ "type": "object",
214
+ "properties": {{
215
+ "topic": {{
216
+ "type": "string",
217
+ "description": "The topic to get information about"
218
+ }}
219
+ }},
220
+ "required": ["topic"]
221
+ }}
222
+ )
223
+ def get_info(args, raw_data):
224
+ topic = args.get("topic", "")
225
+ return SwaigFunctionResult(
226
+ f"Information about {{topic}}: This is a placeholder response."
227
+ )
228
+
229
+ @self.tool(description="Get deployment information")
230
+ def get_deployment_info(args, raw_data):
231
+ app_name = os.getenv("APP_NAME", "unknown")
232
+ app_env = os.getenv("APP_ENV", "unknown")
233
+
234
+ return SwaigFunctionResult(
235
+ f"Running on Dokku. App: {{app_name}}, Environment: {{app_env}}."
236
+ )
237
+
238
+
239
+ # Create agent instance
240
+ agent = {agent_class}()
241
+
242
+ # Expose the FastAPI app for gunicorn/uvicorn
243
+ app = agent.get_app()
244
+
245
+ if __name__ == "__main__":
246
+ agent.run()
247
+ '''
248
+
249
+ APP_TEMPLATE_WITH_WEB = '''#!/usr/bin/env python3
250
+ """
251
+ {agent_name} - SignalWire AI Agent
252
+
253
+ Deployed to Dokku with automatic health checks, SWAIG support, and web interface.
254
+ Includes WebRTC calling support with dynamic token generation.
255
+ """
256
+
257
+ import os
258
+ import time
259
+ from pathlib import Path
260
+ from dotenv import load_dotenv
261
+ import requests
262
+ from starlette.responses import JSONResponse
263
+ from signalwire_agents import AgentBase, AgentServer, SwaigFunctionResult
264
+
265
+ # Load environment variables from .env file
266
+ load_dotenv()
267
+
268
+
269
+ class {agent_class}(AgentBase):
270
+ """{agent_name} agent for Dokku deployment."""
271
+
272
+ def __init__(self):
273
+ super().__init__(name="{agent_slug}", route="/swml")
274
+
275
+ self._configure_prompts()
276
+ self.add_language("English", "en-US", "rime.spore")
277
+ self._setup_functions()
278
+
279
+ def _configure_prompts(self):
280
+ self.prompt_add_section(
281
+ "Role",
282
+ "You are a helpful AI assistant deployed on Dokku."
283
+ )
284
+
285
+ self.prompt_add_section(
286
+ "Guidelines",
287
+ bullets=[
288
+ "Be professional and courteous",
289
+ "Ask clarifying questions when needed",
290
+ "Keep responses concise and helpful"
291
+ ]
292
+ )
293
+
294
+ def _setup_functions(self):
295
+ @self.tool(
296
+ description="Get information about a topic",
297
+ parameters={{
298
+ "type": "object",
299
+ "properties": {{
300
+ "topic": {{
301
+ "type": "string",
302
+ "description": "The topic to get information about"
303
+ }}
304
+ }},
305
+ "required": ["topic"]
306
+ }}
307
+ )
308
+ def get_info(args, raw_data):
309
+ topic = args.get("topic", "")
310
+ return SwaigFunctionResult(
311
+ f"Information about {{topic}}: This is a placeholder response."
312
+ )
313
+
314
+ @self.tool(description="Get deployment information")
315
+ def get_deployment_info(args, raw_data):
316
+ app_name = os.getenv("APP_NAME", "unknown")
317
+ app_env = os.getenv("APP_ENV", "unknown")
318
+
319
+ return SwaigFunctionResult(
320
+ f"Running on Dokku. App: {{app_name}}, Environment: {{app_env}}."
321
+ )
322
+
323
+
324
+ # =============================================================================
325
+ # SignalWire SWML Handler Management
326
+ # =============================================================================
327
+
328
+ def get_signalwire_host():
329
+ """Get the full SignalWire host from space name."""
330
+ space = os.getenv("SIGNALWIRE_SPACE_NAME", "")
331
+ if not space:
332
+ return None
333
+ if "." in space:
334
+ return space
335
+ return f"{{space}}.signalwire.com"
336
+
337
+
338
+ def find_existing_handler(sw_host, auth, agent_name):
339
+ """Find an existing SWML handler by name."""
340
+ try:
341
+ resp = requests.get(
342
+ f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers",
343
+ auth=auth,
344
+ headers={{"Accept": "application/json"}}
345
+ )
346
+ if resp.status_code != 200:
347
+ return None
348
+
349
+ handlers = resp.json().get("data", [])
350
+ for handler in handlers:
351
+ swml_webhook = handler.get("swml_webhook", {{}})
352
+ handler_name = swml_webhook.get("name") or handler.get("display_name")
353
+ if handler_name == agent_name:
354
+ handler_id = handler.get("id")
355
+ handler_url = swml_webhook.get("primary_request_url", "")
356
+ addr_resp = requests.get(
357
+ f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{handler_id}}/addresses",
358
+ auth=auth,
359
+ headers={{"Accept": "application/json"}}
360
+ )
361
+ if addr_resp.status_code == 200:
362
+ addresses = addr_resp.json().get("data", [])
363
+ if addresses:
364
+ return {{
365
+ "id": handler_id,
366
+ "name": handler_name,
367
+ "url": handler_url,
368
+ "address_id": addresses[0]["id"],
369
+ "address": addresses[0]["channels"]["audio"]
370
+ }}
371
+ except Exception as e:
372
+ print(f"Error checking existing handlers: {{e}}")
373
+ return None
374
+
375
+
376
+ # Store SWML handler info
377
+ swml_handler_info = {{"id": None, "address_id": None, "address": None}}
378
+
379
+
380
+ def setup_swml_handler():
381
+ """Set up SWML handler on startup."""
382
+ sw_host = get_signalwire_host()
383
+ project = os.getenv("SIGNALWIRE_PROJECT_ID", "")
384
+ token = os.getenv("SIGNALWIRE_TOKEN", "")
385
+ agent_name = os.getenv("AGENT_NAME", "{agent_slug}")
386
+ proxy_url = os.getenv("SWML_PROXY_URL_BASE", os.getenv("APP_URL", ""))
387
+ auth_user = os.getenv("SWML_BASIC_AUTH_USER", "signalwire")
388
+ auth_pass = os.getenv("SWML_BASIC_AUTH_PASSWORD", "")
389
+
390
+ if not all([sw_host, project, token]):
391
+ print("SignalWire credentials not configured - skipping SWML handler setup")
392
+ return
393
+
394
+ if not proxy_url:
395
+ print("SWML_PROXY_URL_BASE not set - skipping SWML handler setup")
396
+ return
397
+
398
+ # Build SWML URL with basic auth (user defaults to 'signalwire' if not set)
399
+ if auth_pass and "://" in proxy_url:
400
+ scheme, rest = proxy_url.split("://", 1)
401
+ swml_url = f"{{scheme}}://{{auth_user}}:{{auth_pass}}@{{rest}}/swml"
402
+ else:
403
+ swml_url = proxy_url + "/swml"
404
+
405
+ auth = (project, token)
406
+ headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
407
+
408
+ existing = find_existing_handler(sw_host, auth, agent_name)
409
+ if existing:
410
+ swml_handler_info["id"] = existing["id"]
411
+ swml_handler_info["address_id"] = existing["address_id"]
412
+ swml_handler_info["address"] = existing["address"]
413
+
414
+ # Always update the URL on startup to ensure credentials are current
415
+ try:
416
+ requests.put(
417
+ f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{existing['id']}}",
418
+ json={{"primary_request_url": swml_url, "primary_request_method": "POST"}},
419
+ auth=auth,
420
+ headers=headers
421
+ )
422
+ print(f"Updated SWML handler: {{existing['name']}}")
423
+ except Exception as e:
424
+ print(f"Failed to update handler URL: {{e}}")
425
+ print(f"Call address: {{existing['address']}}")
426
+ else:
427
+ try:
428
+ handler_resp = requests.post(
429
+ f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers",
430
+ json={{
431
+ "name": agent_name,
432
+ "used_for": "calling",
433
+ "primary_request_url": swml_url,
434
+ "primary_request_method": "POST"
435
+ }},
436
+ auth=auth,
437
+ headers=headers
438
+ )
439
+ handler_resp.raise_for_status()
440
+ handler_id = handler_resp.json().get("id")
441
+ swml_handler_info["id"] = handler_id
442
+
443
+ addr_resp = requests.get(
444
+ f"https://{{sw_host}}/api/fabric/resources/external_swml_handlers/{{handler_id}}/addresses",
445
+ auth=auth,
446
+ headers={{"Accept": "application/json"}}
447
+ )
448
+ addr_resp.raise_for_status()
449
+ addresses = addr_resp.json().get("data", [])
450
+ if addresses:
451
+ swml_handler_info["address_id"] = addresses[0]["id"]
452
+ swml_handler_info["address"] = addresses[0]["channels"]["audio"]
453
+ print(f"Created SWML handler: {{agent_name}}")
454
+ print(f"Call address: {{swml_handler_info['address']}}")
455
+ except Exception as e:
456
+ print(f"Failed to create SWML handler: {{e}}")
457
+
458
+
459
+ # =============================================================================
460
+ # Server Setup
461
+ # =============================================================================
462
+
463
+ server = AgentServer(host="0.0.0.0", port=int(os.getenv("PORT", 3000)))
464
+ server.register({agent_class}())
465
+
466
+ # Serve static files from web/ directory
467
+ web_dir = Path(__file__).parent / "web"
468
+ if web_dir.exists():
469
+ server.serve_static_files(str(web_dir))
470
+
471
+
472
+ # =============================================================================
473
+ # API Endpoints
474
+ # =============================================================================
475
+
476
+ @server.app.get("/get_token")
477
+ def get_token():
478
+ """Get a guest token for WebRTC calls."""
479
+ sw_host = get_signalwire_host()
480
+ project = os.getenv("SIGNALWIRE_PROJECT_ID", "")
481
+ token = os.getenv("SIGNALWIRE_TOKEN", "")
482
+
483
+ if not all([sw_host, project, token]):
484
+ return JSONResponse({{"error": "SignalWire credentials not configured"}}, status_code=500)
485
+
486
+ if not swml_handler_info["address_id"]:
487
+ return JSONResponse({{"error": "SWML handler not configured - check startup logs"}}, status_code=500)
488
+
489
+ auth = (project, token)
490
+ headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
491
+
492
+ try:
493
+ expire_at = int(time.time()) + 3600 * 24 # 24 hours
494
+
495
+ guest_resp = requests.post(
496
+ f"https://{{sw_host}}/api/fabric/guests/tokens",
497
+ json={{
498
+ "allowed_addresses": [swml_handler_info["address_id"]],
499
+ "expire_at": expire_at
500
+ }},
501
+ auth=auth,
502
+ headers=headers
503
+ )
504
+ guest_resp.raise_for_status()
505
+ guest_token = guest_resp.json().get("token", "")
506
+
507
+ return {{
508
+ "token": guest_token,
509
+ "address": swml_handler_info["address"]
510
+ }}
511
+
512
+ except requests.exceptions.RequestException as e:
513
+ print(f"Token request failed: {{e}}")
514
+ return JSONResponse({{"error": str(e)}}, status_code=500)
515
+
516
+
517
+ @server.app.get("/get_credentials")
518
+ def get_credentials():
519
+ """Get basic auth credentials for curl examples."""
520
+ return {{
521
+ "user": os.getenv("SWML_BASIC_AUTH_USER", ""),
522
+ "password": os.getenv("SWML_BASIC_AUTH_PASSWORD", "")
523
+ }}
524
+
525
+
526
+ @server.app.get("/get_resource_info")
527
+ def get_resource_info():
528
+ """Get SWML handler resource info for dashboard link."""
529
+ sw_host = get_signalwire_host()
530
+ space_name = os.getenv("SIGNALWIRE_SPACE_NAME", "")
531
+ return {{
532
+ "space_name": space_name,
533
+ "resource_id": swml_handler_info["id"],
534
+ "dashboard_url": f"https://{{sw_host}}/neon/resources/{{swml_handler_info['id']}}/edit?t=addresses" if sw_host and swml_handler_info["id"] else None
535
+ }}
536
+
537
+
538
+ # =============================================================================
539
+ # Static File Handling
540
+ # =============================================================================
541
+
542
+ from fastapi import Request, HTTPException
543
+ from fastapi.responses import FileResponse, RedirectResponse
544
+ import mimetypes
545
+
546
+ @server.app.api_route("/swml", methods=["GET", "POST"])
547
+ async def swml_redirect():
548
+ return RedirectResponse(url="/swml/", status_code=307)
549
+
550
+ @server.app.get("/{{full_path:path}}")
551
+ async def serve_static(request: Request, full_path: str):
552
+ """Serve static files from web/ directory"""
553
+ if not web_dir.exists():
554
+ raise HTTPException(status_code=404, detail="Not Found")
555
+
556
+ if not full_path or full_path == "/":
557
+ full_path = "index.html"
558
+
559
+ file_path = web_dir / full_path
560
+
561
+ try:
562
+ file_path = file_path.resolve()
563
+ if not str(file_path).startswith(str(web_dir.resolve())):
564
+ raise HTTPException(status_code=404, detail="Not Found")
565
+ except Exception:
566
+ raise HTTPException(status_code=404, detail="Not Found")
567
+
568
+ if file_path.exists() and file_path.is_file():
569
+ media_type, _ = mimetypes.guess_type(str(file_path))
570
+ return FileResponse(file_path, media_type=media_type)
571
+
572
+ if (web_dir / full_path / "index.html").exists():
573
+ return FileResponse(web_dir / full_path / "index.html", media_type="text/html")
574
+
575
+ raise HTTPException(status_code=404, detail="Not Found")
576
+
577
+
578
+ # Set up SWML handler on startup
579
+ setup_swml_handler()
580
+
581
+ # Expose ASGI app for gunicorn
582
+ app = server.app
583
+
584
+ if __name__ == "__main__":
585
+ server.run()
586
+ '''
587
+
588
+ WEB_INDEX_TEMPLATE = '''<!DOCTYPE html>
589
+ <html lang="en">
590
+ <head>
591
+ <meta charset="UTF-8">
592
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
593
+ <title>{agent_name}</title>
594
+ <style>
595
+ * {{ box-sizing: border-box; }}
596
+ body {{
597
+ font-family: system-ui, -apple-system, sans-serif;
598
+ max-width: 900px;
599
+ margin: 0 auto;
600
+ padding: 40px 20px;
601
+ background: #f8f9fa;
602
+ color: #333;
603
+ }}
604
+ h1 {{
605
+ color: #044cf6;
606
+ border-bottom: 3px solid #044cf6;
607
+ padding-bottom: 10px;
608
+ }}
609
+ h2 {{
610
+ color: #333;
611
+ margin-top: 40px;
612
+ border-bottom: 1px solid #ddd;
613
+ padding-bottom: 8px;
614
+ }}
615
+ .status {{
616
+ background: #d4edda;
617
+ border: 1px solid #c3e6cb;
618
+ color: #155724;
619
+ padding: 15px;
620
+ border-radius: 8px;
621
+ margin: 20px 0;
622
+ }}
623
+ .call-section {{
624
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
625
+ border-radius: 12px;
626
+ padding: 30px;
627
+ margin: 20px 0;
628
+ color: white;
629
+ text-align: center;
630
+ }}
631
+ .call-section h2 {{
632
+ color: white;
633
+ border: none;
634
+ margin-top: 0;
635
+ }}
636
+ .call-controls {{
637
+ display: flex;
638
+ gap: 15px;
639
+ justify-content: center;
640
+ align-items: center;
641
+ flex-wrap: wrap;
642
+ }}
643
+ .call-btn {{
644
+ padding: 15px 40px;
645
+ font-size: 18px;
646
+ font-weight: bold;
647
+ border: none;
648
+ border-radius: 8px;
649
+ cursor: pointer;
650
+ transition: all 0.3s ease;
651
+ }}
652
+ .call-btn:disabled {{
653
+ opacity: 0.5;
654
+ cursor: not-allowed;
655
+ }}
656
+ .call-btn.connect {{
657
+ background: #10b981;
658
+ color: white;
659
+ }}
660
+ .call-btn.connect:hover:not(:disabled) {{
661
+ background: #059669;
662
+ transform: translateY(-2px);
663
+ box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4);
664
+ }}
665
+ .call-btn.disconnect {{
666
+ background: #ef4444;
667
+ color: white;
668
+ }}
669
+ .call-btn.disconnect:hover:not(:disabled) {{
670
+ background: #dc2626;
671
+ }}
672
+ .call-status {{
673
+ margin-top: 15px;
674
+ font-size: 14px;
675
+ opacity: 0.9;
676
+ }}
677
+ .destination-input {{
678
+ padding: 12px 15px;
679
+ font-size: 14px;
680
+ border: 2px solid rgba(255,255,255,0.3);
681
+ border-radius: 8px;
682
+ background: rgba(255,255,255,0.1);
683
+ color: white;
684
+ width: 250px;
685
+ }}
686
+ .destination-input::placeholder {{
687
+ color: rgba(255,255,255,0.6);
688
+ }}
689
+ .destination-input:focus {{
690
+ outline: none;
691
+ border-color: rgba(255,255,255,0.6);
692
+ background: rgba(255,255,255,0.2);
693
+ }}
694
+ .endpoint {{
695
+ background: white;
696
+ border: 1px solid #ddd;
697
+ border-radius: 8px;
698
+ padding: 20px;
699
+ margin: 15px 0;
700
+ }}
701
+ .endpoint h3 {{
702
+ margin-top: 0;
703
+ color: #044cf6;
704
+ }}
705
+ .method {{
706
+ display: inline-block;
707
+ padding: 4px 10px;
708
+ border-radius: 4px;
709
+ font-weight: bold;
710
+ font-size: 12px;
711
+ margin-right: 10px;
712
+ }}
713
+ .method.get {{ background: #61affe; color: white; }}
714
+ .method.post {{ background: #49cc90; color: white; }}
715
+ .path {{
716
+ font-family: monospace;
717
+ font-size: 16px;
718
+ color: #333;
719
+ }}
720
+ code, pre {{
721
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
722
+ }}
723
+ code {{
724
+ background: #e9ecef;
725
+ padding: 2px 6px;
726
+ border-radius: 4px;
727
+ font-size: 14px;
728
+ }}
729
+ pre {{
730
+ background: #1e1e1e;
731
+ color: #d4d4d4;
732
+ padding: 15px;
733
+ border-radius: 8px;
734
+ overflow-x: auto;
735
+ font-size: 13px;
736
+ line-height: 1.5;
737
+ margin: 0;
738
+ }}
739
+ pre .comment {{ color: #6a9955; }}
740
+ .tabs {{
741
+ display: flex;
742
+ gap: 0;
743
+ margin-top: 15px;
744
+ }}
745
+ .tab {{
746
+ padding: 8px 16px;
747
+ background: #e9ecef;
748
+ border: 1px solid #ddd;
749
+ border-bottom: none;
750
+ border-radius: 8px 8px 0 0;
751
+ cursor: pointer;
752
+ font-size: 13px;
753
+ font-weight: 500;
754
+ color: #666;
755
+ transition: all 0.2s;
756
+ }}
757
+ .tab:hover {{ background: #dee2e6; }}
758
+ .tab.active {{
759
+ background: #1e1e1e;
760
+ color: #d4d4d4;
761
+ border-color: #1e1e1e;
762
+ }}
763
+ .tab-content {{
764
+ display: none;
765
+ border-radius: 0 8px 8px 8px;
766
+ }}
767
+ .tab-content.active {{ display: block; }}
768
+ .browser-panel {{
769
+ background: #f8f9fa;
770
+ border: 1px solid #ddd;
771
+ border-radius: 0 8px 8px 8px;
772
+ padding: 15px;
773
+ }}
774
+ .try-btn {{
775
+ padding: 10px 20px;
776
+ background: #044cf6;
777
+ color: white;
778
+ border: none;
779
+ border-radius: 6px;
780
+ cursor: pointer;
781
+ font-weight: 500;
782
+ font-size: 14px;
783
+ transition: all 0.2s;
784
+ }}
785
+ .try-btn:hover {{ background: #0339c2; }}
786
+ .try-btn:disabled {{
787
+ background: #ccc;
788
+ cursor: not-allowed;
789
+ }}
790
+ .response-area {{
791
+ margin-top: 15px;
792
+ display: none;
793
+ }}
794
+ .response-area.visible {{ display: block; }}
795
+ .response-header {{
796
+ display: flex;
797
+ justify-content: space-between;
798
+ align-items: center;
799
+ margin-bottom: 8px;
800
+ }}
801
+ .response-status {{
802
+ font-size: 13px;
803
+ font-weight: 500;
804
+ }}
805
+ .response-status.success {{ color: #10b981; }}
806
+ .response-status.error {{ color: #ef4444; }}
807
+ .response-time {{
808
+ font-size: 12px;
809
+ color: #666;
810
+ }}
811
+ .response-body {{
812
+ background: #1e1e1e;
813
+ color: #d4d4d4;
814
+ padding: 15px;
815
+ border-radius: 8px;
816
+ font-family: 'SF Mono', Monaco, monospace;
817
+ font-size: 12px;
818
+ max-height: 300px;
819
+ overflow: auto;
820
+ white-space: pre-wrap;
821
+ word-break: break-word;
822
+ }}
823
+ .curl-panel {{
824
+ position: relative;
825
+ }}
826
+ .copy-btn {{
827
+ position: absolute;
828
+ top: 10px;
829
+ right: 10px;
830
+ padding: 5px 10px;
831
+ background: rgba(255,255,255,0.1);
832
+ color: #999;
833
+ border: 1px solid rgba(255,255,255,0.2);
834
+ border-radius: 4px;
835
+ cursor: pointer;
836
+ font-size: 11px;
837
+ transition: all 0.2s;
838
+ }}
839
+ .copy-btn:hover {{
840
+ background: rgba(255,255,255,0.2);
841
+ color: #fff;
842
+ }}
843
+ .audio-settings {{
844
+ display: flex;
845
+ gap: 20px;
846
+ justify-content: center;
847
+ margin-top: 15px;
848
+ flex-wrap: wrap;
849
+ }}
850
+ .audio-setting {{
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 8px;
854
+ font-size: 13px;
855
+ color: rgba(255,255,255,0.9);
856
+ }}
857
+ .audio-setting input[type="checkbox"] {{
858
+ width: 18px;
859
+ height: 18px;
860
+ cursor: pointer;
861
+ }}
862
+ .audio-setting label {{
863
+ cursor: pointer;
864
+ }}
865
+ .phone-info {{
866
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
867
+ border: 1px solid #044cf6;
868
+ border-radius: 12px;
869
+ padding: 20px 25px;
870
+ margin: 30px 0;
871
+ max-width: 800px;
872
+ margin-left: auto;
873
+ margin-right: auto;
874
+ }}
875
+ .phone-info h3 {{
876
+ margin: 0 0 10px 0;
877
+ color: #fff;
878
+ font-size: 16px;
879
+ }}
880
+ .phone-info p {{
881
+ margin: 0 0 15px 0;
882
+ color: rgba(255,255,255,0.8);
883
+ font-size: 14px;
884
+ line-height: 1.5;
885
+ }}
886
+ .phone-info a {{
887
+ display: inline-block;
888
+ padding: 10px 20px;
889
+ background: #044cf6;
890
+ color: white;
891
+ text-decoration: none;
892
+ border-radius: 6px;
893
+ font-weight: 500;
894
+ font-size: 14px;
895
+ transition: all 0.2s;
896
+ }}
897
+ .phone-info a:hover {{
898
+ background: #0339c2;
899
+ }}
900
+ .phone-info.hidden {{
901
+ display: none;
902
+ }}
903
+ .footer {{
904
+ margin-top: 50px;
905
+ padding-top: 20px;
906
+ border-top: 1px solid #ddd;
907
+ color: #666;
908
+ font-size: 14px;
909
+ }}
910
+ </style>
911
+ </head>
912
+ <body>
913
+ <h1>{agent_name}</h1>
914
+
915
+ <div class="status">
916
+ Your agent is running and ready to receive calls!
917
+ </div>
918
+
919
+ <div class="call-section">
920
+ <h2>Call Your Agent</h2>
921
+ <p>Test your agent directly from the browser using WebRTC.</p>
922
+ <div class="call-controls">
923
+ <input type="text" id="destination" class="destination-input" placeholder="Address will be auto-filled" />
924
+ <button id="connectBtn" class="call-btn connect">Call Agent</button>
925
+ <button id="disconnectBtn" class="call-btn disconnect" disabled>Hang Up</button>
926
+ </div>
927
+ <div class="audio-settings">
928
+ <div class="audio-setting">
929
+ <input type="checkbox" id="echoCancellation" checked>
930
+ <label for="echoCancellation">Echo Cancellation</label>
931
+ </div>
932
+ <div class="audio-setting">
933
+ <input type="checkbox" id="noiseSuppression">
934
+ <label for="noiseSuppression">Noise Suppression</label>
935
+ </div>
936
+ <div class="audio-setting">
937
+ <input type="checkbox" id="autoGainControl">
938
+ <label for="autoGainControl">Auto Gain Control</label>
939
+ </div>
940
+ </div>
941
+ <div id="callStatus" class="call-status"></div>
942
+ </div>
943
+
944
+ <div id="phoneInfo" class="phone-info hidden">
945
+ <h3>Want to call from a phone number?</h3>
946
+ <p>You can assign a SignalWire phone number to this agent. Click below to add a number in the dashboard.</p>
947
+ <a id="dashboardLink" href="#" target="_blank">Add Phone Number in Dashboard</a>
948
+ </div>
949
+
950
+ <h2>Endpoints</h2>
951
+
952
+ <div class="endpoint">
953
+ <h3><span class="method post">POST</span> <span class="path">/swml</span></h3>
954
+ <p>Main SWML endpoint for SignalWire to fetch agent configuration.</p>
955
+ <div class="tabs">
956
+ <div class="tab active" onclick="switchTab(this, 'swml-browser')">Browser</div>
957
+ <div class="tab" onclick="switchTab(this, 'swml-curl')">curl</div>
958
+ </div>
959
+ <div id="swml-browser" class="tab-content active">
960
+ <div class="browser-panel">
961
+ <button class="try-btn" onclick="tryEndpoint('POST', '/swml', {{}}, 'swml-response', true)">Try it</button>
962
+ <div id="swml-response" class="response-area"></div>
963
+ </div>
964
+ </div>
965
+ <div id="swml-curl" class="tab-content">
966
+ <div class="curl-panel">
967
+ <button class="copy-btn" onclick="copyCode(this)">Copy</button>
968
+ <pre><span class="comment"># Get the SWML configuration</span>
969
+ curl -X POST <span class="base-url"></span>/swml \\
970
+ -u <span class="auth-creds"></span> \\
971
+ -H "Content-Type: application/json" \\
972
+ -d '{{}}'</pre>
973
+ </div>
974
+ </div>
975
+ </div>
976
+
977
+ <div class="endpoint">
978
+ <h3><span class="method get">GET</span> <span class="path">/get_token</span></h3>
979
+ <p>Get a guest token for WebRTC calls. Returns a token and call address.</p>
980
+ <div class="tabs">
981
+ <div class="tab active" onclick="switchTab(this, 'token-browser')">Browser</div>
982
+ <div class="tab" onclick="switchTab(this, 'token-curl')">curl</div>
983
+ </div>
984
+ <div id="token-browser" class="tab-content active">
985
+ <div class="browser-panel">
986
+ <button class="try-btn" onclick="tryEndpoint('GET', '/get_token', null, 'token-response')">Try it</button>
987
+ <div id="token-response" class="response-area"></div>
988
+ </div>
989
+ </div>
990
+ <div id="token-curl" class="tab-content">
991
+ <div class="curl-panel">
992
+ <button class="copy-btn" onclick="copyCode(this)">Copy</button>
993
+ <pre><span class="comment"># Get a guest token</span>
994
+ curl <span class="base-url"></span>/get_token</pre>
995
+ </div>
996
+ </div>
997
+ </div>
998
+
999
+ <div class="endpoint">
1000
+ <h3><span class="method post">POST</span> <span class="path">/swml/swaig/</span></h3>
1001
+ <p>SWAIG function endpoint. Test your agent's functions.</p>
1002
+ <div class="tabs">
1003
+ <div class="tab active" onclick="switchTab(this, 'swaig-browser')">Browser</div>
1004
+ <div class="tab" onclick="switchTab(this, 'swaig-curl')">curl</div>
1005
+ </div>
1006
+ <div id="swaig-browser" class="tab-content active">
1007
+ <div class="browser-panel">
1008
+ <button class="try-btn" onclick="tryEndpoint('POST', '/swml/swaig/', {{function: 'get_info', argument: {{parsed: [{{topic: 'SignalWire'}}]}}}}, 'swaig-response', true)">Try it</button>
1009
+ <div id="swaig-response" class="response-area"></div>
1010
+ </div>
1011
+ </div>
1012
+ <div id="swaig-curl" class="tab-content">
1013
+ <div class="curl-panel">
1014
+ <button class="copy-btn" onclick="copyCode(this)">Copy</button>
1015
+ <pre><span class="comment"># Call a SWAIG function</span>
1016
+ curl -X POST <span class="base-url"></span>/swml/swaig/ \\
1017
+ -u <span class="auth-creds"></span> \\
1018
+ -H "Content-Type: application/json" \\
1019
+ -d '{{"function": "get_info", "argument": {{"parsed": [{{"topic": "SignalWire"}}]}}}}'</pre>
1020
+ </div>
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <div class="endpoint">
1025
+ <h3><span class="method get">GET</span> <span class="path">/health</span></h3>
1026
+ <p>Health check endpoint for load balancers and monitoring.</p>
1027
+ </div>
1028
+
1029
+ <div class="footer">
1030
+ Powered by <a href="https://signalwire.com">SignalWire</a> and the
1031
+ <a href="https://github.com/signalwire/signalwire-agents">SignalWire Agents SDK</a>
1032
+ </div>
1033
+
1034
+ <script src="https://cdn.signalwire.com/@signalwire/client"></script>
1035
+ <script>
1036
+ let authCreds = null;
1037
+
1038
+ function switchTab(tabEl, contentId) {{
1039
+ const endpoint = tabEl.closest('.endpoint');
1040
+ endpoint.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1041
+ endpoint.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
1042
+ tabEl.classList.add('active');
1043
+ document.getElementById(contentId).classList.add('active');
1044
+ }}
1045
+
1046
+ function copyCode(btn) {{
1047
+ const pre = btn.parentElement.querySelector('pre');
1048
+ const text = pre.textContent;
1049
+ navigator.clipboard.writeText(text).then(() => {{
1050
+ const orig = btn.textContent;
1051
+ btn.textContent = 'Copied!';
1052
+ setTimeout(() => btn.textContent = orig, 1500);
1053
+ }});
1054
+ }}
1055
+
1056
+ async function tryEndpoint(method, path, body, responseId, requiresAuth) {{
1057
+ const responseArea = document.getElementById(responseId);
1058
+ responseArea.classList.add('visible');
1059
+ responseArea.innerHTML = '<div class="response-status">Loading...</div>';
1060
+
1061
+ const startTime = performance.now();
1062
+ try {{
1063
+ const options = {{
1064
+ method: method,
1065
+ headers: {{}}
1066
+ }};
1067
+ if (body) {{
1068
+ options.headers['Content-Type'] = 'application/json';
1069
+ options.body = JSON.stringify(body);
1070
+ }}
1071
+ if (requiresAuth && authCreds) {{
1072
+ options.headers['Authorization'] = 'Basic ' + btoa(authCreds.user + ':' + authCreds.password);
1073
+ }}
1074
+
1075
+ const resp = await fetch(path, options);
1076
+ const endTime = performance.now();
1077
+ const duration = Math.round(endTime - startTime);
1078
+
1079
+ let data;
1080
+ const contentType = resp.headers.get('content-type') || '';
1081
+ if (contentType.includes('json')) {{
1082
+ data = await resp.json();
1083
+ data = JSON.stringify(data, null, 2);
1084
+ }} else {{
1085
+ data = await resp.text();
1086
+ }}
1087
+
1088
+ const statusClass = resp.ok ? 'success' : 'error';
1089
+ responseArea.innerHTML = `
1090
+ <div class="response-header">
1091
+ <span class="response-status ${{statusClass}}">${{resp.status}} ${{resp.statusText}}</span>
1092
+ <span class="response-time">${{duration}}ms</span>
1093
+ </div>
1094
+ <div class="response-body">${{escapeHtml(data)}}</div>
1095
+ `;
1096
+ }} catch (err) {{
1097
+ responseArea.innerHTML = `
1098
+ <div class="response-header">
1099
+ <span class="response-status error">Error</span>
1100
+ </div>
1101
+ <div class="response-body">${{escapeHtml(err.message)}}</div>
1102
+ `;
1103
+ }}
1104
+ }}
1105
+
1106
+ function escapeHtml(text) {{
1107
+ const div = document.createElement('div');
1108
+ div.textContent = text;
1109
+ return div.innerHTML;
1110
+ }}
1111
+
1112
+ // WebRTC calling - robust pattern from santa app.js
1113
+ let client = null;
1114
+ let roomSession = null;
1115
+ let currentToken = null;
1116
+ let currentDestination = null;
1117
+
1118
+ const connectBtn = document.getElementById('connectBtn');
1119
+ const disconnectBtn = document.getElementById('disconnectBtn');
1120
+ const destinationInput = document.getElementById('destination');
1121
+ const callStatus = document.getElementById('callStatus');
1122
+
1123
+ function updateCallStatus(message) {{
1124
+ callStatus.textContent = message;
1125
+ }}
1126
+
1127
+ async function connect() {{
1128
+ // Debounce - prevent double-clicks
1129
+ if (connectBtn.disabled) {{
1130
+ console.log('Call already in progress');
1131
+ return;
1132
+ }}
1133
+ connectBtn.disabled = true;
1134
+ connectBtn.textContent = 'Connecting...';
1135
+
1136
+ try {{
1137
+ updateCallStatus('Getting token...');
1138
+
1139
+ const tokenResp = await fetch('/get_token');
1140
+ const tokenData = await tokenResp.json();
1141
+
1142
+ if (tokenData.error) {{
1143
+ throw new Error(tokenData.error);
1144
+ }}
1145
+
1146
+ currentToken = tokenData.token;
1147
+ currentDestination = tokenData.address;
1148
+
1149
+ if (tokenData.address) {{
1150
+ destinationInput.value = tokenData.address;
1151
+ }}
1152
+
1153
+ console.log('Got token, destination:', currentDestination);
1154
+ updateCallStatus('Connecting...');
1155
+
1156
+ // Initialize SignalWire client
1157
+ if (window.SignalWire && typeof window.SignalWire.SignalWire === 'function') {{
1158
+ console.log('Initializing SignalWire client...');
1159
+ client = await window.SignalWire.SignalWire({{
1160
+ token: currentToken
1161
+ }});
1162
+ }} else {{
1163
+ console.error('SignalWire SDK structure:', window.SignalWire);
1164
+ throw new Error('SignalWire.SignalWire function not found');
1165
+ }}
1166
+
1167
+ const destination = currentDestination || destinationInput.value;
1168
+ roomSession = await client.dial({{
1169
+ to: destination,
1170
+ audio: {{
1171
+ echoCancellation: document.getElementById('echoCancellation').checked,
1172
+ noiseSuppression: document.getElementById('noiseSuppression').checked,
1173
+ autoGainControl: document.getElementById('autoGainControl').checked
1174
+ }},
1175
+ video: false
1176
+ }});
1177
+
1178
+ console.log('Room session created:', roomSession);
1179
+
1180
+ roomSession.on('call.joined', () => {{
1181
+ console.log('Call joined');
1182
+ updateCallStatus('Connected');
1183
+ disconnectBtn.disabled = false;
1184
+ }});
1185
+
1186
+ roomSession.on('call.left', () => {{
1187
+ console.log('Call left');
1188
+ updateCallStatus('Call ended');
1189
+ cleanup();
1190
+ }});
1191
+
1192
+ roomSession.on('destroy', () => {{
1193
+ console.log('Session destroyed');
1194
+ updateCallStatus('Call ended');
1195
+ cleanup();
1196
+ }});
1197
+
1198
+ roomSession.on('room.left', () => {{
1199
+ console.log('Room left');
1200
+ cleanup();
1201
+ }});
1202
+
1203
+ await roomSession.start();
1204
+ console.log('Call started successfully');
1205
+
1206
+ // Update UI
1207
+ connectBtn.style.display = 'none';
1208
+ disconnectBtn.style.display = 'inline-block';
1209
+
1210
+ }} catch (err) {{
1211
+ console.error('Connection error:', err);
1212
+ updateCallStatus('Error: ' + err.message);
1213
+ cleanup();
1214
+ }}
1215
+ }}
1216
+
1217
+ async function disconnect() {{
1218
+ console.log('Disconnect called');
1219
+ await hangup();
1220
+ }}
1221
+
1222
+ async function hangup() {{
1223
+ try {{
1224
+ if (roomSession) {{
1225
+ console.log('Hanging up call...');
1226
+ await roomSession.hangup();
1227
+ console.log('Call hung up successfully');
1228
+ }}
1229
+ }} catch (err) {{
1230
+ console.error('Hangup error:', err);
1231
+ }}
1232
+ cleanup();
1233
+ }}
1234
+
1235
+ function cleanup() {{
1236
+ console.log('Cleanup called');
1237
+
1238
+ // Clean up local stream if it exists
1239
+ if (roomSession && roomSession.localStream) {{
1240
+ console.log('Stopping local stream tracks');
1241
+ roomSession.localStream.getTracks().forEach(track => {{
1242
+ track.stop();
1243
+ }});
1244
+ }}
1245
+
1246
+ roomSession = null;
1247
+
1248
+ // Disconnect the client properly
1249
+ if (client) {{
1250
+ try {{
1251
+ console.log('Disconnecting client');
1252
+ client.disconnect();
1253
+ }} catch (e) {{
1254
+ console.log('Client disconnect error:', e);
1255
+ }}
1256
+ client = null;
1257
+ }}
1258
+
1259
+ // Reset UI
1260
+ connectBtn.disabled = false;
1261
+ connectBtn.textContent = 'Call Agent';
1262
+ connectBtn.style.display = 'inline-block';
1263
+ disconnectBtn.disabled = true;
1264
+ disconnectBtn.style.display = 'none';
1265
+ }}
1266
+
1267
+ connectBtn.addEventListener('click', connect);
1268
+ disconnectBtn.addEventListener('click', disconnect);
1269
+
1270
+ // Clean up on page unload
1271
+ window.addEventListener('beforeunload', () => {{
1272
+ if (roomSession) {{
1273
+ hangup();
1274
+ }}
1275
+ }});
1276
+
1277
+ // Initialize on load
1278
+ document.addEventListener('DOMContentLoaded', async function() {{
1279
+ const baseUrl = window.location.origin;
1280
+ document.querySelectorAll('.base-url').forEach(function(el) {{
1281
+ el.textContent = baseUrl;
1282
+ }});
1283
+
1284
+ // Fetch auth credentials
1285
+ try {{
1286
+ const credsResp = await fetch('/get_credentials');
1287
+ if (credsResp.ok) {{
1288
+ authCreds = await credsResp.json();
1289
+ document.querySelectorAll('.auth-creds').forEach(function(el) {{
1290
+ el.textContent = authCreds.user + ':' + authCreds.password;
1291
+ }});
1292
+ }}
1293
+ }} catch (e) {{
1294
+ document.querySelectorAll('.auth-creds').forEach(function(el) {{
1295
+ el.textContent = 'user:password';
1296
+ }});
1297
+ }}
1298
+
1299
+ // Fetch resource info for dashboard link
1300
+ try {{
1301
+ const resourceResp = await fetch('/get_resource_info');
1302
+ if (resourceResp.ok) {{
1303
+ const resourceInfo = await resourceResp.json();
1304
+ if (resourceInfo.dashboard_url) {{
1305
+ document.getElementById('dashboardLink').href = resourceInfo.dashboard_url;
1306
+ document.getElementById('phoneInfo').classList.remove('hidden');
1307
+ }}
1308
+ }}
1309
+ }} catch (e) {{
1310
+ console.log('Could not fetch resource info:', e);
1311
+ }}
1312
+ }});
1313
+ </script>
1314
+ </body>
1315
+ </html>
1316
+ '''
1317
+
1318
+ APP_JSON_TEMPLATE = '''{{
1319
+ "name": "{app_name}",
1320
+ "description": "SignalWire AI Agent with WebRTC calling support",
1321
+ "keywords": ["signalwire", "ai", "agent", "python", "webrtc"],
1322
+ "env": {{
1323
+ "APP_ENV": {{
1324
+ "description": "Application environment",
1325
+ "value": "production"
1326
+ }},
1327
+ "AGENT_NAME": {{
1328
+ "description": "Name for the SWML handler resource",
1329
+ "value": "{app_name}"
1330
+ }},
1331
+ "SIGNALWIRE_SPACE_NAME": {{
1332
+ "description": "SignalWire space name (e.g., 'myspace' or 'myspace.signalwire.com')",
1333
+ "required": true
1334
+ }},
1335
+ "SIGNALWIRE_PROJECT_ID": {{
1336
+ "description": "SignalWire project ID",
1337
+ "required": true
1338
+ }},
1339
+ "SIGNALWIRE_TOKEN": {{
1340
+ "description": "SignalWire API token",
1341
+ "required": true
1342
+ }},
1343
+ "SWML_PROXY_URL_BASE": {{
1344
+ "description": "Public URL base for SWML callbacks (e.g., 'https://myapp.example.com')",
1345
+ "required": true
1346
+ }},
1347
+ "SWML_BASIC_AUTH_USER": {{
1348
+ "description": "Basic auth username for SWML endpoints",
1349
+ "required": true
1350
+ }},
1351
+ "SWML_BASIC_AUTH_PASSWORD": {{
1352
+ "description": "Basic auth password for SWML endpoints",
1353
+ "required": true
1354
+ }}
1355
+ }},
1356
+ "formation": {{
1357
+ "web": {{
1358
+ "quantity": 1
1359
+ }}
1360
+ }},
1361
+ "buildpacks": [
1362
+ {{
1363
+ "url": "heroku/python"
1364
+ }}
1365
+ ],
1366
+ "healthchecks": {{
1367
+ "web": [
1368
+ {{
1369
+ "type": "startup",
1370
+ "name": "port listening",
1371
+ "listening": true,
1372
+ "attempts": 10,
1373
+ "wait": 5,
1374
+ "timeout": 60
1375
+ }}
1376
+ ]
1377
+ }}
1378
+ }}
1379
+ '''
1380
+
1381
+ # =============================================================================
1382
+ # Templates - Simple Mode
1383
+ # =============================================================================
1384
+
1385
+ DEPLOY_SCRIPT_TEMPLATE = '''#!/bin/bash
1386
+ # Dokku deployment helper for {app_name}
1387
+ set -e
1388
+
1389
+ APP_NAME="${{1:-{app_name}}}"
1390
+ DOKKU_HOST="${{2:-{dokku_host}}}"
1391
+
1392
+ echo "═══════════════════════════════════════════════════════════"
1393
+ echo " Deploying $APP_NAME to $DOKKU_HOST"
1394
+ echo "═══════════════════════════════════════════════════════════"
1395
+
1396
+ # Initialize git if needed
1397
+ if [ ! -d .git ]; then
1398
+ echo "→ Initializing git repository..."
1399
+ git init
1400
+ git add .
1401
+ git commit -m "Initial commit"
1402
+ fi
1403
+
1404
+ # Create app if it doesn't exist
1405
+ echo "→ Creating app (if not exists)..."
1406
+ ssh dokku@$DOKKU_HOST apps:create $APP_NAME 2>/dev/null || true
1407
+
1408
+ # Set environment variables
1409
+ echo "→ Setting environment variables..."
1410
+ AUTH_PASS=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)
1411
+ ssh dokku@$DOKKU_HOST config:set --no-restart $APP_NAME \\
1412
+ APP_ENV=production \\
1413
+ APP_NAME=$APP_NAME \\
1414
+ SWML_BASIC_AUTH_USER=admin \\
1415
+ SWML_BASIC_AUTH_PASSWORD=$AUTH_PASS
1416
+
1417
+ # Add dokku remote
1418
+ echo "→ Configuring git remote..."
1419
+ git remote add dokku dokku@$DOKKU_HOST:$APP_NAME 2>/dev/null || \\
1420
+ git remote set-url dokku dokku@$DOKKU_HOST:$APP_NAME
1421
+
1422
+ # Deploy
1423
+ echo "→ Pushing to Dokku..."
1424
+ git push dokku main --force
1425
+
1426
+ # Enable SSL
1427
+ echo "→ Enabling Let's Encrypt SSL..."
1428
+ ssh dokku@$DOKKU_HOST letsencrypt:enable $APP_NAME 2>/dev/null || \\
1429
+ echo " (SSL setup may require manual configuration)"
1430
+
1431
+ echo ""
1432
+ echo "═══════════════════════════════════════════════════════════"
1433
+ echo " ✅ Deployment complete!"
1434
+ echo ""
1435
+ echo " 🌐 URL: https://$APP_NAME.$DOKKU_HOST"
1436
+ echo " 🔑 Auth: admin / $AUTH_PASS"
1437
+ echo ""
1438
+ echo " Configure SignalWire phone number SWML URL to:"
1439
+ echo " https://admin:$AUTH_PASS@$APP_NAME.$DOKKU_HOST/{route}"
1440
+ echo "═══════════════════════════════════════════════════════════"
1441
+ '''
1442
+
1443
+ README_SIMPLE_TEMPLATE = '''# {app_name}
1444
+
1445
+ A SignalWire AI Agent deployed to Dokku.
1446
+
1447
+ ## Quick Deploy
1448
+
1449
+ ```bash
1450
+ ./deploy.sh {app_name} {dokku_host}
1451
+ ```
1452
+
1453
+ ## Manual Deployment
1454
+
1455
+ 1. **Create the app:**
1456
+ ```bash
1457
+ ssh dokku@{dokku_host} apps:create {app_name}
1458
+ ```
1459
+
1460
+ 2. **Set environment variables:**
1461
+ ```bash
1462
+ ssh dokku@{dokku_host} config:set {app_name} \\
1463
+ SWML_BASIC_AUTH_USER=admin \\
1464
+ SWML_BASIC_AUTH_PASSWORD=secure-password \\
1465
+ APP_ENV=production
1466
+ ```
1467
+
1468
+ 3. **Add git remote and deploy:**
1469
+ ```bash
1470
+ git remote add dokku dokku@{dokku_host}:{app_name}
1471
+ git push dokku main
1472
+ ```
1473
+
1474
+ 4. **Enable SSL:**
1475
+ ```bash
1476
+ ssh dokku@{dokku_host} letsencrypt:enable {app_name}
1477
+ ```
1478
+
1479
+ ## Usage
1480
+
1481
+ Your agent is available at: `https://{app_name}.{dokku_host_domain}`
1482
+
1483
+ Configure your SignalWire phone number:
1484
+ - **SWML URL:** `https://{app_name}.{dokku_host_domain}/{route}`
1485
+ - **Auth:** Basic auth with your configured credentials
1486
+
1487
+ ## Useful Commands
1488
+
1489
+ ```bash
1490
+ # View logs
1491
+ ssh dokku@{dokku_host} logs {app_name} -t
1492
+
1493
+ # Restart app
1494
+ ssh dokku@{dokku_host} ps:restart {app_name}
1495
+
1496
+ # View environment variables
1497
+ ssh dokku@{dokku_host} config:show {app_name}
1498
+
1499
+ # Scale workers
1500
+ ssh dokku@{dokku_host} ps:scale {app_name} web=2
1501
+
1502
+ # Rollback to previous release
1503
+ ssh dokku@{dokku_host} releases:rollback {app_name}
1504
+ ```
1505
+
1506
+ ## Local Development
1507
+
1508
+ ```bash
1509
+ pip install -r requirements.txt
1510
+ uvicorn app:app --reload --port 8080
1511
+ ```
1512
+
1513
+ Test with swaig-test:
1514
+ ```bash
1515
+ swaig-test app.py --list-tools
1516
+ ```
1517
+ '''
1518
+
1519
+ # =============================================================================
1520
+ # Templates - CI/CD Mode
1521
+ # =============================================================================
1522
+
1523
+ DEPLOY_WORKFLOW_TEMPLATE = '''# Deploy to Dokku - calls reusable workflow from dokku-deploy-system
1524
+ name: Deploy
1525
+
1526
+ on:
1527
+ workflow_dispatch:
1528
+ push:
1529
+ branches: [main, staging, develop]
1530
+
1531
+ permissions:
1532
+ contents: read
1533
+ deployments: write
1534
+
1535
+ concurrency:
1536
+ group: deploy-${{{{ github.ref }}}}
1537
+ cancel-in-progress: true
1538
+
1539
+ jobs:
1540
+ deploy:
1541
+ uses: signalwire-demos/dokku-deploy-system/.github/workflows/deploy.yml@main
1542
+ secrets: inherit
1543
+ # Optional: customize health check path
1544
+ # with:
1545
+ # health_check_path: '/health'
1546
+ '''
1547
+
1548
+ PREVIEW_WORKFLOW_TEMPLATE = '''# Preview environments for pull requests - calls reusable workflow from dokku-deploy-system
1549
+ name: Preview
1550
+
1551
+ on:
1552
+ pull_request:
1553
+ types: [opened, synchronize, reopened, closed]
1554
+
1555
+ concurrency:
1556
+ group: preview-${{{{ github.event.pull_request.number }}}}
1557
+
1558
+ jobs:
1559
+ preview:
1560
+ uses: signalwire-demos/dokku-deploy-system/.github/workflows/preview.yml@main
1561
+ secrets: inherit
1562
+ # Optional: customize memory limit for previews
1563
+ # with:
1564
+ # memory_limit: '256m'
1565
+ '''
1566
+
1567
+ DOKKU_CONFIG_TEMPLATE = '''# ═══════════════════════════════════════════════════════════════════════════════
1568
+ # Dokku App Configuration
1569
+ # ═══════════════════════════════════════════════════════════════════════════════
1570
+ #
1571
+ # Configuration for your Dokku app deployment.
1572
+ # These settings are applied during the deployment workflow.
1573
+ #
1574
+ # ═══════════════════════════════════════════════════════════════════════════════
1575
+
1576
+ # ─────────────────────────────────────────────────────────────────────────────
1577
+ # Resource Limits
1578
+ # ─────────────────────────────────────────────────────────────────────────────
1579
+ # Memory: 256m, 512m, 1g, 2g, etc.
1580
+ # CPU: Number of cores (can be fractional, e.g., 0.5)
1581
+ resources:
1582
+ memory: 512m
1583
+ cpu: 1
1584
+
1585
+ # ─────────────────────────────────────────────────────────────────────────────
1586
+ # Health Check
1587
+ # ─────────────────────────────────────────────────────────────────────────────
1588
+ # Path that returns 200 OK when app is healthy
1589
+ healthcheck:
1590
+ path: /health
1591
+ timeout: 30
1592
+ attempts: 5
1593
+
1594
+ # ─────────────────────────────────────────────────────────────────────────────
1595
+ # Scaling
1596
+ # ─────────────────────────────────────────────────────────────────────────────
1597
+ # Number of web workers (dynos)
1598
+ scale:
1599
+ web: 1
1600
+ # worker: 1 # Uncomment for background workers
1601
+
1602
+ # ─────────────────────────────────────────────────────────────────────────────
1603
+ # Custom Domains
1604
+ # ─────────────────────────────────────────────────────────────────────────────
1605
+ # Additional domains for this app (requires DNS configuration)
1606
+ # custom_domains:
1607
+ # - www.example.com
1608
+ # - api.example.com
1609
+
1610
+ # ─────────────────────────────────────────────────────────────────────────────
1611
+ # Environment-Specific Overrides
1612
+ # ─────────────────────────────────────────────────────────────────────────────
1613
+ environments:
1614
+ production:
1615
+ resources:
1616
+ memory: 1g
1617
+ cpu: 2
1618
+ scale:
1619
+ web: 2
1620
+
1621
+ staging:
1622
+ resources:
1623
+ memory: 512m
1624
+ cpu: 1
1625
+
1626
+ preview:
1627
+ resources:
1628
+ memory: 256m
1629
+ cpu: 0.5
1630
+ '''
1631
+
1632
+ SERVICES_TEMPLATE = '''# ═══════════════════════════════════════════════════════════════════════════════
1633
+ # Dokku Services Configuration
1634
+ # ═══════════════════════════════════════════════════════════════════════════════
1635
+ #
1636
+ # Define which backing services your app needs.
1637
+ # Services are automatically provisioned and linked during deployment.
1638
+ #
1639
+ # When a service is linked, its connection URL is automatically
1640
+ # injected as an environment variable (e.g., DATABASE_URL, REDIS_URL).
1641
+ #
1642
+ # ═══════════════════════════════════════════════════════════════════════════════
1643
+
1644
+ services:
1645
+ # ─────────────────────────────────────────────────────────────────────────────
1646
+ # PostgreSQL Database
1647
+ # ─────────────────────────────────────────────────────────────────────────────
1648
+ # Environment variable: DATABASE_URL
1649
+ # Format: postgres://user:pass@host:5432/database
1650
+ postgres:
1651
+ enabled: false # Set to true to enable
1652
+ environments:
1653
+ production:
1654
+ # Production gets its own dedicated database
1655
+ dedicated: true
1656
+ staging:
1657
+ # Staging gets its own database
1658
+ dedicated: true
1659
+ preview:
1660
+ # All preview apps share a single database to save resources
1661
+ shared: true
1662
+
1663
+ # ─────────────────────────────────────────────────────────────────────────────
1664
+ # Redis Cache/Queue
1665
+ # ─────────────────────────────────────────────────────────────────────────────
1666
+ # Environment variable: REDIS_URL
1667
+ # Format: redis://host:6379
1668
+ redis:
1669
+ enabled: false # Set to true to enable
1670
+ environments:
1671
+ production:
1672
+ dedicated: true
1673
+ staging:
1674
+ dedicated: true
1675
+ preview:
1676
+ shared: true
1677
+
1678
+ # ─────────────────────────────────────────────────────────────────────────────
1679
+ # MySQL Database
1680
+ # ─────────────────────────────────────────────────────────────────────────────
1681
+ # Environment variable: DATABASE_URL
1682
+ # Format: mysql://user:pass@host:3306/database
1683
+ mysql:
1684
+ enabled: false
1685
+ environments:
1686
+ preview:
1687
+ shared: true
1688
+
1689
+ # ─────────────────────────────────────────────────────────────────────────────
1690
+ # MongoDB
1691
+ # ─────────────────────────────────────────────────────────────────────────────
1692
+ # Environment variable: MONGO_URL
1693
+ # Format: mongodb://user:pass@host:27017/database
1694
+ mongo:
1695
+ enabled: false
1696
+ environments:
1697
+ preview:
1698
+ shared: true
1699
+
1700
+ # ─────────────────────────────────────────────────────────────────────────────
1701
+ # RabbitMQ Message Queue
1702
+ # ─────────────────────────────────────────────────────────────────────────────
1703
+ # Environment variable: RABBITMQ_URL
1704
+ # Format: amqp://user:pass@host:5672
1705
+ rabbitmq:
1706
+ enabled: false
1707
+ environments:
1708
+ preview:
1709
+ # Don't provision RabbitMQ for previews (too expensive)
1710
+ enabled: false
1711
+
1712
+ # ─────────────────────────────────────────────────────────────────────────────
1713
+ # Elasticsearch
1714
+ # ─────────────────────────────────────────────────────────────────────────────
1715
+ # Environment variable: ELASTICSEARCH_URL
1716
+ # Format: http://host:9200
1717
+ elasticsearch:
1718
+ enabled: false
1719
+ environments:
1720
+ preview:
1721
+ enabled: false
1722
+
1723
+ # ═══════════════════════════════════════════════════════════════════════════════
1724
+ # External/Managed Services
1725
+ # ═══════════════════════════════════════════════════════════════════════════════
1726
+ #
1727
+ # For production, you may want to use managed services like AWS RDS,
1728
+ # ElastiCache, etc. Define the connection URLs here (reference GitHub secrets).
1729
+ #
1730
+ # These override the Dokku-managed services for the specified environment.
1731
+ #
1732
+ # ═══════════════════════════════════════════════════════════════════════════════
1733
+
1734
+ # external_services:
1735
+ # production:
1736
+ # DATABASE_URL: "${secrets.PROD_DATABASE_URL}"
1737
+ # REDIS_URL: "${secrets.PROD_REDIS_URL}"
1738
+ # staging:
1739
+ # DATABASE_URL: "${secrets.STAGING_DATABASE_URL}"
1740
+ '''
1741
+
1742
+ README_CICD_TEMPLATE = '''# {app_name}
1743
+
1744
+ A SignalWire AI Agent with automated GitHub → Dokku deployments.
1745
+
1746
+ ## Features
1747
+
1748
+ - ✅ Auto-deploy on push to main/staging/develop
1749
+ - ✅ Preview environments for pull requests
1750
+ - ✅ Automatic SSL via Let's Encrypt
1751
+ - ✅ Zero-downtime deployments
1752
+ - ✅ Multi-environment support
1753
+
1754
+ ## Setup
1755
+
1756
+ ### 1. GitHub Secrets
1757
+
1758
+ Add these secrets to your repository (Settings → Secrets → Actions):
1759
+
1760
+ | Secret | Description |
1761
+ |--------|-------------|
1762
+ | `DOKKU_HOST` | Your Dokku server hostname |
1763
+ | `DOKKU_SSH_PRIVATE_KEY` | SSH private key for deployments |
1764
+ | `BASE_DOMAIN` | Base domain (e.g., `yourdomain.com`) |
1765
+ | `SWML_BASIC_AUTH_USER` | Basic auth username |
1766
+ | `SWML_BASIC_AUTH_PASSWORD` | Basic auth password |
1767
+
1768
+ ### 2. GitHub Environments
1769
+
1770
+ Create these environments (Settings → Environments):
1771
+ - `production` - Deploy from `main` branch
1772
+ - `staging` - Deploy from `staging` branch
1773
+ - `development` - Deploy from `develop` branch
1774
+ - `preview` - Deploy from pull requests
1775
+
1776
+ ### 3. Deploy
1777
+
1778
+ Just push to a branch:
1779
+
1780
+ ```bash
1781
+ git push origin main # → {app_name}.yourdomain.com
1782
+ git push origin staging # → {app_name}-staging.yourdomain.com
1783
+ git push origin develop # → {app_name}-dev.yourdomain.com
1784
+ ```
1785
+
1786
+ Or open a PR for a preview environment.
1787
+
1788
+ ## Branch → Environment Mapping
1789
+
1790
+ | Branch | App Name | URL |
1791
+ |--------|----------|-----|
1792
+ | `main` | `{app_name}` | `{app_name}.yourdomain.com` |
1793
+ | `staging` | `{app_name}-staging` | `{app_name}-staging.yourdomain.com` |
1794
+ | `develop` | `{app_name}-dev` | `{app_name}-dev.yourdomain.com` |
1795
+ | PR #42 | `{app_name}-pr-42` | `{app_name}-pr-42.yourdomain.com` |
1796
+
1797
+ ## Manual Operations
1798
+
1799
+ ```bash
1800
+ # View logs
1801
+ ssh dokku@server logs {app_name} -t
1802
+
1803
+ # SSH into container
1804
+ ssh dokku@server enter {app_name}
1805
+
1806
+ # Restart
1807
+ ssh dokku@server ps:restart {app_name}
1808
+
1809
+ # Rollback
1810
+ ssh dokku@server releases:rollback {app_name}
1811
+
1812
+ # Scale
1813
+ ssh dokku@server ps:scale {app_name} web=2
1814
+ ```
1815
+
1816
+ ## Local Development
1817
+
1818
+ ```bash
1819
+ pip install -r requirements.txt
1820
+ uvicorn app:app --reload --port 8080
1821
+ ```
1822
+
1823
+ Test with swaig-test:
1824
+ ```bash
1825
+ swaig-test app.py --list-tools
1826
+ ```
1827
+ '''
1828
+
1829
+
1830
+ # =============================================================================
1831
+ # Project Generator
1832
+ # =============================================================================
1833
+
1834
+ class DokkuProjectGenerator:
1835
+ """Generates Dokku deployment files for SignalWire agents."""
1836
+
1837
+ def __init__(self, app_name: str, options: Dict[str, Any]):
1838
+ self.app_name = app_name
1839
+ self.options = options
1840
+ self.project_dir = Path(options.get('project_dir', f'./{app_name}'))
1841
+
1842
+ # Derived names
1843
+ self.agent_slug = app_name.lower().replace(' ', '-').replace('_', '-')
1844
+ self.agent_class = ''.join(
1845
+ word.capitalize()
1846
+ for word in app_name.replace('-', ' ').replace('_', ' ').split()
1847
+ ) + 'Agent'
1848
+
1849
+ def generate(self) -> bool:
1850
+ """Generate the project files."""
1851
+ try:
1852
+ self.project_dir.mkdir(parents=True, exist_ok=True)
1853
+ print_success(f"Created {self.project_dir}/")
1854
+
1855
+ # Core files (both modes)
1856
+ self._write_core_files()
1857
+
1858
+ # Mode-specific files
1859
+ if self.options.get('cicd'):
1860
+ self._write_cicd_files()
1861
+ else:
1862
+ self._write_simple_files()
1863
+
1864
+ return True
1865
+ except Exception as e:
1866
+ print_error(f"Failed to generate project: {e}")
1867
+ return False
1868
+
1869
+ def _write_core_files(self):
1870
+ """Write files common to both modes."""
1871
+ # Procfile
1872
+ self._write_file('Procfile', PROCFILE_TEMPLATE)
1873
+
1874
+ # runtime.txt
1875
+ self._write_file('runtime.txt', RUNTIME_TEMPLATE)
1876
+
1877
+ # requirements.txt
1878
+ self._write_file('requirements.txt', REQUIREMENTS_TEMPLATE)
1879
+
1880
+ # CHECKS
1881
+ self._write_file('CHECKS', CHECKS_TEMPLATE)
1882
+
1883
+ # .gitignore
1884
+ self._write_file('.gitignore', GITIGNORE_TEMPLATE)
1885
+
1886
+ # .env.example
1887
+ self._write_file('.env.example', ENV_EXAMPLE_TEMPLATE.format(
1888
+ app_name=self.app_name
1889
+ ))
1890
+
1891
+ # app.json
1892
+ self._write_file('app.json', APP_JSON_TEMPLATE.format(
1893
+ app_name=self.app_name
1894
+ ))
1895
+
1896
+ # app.py - use web template if web option is enabled
1897
+ if self.options.get('web'):
1898
+ self._write_file('app.py', APP_TEMPLATE_WITH_WEB.format(
1899
+ agent_name=self.app_name,
1900
+ agent_class=self.agent_class,
1901
+ agent_slug=self.agent_slug
1902
+ ))
1903
+ self._write_web_files()
1904
+ else:
1905
+ self._write_file('app.py', APP_TEMPLATE.format(
1906
+ agent_name=self.app_name,
1907
+ agent_class=self.agent_class,
1908
+ agent_slug=self.agent_slug
1909
+ ))
1910
+
1911
+ def _write_web_files(self):
1912
+ """Write web interface files."""
1913
+ # Create web directory
1914
+ web_dir = self.project_dir / 'web'
1915
+ web_dir.mkdir(parents=True, exist_ok=True)
1916
+
1917
+ route = self.options.get('route', 'swaig')
1918
+
1919
+ # index.html
1920
+ self._write_file('web/index.html', WEB_INDEX_TEMPLATE.format(
1921
+ agent_name=self.app_name,
1922
+ route=route
1923
+ ))
1924
+
1925
+ def _write_simple_files(self):
1926
+ """Write files for simple deployment mode."""
1927
+ dokku_host = self.options.get('dokku_host', 'dokku.yourdomain.com')
1928
+ route = self.options.get('route', 'swaig')
1929
+
1930
+ # deploy.sh
1931
+ deploy_script = DEPLOY_SCRIPT_TEMPLATE.format(
1932
+ app_name=self.app_name,
1933
+ dokku_host=dokku_host,
1934
+ route=route
1935
+ )
1936
+ self._write_file('deploy.sh', deploy_script, executable=True)
1937
+
1938
+ # README.md
1939
+ readme = README_SIMPLE_TEMPLATE.format(
1940
+ app_name=self.app_name,
1941
+ dokku_host=dokku_host,
1942
+ dokku_host_domain=dokku_host.replace('dokku.', ''),
1943
+ route=route
1944
+ )
1945
+ self._write_file('README.md', readme)
1946
+
1947
+ def _write_cicd_files(self):
1948
+ """Write files for CI/CD deployment mode."""
1949
+ # Create .github/workflows directory
1950
+ workflows_dir = self.project_dir / '.github' / 'workflows'
1951
+ workflows_dir.mkdir(parents=True, exist_ok=True)
1952
+
1953
+ # deploy.yml
1954
+ deploy_workflow = DEPLOY_WORKFLOW_TEMPLATE.format(app_name=self.app_name)
1955
+ self._write_file('.github/workflows/deploy.yml', deploy_workflow)
1956
+
1957
+ # preview.yml
1958
+ preview_workflow = PREVIEW_WORKFLOW_TEMPLATE.format(app_name=self.app_name)
1959
+ self._write_file('.github/workflows/preview.yml', preview_workflow)
1960
+
1961
+ # Create .dokku directory
1962
+ dokku_dir = self.project_dir / '.dokku'
1963
+ dokku_dir.mkdir(parents=True, exist_ok=True)
1964
+
1965
+ # config.yml
1966
+ self._write_file('.dokku/config.yml', DOKKU_CONFIG_TEMPLATE)
1967
+
1968
+ # services.yml
1969
+ self._write_file('.dokku/services.yml', SERVICES_TEMPLATE)
1970
+
1971
+ # README.md
1972
+ readme = README_CICD_TEMPLATE.format(app_name=self.app_name)
1973
+ self._write_file('README.md', readme)
1974
+
1975
+ def _write_file(self, path: str, content: str, executable: bool = False):
1976
+ """Write a file to the project directory."""
1977
+ file_path = self.project_dir / path
1978
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1979
+ file_path.write_text(content)
1980
+
1981
+ if executable:
1982
+ file_path.chmod(0o755)
1983
+
1984
+ print_success(f"Created {path}")
1985
+
1986
+
1987
+ # =============================================================================
1988
+ # CLI Commands
1989
+ # =============================================================================
1990
+
1991
+ def cmd_init(args):
1992
+ """Initialize a new Dokku project."""
1993
+ app_name = args.name
1994
+
1995
+ print_header(f"Creating Dokku project: {app_name}")
1996
+
1997
+ # Gather options
1998
+ options = {
1999
+ 'project_dir': args.dir or f'./{app_name}',
2000
+ 'cicd': args.cicd,
2001
+ 'dokku_host': args.host or 'dokku.yourdomain.com',
2002
+ 'route': 'swaig',
2003
+ 'web': args.web,
2004
+ }
2005
+
2006
+ # Interactive mode if not all options provided
2007
+ if not args.host and not args.cicd:
2008
+ print("\n")
2009
+ if prompt_yes_no("Enable GitHub Actions CI/CD?", default=False):
2010
+ options['cicd'] = True
2011
+ else:
2012
+ options['dokku_host'] = prompt("Dokku server hostname", "dokku.yourdomain.com")
2013
+
2014
+ # Ask about web interface if not specified
2015
+ if not args.web:
2016
+ options['web'] = prompt_yes_no("Include web interface (static files at /)?", default=True)
2017
+
2018
+ # Check if directory exists
2019
+ project_dir = Path(options['project_dir'])
2020
+ if project_dir.exists():
2021
+ if not args.force:
2022
+ if not prompt_yes_no(f"Directory {project_dir} exists. Overwrite?", default=False):
2023
+ print("Aborted.")
2024
+ return 1
2025
+ shutil.rmtree(project_dir)
2026
+
2027
+ # Generate project
2028
+ generator = DokkuProjectGenerator(app_name, options)
2029
+ if generator.generate():
2030
+ print(f"\n{Colors.GREEN}{Colors.BOLD}Project created successfully!{Colors.NC}\n")
2031
+
2032
+ if options['cicd']:
2033
+ _print_cicd_instructions(app_name)
2034
+ else:
2035
+ _print_simple_instructions(app_name, options['dokku_host'], project_dir)
2036
+
2037
+ return 0
2038
+ return 1
2039
+
2040
+
2041
+ def _print_simple_instructions(app_name: str, dokku_host: str, project_dir: Path):
2042
+ """Print instructions for simple mode."""
2043
+ print(f"""To deploy your agent:
2044
+
2045
+ {Colors.CYAN}cd {project_dir}{Colors.NC}
2046
+ {Colors.CYAN}./deploy.sh{Colors.NC}
2047
+
2048
+ Or manually:
2049
+
2050
+ {Colors.DIM}git init && git add . && git commit -m "Initial commit"
2051
+ git remote add dokku dokku@{dokku_host}:{app_name}
2052
+ git push dokku main{Colors.NC}
2053
+ """)
2054
+
2055
+
2056
+ def _print_cicd_instructions(app_name: str):
2057
+ """Print instructions for CI/CD mode."""
2058
+ print(f"""
2059
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
2060
+ {Colors.BOLD} CI/CD Setup Instructions{Colors.NC}
2061
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
2062
+
2063
+ 1. Push this repository to GitHub
2064
+
2065
+ 2. Add these secrets to your GitHub repository:
2066
+ (Settings → Secrets → Actions)
2067
+
2068
+ • {Colors.CYAN}DOKKU_HOST{Colors.NC} - Your Dokku server hostname
2069
+ • {Colors.CYAN}DOKKU_SSH_PRIVATE_KEY{Colors.NC} - SSH key for deployments
2070
+ • {Colors.CYAN}BASE_DOMAIN{Colors.NC} - Base domain (e.g., yourdomain.com)
2071
+ • {Colors.CYAN}SWML_BASIC_AUTH_USER{Colors.NC} - Basic auth username
2072
+ • {Colors.CYAN}SWML_BASIC_AUTH_PASSWORD{Colors.NC} - Basic auth password
2073
+
2074
+ 3. Create GitHub environments:
2075
+ (Settings → Environments)
2076
+
2077
+ • production
2078
+ • staging
2079
+ • development
2080
+ • preview
2081
+
2082
+ 4. Push to deploy:
2083
+
2084
+ {Colors.CYAN}git push origin main{Colors.NC} # Deploys to production
2085
+
2086
+ 5. Open a PR for preview environments!
2087
+
2088
+ {Colors.BOLD}═══════════════════════════════════════════════════════════{Colors.NC}
2089
+ """)
2090
+
2091
+
2092
+ def cmd_deploy(args):
2093
+ """Deploy to Dokku."""
2094
+ # Check if we're in a Dokku project
2095
+ if not Path('Procfile').exists():
2096
+ print_error("No Procfile found. Are you in a Dokku project directory?")
2097
+ print("Run 'sw-agent-dokku init <name>' to create a new project.")
2098
+ return 1
2099
+
2100
+ dokku_host = args.host
2101
+ app_name = args.app
2102
+
2103
+ # Try to get app name from app.json
2104
+ if not app_name and Path('app.json').exists():
2105
+ import json
2106
+ try:
2107
+ with open('app.json') as f:
2108
+ app_json = json.load(f)
2109
+ app_name = app_json.get('name')
2110
+ except:
2111
+ pass
2112
+
2113
+ if not app_name:
2114
+ app_name = prompt("App name", Path.cwd().name)
2115
+
2116
+ if not dokku_host:
2117
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
2118
+
2119
+ print_header(f"Deploying {app_name} to {dokku_host}")
2120
+
2121
+ # Check git status
2122
+ if not Path('.git').exists():
2123
+ print_step("Initializing git repository...")
2124
+ subprocess.run(['git', 'init'], check=True)
2125
+ subprocess.run(['git', 'add', '.'], check=True)
2126
+ subprocess.run(['git', 'commit', '-m', 'Initial commit'], check=True)
2127
+
2128
+ # Create app
2129
+ print_step("Creating app (if not exists)...")
2130
+ subprocess.run(
2131
+ ['ssh', f'dokku@{dokku_host}', 'apps:create', app_name],
2132
+ capture_output=True
2133
+ )
2134
+
2135
+ # Set up git remote
2136
+ print_step("Configuring git remote...")
2137
+ remote_url = f'dokku@{dokku_host}:{app_name}'
2138
+ subprocess.run(['git', 'remote', 'remove', 'dokku'], capture_output=True)
2139
+ subprocess.run(['git', 'remote', 'add', 'dokku', remote_url], check=True)
2140
+
2141
+ # Deploy
2142
+ print_step("Pushing to Dokku...")
2143
+ result = subprocess.run(
2144
+ ['git', 'push', 'dokku', 'HEAD:main', '--force'],
2145
+ capture_output=False
2146
+ )
2147
+
2148
+ if result.returncode == 0:
2149
+ print_success(f"Deployed to https://{app_name}.{dokku_host.replace('dokku.', '')}")
2150
+ else:
2151
+ print_error("Deployment failed")
2152
+ return 1
2153
+
2154
+ return 0
2155
+
2156
+
2157
+ def cmd_logs(args):
2158
+ """Tail Dokku logs."""
2159
+ app_name = args.app
2160
+ dokku_host = args.host
2161
+
2162
+ if not app_name:
2163
+ app_name = _get_app_name()
2164
+ if not dokku_host:
2165
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
2166
+
2167
+ print_header(f"Tailing logs for {app_name}")
2168
+
2169
+ cmd = ['ssh', f'dokku@{dokku_host}', 'logs', app_name]
2170
+ if args.tail:
2171
+ cmd.append('-t')
2172
+ if args.num:
2173
+ cmd.extend(['--num', str(args.num)])
2174
+
2175
+ subprocess.run(cmd)
2176
+ return 0
2177
+
2178
+
2179
+ def cmd_config(args):
2180
+ """Manage Dokku config."""
2181
+ app_name = args.app
2182
+ dokku_host = args.host
2183
+
2184
+ if not app_name:
2185
+ app_name = _get_app_name()
2186
+ if not dokku_host:
2187
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
2188
+
2189
+ if args.config_action == 'show':
2190
+ subprocess.run(['ssh', f'dokku@{dokku_host}', 'config:show', app_name])
2191
+ elif args.config_action == 'set':
2192
+ if not args.vars:
2193
+ print_error("No variables provided. Use: sw-agent-dokku config set KEY=value")
2194
+ return 1
2195
+ cmd = ['ssh', f'dokku@{dokku_host}', 'config:set', app_name] + args.vars
2196
+ subprocess.run(cmd)
2197
+ elif args.config_action == 'unset':
2198
+ if not args.vars:
2199
+ print_error("No variables provided. Use: sw-agent-dokku config unset KEY")
2200
+ return 1
2201
+ cmd = ['ssh', f'dokku@{dokku_host}', 'config:unset', app_name] + args.vars
2202
+ subprocess.run(cmd)
2203
+
2204
+ return 0
2205
+
2206
+
2207
+ def cmd_scale(args):
2208
+ """Scale Dokku processes."""
2209
+ app_name = args.app
2210
+ dokku_host = args.host
2211
+
2212
+ if not app_name:
2213
+ app_name = _get_app_name()
2214
+ if not dokku_host:
2215
+ dokku_host = prompt("Dokku host", "dokku.yourdomain.com")
2216
+
2217
+ if not args.scale_args:
2218
+ # Show current scale
2219
+ subprocess.run(['ssh', f'dokku@{dokku_host}', 'ps:scale', app_name])
2220
+ else:
2221
+ # Set scale
2222
+ cmd = ['ssh', f'dokku@{dokku_host}', 'ps:scale', app_name] + args.scale_args
2223
+ subprocess.run(cmd)
2224
+
2225
+ return 0
2226
+
2227
+
2228
+ def _get_app_name() -> str:
2229
+ """Try to get app name from app.json or prompt."""
2230
+ if Path('app.json').exists():
2231
+ import json
2232
+ try:
2233
+ with open('app.json') as f:
2234
+ return json.load(f).get('name', '')
2235
+ except:
2236
+ pass
2237
+ return prompt("App name", Path.cwd().name)
2238
+
2239
+
2240
+ # =============================================================================
2241
+ # Main Entry Point
2242
+ # =============================================================================
2243
+
2244
+ def main():
2245
+ parser = argparse.ArgumentParser(
2246
+ description='SignalWire Agent Dokku Deployment Tool',
2247
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2248
+ epilog='''
2249
+ Examples:
2250
+ sw-agent-dokku init myagent # Create simple project (with prompts)
2251
+ sw-agent-dokku init myagent --web # Create with web interface at /
2252
+ sw-agent-dokku init myagent --cicd # Create with CI/CD workflows
2253
+ sw-agent-dokku init myagent --host dokku.example.com
2254
+ sw-agent-dokku deploy # Deploy current directory
2255
+ sw-agent-dokku logs -t # Tail logs
2256
+ sw-agent-dokku config show # Show config
2257
+ sw-agent-dokku config set KEY=value # Set config
2258
+ sw-agent-dokku scale web=2 # Scale processes
2259
+ '''
2260
+ )
2261
+
2262
+ subparsers = parser.add_subparsers(dest='command', help='Commands')
2263
+
2264
+ # init command
2265
+ init_parser = subparsers.add_parser('init', help='Initialize a new Dokku project')
2266
+ init_parser.add_argument('name', help='Project/app name')
2267
+ init_parser.add_argument('--cicd', action='store_true',
2268
+ help='Include GitHub Actions CI/CD workflows')
2269
+ init_parser.add_argument('--web', action='store_true',
2270
+ help='Include web interface (static files at /)')
2271
+ init_parser.add_argument('--host', help='Dokku server hostname')
2272
+ init_parser.add_argument('--dir', help='Project directory')
2273
+ init_parser.add_argument('--force', '-f', action='store_true',
2274
+ help='Overwrite existing directory')
2275
+
2276
+ # deploy command
2277
+ deploy_parser = subparsers.add_parser('deploy', help='Deploy to Dokku')
2278
+ deploy_parser.add_argument('--app', '-a', help='App name')
2279
+ deploy_parser.add_argument('--host', '-H', help='Dokku server hostname')
2280
+
2281
+ # logs command
2282
+ logs_parser = subparsers.add_parser('logs', help='View Dokku logs')
2283
+ logs_parser.add_argument('--app', '-a', help='App name')
2284
+ logs_parser.add_argument('--host', '-H', help='Dokku server hostname')
2285
+ logs_parser.add_argument('--tail', '-t', action='store_true', help='Tail logs')
2286
+ logs_parser.add_argument('--num', '-n', type=int, help='Number of lines')
2287
+
2288
+ # config command
2289
+ config_parser = subparsers.add_parser('config', help='Manage config variables')
2290
+ config_parser.add_argument('config_action', choices=['show', 'set', 'unset'],
2291
+ help='Config action')
2292
+ config_parser.add_argument('vars', nargs='*', help='Variables (KEY=value)')
2293
+ config_parser.add_argument('--app', '-a', help='App name')
2294
+ config_parser.add_argument('--host', '-H', help='Dokku server hostname')
2295
+
2296
+ # scale command
2297
+ scale_parser = subparsers.add_parser('scale', help='Scale processes')
2298
+ scale_parser.add_argument('scale_args', nargs='*', help='Scale args (web=2)')
2299
+ scale_parser.add_argument('--app', '-a', help='App name')
2300
+ scale_parser.add_argument('--host', '-H', help='Dokku server hostname')
2301
+
2302
+ args = parser.parse_args()
2303
+
2304
+ if not args.command:
2305
+ parser.print_help()
2306
+ return 1
2307
+
2308
+ commands = {
2309
+ 'init': cmd_init,
2310
+ 'deploy': cmd_deploy,
2311
+ 'logs': cmd_logs,
2312
+ 'config': cmd_config,
2313
+ 'scale': cmd_scale,
2314
+ }
2315
+
2316
+ return commands[args.command](args)
2317
+
2318
+
2319
+ if __name__ == '__main__':
2320
+ sys.exit(main())