general-augment-cli 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 (42) hide show
  1. general_augment_cli-0.1.0.dist-info/METADATA +180 -0
  2. general_augment_cli-0.1.0.dist-info/RECORD +42 -0
  3. general_augment_cli-0.1.0.dist-info/WHEEL +4 -0
  4. general_augment_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. platform_cli/__init__.py +5 -0
  6. platform_cli/branding.py +27 -0
  7. platform_cli/client.py +179 -0
  8. platform_cli/commands/__init__.py +1 -0
  9. platform_cli/commands/approvals.py +150 -0
  10. platform_cli/commands/auth.py +96 -0
  11. platform_cli/commands/billing.py +143 -0
  12. platform_cli/commands/channels.py +212 -0
  13. platform_cli/commands/deploy.py +72 -0
  14. platform_cli/commands/dev.py +38 -0
  15. platform_cli/commands/doctor.py +170 -0
  16. platform_cli/commands/identity.py +433 -0
  17. platform_cli/commands/init.py +55 -0
  18. platform_cli/commands/integrate.py +94 -0
  19. platform_cli/commands/keys.py +116 -0
  20. platform_cli/commands/logs.py +43 -0
  21. platform_cli/commands/mcp.py +258 -0
  22. platform_cli/commands/memory.py +316 -0
  23. platform_cli/commands/mock.py +30 -0
  24. platform_cli/commands/model_providers.py +226 -0
  25. platform_cli/commands/observability.py +174 -0
  26. platform_cli/commands/onboarding.py +72 -0
  27. platform_cli/commands/projects.py +302 -0
  28. platform_cli/commands/skills.py +116 -0
  29. platform_cli/commands/smoke.py +280 -0
  30. platform_cli/commands/status.py +49 -0
  31. platform_cli/commands/tools.py +179 -0
  32. platform_cli/commands/users.py +150 -0
  33. platform_cli/commands/validate.py +96 -0
  34. platform_cli/commands/verify.py +648 -0
  35. platform_cli/config.py +114 -0
  36. platform_cli/errors.py +103 -0
  37. platform_cli/local_mock.py +1392 -0
  38. platform_cli/main.py +130 -0
  39. platform_cli/openapi.py +1048 -0
  40. platform_cli/output.py +47 -0
  41. platform_cli/readiness.py +176 -0
  42. platform_cli/runtime.py +22 -0
