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.
Files changed (39) hide show
  1. agentforce_byoc/__init__.py +26 -0
  2. agentforce_byoc/_http.py +142 -0
  3. agentforce_byoc/cli/__init__.py +8 -0
  4. agentforce_byoc/cli/commands.py +913 -0
  5. agentforce_byoc/cli/main.py +44 -0
  6. agentforce_byoc/client.py +224 -0
  7. agentforce_byoc/deploy_client.py +246 -0
  8. agentforce_byoc/gateway/__init__.py +26 -0
  9. agentforce_byoc/gateway/base.py +66 -0
  10. agentforce_byoc/gateway/mock_relay_client.py +54 -0
  11. agentforce_byoc/gateway/relay_client.py +394 -0
  12. agentforce_byoc/logging/__init__.py +20 -0
  13. agentforce_byoc/logging/formatter.py +96 -0
  14. agentforce_byoc/logging/logger.py +59 -0
  15. agentforce_byoc/runtime/__init__.py +21 -0
  16. agentforce_byoc/runtime/agentforce_runtime.py +47 -0
  17. agentforce_byoc/runtime/base.py +54 -0
  18. agentforce_byoc/runtime/mock_runtime.py +50 -0
  19. agentforce_byoc/schema_utils/__init__.py +35 -0
  20. agentforce_byoc/schema_utils/entry_func.py +48 -0
  21. agentforce_byoc/schema_utils/generator.py +694 -0
  22. agentforce_byoc/schema_utils/schema.py +79 -0
  23. agentforce_byoc/templates/__init__.py +4 -0
  24. agentforce_byoc/templates/agent/.dockerignore.template +51 -0
  25. agentforce_byoc/templates/agent/.gitignore.template +64 -0
  26. agentforce_byoc/templates/agent/.python-version.template +1 -0
  27. agentforce_byoc/templates/agent/Dockerfile.template +41 -0
  28. agentforce_byoc/templates/agent/Makefile.template +51 -0
  29. agentforce_byoc/templates/agent/README.md.template +358 -0
  30. agentforce_byoc/templates/agent/config.json.template +3 -0
  31. agentforce_byoc/templates/agent/entry_func.py.template +51 -0
  32. agentforce_byoc/templates/agent/main.py.template +149 -0
  33. agentforce_byoc/templates/agent/pyproject.toml.template +24 -0
  34. agentforce_byoc/templates/agent/schema.py.template +73 -0
  35. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/METADATA +439 -0
  36. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/RECORD +39 -0
  37. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/WHEEL +4 -0
  38. sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  39. 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