sfdc-agentforce-byoc-python-sdk 0.1.0__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.
- agentforce_byoc/__init__.py +26 -0
- agentforce_byoc/_http.py +142 -0
- agentforce_byoc/cli/__init__.py +8 -0
- agentforce_byoc/cli/commands.py +913 -0
- agentforce_byoc/cli/main.py +44 -0
- agentforce_byoc/client.py +224 -0
- agentforce_byoc/deploy_client.py +246 -0
- agentforce_byoc/gateway/__init__.py +26 -0
- agentforce_byoc/gateway/base.py +66 -0
- agentforce_byoc/gateway/mock_relay_client.py +54 -0
- agentforce_byoc/gateway/relay_client.py +394 -0
- agentforce_byoc/logging/__init__.py +20 -0
- agentforce_byoc/logging/formatter.py +96 -0
- agentforce_byoc/logging/logger.py +59 -0
- agentforce_byoc/runtime/__init__.py +21 -0
- agentforce_byoc/runtime/agentforce_runtime.py +47 -0
- agentforce_byoc/runtime/base.py +54 -0
- agentforce_byoc/runtime/mock_runtime.py +50 -0
- agentforce_byoc/schema_utils/__init__.py +35 -0
- agentforce_byoc/schema_utils/entry_func.py +48 -0
- agentforce_byoc/schema_utils/generator.py +694 -0
- agentforce_byoc/schema_utils/schema.py +79 -0
- agentforce_byoc/templates/__init__.py +4 -0
- agentforce_byoc/templates/agent/.dockerignore.template +51 -0
- agentforce_byoc/templates/agent/.gitignore.template +64 -0
- agentforce_byoc/templates/agent/.python-version.template +1 -0
- agentforce_byoc/templates/agent/Dockerfile.template +41 -0
- agentforce_byoc/templates/agent/Makefile.template +51 -0
- agentforce_byoc/templates/agent/README.md.template +358 -0
- agentforce_byoc/templates/agent/config.json.template +3 -0
- agentforce_byoc/templates/agent/entry_func.py.template +51 -0
- agentforce_byoc/templates/agent/main.py.template +149 -0
- agentforce_byoc/templates/agent/pyproject.toml.template +24 -0
- agentforce_byoc/templates/agent/schema.py.template +73 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/METADATA +439 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/RECORD +39 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/licenses/LICENSE +206 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""CLI commands for Agentforce BYOC SDK."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from agentforce_byoc.cli.main import cli
|
|
14
|
+
from agentforce_byoc.gateway.relay_client import resolve_sfap_base_url
|
|
15
|
+
from agentforce_byoc.logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command()
|
|
21
|
+
@click.argument("directory", default=".")
|
|
22
|
+
def init(directory: str):
|
|
23
|
+
"""
|
|
24
|
+
Initialize a new agent project with Poetry.
|
|
25
|
+
|
|
26
|
+
Creates a new directory with:
|
|
27
|
+
- main.py (template agent)
|
|
28
|
+
- entry_func.py (entry point decorator)
|
|
29
|
+
- schema.py (request/response schema decorators)
|
|
30
|
+
- config.json (function metadata)
|
|
31
|
+
- Dockerfile (container definition)
|
|
32
|
+
- pyproject.toml (Poetry config)
|
|
33
|
+
- README.md (documentation)
|
|
34
|
+
- .python-version (Python 3.11)
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
agentforce init my-agent
|
|
38
|
+
cd my-agent
|
|
39
|
+
poetry install
|
|
40
|
+
agentforce run main.py
|
|
41
|
+
"""
|
|
42
|
+
project_path = Path(directory)
|
|
43
|
+
project_name = project_path.name
|
|
44
|
+
|
|
45
|
+
# Validate project name
|
|
46
|
+
if not re.match(r"^[a-z][a-z0-9_-]*$", project_name):
|
|
47
|
+
raise click.BadParameter(
|
|
48
|
+
f"Invalid project name: {project_name}. "
|
|
49
|
+
"Must start with lowercase letter and contain only lowercase letters, "
|
|
50
|
+
"numbers, hyphens, and underscores."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Create directory
|
|
54
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
logger.info(f"Creating project: {project_name}")
|
|
56
|
+
|
|
57
|
+
# Generate agent class name (e.g., my-agent -> MyAgent)
|
|
58
|
+
agent_class_name = "".join(
|
|
59
|
+
word.capitalize() for word in project_name.replace("-", "_").split("_")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Template variables
|
|
63
|
+
template_vars = {
|
|
64
|
+
"project_name": project_name,
|
|
65
|
+
"agent_class_name": agent_class_name,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Copy and render templates
|
|
69
|
+
templates_dir = Path(__file__).parent.parent / "templates" / "agent"
|
|
70
|
+
|
|
71
|
+
files_created = []
|
|
72
|
+
for template_file in templates_dir.glob("*.template"):
|
|
73
|
+
output_filename = template_file.stem
|
|
74
|
+
# Handle hidden files (e.g., .gitignore.template -> .gitignore)
|
|
75
|
+
if template_file.stem.startswith("."):
|
|
76
|
+
output_filename = template_file.stem
|
|
77
|
+
output_file = project_path / output_filename
|
|
78
|
+
render_template(template_file, output_file, template_vars)
|
|
79
|
+
files_created.append(output_filename)
|
|
80
|
+
|
|
81
|
+
# Vendor the SDK runtime package into the project so `main.py` can import
|
|
82
|
+
# agentforce_byoc and `agentforce zip` can ship it. TODO(WI): remove once
|
|
83
|
+
# the SDK is on PyPI (see vendor_sdk_into_project).
|
|
84
|
+
sdk_file_count = vendor_sdk_into_project(project_path)
|
|
85
|
+
if sdk_file_count:
|
|
86
|
+
files_created.append(f"agentforce_byoc/ ({sdk_file_count} files)")
|
|
87
|
+
|
|
88
|
+
logger.info(f"\n✅ Project {project_name} created successfully!")
|
|
89
|
+
logger.info("\nFiles created:")
|
|
90
|
+
for filename in sorted(files_created):
|
|
91
|
+
logger.info(f" ✓ {filename}")
|
|
92
|
+
|
|
93
|
+
logger.info("\n📦 Project structure:")
|
|
94
|
+
logger.info(f" {project_name}/")
|
|
95
|
+
logger.info(" ├── main.py # Your function implementation")
|
|
96
|
+
logger.info(" ├── entry_func.py # Entry point decorator")
|
|
97
|
+
logger.info(" ├── schema.py # Request/response schema decorators")
|
|
98
|
+
logger.info(" ├── agentforce_byoc/ # Vendored SDK (until published to PyPI)")
|
|
99
|
+
logger.info(" ├── config.json # Function metadata")
|
|
100
|
+
logger.info(" ├── Dockerfile # Container definition")
|
|
101
|
+
logger.info(" ├── pyproject.toml # Dependencies (Poetry)")
|
|
102
|
+
logger.info(" ├── .python-version # Python 3.11")
|
|
103
|
+
logger.info(" ├── .gitignore # Git ignore patterns")
|
|
104
|
+
logger.info(" ├── .dockerignore # Docker ignore patterns")
|
|
105
|
+
logger.info(" ├── Makefile # Development tasks")
|
|
106
|
+
logger.info(" └── README.md # Documentation")
|
|
107
|
+
|
|
108
|
+
logger.info("\n🚀 Next steps:")
|
|
109
|
+
logger.info(f" 1. cd {directory}")
|
|
110
|
+
logger.info(" 2. poetry install")
|
|
111
|
+
logger.info(" 3. # Implement your function logic in main.py")
|
|
112
|
+
logger.info(
|
|
113
|
+
" 4. export ORG_JWT_TOKEN=<jwt> && export SFDC_CORE_TENANT_ID=core/<inst>-<fd>/<org>"
|
|
114
|
+
" && export SFDC_USER_ID=<user-id> && export SFDC_APP_CONTEXT=<app-context>"
|
|
115
|
+
)
|
|
116
|
+
logger.info(" 5. agentforce run main.py # Test locally")
|
|
117
|
+
logger.info(" 6. agentforce zip --package-dir . # Package for deployment")
|
|
118
|
+
logger.info(" 7. agentforce deploy --package-name <name> --package-file <archive>.tar.gz")
|
|
119
|
+
|
|
120
|
+
logger.info("\n💡 Tips:")
|
|
121
|
+
logger.info(" • Edit main.py to implement your function")
|
|
122
|
+
logger.info(
|
|
123
|
+
" • Set ORG_JWT_TOKEN + SFDC_CORE_TENANT_ID + SFDC_USER_ID + SFDC_APP_CONTEXT"
|
|
124
|
+
" env vars before run/deploy"
|
|
125
|
+
)
|
|
126
|
+
logger.info(" • Add dependencies with: poetry add <package>")
|
|
127
|
+
logger.info(" • Check Makefile for useful commands")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@cli.command()
|
|
131
|
+
@click.argument("entrypoint", type=click.Path(exists=True), default="main.py")
|
|
132
|
+
def run(entrypoint: str):
|
|
133
|
+
"""
|
|
134
|
+
Run agent locally against the Agentforce BYOC runtime.
|
|
135
|
+
|
|
136
|
+
Configuration is taken from environment variables (no CLI flags):
|
|
137
|
+
- ORG_JWT_TOKEN: org JWT for authentication
|
|
138
|
+
- SFDC_CORE_TENANT_ID: core tenant id (core/<instance>-<fd>/<org-id>)
|
|
139
|
+
- SFDC_USER_ID: caller's user id
|
|
140
|
+
- SFDC_APP_CONTEXT: app context (relay requires the x-sfdc-app-context header)
|
|
141
|
+
If any is unset, the command aborts with an export instruction.
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
export ORG_JWT_TOKEN=<org-jwt>
|
|
145
|
+
export SFDC_CORE_TENANT_ID=core/<instance>-<fd>/<org-id>
|
|
146
|
+
export SFDC_USER_ID=<user-id>
|
|
147
|
+
export SFDC_APP_CONTEXT=<app-context>
|
|
148
|
+
agentforce run main.py
|
|
149
|
+
"""
|
|
150
|
+
logger.info("🚀 Running with Agentforce BYOC runtime")
|
|
151
|
+
# The relay client reads ORG_JWT_TOKEN, SFDC_CORE_TENANT_ID, SFDC_USER_ID,
|
|
152
|
+
# and SFDC_APP_CONTEXT from the environment (the runtime injects these in
|
|
153
|
+
# the managed sandbox). Require them locally so a missing value aborts with
|
|
154
|
+
# an actionable export hint instead of failing mid-relay, then pass them to
|
|
155
|
+
# the agent subprocess.
|
|
156
|
+
require_env_var("ORG_JWT_TOKEN", "org JWT", "<jwt>")
|
|
157
|
+
require_env_var("SFDC_CORE_TENANT_ID", "core tenant id", "core/<instance>-<fd>/<org-id>")
|
|
158
|
+
require_env_var("SFDC_USER_ID", "user id", "<user-id>")
|
|
159
|
+
require_env_var("SFDC_APP_CONTEXT", "app context", "<app-context>")
|
|
160
|
+
|
|
161
|
+
# Child process inherits the environment.
|
|
162
|
+
env = os.environ.copy()
|
|
163
|
+
|
|
164
|
+
# Execute the agent
|
|
165
|
+
logger.info(f"▶️ Executing: {entrypoint}")
|
|
166
|
+
logger.info("")
|
|
167
|
+
try:
|
|
168
|
+
subprocess.run(["python", entrypoint], check=True, env=env)
|
|
169
|
+
except subprocess.CalledProcessError as e:
|
|
170
|
+
logger.error(f"❌ Agent execution failed: {e}")
|
|
171
|
+
raise click.Abort()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@cli.command()
|
|
175
|
+
@click.argument("path", type=click.Path(exists=True), default=".")
|
|
176
|
+
def validate(path: str):
|
|
177
|
+
"""
|
|
178
|
+
Validate agent package before deployment.
|
|
179
|
+
|
|
180
|
+
Checks:
|
|
181
|
+
- pyproject.toml exists and is valid
|
|
182
|
+
- poetry.lock exists
|
|
183
|
+
- main.py exists
|
|
184
|
+
- entry_func.py exists
|
|
185
|
+
- schema.py exists
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
agentforce validate .
|
|
189
|
+
"""
|
|
190
|
+
project_path = Path(path)
|
|
191
|
+
logger.info(f"Validating project: {project_path}")
|
|
192
|
+
|
|
193
|
+
# Check pyproject.toml
|
|
194
|
+
pyproject_path = project_path / "pyproject.toml"
|
|
195
|
+
if not pyproject_path.exists():
|
|
196
|
+
logger.error("pyproject.toml not found")
|
|
197
|
+
raise click.Abort()
|
|
198
|
+
logger.info("✓ pyproject.toml found")
|
|
199
|
+
|
|
200
|
+
# Check poetry.lock
|
|
201
|
+
lock_path = project_path / "poetry.lock"
|
|
202
|
+
if not lock_path.exists():
|
|
203
|
+
logger.error("poetry.lock not found. Run 'poetry install' first.")
|
|
204
|
+
raise click.Abort()
|
|
205
|
+
logger.info("✓ poetry.lock found")
|
|
206
|
+
|
|
207
|
+
# Check required source files
|
|
208
|
+
required_files = ["main.py", "entry_func.py", "schema.py"]
|
|
209
|
+
for filename in required_files:
|
|
210
|
+
file_path = project_path / filename
|
|
211
|
+
if not file_path.exists():
|
|
212
|
+
logger.error(f"{filename} not found")
|
|
213
|
+
raise click.Abort()
|
|
214
|
+
logger.info(f"✓ {filename} found")
|
|
215
|
+
|
|
216
|
+
logger.info("\n✅ Validation passed!")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@cli.command()
|
|
220
|
+
@click.argument("package_dir", type=click.Path(exists=True), default=".")
|
|
221
|
+
@click.option(
|
|
222
|
+
"--output",
|
|
223
|
+
type=click.Path(),
|
|
224
|
+
help="Output archive path (default: <name>-<version>.tar.gz)",
|
|
225
|
+
)
|
|
226
|
+
def zip(package_dir: str, output: str):
|
|
227
|
+
"""
|
|
228
|
+
Create a gzip-compressed tar archive of the function package for deployment.
|
|
229
|
+
|
|
230
|
+
The Agentforce BYOC upload contract expects a gzip archive
|
|
231
|
+
(``application/gzip``), so this produces a ``.tar.gz`` ready for
|
|
232
|
+
``agentforce deploy``. Contains:
|
|
233
|
+
- main.py (function code)
|
|
234
|
+
- entry_func.py (entry point decorator)
|
|
235
|
+
- schema.py (request/response schema decorators)
|
|
236
|
+
- config.json (metadata)
|
|
237
|
+
- Dockerfile (container definition)
|
|
238
|
+
- pyproject.toml (dependencies)
|
|
239
|
+
- poetry.lock (locked versions)
|
|
240
|
+
- requirements.txt (exported from poetry)
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
agentforce zip --package-dir .
|
|
244
|
+
agentforce zip --package-dir . --output my-function.tar.gz
|
|
245
|
+
"""
|
|
246
|
+
import json
|
|
247
|
+
import tarfile
|
|
248
|
+
|
|
249
|
+
project_path = Path(package_dir).resolve()
|
|
250
|
+
logger.info(f"📦 Packaging function: {project_path.name}")
|
|
251
|
+
|
|
252
|
+
# Validate required files
|
|
253
|
+
config_path = project_path / "config.json"
|
|
254
|
+
if not config_path.exists():
|
|
255
|
+
logger.error("❌ config.json not found. Is this an Agentforce BYOC function?")
|
|
256
|
+
logger.info("Run 'agentforce init' to create a new function.")
|
|
257
|
+
raise click.Abort()
|
|
258
|
+
|
|
259
|
+
agent_path = project_path / "main.py"
|
|
260
|
+
if not agent_path.exists():
|
|
261
|
+
logger.error("❌ main.py not found")
|
|
262
|
+
raise click.Abort()
|
|
263
|
+
|
|
264
|
+
dockerfile_path = project_path / "Dockerfile"
|
|
265
|
+
if not dockerfile_path.exists():
|
|
266
|
+
logger.error("❌ Dockerfile not found")
|
|
267
|
+
raise click.Abort()
|
|
268
|
+
|
|
269
|
+
pyproject_path = project_path / "pyproject.toml"
|
|
270
|
+
if not pyproject_path.exists():
|
|
271
|
+
logger.error("❌ pyproject.toml not found")
|
|
272
|
+
raise click.Abort()
|
|
273
|
+
|
|
274
|
+
lock_path = project_path / "poetry.lock"
|
|
275
|
+
if not lock_path.exists():
|
|
276
|
+
logger.error("❌ poetry.lock not found. Run 'poetry install' first.")
|
|
277
|
+
raise click.Abort()
|
|
278
|
+
|
|
279
|
+
# Read config.json
|
|
280
|
+
try:
|
|
281
|
+
with open(config_path, "r") as f:
|
|
282
|
+
config = json.load(f)
|
|
283
|
+
function_name = config.get("name", project_path.name)
|
|
284
|
+
function_version = config.get("version", "0.1.0")
|
|
285
|
+
logger.info(f"Function: {function_name} v{function_version}")
|
|
286
|
+
except json.JSONDecodeError as e:
|
|
287
|
+
logger.error(f"❌ Invalid config.json: {e}")
|
|
288
|
+
raise click.Abort()
|
|
289
|
+
|
|
290
|
+
# Export requirements.txt from poetry
|
|
291
|
+
requirements_path = project_path / "requirements.txt"
|
|
292
|
+
logger.info("📝 Exporting dependencies...")
|
|
293
|
+
export_requirements(project_path, requirements_path)
|
|
294
|
+
|
|
295
|
+
# Download dependencies for bundling
|
|
296
|
+
dependencies_dir = project_path / "dependencies"
|
|
297
|
+
logger.info("📥 Downloading dependencies for bundling...")
|
|
298
|
+
try:
|
|
299
|
+
# Create temporary directory for downloads
|
|
300
|
+
import shutil
|
|
301
|
+
import tempfile
|
|
302
|
+
|
|
303
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
304
|
+
temp_path = Path(temp_dir)
|
|
305
|
+
|
|
306
|
+
# Download all dependencies using pip
|
|
307
|
+
subprocess.run(
|
|
308
|
+
[
|
|
309
|
+
"pip",
|
|
310
|
+
"download",
|
|
311
|
+
"-r",
|
|
312
|
+
str(requirements_path),
|
|
313
|
+
"-d",
|
|
314
|
+
str(temp_path),
|
|
315
|
+
"--no-deps", # Don't download transitive dependencies (already in requirements.txt)
|
|
316
|
+
],
|
|
317
|
+
capture_output=True,
|
|
318
|
+
text=True,
|
|
319
|
+
check=True,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Filter out packages to exclude
|
|
323
|
+
exclude_packages = [
|
|
324
|
+
"agentforce-byoc",
|
|
325
|
+
"agentforce_byoc",
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
# Create dependencies directory
|
|
329
|
+
if dependencies_dir.exists():
|
|
330
|
+
shutil.rmtree(dependencies_dir)
|
|
331
|
+
dependencies_dir.mkdir()
|
|
332
|
+
|
|
333
|
+
# Copy filtered packages
|
|
334
|
+
package_count = 0
|
|
335
|
+
for file in temp_path.iterdir():
|
|
336
|
+
if file.is_file():
|
|
337
|
+
# Check if this package should be excluded
|
|
338
|
+
filename_lower = file.name.lower()
|
|
339
|
+
should_exclude = any(
|
|
340
|
+
pkg.lower().replace("-", "_") in filename_lower.replace("-", "_")
|
|
341
|
+
for pkg in exclude_packages
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if not should_exclude:
|
|
345
|
+
shutil.copy2(file, dependencies_dir / file.name)
|
|
346
|
+
package_count += 1
|
|
347
|
+
logger.info(f" ✓ Bundled: {file.name}")
|
|
348
|
+
|
|
349
|
+
logger.info(f"✓ Downloaded and bundled {package_count} dependencies")
|
|
350
|
+
|
|
351
|
+
except subprocess.CalledProcessError as e:
|
|
352
|
+
logger.error(f"❌ Failed to download dependencies: {e.stderr}")
|
|
353
|
+
logger.info("Make sure pip is available and requirements.txt is valid.")
|
|
354
|
+
raise click.Abort()
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.error(f"❌ Error bundling dependencies: {e}")
|
|
357
|
+
raise click.Abort()
|
|
358
|
+
|
|
359
|
+
# Determine output filename
|
|
360
|
+
if output is None:
|
|
361
|
+
output = f"{function_name}-{function_version}.tar.gz"
|
|
362
|
+
output_path = Path(output).resolve()
|
|
363
|
+
|
|
364
|
+
# Files to include in the archive
|
|
365
|
+
files_to_zip = [
|
|
366
|
+
"main.py",
|
|
367
|
+
"entry_func.py",
|
|
368
|
+
"schema.py",
|
|
369
|
+
"config.json",
|
|
370
|
+
"Dockerfile",
|
|
371
|
+
"pyproject.toml",
|
|
372
|
+
"requirements.txt",
|
|
373
|
+
".python-version",
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# Optional files
|
|
377
|
+
optional_files = [
|
|
378
|
+
"poetry.lock",
|
|
379
|
+
".dockerignore",
|
|
380
|
+
"README.md",
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
logger.info(f"🗜️ Creating archive: {output_path.name}")
|
|
384
|
+
|
|
385
|
+
# Create gzip-compressed tar archive
|
|
386
|
+
try:
|
|
387
|
+
with tarfile.open(output_path, "w:gz") as tar:
|
|
388
|
+
# Add required files
|
|
389
|
+
for filename in files_to_zip:
|
|
390
|
+
file_path = project_path / filename
|
|
391
|
+
if file_path.exists():
|
|
392
|
+
tar.add(file_path, arcname=filename)
|
|
393
|
+
logger.info(f" ✓ Added: {filename}")
|
|
394
|
+
else:
|
|
395
|
+
if filename not in [
|
|
396
|
+
"Dockerfile",
|
|
397
|
+
".dockerignore",
|
|
398
|
+
]: # Docker files are optional for the managed sandbox
|
|
399
|
+
logger.warning(f" ⚠️ Missing: {filename}")
|
|
400
|
+
|
|
401
|
+
# Add optional files
|
|
402
|
+
for filename in optional_files:
|
|
403
|
+
file_path = project_path / filename
|
|
404
|
+
if file_path.exists():
|
|
405
|
+
tar.add(file_path, arcname=filename)
|
|
406
|
+
logger.info(f" ✓ Added: {filename}")
|
|
407
|
+
|
|
408
|
+
# Bundle the vendored agentforce_byoc/ SDK package if present.
|
|
409
|
+
# TODO(WI): the SDK is not yet published to PyPI, so projects vendor
|
|
410
|
+
# the package directory and we ship it inside the archive. Once
|
|
411
|
+
# agentforce-byoc is on PyPI, drop this and install it via
|
|
412
|
+
# requirements.txt instead (the runtime pip-installs deps), so
|
|
413
|
+
# packages don't carry a copy of the SDK.
|
|
414
|
+
vendored_sdk = project_path / "agentforce_byoc"
|
|
415
|
+
if vendored_sdk.is_dir():
|
|
416
|
+
sdk_file_count = 0
|
|
417
|
+
for sdk_path in sorted(vendored_sdk.rglob("*")):
|
|
418
|
+
# Skip caches/compiled artifacts.
|
|
419
|
+
if "__pycache__" in sdk_path.parts or sdk_path.suffix == ".pyc":
|
|
420
|
+
continue
|
|
421
|
+
if sdk_path.is_file():
|
|
422
|
+
arcname = str(sdk_path.relative_to(project_path))
|
|
423
|
+
tar.add(sdk_path, arcname=arcname)
|
|
424
|
+
sdk_file_count += 1
|
|
425
|
+
logger.info(f" ✓ Added vendored agentforce_byoc/ ({sdk_file_count} files)")
|
|
426
|
+
|
|
427
|
+
# Add dependencies directory
|
|
428
|
+
if dependencies_dir.exists():
|
|
429
|
+
dep_count = 0
|
|
430
|
+
for dep_file in dependencies_dir.iterdir():
|
|
431
|
+
if dep_file.is_file():
|
|
432
|
+
arcname = f"dependencies/{dep_file.name}"
|
|
433
|
+
tar.add(dep_file, arcname=arcname)
|
|
434
|
+
dep_count += 1
|
|
435
|
+
logger.info(f" ✓ Added {dep_count} dependency packages")
|
|
436
|
+
|
|
437
|
+
# Get archive file size
|
|
438
|
+
zip_size = output_path.stat().st_size
|
|
439
|
+
zip_size_mb = zip_size / (1024 * 1024)
|
|
440
|
+
|
|
441
|
+
logger.info("\n✅ Package created successfully!")
|
|
442
|
+
logger.info(f"📦 Output: {output_path}")
|
|
443
|
+
logger.info(f"📊 Size: {zip_size_mb:.2f} MB")
|
|
444
|
+
|
|
445
|
+
logger.info("\n🚀 Next step:")
|
|
446
|
+
logger.info(
|
|
447
|
+
" export ORG_JWT_TOKEN=<jwt> && export SFDC_CORE_TENANT_ID=core/<inst>-<fd>/<org>"
|
|
448
|
+
)
|
|
449
|
+
logger.info(f" agentforce deploy --package-name <name> --package-file {output_path.name}")
|
|
450
|
+
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"❌ Failed to create archive: {e}")
|
|
453
|
+
raise click.Abort()
|
|
454
|
+
finally:
|
|
455
|
+
# Clean up temporary dependencies directory
|
|
456
|
+
if dependencies_dir.exists():
|
|
457
|
+
import shutil
|
|
458
|
+
|
|
459
|
+
shutil.rmtree(dependencies_dir)
|
|
460
|
+
logger.debug("🧹 Cleaned up temporary dependencies directory")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@cli.command()
|
|
464
|
+
@click.argument("path", type=click.Path(exists=True), default=".")
|
|
465
|
+
@click.option("--output", type=click.Path(), help="Output zip file path")
|
|
466
|
+
def package(path: str, output: str):
|
|
467
|
+
"""
|
|
468
|
+
Build the agent end-to-end: validate, run, then zip.
|
|
469
|
+
|
|
470
|
+
Runs the following commands in sequence:
|
|
471
|
+
1. agentforce validate <path>
|
|
472
|
+
2. agentforce run main.py
|
|
473
|
+
3. agentforce zip --package-dir <path>
|
|
474
|
+
|
|
475
|
+
Step 2 runs the agent against the Agentforce BYOC runtime, so ORG_JWT_TOKEN,
|
|
476
|
+
SFDC_CORE_TENANT_ID, SFDC_USER_ID, and SFDC_APP_CONTEXT must be set in the
|
|
477
|
+
environment.
|
|
478
|
+
|
|
479
|
+
Example:
|
|
480
|
+
export ORG_JWT_TOKEN=<jwt> && export SFDC_CORE_TENANT_ID=core/<inst>-<fd>/<org>
|
|
481
|
+
export SFDC_USER_ID=<user-id> && export SFDC_APP_CONTEXT=<app-context>
|
|
482
|
+
agentforce package
|
|
483
|
+
agentforce package . --output my-function.tar.gz
|
|
484
|
+
"""
|
|
485
|
+
from click import get_current_context
|
|
486
|
+
|
|
487
|
+
ctx = get_current_context()
|
|
488
|
+
|
|
489
|
+
project_path = Path(path)
|
|
490
|
+
|
|
491
|
+
logger.info("📋 Step 1/3: Validating project...")
|
|
492
|
+
ctx.invoke(validate, path=path)
|
|
493
|
+
|
|
494
|
+
logger.info("\n🚀 Step 2/3: Running agent...")
|
|
495
|
+
entrypoint = str(project_path / "main.py")
|
|
496
|
+
ctx.invoke(run, entrypoint=entrypoint)
|
|
497
|
+
|
|
498
|
+
logger.info("\n📦 Step 3/3: Packaging for deployment...")
|
|
499
|
+
ctx.invoke(zip, package_dir=path, output=output)
|
|
500
|
+
|
|
501
|
+
logger.info("\n✅ Package pipeline complete!")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _resolve_base_url(tenant_id: str) -> str:
|
|
505
|
+
"""Resolve the Agentforce BYOC API base URL, aborting on a bad tenant id.
|
|
506
|
+
|
|
507
|
+
Prefers ``BYOC_PROXY_URL`` (see :func:`resolve_sfap_base_url`); otherwise
|
|
508
|
+
defaults to production. The tenant id is validated for shape but does not
|
|
509
|
+
select the host.
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
return resolve_sfap_base_url(tenant_id)
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
logger.error(f"❌ Invalid tenant id: {e}")
|
|
515
|
+
raise click.Abort()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def require_env_var(var_name: str, label: str, example: str = "") -> str:
|
|
519
|
+
"""
|
|
520
|
+
Return an environment variable's value, or abort with export instructions.
|
|
521
|
+
|
|
522
|
+
All commands take the tenant id and org JWT from the environment
|
|
523
|
+
(SFDC_CORE_TENANT_ID, ORG_JWT_TOKEN) rather than CLI flags, so the surface
|
|
524
|
+
is consistent.
|
|
525
|
+
|
|
526
|
+
We deliberately do NOT prompt for these values. The org JWT routinely
|
|
527
|
+
exceeds 1024 characters, which is the terminal's canonical-mode line-length
|
|
528
|
+
limit — a typed/pasted line that long can't be submitted (Enter never
|
|
529
|
+
registers). So when a variable is unset we print an `export` instruction
|
|
530
|
+
and abort, which works for a value of any length.
|
|
531
|
+
"""
|
|
532
|
+
value = os.getenv(var_name)
|
|
533
|
+
if value:
|
|
534
|
+
return value
|
|
535
|
+
|
|
536
|
+
hint = example or "<value>"
|
|
537
|
+
logger.error(f"❌ {var_name} is not set ({label}).")
|
|
538
|
+
logger.info(f" Set it, then re-run: export {var_name}={hint}")
|
|
539
|
+
raise click.Abort()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@cli.command()
|
|
543
|
+
@click.option("--package-name", required=True, help="Package name")
|
|
544
|
+
@click.option(
|
|
545
|
+
"--package-file",
|
|
546
|
+
required=True,
|
|
547
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
548
|
+
help="Path to the package archive produced by 'agentforce zip'",
|
|
549
|
+
)
|
|
550
|
+
@click.option("--entry-point", default="main.py", help="Function entry point (default: main.py)")
|
|
551
|
+
@click.option("--version", default="1.0", help="Package version (default: 1.0)")
|
|
552
|
+
@click.option(
|
|
553
|
+
"--max-live-duration",
|
|
554
|
+
default=300,
|
|
555
|
+
type=int,
|
|
556
|
+
help="Max live duration in seconds (default: 300)",
|
|
557
|
+
)
|
|
558
|
+
def deploy(
|
|
559
|
+
package_name: str,
|
|
560
|
+
package_file: str,
|
|
561
|
+
entry_point: str,
|
|
562
|
+
version: str,
|
|
563
|
+
max_live_duration: int,
|
|
564
|
+
):
|
|
565
|
+
"""
|
|
566
|
+
Register and upload a packaged agent to the Agentforce BYOC platform.
|
|
567
|
+
|
|
568
|
+
Calls <base>/byoc/upload/request, which registers the package metadata
|
|
569
|
+
(size, entry point, max live duration) AND returns a presigned upload URL in
|
|
570
|
+
one step, then uploads the archive to that URL. Poll the result afterward
|
|
571
|
+
with 'agentforce deploy-status'.
|
|
572
|
+
|
|
573
|
+
Auth/identity come from environment variables (required; abort if unset):
|
|
574
|
+
- ORG_JWT_TOKEN: org JWT for authentication
|
|
575
|
+
- SFDC_CORE_TENANT_ID: core tenant id (core/<instance>-<fd>/<org-id>)
|
|
576
|
+
- SFDC_USER_ID: caller's user id (sent as x-sfdc-user-id)
|
|
577
|
+
- SFDC_APP_CONTEXT: app context (sent as x-sfdc-app-context)
|
|
578
|
+
|
|
579
|
+
The endpoint URL defaults to production. Target another environment by
|
|
580
|
+
exporting BYOC_PROXY_URL (e.g. https://<env>.api.salesforce.com).
|
|
581
|
+
|
|
582
|
+
Example:
|
|
583
|
+
export ORG_JWT_TOKEN=<org-jwt>
|
|
584
|
+
export SFDC_CORE_TENANT_ID=core/<instance>-<fd>/<org-id>
|
|
585
|
+
export SFDC_USER_ID=<user-id>
|
|
586
|
+
export SFDC_APP_CONTEXT=<app-context>
|
|
587
|
+
agentforce deploy --package-name test --package-file test-0.1.0.tar.gz
|
|
588
|
+
"""
|
|
589
|
+
from agentforce_byoc import deploy_client
|
|
590
|
+
|
|
591
|
+
tenant_id = require_env_var(
|
|
592
|
+
"SFDC_CORE_TENANT_ID", "core tenant id", "core/<instance>-<fd>/<org-id>"
|
|
593
|
+
)
|
|
594
|
+
org_jwt = require_env_var("ORG_JWT_TOKEN", "org JWT", "<jwt>")
|
|
595
|
+
user_id = require_env_var("SFDC_USER_ID", "user id", "<user-id>")
|
|
596
|
+
app_context = require_env_var("SFDC_APP_CONTEXT", "app context", "<app-context>")
|
|
597
|
+
|
|
598
|
+
package_path = Path(package_file)
|
|
599
|
+
package_size = package_path.stat().st_size
|
|
600
|
+
logger.info(f"Deploying package: {package_name} v{version} (tenant: {tenant_id})")
|
|
601
|
+
logger.info(f" Entry point: {entry_point}")
|
|
602
|
+
logger.info(f" Package size: {package_size} bytes")
|
|
603
|
+
logger.info(f" Max live duration: {max_live_duration}s")
|
|
604
|
+
|
|
605
|
+
base_url = _resolve_base_url(tenant_id)
|
|
606
|
+
logger.info(f" BYOC endpoint: {base_url}/byoc/upload/request")
|
|
607
|
+
|
|
608
|
+
# Step 1: register metadata + request a presigned upload URL (one call).
|
|
609
|
+
try:
|
|
610
|
+
presigned = deploy_client.request_presigned_upload(
|
|
611
|
+
base_url=base_url,
|
|
612
|
+
tenant_id=tenant_id,
|
|
613
|
+
org_jwt=org_jwt,
|
|
614
|
+
user_id=user_id,
|
|
615
|
+
app_context=app_context,
|
|
616
|
+
package_name=package_name,
|
|
617
|
+
version=version,
|
|
618
|
+
package_size=package_size,
|
|
619
|
+
entry_points=[entry_point],
|
|
620
|
+
max_live_duration_s=max_live_duration,
|
|
621
|
+
)
|
|
622
|
+
except RuntimeError as e:
|
|
623
|
+
logger.error(f"❌ Failed to register package + request upload URL: {e}")
|
|
624
|
+
raise click.Abort()
|
|
625
|
+
|
|
626
|
+
upload_url = presigned.get("uploadUrl")
|
|
627
|
+
s3_key = presigned.get("s3Key")
|
|
628
|
+
if not upload_url:
|
|
629
|
+
logger.error(f"❌ Upload response missing 'uploadUrl': {presigned}")
|
|
630
|
+
raise click.Abort()
|
|
631
|
+
logger.info(f"✓ Registered metadata + received presigned URL (s3Key: {s3_key})")
|
|
632
|
+
|
|
633
|
+
# Step 2: upload the package archive to the presigned URL.
|
|
634
|
+
try:
|
|
635
|
+
deploy_client.upload_package(upload_url, package_path)
|
|
636
|
+
except RuntimeError as e:
|
|
637
|
+
logger.error(f"❌ Package upload failed: {e}")
|
|
638
|
+
raise click.Abort()
|
|
639
|
+
|
|
640
|
+
logger.info("\n✅ Package deployed successfully!")
|
|
641
|
+
logger.info(f" Package: {package_name} v{version}")
|
|
642
|
+
logger.info(f" s3Key: {s3_key}")
|
|
643
|
+
logger.info("\n🔎 Verify it uploaded:")
|
|
644
|
+
logger.info(f" agentforce deploy-status --package-name {package_name} --version {version}")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@cli.command(name="deploy-status")
|
|
648
|
+
@click.option("--package-name", required=True, help="Package name")
|
|
649
|
+
@click.option("--version", default="1.0", help="Package version (default: 1.0)")
|
|
650
|
+
def deploy_status(package_name: str, version: str):
|
|
651
|
+
"""
|
|
652
|
+
Poll deployment status of a package on the Agentforce BYOC platform.
|
|
653
|
+
|
|
654
|
+
Calls <base>/byoc/upload/status (read-only) and reports whether the package
|
|
655
|
+
metadata is registered and whether the archive has been uploaded.
|
|
656
|
+
Registration happens during 'agentforce deploy' — this command only reports;
|
|
657
|
+
it never writes.
|
|
658
|
+
|
|
659
|
+
Auth/identity come from environment variables (required; abort if unset):
|
|
660
|
+
- ORG_JWT_TOKEN: org JWT for authentication
|
|
661
|
+
- SFDC_CORE_TENANT_ID: core tenant id (core/<instance>-<fd>/<org-id>)
|
|
662
|
+
- SFDC_USER_ID: caller's user id (sent as x-sfdc-user-id)
|
|
663
|
+
- SFDC_APP_CONTEXT: app context (sent as x-sfdc-app-context)
|
|
664
|
+
|
|
665
|
+
The endpoint URL defaults to production. Target another environment by
|
|
666
|
+
exporting BYOC_PROXY_URL (e.g. https://<env>.api.salesforce.com).
|
|
667
|
+
|
|
668
|
+
Example:
|
|
669
|
+
export ORG_JWT_TOKEN=<org-jwt>
|
|
670
|
+
export SFDC_CORE_TENANT_ID=core/<instance>-<fd>/<org-id>
|
|
671
|
+
export SFDC_USER_ID=<user-id>
|
|
672
|
+
export SFDC_APP_CONTEXT=<app-context>
|
|
673
|
+
agentforce deploy-status --package-name test
|
|
674
|
+
"""
|
|
675
|
+
from agentforce_byoc import deploy_client
|
|
676
|
+
|
|
677
|
+
tenant_id = require_env_var(
|
|
678
|
+
"SFDC_CORE_TENANT_ID", "core tenant id", "core/<instance>-<fd>/<org-id>"
|
|
679
|
+
)
|
|
680
|
+
org_jwt = require_env_var("ORG_JWT_TOKEN", "org JWT", "<jwt>")
|
|
681
|
+
user_id = require_env_var("SFDC_USER_ID", "user id", "<user-id>")
|
|
682
|
+
app_context = require_env_var("SFDC_APP_CONTEXT", "app context", "<app-context>")
|
|
683
|
+
|
|
684
|
+
logger.info(f"Polling upload status: {package_name} v{version} (tenant: {tenant_id})")
|
|
685
|
+
|
|
686
|
+
base_url = _resolve_base_url(tenant_id)
|
|
687
|
+
logger.info(f" BYOC endpoint: {base_url}/byoc/upload/status")
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
status = deploy_client.get_upload_status(
|
|
691
|
+
base_url=base_url,
|
|
692
|
+
tenant_id=tenant_id,
|
|
693
|
+
org_jwt=org_jwt,
|
|
694
|
+
user_id=user_id,
|
|
695
|
+
app_context=app_context,
|
|
696
|
+
package_name=package_name,
|
|
697
|
+
version=version,
|
|
698
|
+
)
|
|
699
|
+
except RuntimeError as e:
|
|
700
|
+
logger.error(f"❌ Failed to poll upload status: {e}")
|
|
701
|
+
raise click.Abort()
|
|
702
|
+
|
|
703
|
+
logger.info("\n📦 Deployment status:")
|
|
704
|
+
logger.info(f" Package: {package_name} v{version}")
|
|
705
|
+
logger.info(f" s3Key: {status.get('s3Key')}")
|
|
706
|
+
logger.info(f" Registration: {status.get('registrationStatus')}")
|
|
707
|
+
logger.info(f" Upload: {status.get('uploadStatus')}")
|
|
708
|
+
if status.get("uploadTime") is not None:
|
|
709
|
+
logger.info(f" Upload time: {status.get('uploadTime')}")
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@cli.command(name="generate-schema")
|
|
713
|
+
@click.argument("input", type=click.Path(exists=True, dir_okay=False))
|
|
714
|
+
@click.option("-o", "--output", default=None, help="Output YAML file (default: stdout)")
|
|
715
|
+
@click.option("--namespace", default=None, help="Function namespace (overrides config file)")
|
|
716
|
+
@click.option("--package", default=None, help="Function package (overrides config file)")
|
|
717
|
+
@click.option(
|
|
718
|
+
"-c",
|
|
719
|
+
"--config",
|
|
720
|
+
"config_path",
|
|
721
|
+
default=None,
|
|
722
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
723
|
+
help="Path to a YAML or JSON config file with namespace/package",
|
|
724
|
+
)
|
|
725
|
+
def generate_schema(
|
|
726
|
+
input: str,
|
|
727
|
+
output: str,
|
|
728
|
+
namespace: str,
|
|
729
|
+
package: str,
|
|
730
|
+
config_path: str,
|
|
731
|
+
):
|
|
732
|
+
"""
|
|
733
|
+
Generate an OpenAPI 3.0 spec from @entry_func functions.
|
|
734
|
+
|
|
735
|
+
Scans a Python source file (e.g. main.py) at the AST level — it is never
|
|
736
|
+
imported or executed — for @entry_func functions and their
|
|
737
|
+
@requestSchema/@responseSchema classes, and emits an OpenAPI spec with the
|
|
738
|
+
x-sfdc.code-extension block used for BYOC Code Extension registration.
|
|
739
|
+
|
|
740
|
+
Precedence for namespace/package: CLI flags > config file > defaults.
|
|
741
|
+
|
|
742
|
+
Example:
|
|
743
|
+
agentforce generate-schema main.py -o MyFunc.yaml --namespace myOrg --package myPkg
|
|
744
|
+
"""
|
|
745
|
+
from agentforce_byoc.schema_utils import generator
|
|
746
|
+
|
|
747
|
+
ns, pkg = generator.resolve_config(
|
|
748
|
+
cli_namespace=namespace,
|
|
749
|
+
cli_package=package,
|
|
750
|
+
config_path=config_path,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
yaml_str = generator.generate_schema_yaml(input, namespace=ns, package=pkg)
|
|
755
|
+
except (ValueError, SyntaxError) as e:
|
|
756
|
+
logger.error(f"❌ Failed to generate schema: {e}")
|
|
757
|
+
raise click.Abort()
|
|
758
|
+
|
|
759
|
+
if output:
|
|
760
|
+
with open(output, "w") as f:
|
|
761
|
+
f.write(yaml_str)
|
|
762
|
+
logger.info(f"✅ Wrote OpenAPI spec to {output}")
|
|
763
|
+
else:
|
|
764
|
+
click.echo(yaml_str)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def export_requirements(project_path: Path, requirements_path: Path):
|
|
768
|
+
"""
|
|
769
|
+
Export requirements.txt from Poetry's lockfile.
|
|
770
|
+
|
|
771
|
+
Poetry 2.0+ removed the built-in ``export`` command in favor of the
|
|
772
|
+
``poetry-plugin-export`` plugin. If the command is missing, this helper
|
|
773
|
+
installs the plugin via ``poetry self add`` and retries once.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
project_path: Directory containing pyproject.toml / poetry.lock
|
|
777
|
+
requirements_path: Destination path for the exported requirements.txt
|
|
778
|
+
|
|
779
|
+
Raises:
|
|
780
|
+
click.Abort: If the export fails for any reason other than a missing
|
|
781
|
+
plugin, or if installing the plugin does not resolve the issue.
|
|
782
|
+
"""
|
|
783
|
+
export_cmd = [
|
|
784
|
+
"poetry",
|
|
785
|
+
"export",
|
|
786
|
+
"-f",
|
|
787
|
+
"requirements.txt",
|
|
788
|
+
"--output",
|
|
789
|
+
str(requirements_path),
|
|
790
|
+
"--without-hashes",
|
|
791
|
+
"--without-urls",
|
|
792
|
+
]
|
|
793
|
+
|
|
794
|
+
def _run_export():
|
|
795
|
+
return subprocess.run(
|
|
796
|
+
export_cmd,
|
|
797
|
+
cwd=project_path,
|
|
798
|
+
check=True,
|
|
799
|
+
capture_output=True,
|
|
800
|
+
text=True,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
try:
|
|
804
|
+
_run_export()
|
|
805
|
+
logger.info("✓ Exported requirements.txt")
|
|
806
|
+
return
|
|
807
|
+
except subprocess.CalledProcessError as e:
|
|
808
|
+
combined = f"{e.stdout or ''}\n{e.stderr or ''}"
|
|
809
|
+
# Poetry 2.x without the export plugin reports the command as unknown.
|
|
810
|
+
if "command export does not exist" not in combined:
|
|
811
|
+
logger.error(f"❌ Failed to export requirements: {e.stderr}")
|
|
812
|
+
logger.info("Make sure poetry.lock exists. Run 'poetry install' first.")
|
|
813
|
+
raise click.Abort()
|
|
814
|
+
|
|
815
|
+
# 'poetry export' is unavailable: install the export plugin and retry.
|
|
816
|
+
logger.info("ℹ️ 'poetry export' not found (Poetry 2.x). Installing poetry-plugin-export...")
|
|
817
|
+
try:
|
|
818
|
+
subprocess.run(
|
|
819
|
+
["poetry", "self", "add", "poetry-plugin-export"],
|
|
820
|
+
check=True,
|
|
821
|
+
capture_output=True,
|
|
822
|
+
text=True,
|
|
823
|
+
)
|
|
824
|
+
logger.info("✓ Installed poetry-plugin-export")
|
|
825
|
+
except subprocess.CalledProcessError as e:
|
|
826
|
+
logger.error(f"❌ Failed to install poetry-plugin-export: {e.stderr}")
|
|
827
|
+
logger.info("Install it manually: poetry self add poetry-plugin-export")
|
|
828
|
+
raise click.Abort()
|
|
829
|
+
|
|
830
|
+
try:
|
|
831
|
+
_run_export()
|
|
832
|
+
logger.info("✓ Exported requirements.txt")
|
|
833
|
+
except subprocess.CalledProcessError as e:
|
|
834
|
+
logger.error(f"❌ Failed to export requirements after installing plugin: {e.stderr}")
|
|
835
|
+
logger.info("Make sure poetry.lock exists. Run 'poetry install' first.")
|
|
836
|
+
raise click.Abort()
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def render_template(template_path: Path, output_path: Path, variables: dict):
|
|
840
|
+
"""
|
|
841
|
+
Render a template file with variables.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
template_path: Path to template file
|
|
845
|
+
output_path: Path to output file
|
|
846
|
+
variables: Dictionary of template variables
|
|
847
|
+
"""
|
|
848
|
+
with open(template_path, "r") as f:
|
|
849
|
+
content = f.read()
|
|
850
|
+
|
|
851
|
+
# Simple template rendering (replace {{variable}})
|
|
852
|
+
for key, value in variables.items():
|
|
853
|
+
content = content.replace(f"{{{{{key}}}}}", value)
|
|
854
|
+
|
|
855
|
+
with open(output_path, "w") as f:
|
|
856
|
+
f.write(content)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# Runtime subset of the SDK package vendored into each generated project.
|
|
860
|
+
# Excludes cli/ (pulls in click; not needed at runtime), templates/, and
|
|
861
|
+
# deploy_client.py (only the `agentforce` CLI deploys — packages never do).
|
|
862
|
+
_VENDOR_INCLUDE = [
|
|
863
|
+
"__init__.py",
|
|
864
|
+
"client.py",
|
|
865
|
+
"_http.py",
|
|
866
|
+
"logging",
|
|
867
|
+
"runtime",
|
|
868
|
+
"gateway",
|
|
869
|
+
"schema_utils",
|
|
870
|
+
]
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def vendor_sdk_into_project(project_path: Path) -> int:
|
|
874
|
+
"""
|
|
875
|
+
Copy the installed agentforce_byoc runtime package into a generated project.
|
|
876
|
+
|
|
877
|
+
TODO(WI): the SDK is not yet published to PyPI, so each generated project
|
|
878
|
+
vendors a copy of the runtime package (and `agentforce zip` ships it inside
|
|
879
|
+
the deployment archive). Once agentforce-byoc is on PyPI, REMOVE this
|
|
880
|
+
vendoring — declare the dependency in the project's pyproject.toml and let
|
|
881
|
+
the runtime install it, so projects don't carry a copy of the SDK.
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
The number of files copied (0 if the SDK source can't be located).
|
|
885
|
+
"""
|
|
886
|
+
import shutil
|
|
887
|
+
|
|
888
|
+
# This module lives at agentforce_byoc/cli/commands.py, so the package root
|
|
889
|
+
# is two parents up.
|
|
890
|
+
sdk_root = Path(__file__).parent.parent
|
|
891
|
+
dest_root = project_path / "agentforce_byoc"
|
|
892
|
+
|
|
893
|
+
file_count = 0
|
|
894
|
+
for name in _VENDOR_INCLUDE:
|
|
895
|
+
src = sdk_root / name
|
|
896
|
+
if not src.exists():
|
|
897
|
+
continue
|
|
898
|
+
if src.is_dir():
|
|
899
|
+
for path in sorted(src.rglob("*")):
|
|
900
|
+
if "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
901
|
+
continue
|
|
902
|
+
if path.is_file():
|
|
903
|
+
rel = path.relative_to(sdk_root)
|
|
904
|
+
out = dest_root / rel
|
|
905
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
906
|
+
shutil.copy2(path, out)
|
|
907
|
+
file_count += 1
|
|
908
|
+
else:
|
|
909
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
910
|
+
shutil.copy2(src, dest_root / name)
|
|
911
|
+
file_count += 1
|
|
912
|
+
|
|
913
|
+
return file_count
|