@@ -0,0 +1,1048 @@
1
+ """Standalone OpenAPI-to-agent scaffold helpers for the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ import httpx
12
+ import yaml
13
+ from pydantic import BaseModel, Field
14
+
15
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
16
+ PUBLIC_MANIFEST_FILENAME = "genaug-agent.yaml"
17
+ CODING_AGENT_PROMPT_FILENAME = "CODING_AGENT_PROMPT.md"
18
+ PUBLIC_API_VERSION = "genaug/v1"
19
+ MODEL_KEYS = {"simple", "balanced", "complex"}
20
+ VALID_MODEL_PREFIXES = (
21
+ "anthropic/",
22
+ "claude-",
23
+ "gemini-",
24
+ "google/gemini-",
25
+ "openai/",
26
+ )
27
+ TOOL_DISCOVERY_MODES = {"auto", "always", "direct"}
28
+ DEFAULT_TOOL_DISCOVERY: dict[str, int | str] = {
29
+ "mode": "auto",
30
+ "direct_schema_tool_limit": 10,
31
+ "max_search_results": 5,
32
+ }
33
+ SENSITIVE_KEY_MARKERS = ("auth", "authorization", "api_key", "apikey", "key", "secret", "token")
34
+ SECRET_PLACEHOLDER_RE = re.compile(r"\$\{\{\s*(secrets|credentials)\.[A-Za-z0-9_.-]+\s*\}\}")
35
+
36
+
37
+ class ToolCandidate(BaseModel):
38
+ """Generated API tool metadata."""
39
+
40
+ tool_id: str
41
+ name: str
42
+ description: str
43
+ http_method: str
44
+ path: str
45
+ input_schema: dict[str, Any] = Field(default_factory=dict)
46
+ risk_level: str
47
+ requires_approval: bool
48
+ enabled: bool = True
49
+
50
+
51
+ class ParsedAPI(BaseModel):
52
+ """Parsed OpenAPI specification fields needed by the CLI."""
53
+
54
+ title: str
55
+ version: str
56
+ description: str
57
+ base_url: str
58
+ auth_schemes: list[str]
59
+ tools: list[ToolCandidate]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ScaffoldResult:
64
+ """Local files generated from an OpenAPI specification."""
65
+
66
+ root: Path
67
+ config_path: Path
68
+ soul_path: Path
69
+ tools_dir: Path
70
+ env_path: Path
71
+ agent_prompt_path: Path
72
+ parsed_api: ParsedAPI
73
+ tools: list[ToolCandidate]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class BasicScaffoldResult:
78
+ """Local files generated for a starter agent without an OpenAPI spec."""
79
+
80
+ root: Path
81
+ config_path: Path
82
+ soul_path: Path
83
+ skills_dir: Path
84
+ tools_dir: Path
85
+ env_path: Path
86
+ agent_prompt_path: Path
87
+ builtin_tools: list[str]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class LocalValidationResult:
92
+ """Local validation result for a genaug-agent.yaml manifest."""
93
+
94
+ config_path: Path
95
+ status: str
96
+ project_name: str | None
97
+ errors: list[str]
98
+ warnings: list[str]
99
+ soul_file: Path | None
100
+ skills_dir: Path | None
101
+ skill_count: int
102
+ builtin_tools: list[str]
103
+ mcp_servers: list[str]
104
+ tool_discovery: dict[str, int | str]
105
+
106
+
107
+ def parse_openapi(spec_source: str) -> ParsedAPI:
108
+ """Parse an OpenAPI spec from URL, file path, or raw JSON/YAML."""
109
+ spec = _load_spec(spec_source)
110
+ raw_info = spec.get("info")
111
+ info: dict[str, Any] = raw_info if isinstance(raw_info, dict) else {}
112
+ raw_servers = spec.get("servers")
113
+ servers: list[Any] = raw_servers if isinstance(raw_servers, list) else []
114
+ first_server = servers[0] if servers and isinstance(servers[0], dict) else {}
115
+ first_server_data: dict[str, Any] = cast(dict[str, Any], first_server)
116
+ title = str(info.get("title") or "API")
117
+ tools = _extract_tools(spec)
118
+ return ParsedAPI(
119
+ title=title,
120
+ version=str(info.get("version") or "1.0.0"),
121
+ description=str(info.get("description") or ""),
122
+ base_url=str(first_server_data.get("url") or ""),
123
+ auth_schemes=_extract_auth_schemes(spec),
124
+ tools=tools,
125
+ )
126
+
127
+
128
+ def auto_curate(tools: list[ToolCandidate], target_count: int) -> list[ToolCandidate]:
129
+ """Curate generated tools using simple local heuristics."""
130
+ visible = [
131
+ tool
132
+ for tool in tools
133
+ if not any(marker in tool.path.lower() for marker in ("/admin", "/internal", "/debug"))
134
+ ]
135
+ ranked = sorted(visible, key=lambda tool: (_risk_rank(tool.risk_level), tool.tool_id))
136
+ curated = ranked[:target_count]
137
+ return [
138
+ tool.model_copy(update={"enabled": tool.risk_level != "high"})
139
+ for tool in curated
140
+ ]
141
+
142
+
143
+ def scaffold_basic_agent(
144
+ *,
145
+ name: str,
146
+ output_dir: Path | None,
147
+ display_name: str | None = None,
148
+ description: str | None = None,
149
+ builtin_tools: list[str] | None = None,
150
+ force: bool = False,
151
+ ) -> BasicScaffoldResult:
152
+ """Generate a deployable starter agent project without requiring an OpenAPI spec."""
153
+ slug = _slugify(name)
154
+ resolved_display_name = display_name or _display_name(name)
155
+ project_description = (
156
+ description
157
+ or f"{resolved_display_name} helps app users complete useful work with memory and tools."
158
+ )
159
+ root = output_dir or (Path.cwd() / f"{slug}-agent")
160
+ config_path = root / PUBLIC_MANIFEST_FILENAME
161
+ soul_path = root / "SOUL.md"
162
+ skills_dir = root / "skills"
163
+ tools_dir = root / "tools"
164
+ env_path = root / ".env.example"
165
+ agent_prompt_path = root / CODING_AGENT_PROMPT_FILENAME
166
+ files = [
167
+ config_path,
168
+ soul_path,
169
+ env_path,
170
+ agent_prompt_path,
171
+ skills_dir / "README.md",
172
+ tools_dir / "README.md",
173
+ ]
174
+ existing = [path for path in files if path.exists()]
175
+ if existing and not force:
176
+ names = ", ".join(str(path) for path in existing)
177
+ raise FileExistsError(f"Refusing to overwrite existing files: {names}")
178
+ skills_dir.mkdir(parents=True, exist_ok=True)
179
+ tools_dir.mkdir(parents=True, exist_ok=True)
180
+ normalized_tools = _normalize_builtin_tools(builtin_tools or [])
181
+ config_path.write_text(
182
+ _agent_yaml(
183
+ slug=slug,
184
+ display_name=resolved_display_name,
185
+ role=f"{resolved_display_name} Agent",
186
+ description=project_description,
187
+ tools=[],
188
+ api_version=PUBLIC_API_VERSION,
189
+ builtin_tools=normalized_tools,
190
+ ),
191
+ encoding="utf-8",
192
+ )
193
+ soul_path.write_text(
194
+ _soul_md(
195
+ display_name=resolved_display_name,
196
+ role=f"{resolved_display_name} Agent",
197
+ description=project_description,
198
+ ),
199
+ encoding="utf-8",
200
+ )
201
+ env_path.write_text(_env_example([]), encoding="utf-8")
202
+ agent_prompt_path.write_text(
203
+ _coding_agent_prompt(
204
+ slug=slug,
205
+ display_name=resolved_display_name,
206
+ description=project_description,
207
+ ),
208
+ encoding="utf-8",
209
+ )
210
+ (skills_dir / "README.md").write_text(
211
+ "# Skills\n\nAdd SKILL.md files here for repeatable tenant workflows.\n",
212
+ encoding="utf-8",
213
+ )
214
+ (tools_dir / "README.md").write_text(
215
+ (
216
+ "# Tools\n\n"
217
+ "Use `genaug tools toggle`, `genaug mcp add`, or `genaug integrate` "
218
+ "to add governed tools after the starter agent is created.\n"
219
+ ),
220
+ encoding="utf-8",
221
+ )
222
+ return BasicScaffoldResult(
223
+ root=root,
224
+ config_path=config_path,
225
+ soul_path=soul_path,
226
+ skills_dir=skills_dir,
227
+ tools_dir=tools_dir,
228
+ env_path=env_path,
229
+ agent_prompt_path=agent_prompt_path,
230
+ builtin_tools=normalized_tools,
231
+ )
232
+
233
+
234
+ def validate_local_agent_config(config_path: Path) -> LocalValidationResult:
235
+ """Validate a local genaug-agent.yaml manifest without calling the hosted API."""
236
+ errors: list[str] = []
237
+ warnings: list[str] = []
238
+ payload = _load_yaml_mapping(config_path, errors)
239
+ project_name: str | None = None
240
+ soul_file: Path | None = None
241
+ skills_dir: Path | None = None
242
+ skill_count = 0
243
+ builtin_tools: list[str] = []
244
+ mcp_servers: list[str] = []
245
+ tool_discovery = dict(DEFAULT_TOOL_DISCOVERY)
246
+ if payload is not None:
247
+ _validate_manifest_identity(payload, errors)
248
+ project_name = _validate_metadata(payload.get("metadata"), errors)
249
+ _validate_model_routes(payload.get("model"), errors, warnings)
250
+ soul_file = _validate_personality(payload.get("personality"), config_path, errors, warnings)
251
+ builtin_tools, mcp_servers = _validate_tools(payload.get("tools"), errors, warnings)
252
+ skills_dir, skill_count = _validate_skills(payload.get("skills"), config_path, warnings)
253
+ tool_discovery = _validate_behavior(payload.get("behavior"), errors)
254
+ _validate_channels(payload.get("channels"), warnings)
255
+ return LocalValidationResult(
256
+ config_path=config_path,
257
+ status="FAIL" if errors else "PASS",
258
+ project_name=project_name,
259
+ errors=errors,
260
+ warnings=warnings,
261
+ soul_file=soul_file,
262
+ skills_dir=skills_dir,
263
+ skill_count=skill_count,
264
+ builtin_tools=builtin_tools,
265
+ mcp_servers=mcp_servers,
266
+ tool_discovery=tool_discovery,
267
+ )
268
+
269
+
270
+ def scaffold_from_openapi(
271
+ spec_source: str,
272
+ *,
273
+ output_dir: Path | None,
274
+ name: str | None,
275
+ description: str | None,
276
+ target_count: int = 15,
277
+ force: bool = False,
278
+ ) -> ScaffoldResult:
279
+ """Generate a deployable local agent project from an OpenAPI spec."""
280
+ parsed = parse_openapi(spec_source)
281
+ tools = auto_curate(parsed.tools, target_count=target_count)
282
+ slug = _slugify(name or parsed.title)
283
+ display_name = _display_name(name or parsed.title)
284
+ root = output_dir or (Path.cwd() / f"{slug}-agent")
285
+ config_path = root / PUBLIC_MANIFEST_FILENAME
286
+ soul_path = root / "SOUL.md"
287
+ tools_dir = root / "tools"
288
+ env_path = root / ".env.example"
289
+ agent_prompt_path = root / CODING_AGENT_PROMPT_FILENAME
290
+ skill_readme = root / "skills" / "README.md"
291
+ tool_paths = [tools_dir / f"{tool.tool_id}.yaml" for tool in tools]
292
+ files = [
293
+ config_path,
294
+ soul_path,
295
+ env_path,
296
+ agent_prompt_path,
297
+ skill_readme,
298
+ *tool_paths,
299
+ ]
300
+ existing = [path for path in files if path.exists()]
301
+ if existing and not force:
302
+ names = ", ".join(str(path) for path in existing)
303
+ raise FileExistsError(f"Refusing to overwrite existing files: {names}")
304
+ tools_dir.mkdir(parents=True, exist_ok=True)
305
+ skill_readme.parent.mkdir(parents=True, exist_ok=True)
306
+ project_description = description or parsed.description or f"{display_name} API assistant."
307
+ public_manifest = _agent_yaml(
308
+ slug=slug,
309
+ display_name=display_name,
310
+ role=f"{parsed.title} Assistant",
311
+ description=project_description,
312
+ tools=tools,
313
+ api_version=PUBLIC_API_VERSION,
314
+ )
315
+ config_path.write_text(public_manifest, encoding="utf-8")
316
+ soul_path.write_text(
317
+ _soul_md(
318
+ display_name=display_name,
319
+ role=f"{parsed.title} Assistant",
320
+ description=project_description,
321
+ ),
322
+ encoding="utf-8",
323
+ )
324
+ env_path.write_text(_env_example(parsed.auth_schemes), encoding="utf-8")
325
+ agent_prompt_path.write_text(
326
+ _coding_agent_prompt(
327
+ slug=slug,
328
+ display_name=display_name,
329
+ description=project_description,
330
+ ),
331
+ encoding="utf-8",
332
+ )
333
+ skill_readme.write_text(
334
+ "# Skills\n\nAdd SKILL.md files here for repeatable workflows.\n",
335
+ encoding="utf-8",
336
+ )
337
+ for tool in tools:
338
+ (tools_dir / f"{tool.tool_id}.yaml").write_text(
339
+ yaml.safe_dump(tool.model_dump(), sort_keys=False),
340
+ encoding="utf-8",
341
+ )
342
+ return ScaffoldResult(
343
+ root=root,
344
+ config_path=config_path,
345
+ soul_path=soul_path,
346
+ tools_dir=tools_dir,
347
+ env_path=env_path,
348
+ agent_prompt_path=agent_prompt_path,
349
+ parsed_api=parsed,
350
+ tools=tools,
351
+ )
352
+
353
+
354
+ def load_deploy_payload(config_path: Path) -> dict[str, Any]:
355
+ """Validate and load local agent config plus optional SOUL.md and skills."""
356
+ validation = validate_local_agent_config(config_path)
357
+ if validation.errors:
358
+ raise ValueError(f"Agent manifest validation failed: {'; '.join(validation.errors)}")
359
+ yaml_content = config_path.read_text(encoding="utf-8")
360
+ payload = yaml.safe_load(yaml_content)
361
+ if not isinstance(payload, dict):
362
+ raise ValueError("Agent manifest must contain a YAML object.")
363
+ if payload.get("apiVersion") != PUBLIC_API_VERSION or payload.get("kind") != "Agent":
364
+ raise ValueError(
365
+ "Agent manifest must use apiVersion genaug/v1 and kind Agent."
366
+ )
367
+ metadata = payload.get("metadata")
368
+ if not isinstance(metadata, dict) or not metadata.get("name"):
369
+ raise ValueError("Agent manifest metadata.name is required.")
370
+
371
+ personality = payload.get("personality") if isinstance(payload.get("personality"), dict) else {}
372
+ soul_content = None
373
+ soul_file = personality.get("soul_file") if isinstance(personality, dict) else None
374
+ if isinstance(soul_file, str) and soul_file:
375
+ soul_path = (config_path.parent / soul_file).resolve()
376
+ soul_content = soul_path.read_text(encoding="utf-8")
377
+
378
+ skills: list[str] = []
379
+ skills_block = payload.get("skills") if isinstance(payload.get("skills"), dict) else {}
380
+ skills_dir = skills_block.get("directory") if isinstance(skills_block, dict) else None
381
+ if isinstance(skills_dir, str) and skills_dir:
382
+ root = (config_path.parent / skills_dir).resolve()
383
+ if root.exists():
384
+ skills = [
385
+ path.read_text(encoding="utf-8")
386
+ for path in sorted(root.rglob("*.md"))
387
+ if path.name.upper() == "SKILL.MD"
388
+ ]
389
+ return {
390
+ "yaml_content": yaml_content,
391
+ "soul_content": soul_content,
392
+ "skills": skills,
393
+ }
394
+
395
+
396
+ def project_name_from_config(config_path: Path) -> str:
397
+ """Return metadata.name from a local config."""
398
+ payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
399
+ if not isinstance(payload, dict) or not isinstance(payload.get("metadata"), dict):
400
+ raise ValueError("Agent manifest metadata.name is required.")
401
+ name = payload["metadata"].get("name")
402
+ if not isinstance(name, str) or not name:
403
+ raise ValueError("Agent manifest metadata.name is required.")
404
+ return name
405
+
406
+
407
+ def _load_yaml_mapping(config_path: Path, errors: list[str]) -> dict[str, Any] | None:
408
+ """Load a YAML object for local validation."""
409
+ try:
410
+ payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
411
+ except yaml.YAMLError as exc:
412
+ errors.append(f"Invalid YAML: {exc}")
413
+ return None
414
+ except OSError as exc:
415
+ errors.append(f"Could not read manifest: {exc}")
416
+ return None
417
+ if not isinstance(payload, dict):
418
+ errors.append("Agent manifest must contain a YAML object.")
419
+ return None
420
+ return payload
421
+
422
+
423
+ def _validate_manifest_identity(payload: dict[str, Any], errors: list[str]) -> None:
424
+ """Validate top-level manifest identity fields."""
425
+ if payload.get("apiVersion") != PUBLIC_API_VERSION or payload.get("kind") != "Agent":
426
+ errors.append("Agent manifest must use apiVersion genaug/v1 and kind Agent.")
427
+
428
+
429
+ def _validate_metadata(metadata: object, errors: list[str]) -> str | None:
430
+ """Validate metadata and return the project name."""
431
+ if not isinstance(metadata, dict):
432
+ errors.append("metadata must be an object.")
433
+ return None
434
+ raw_name = metadata.get("name")
435
+ if not isinstance(raw_name, str) or not raw_name.strip():
436
+ errors.append("metadata.name is required.")
437
+ return None
438
+ if not _slugify(raw_name):
439
+ errors.append("metadata.name must contain at least one alphanumeric character.")
440
+ display_name = metadata.get("display_name")
441
+ if display_name is not None and not isinstance(display_name, str):
442
+ errors.append("metadata.display_name must be a string when provided.")
443
+ return raw_name
444
+
445
+
446
+ def _validate_model_routes(model: object, errors: list[str], warnings: list[str]) -> None:
447
+ """Validate local model tier route declarations."""
448
+ if model is None:
449
+ warnings.append("model block is missing; server defaults will apply.")
450
+ return
451
+ if not isinstance(model, dict):
452
+ errors.append("model must be an object with simple, balanced, and complex slots.")
453
+ return
454
+ model_keys = {str(key) for key in model}
455
+ unknown_model_keys = sorted(model_keys - MODEL_KEYS)
456
+ missing_model_keys = sorted(MODEL_KEYS - model_keys)
457
+ if unknown_model_keys:
458
+ errors.append(f"Unknown model slots: {', '.join(unknown_model_keys)}")
459
+ if missing_model_keys:
460
+ errors.append(f"Missing model slots: {', '.join(missing_model_keys)}")
461
+ for slot, model_name in sorted(model.items()):
462
+ if not isinstance(model_name, str) or not _valid_model_name(model_name):
463
+ errors.append(f"Invalid model for {slot}: {model_name}")
464
+
465
+
466
+ def _validate_personality(
467
+ personality: object,
468
+ config_path: Path,
469
+ errors: list[str],
470
+ warnings: list[str],
471
+ ) -> Path | None:
472
+ """Validate personality references and return a resolved SOUL path when present."""
473
+ if personality is None:
474
+ warnings.append("personality block is missing; hosted defaults will apply.")
475
+ return None
476
+ if not isinstance(personality, dict):
477
+ errors.append("personality must be an object.")
478
+ return None
479
+ raw_soul_file = personality.get("soul_file")
480
+ description = personality.get("description")
481
+ if raw_soul_file is None:
482
+ if not isinstance(description, str) or not description.strip():
483
+ warnings.append("No personality.soul_file or personality.description was provided.")
484
+ return None
485
+ if not isinstance(raw_soul_file, str) or not raw_soul_file.strip():
486
+ errors.append("personality.soul_file must be a non-empty string when provided.")
487
+ return None
488
+ soul_path = (config_path.parent / raw_soul_file).resolve()
489
+ if not soul_path.is_file():
490
+ errors.append(f"personality.soul_file was not found: {raw_soul_file}")
491
+ return soul_path
492
+ return soul_path
493
+
494
+
495
+ def _validate_tools(
496
+ tools: object,
497
+ errors: list[str],
498
+ warnings: list[str],
499
+ ) -> tuple[list[str], list[str]]:
500
+ """Validate builtin and MCP tool declarations."""
501
+ if tools is None:
502
+ warnings.append("tools block is missing; no tools will be enabled by this manifest.")
503
+ return [], []
504
+ if not isinstance(tools, dict):
505
+ errors.append("tools must be an object.")
506
+ return [], []
507
+ builtin = _string_list(tools.get("builtin"), field_name="tools.builtin", errors=errors)
508
+ duplicates = sorted({tool for tool in builtin if builtin.count(tool) > 1})
509
+ if duplicates:
510
+ warnings.append(f"Duplicate builtin tools should be reviewed: {', '.join(duplicates)}")
511
+ raw_mcp = tools.get("mcp")
512
+ if raw_mcp is None:
513
+ return builtin, []
514
+ if not isinstance(raw_mcp, list):
515
+ errors.append("tools.mcp must be a list.")
516
+ return builtin, []
517
+ server_names: list[str] = []
518
+ for index, server in enumerate(raw_mcp):
519
+ if not isinstance(server, dict):
520
+ errors.append(f"tools.mcp[{index}] must be an object.")
521
+ continue
522
+ server_name = _validate_mcp_server(server, index, errors)
523
+ if server_name:
524
+ server_names.append(server_name)
525
+ return builtin, server_names
526
+
527
+
528
+ def _validate_mcp_server(server: dict[str, Any], index: int, errors: list[str]) -> str | None:
529
+ """Validate one local MCP server declaration."""
530
+ name = server.get("name")
531
+ if not isinstance(name, str) or not name.strip():
532
+ errors.append(f"tools.mcp[{index}].name is required.")
533
+ server_name = None
534
+ else:
535
+ server_name = name
536
+ has_url = bool(server.get("url"))
537
+ has_command = bool(server.get("command"))
538
+ if has_url == has_command:
539
+ errors.append(f"tools.mcp[{index}] must define exactly one of url or command.")
540
+ auth = server.get("auth")
541
+ if auth is not None and (
542
+ not isinstance(auth, str) or not _contains_secret_placeholder(auth)
543
+ ):
544
+ errors.append(
545
+ f"tools.mcp[{index}].auth must use a credential placeholder such as "
546
+ "${{ secrets.NAME }} or ${{ credentials.name }}."
547
+ )
548
+ _validate_secret_mapping(server.get("headers"), f"tools.mcp[{index}].headers", errors)
549
+ _validate_secret_mapping(server.get("env"), f"tools.mcp[{index}].env", errors)
550
+ _validate_mcp_tool_filters(server.get("tools"), index, errors)
551
+ return server_name
552
+
553
+
554
+ def _validate_secret_mapping(value: object, field_name: str, errors: list[str]) -> None:
555
+ """Validate sensitive header/env values use placeholders."""
556
+ if value is None:
557
+ return
558
+ if not isinstance(value, dict):
559
+ errors.append(f"{field_name} must be an object.")
560
+ return
561
+ for raw_key, raw_value in value.items():
562
+ key = str(raw_key)
563
+ if not isinstance(raw_value, str):
564
+ errors.append(f"{field_name}.{key} must be a string.")
565
+ continue
566
+ if _is_sensitive_key(key) and raw_value and not _contains_secret_placeholder(raw_value):
567
+ errors.append(
568
+ f"{field_name}.{key} must use a credential placeholder, not a raw secret."
569
+ )
570
+
571
+
572
+ def _validate_mcp_tool_filters(value: object, index: int, errors: list[str]) -> None:
573
+ """Validate optional MCP include/exclude lists."""
574
+ if value is None:
575
+ return
576
+ if not isinstance(value, dict):
577
+ errors.append(f"tools.mcp[{index}].tools must be an object.")
578
+ return
579
+ for filter_name in ("include", "exclude"):
580
+ if filter_name in value:
581
+ _string_list(
582
+ value.get(filter_name),
583
+ field_name=f"tools.mcp[{index}].tools.{filter_name}",
584
+ errors=errors,
585
+ )
586
+
587
+
588
+ def _validate_skills(
589
+ skills: object,
590
+ config_path: Path,
591
+ warnings: list[str],
592
+ ) -> tuple[Path | None, int]:
593
+ """Validate skills directory and return resolved directory plus SKILL.md count."""
594
+ if skills is None:
595
+ warnings.append("skills block is missing; no local skills will be deployed.")
596
+ return None, 0
597
+ if not isinstance(skills, dict):
598
+ warnings.append("skills block is not an object; no local skills were counted.")
599
+ return None, 0
600
+ raw_directory = skills.get("directory")
601
+ if not isinstance(raw_directory, str) or not raw_directory.strip():
602
+ warnings.append("skills.directory is missing; no local skills will be deployed.")
603
+ return None, 0
604
+ skills_dir = (config_path.parent / raw_directory).resolve()
605
+ if not skills_dir.exists():
606
+ warnings.append(f"skills.directory was not found: {raw_directory}")
607
+ return skills_dir, 0
608
+ if not skills_dir.is_dir():
609
+ warnings.append(f"skills.directory is not a directory: {raw_directory}")
610
+ return skills_dir, 0
611
+ skill_count = sum(1 for path in skills_dir.rglob("SKILL.md") if path.is_file())
612
+ return skills_dir, skill_count
613
+
614
+
615
+ def _validate_behavior(behavior: object, errors: list[str]) -> dict[str, int | str]:
616
+ """Validate behavior controls and return normalized tool discovery."""
617
+ if behavior is None:
618
+ return dict(DEFAULT_TOOL_DISCOVERY)
619
+ if not isinstance(behavior, dict):
620
+ errors.append("behavior must be an object.")
621
+ return dict(DEFAULT_TOOL_DISCOVERY)
622
+ for field_name in (
623
+ "max_tool_calls_per_turn",
624
+ "session_timeout_minutes",
625
+ "messages_per_user_per_minute",
626
+ ):
627
+ if field_name in behavior and not _positive_int_value(behavior.get(field_name)):
628
+ errors.append(f"behavior.{field_name} must be a positive integer.")
629
+ if "daily_token_budget_usd" in behavior:
630
+ budget = behavior.get("daily_token_budget_usd")
631
+ if isinstance(budget, bool) or not isinstance(budget, int | float) or budget < 0:
632
+ errors.append("behavior.daily_token_budget_usd must be a non-negative number.")
633
+ return _validate_tool_discovery(behavior.get("tool_discovery"), errors)
634
+
635
+
636
+ def _validate_tool_discovery(value: object, errors: list[str]) -> dict[str, int | str]:
637
+ """Validate local tool discovery config."""
638
+ if value is None:
639
+ return dict(DEFAULT_TOOL_DISCOVERY)
640
+ if not isinstance(value, dict):
641
+ errors.append("behavior.tool_discovery must be an object.")
642
+ return dict(DEFAULT_TOOL_DISCOVERY)
643
+ mode = str(value.get("mode") or DEFAULT_TOOL_DISCOVERY["mode"]).casefold()
644
+ if mode not in TOOL_DISCOVERY_MODES:
645
+ errors.append("behavior.tool_discovery.mode must be one of: auto, always, direct.")
646
+ mode = str(DEFAULT_TOOL_DISCOVERY["mode"])
647
+ direct_limit = _positive_int_or_default(
648
+ value.get("direct_schema_tool_limit"),
649
+ default=int(DEFAULT_TOOL_DISCOVERY["direct_schema_tool_limit"]),
650
+ field_name="behavior.tool_discovery.direct_schema_tool_limit",
651
+ errors=errors,
652
+ )
653
+ max_results = _positive_int_or_default(
654
+ value.get("max_search_results"),
655
+ default=int(DEFAULT_TOOL_DISCOVERY["max_search_results"]),
656
+ field_name="behavior.tool_discovery.max_search_results",
657
+ errors=errors,
658
+ )
659
+ if max_results > 10:
660
+ errors.append(
661
+ "behavior.tool_discovery.max_search_results must be less than or equal to 10."
662
+ )
663
+ max_results = 10
664
+ return {
665
+ "mode": mode,
666
+ "direct_schema_tool_limit": direct_limit,
667
+ "max_search_results": max_results,
668
+ }
669
+
670
+
671
+ def _validate_channels(channels: object, warnings: list[str]) -> None:
672
+ """Warn when channel config is omitted entirely."""
673
+ if channels is None:
674
+ warnings.append("channels block is missing; channel setup can be added later.")
675
+
676
+
677
+ def _load_spec(spec_source: str) -> dict[str, Any]:
678
+ """Load an OpenAPI document."""
679
+ if spec_source.startswith(("http://", "https://")):
680
+ response = httpx.get(spec_source, timeout=30.0)
681
+ response.raise_for_status()
682
+ text = response.text
683
+ else:
684
+ path = Path(spec_source).expanduser()
685
+ text = path.read_text(encoding="utf-8") if path.exists() else spec_source
686
+ try:
687
+ payload = json.loads(text)
688
+ except json.JSONDecodeError:
689
+ payload = yaml.safe_load(text)
690
+ if not isinstance(payload, dict) or "paths" not in payload:
691
+ raise ValueError("OpenAPI document must contain a paths object.")
692
+ return payload
693
+
694
+
695
+ def _extract_tools(spec: dict[str, Any]) -> list[ToolCandidate]:
696
+ """Extract OpenAPI operations as tool candidates."""
697
+ paths = spec.get("paths")
698
+ if not isinstance(paths, dict):
699
+ return []
700
+ tools: list[ToolCandidate] = []
701
+ for path, path_item in paths.items():
702
+ if not isinstance(path_item, dict):
703
+ continue
704
+ for method, operation in path_item.items():
705
+ method_lower = str(method).lower()
706
+ if method_lower not in HTTP_METHODS or not isinstance(operation, dict):
707
+ continue
708
+ risk_level, requires_approval = _classify_risk(method_lower)
709
+ tool_id = _sanitize_tool_id(
710
+ str(operation.get("operationId") or ""),
711
+ method_lower,
712
+ str(path),
713
+ )
714
+ tools.append(
715
+ ToolCandidate(
716
+ tool_id=tool_id,
717
+ name=str(operation.get("summary") or _display_name(tool_id)),
718
+ description=str(
719
+ operation.get("description")
720
+ or operation.get("summary")
721
+ or f"{method_lower.upper()} {path}"
722
+ ),
723
+ http_method=method_lower.upper(),
724
+ path=str(path),
725
+ input_schema=_input_schema(path_item, operation),
726
+ risk_level=risk_level,
727
+ requires_approval=requires_approval,
728
+ enabled=risk_level != "high",
729
+ )
730
+ )
731
+ return tools
732
+
733
+
734
+ def _input_schema(path_item: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any]:
735
+ """Build a JSON schema for one operation input."""
736
+ properties: dict[str, Any] = {}
737
+ required: list[str] = []
738
+ parameters = [
739
+ *[item for item in path_item.get("parameters", []) if isinstance(item, dict)],
740
+ *[item for item in operation.get("parameters", []) if isinstance(item, dict)],
741
+ ]
742
+ for parameter in parameters:
743
+ name = parameter.get("name")
744
+ if not isinstance(name, str):
745
+ continue
746
+ raw_schema = parameter.get("schema")
747
+ schema: dict[str, Any] = raw_schema if isinstance(raw_schema, dict) else {}
748
+ properties[name] = {
749
+ **schema,
750
+ "description": str(
751
+ parameter.get("description") or f"{parameter.get('in', 'parameter')} parameter"
752
+ ),
753
+ }
754
+ if parameter.get("required") or parameter.get("in") == "path":
755
+ required.append(name)
756
+ request_body = operation.get("requestBody")
757
+ if isinstance(request_body, dict):
758
+ request_body_data: dict[str, Any] = request_body
759
+ raw_content = request_body_data.get("content")
760
+ content = (
761
+ raw_content if isinstance(raw_content, dict) else {}
762
+ )
763
+ content_data: dict[str, Any] = content
764
+ raw_json_body = content_data.get("application/json")
765
+ json_body = (
766
+ raw_json_body if isinstance(raw_json_body, dict) else {}
767
+ )
768
+ json_body_data: dict[str, Any] = json_body
769
+ raw_body_schema = json_body_data.get("schema")
770
+ body_schema: dict[str, Any] = (
771
+ raw_body_schema if isinstance(raw_body_schema, dict) else {}
772
+ )
773
+ properties["body"] = {"type": "object", "description": "JSON request body", **body_schema}
774
+ if request_body_data.get("required"):
775
+ required.append("body")
776
+ return {
777
+ "type": "object",
778
+ "properties": properties,
779
+ "required": sorted(set(required)),
780
+ }
781
+
782
+
783
+ def _extract_auth_schemes(spec: dict[str, Any]) -> list[str]:
784
+ """Extract readable auth scheme summaries."""
785
+ raw_components = spec.get("components")
786
+ components: dict[str, Any] = raw_components if isinstance(raw_components, dict) else {}
787
+ raw_schemes = components.get("securitySchemes")
788
+ schemes: dict[str, Any] = raw_schemes if isinstance(raw_schemes, dict) else {}
789
+ result = []
790
+ for name, scheme in schemes.items():
791
+ if isinstance(scheme, dict):
792
+ result.append(f"{name}: {scheme.get('type', 'unknown')}")
793
+ return result
794
+
795
+
796
+ def _classify_risk(method: str) -> tuple[str, bool]:
797
+ """Return risk level and approval requirement."""
798
+ if method == "get":
799
+ return "low", False
800
+ if method == "delete":
801
+ return "high", True
802
+ return "medium", True
803
+
804
+
805
+ def _sanitize_tool_id(operation_id: str, method: str, path: str) -> str:
806
+ """Return a valid, stable tool id."""
807
+ source = operation_id or f"{method}_{path.strip('/')}"
808
+ tool_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", source).strip("_-").lower()
809
+ return (tool_id or "tool")[:64]
810
+
811
+
812
+ def _agent_yaml(
813
+ *,
814
+ slug: str,
815
+ display_name: str,
816
+ role: str,
817
+ description: str,
818
+ tools: list[ToolCandidate],
819
+ api_version: str,
820
+ builtin_tools: list[str] | None = None,
821
+ ) -> str:
822
+ """Create a deployable agent manifest document."""
823
+ enabled_tools = [tool.tool_id for tool in tools if tool.enabled]
824
+ mcp_tools = [
825
+ {
826
+ "name": f"{slug}-api",
827
+ "url": f"http://localhost:9090/{slug}",
828
+ "tools": {"include": enabled_tools},
829
+ }
830
+ ] if enabled_tools else []
831
+ payload = {
832
+ "apiVersion": api_version,
833
+ "kind": "Agent",
834
+ "metadata": {"name": slug, "display_name": display_name, "version": "1.0.0"},
835
+ "personality": {
836
+ "role": role,
837
+ "soul_file": "./SOUL.md",
838
+ "description": description,
839
+ "tone": "concise, proactive, careful",
840
+ "rules": ["Confirm before write actions.", "Explain missing account links plainly."],
841
+ },
842
+ "model": {
843
+ "simple": "google/gemini-2.5-flash-lite",
844
+ "balanced": "google/gemini-2.5-flash",
845
+ "complex": "google/gemini-2.5-pro",
846
+ },
847
+ "tools": {
848
+ "builtin": list(builtin_tools or []),
849
+ "mcp": mcp_tools,
850
+ },
851
+ "skills": {"directory": "./skills/", "learning_enabled": True},
852
+ "channels": {"whatsapp": {}, "sms": {}, "telegram": {}},
853
+ "behavior": {
854
+ "max_tool_calls_per_turn": 10,
855
+ "session_timeout_minutes": 30,
856
+ "daily_token_budget_usd": 50.0,
857
+ "messages_per_user_per_minute": 30,
858
+ "tool_discovery": {
859
+ "mode": "auto",
860
+ "direct_schema_tool_limit": 10,
861
+ "max_search_results": 5,
862
+ },
863
+ },
864
+ "welcome": {"message": f"Hi, I'm {display_name}. How can I help?"},
865
+ }
866
+ return yaml.safe_dump(payload, sort_keys=False)
867
+
868
+
869
+ def _soul_md(*, display_name: str, role: str, description: str) -> str:
870
+ """Create SOUL.md content."""
871
+ return f"""---
872
+ name: {display_name}
873
+ role: {role}
874
+ tone: concise, proactive, careful
875
+ ---
876
+
877
+ # {display_name}
878
+
879
+ {description}
880
+
881
+ ## Rules
882
+
883
+ - Confirm before changing user data.
884
+ - Keep chat responses concise.
885
+ - Ask users to link their account when identity is missing.
886
+ """
887
+
888
+
889
+ def _env_example(auth_schemes: list[str]) -> str:
890
+ """Create .env.example content for generated integrations."""
891
+ lines = [
892
+ "GENAUG_ADMIN_API_KEY=",
893
+ "GENAUG_API_BASE_URL=https://api.generalaugment.com",
894
+ ]
895
+ for scheme in auth_schemes:
896
+ key = re.sub(r"[^A-Z0-9]+", "_", scheme.upper()).strip("_")
897
+ lines.append(f"{key or 'API_AUTH'}=")
898
+ return "\n".join(dict.fromkeys(lines)) + "\n"
899
+
900
+
901
+ def _coding_agent_prompt(*, slug: str, display_name: str, description: str) -> str:
902
+ """Create a paste-ready coding agent handoff for app developers."""
903
+ return f"""# Coding Agent Handoff
904
+
905
+ Paste this into the coding agent that owns your app backend.
906
+
907
+ ```text
908
+ You are integrating our app backend with General Augment.
909
+
910
+ Goal:
911
+ - Keep General Augment API keys server-side only.
912
+ - Use our app's stable signed-in user id as the Responses API `user` value.
913
+ - Call `POST /v1/responses` from the backend, never from browser or mobile code.
914
+ - Add app APIs as governed tools only through approved OpenAPI or MCP registration.
915
+ - Keep write actions approval-required and destructive actions disabled until reviewed.
916
+ - Prove the setup with CLI smoke and verify before production traffic.
917
+
918
+ Project:
919
+ - General Augment project slug: {slug}
920
+ - Agent display name: {display_name}
921
+ - Agent purpose: {description}
922
+
923
+ Required environment variables:
924
+ - GENAUG_API_BASE_URL=https://api.generalaugment.com
925
+ - GENAUG_API_KEY=<project-api-key-from-dashboard-or-cli>
926
+
927
+ Implementation steps:
928
+ 1. Install or run the CLI:
929
+ pip install --upgrade general-augment-cli
930
+ genaug --version
931
+ # Private-beta repo fallback if the package is not available yet:
932
+ uv run --project packages/cli genaug --version
933
+ # Use `genaug` below for an installed CLI, or prefix commands with
934
+ # `uv run --project packages/cli` from the repo checkout.
935
+ 2. Authenticate and diagnose:
936
+ genaug auth login --api-key "$GENAUG_API_KEY" --base-url "$GENAUG_API_BASE_URL"
937
+ genaug doctor --json
938
+ genaug auth whoami
939
+ 3. Review this scaffold:
940
+ - genaug-agent.yaml
941
+ - SOUL.md
942
+ - skills/
943
+ - tools/
944
+ 4. Deploy the scaffold:
945
+ genaug deploy ./genaug-agent.yaml
946
+ 5. Wire the backend helper:
947
+ - POST "$GENAUG_API_BASE_URL/v1/responses"
948
+ - Authorization: Bearer $GENAUG_API_KEY
949
+ - Body includes model, user, input, metadata.feature, and metadata.trace_id.
950
+ - Store returned response id and metadata.general_augment_trace_id in app logs.
951
+ 6. Add explicit memory only for durable facts:
952
+ - POST /api/v1/agent/memory/store with user_id matching the Responses `user`.
953
+ - Search/profile/delete memory through the server-side project key only.
954
+ 7. Verify before launch:
955
+ genaug smoke --project {slug} --message "Reply exactly with: ok" --json
956
+ genaug verify --project {slug} --json
957
+ genaug onboarding verify --project {slug} --json
958
+
959
+ Do not:
960
+ - Commit API keys.
961
+ - Put General Augment keys in client-side code.
962
+ - Send secrets in request metadata, memory facts, SOUL.md, skills, or tool definitions.
963
+ - Enable destructive tools until product approval UX exists.
964
+
965
+ Return a final ready/blocked report with exact commands run, response id, trace id,
966
+ dashboard links, CLI/API versions, rate-limit or budget warnings, and any missing auth,
967
+ keys, network, provider, memory, trace, or dashboard setup.
968
+ ```
969
+ """
970
+
971
+
972
+ def _risk_rank(risk_level: str) -> int:
973
+ """Rank tools for auto-curation."""
974
+ return {"low": 0, "medium": 1, "high": 2}.get(risk_level, 3)
975
+
976
+
977
+ def _slugify(value: str) -> str:
978
+ """Create a project slug."""
979
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
980
+ return slug or "agent"
981
+
982
+
983
+ def _normalize_builtin_tools(tools: list[str]) -> list[str]:
984
+ """Return stable, de-duplicated builtin tool ids."""
985
+ normalized: list[str] = []
986
+ for tool in tools:
987
+ value = re.sub(r"[^a-z0-9_-]+", "_", tool.lower()).strip("_-")
988
+ if value and value not in normalized:
989
+ normalized.append(value)
990
+ return normalized
991
+
992
+
993
+ def _string_list(value: object, *, field_name: str, errors: list[str]) -> list[str]:
994
+ """Return a string list or append a validation error."""
995
+ if value is None:
996
+ return []
997
+ if not isinstance(value, list):
998
+ errors.append(f"{field_name} must be a list.")
999
+ return []
1000
+ result: list[str] = []
1001
+ for index, item in enumerate(value):
1002
+ if not isinstance(item, str) or not item.strip():
1003
+ errors.append(f"{field_name}[{index}] must be a non-empty string.")
1004
+ continue
1005
+ result.append(item)
1006
+ return result
1007
+
1008
+
1009
+ def _valid_model_name(value: str) -> bool:
1010
+ """Validate model identifiers used by the current public manifest format."""
1011
+ return bool(value) and value.startswith(VALID_MODEL_PREFIXES) and " " not in value
1012
+
1013
+
1014
+ def _contains_secret_placeholder(value: str) -> bool:
1015
+ """Return whether a value contains an accepted credential placeholder."""
1016
+ return bool(SECRET_PLACEHOLDER_RE.search(value))
1017
+
1018
+
1019
+ def _is_sensitive_key(value: str) -> bool:
1020
+ """Return whether an env/header key is likely secret-bearing."""
1021
+ normalized = re.sub(r"[^a-z0-9]+", "_", value.lower())
1022
+ return any(marker in normalized for marker in SENSITIVE_KEY_MARKERS)
1023
+
1024
+
1025
+ def _positive_int_value(value: object) -> bool:
1026
+ """Return whether a value is a positive integer."""
1027
+ return not isinstance(value, bool) and isinstance(value, int) and value >= 1
1028
+
1029
+
1030
+ def _positive_int_or_default(
1031
+ value: object,
1032
+ *,
1033
+ default: int,
1034
+ field_name: str,
1035
+ errors: list[str],
1036
+ ) -> int:
1037
+ """Return a positive integer or record an error and return a default."""
1038
+ if value is None:
1039
+ return default
1040
+ if not isinstance(value, bool) and isinstance(value, int) and value >= 1:
1041
+ return value
1042
+ errors.append(f"{field_name} must be a positive integer.")
1043
+ return default
1044
+
1045
+
1046
+ def _display_name(value: str) -> str:
1047
+ """Create a display name."""
1048
+ return value.replace("-", " ").replace("_", " ").title()