agentforge-py 0.2.1__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 (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,85 @@
1
+ """`agentforge list modules` — show every registered module.
2
+
3
+ Triggers the resolver's lazy entry-point discovery (feat-010 chunk
4
+ 1) so the table reflects every `agentforge-*` package pip-installed
5
+ in the active environment.
6
+
7
+ Output groups by category; `--category` narrows; `--json` emits
8
+ machine-readable output for piping into other tools.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from collections import defaultdict
17
+ from collections.abc import Iterable
18
+
19
+ from agentforge_core import ModuleInfo, Resolver
20
+
21
+
22
+ def register_list_modules(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
23
+ """Attach the `list` subcommand + its `modules` child."""
24
+ list_parser = sub.add_parser(
25
+ "list",
26
+ help="Inspect installed AgentForge modules.",
27
+ )
28
+ list_sub = list_parser.add_subparsers(dest="list_target", required=True)
29
+
30
+ modules = list_sub.add_parser(
31
+ "modules",
32
+ help="Show every registered module, grouped by category.",
33
+ )
34
+ modules.add_argument(
35
+ "--category",
36
+ help="Filter to one category (providers, memory, tools, ...).",
37
+ )
38
+ modules.add_argument(
39
+ "--json",
40
+ action="store_true",
41
+ help="Emit JSON instead of a text table.",
42
+ )
43
+ modules.set_defaults(_handler=_run_list_modules)
44
+
45
+
46
+ def _run_list_modules(args: argparse.Namespace) -> int:
47
+ infos = Resolver.global_().list_installed(category=args.category)
48
+ if args.json:
49
+ sys.stdout.write(_format_json(infos) + "\n")
50
+ return 0
51
+ sys.stdout.write(_format_table(infos))
52
+ return 0
53
+
54
+
55
+ def _format_json(infos: Iterable[ModuleInfo]) -> str:
56
+ return json.dumps([m.model_dump(mode="json") for m in infos], indent=2)
57
+
58
+
59
+ def _format_table(infos: Iterable[ModuleInfo]) -> str:
60
+ """Render a grouped-by-category text table.
61
+
62
+ Empty registry prints a friendly hint instead of nothing.
63
+ """
64
+ grouped: dict[str, list[ModuleInfo]] = defaultdict(list)
65
+ for info in infos:
66
+ grouped[info.category].append(info)
67
+
68
+ if not grouped:
69
+ return (
70
+ "No modules registered.\n"
71
+ "Install one with `uv add agentforge-bedrock` (or any agentforge-* package),\n"
72
+ "or register a custom class with `@register('category', 'name')`.\n"
73
+ )
74
+
75
+ lines: list[str] = []
76
+ for category in sorted(grouped):
77
+ lines.append(f"\n{category.upper()}")
78
+ for info in grouped[category]:
79
+ origin = (
80
+ f" ({info.package} {info.version})"
81
+ if info.package and info.version
82
+ else " (in-process)"
83
+ )
84
+ lines.append(f" {info.name:<28}{origin}")
85
+ return "\n".join(lines) + "\n"
agentforge/cli/main.py ADDED
@@ -0,0 +1,81 @@
1
+ """`agentforge` CLI entry point — argparse-based dispatcher.
2
+
3
+ No third-party CLI dep (Click, Typer) — keeps `agentforge`'s
4
+ top-level surface lean. Subcommands live in sibling modules and
5
+ are registered here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from collections.abc import Sequence
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ from agentforge.cli.config_cmd import register_config_cmd
16
+ from agentforge.cli.db_cmd import register_db_cmd
17
+ from agentforge.cli.debug_cmd import register_debug_cmd
18
+ from agentforge.cli.docs_cmd import register_docs_cmd
19
+ from agentforge.cli.eval_cmd import register_eval_cmd
20
+ from agentforge.cli.health_cmd import register_health_cmd
21
+ from agentforge.cli.list_modules import register_list_modules
22
+ from agentforge.cli.module_cmd import register_module_cmd
23
+ from agentforge.cli.new_cmd import register_new_cmd
24
+ from agentforge.cli.run_cmd import register_run_cmd
25
+ from agentforge.cli.upgrade_cmd import register_upgrade_cmds
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="agentforge",
31
+ description="AgentForge CLI — inspect installed modules.",
32
+ )
33
+ parser.add_argument(
34
+ "--version",
35
+ action="version",
36
+ version=_resolve_version(),
37
+ )
38
+ sub = parser.add_subparsers(dest="command", required=True)
39
+ register_list_modules(sub)
40
+ register_config_cmd(sub)
41
+ register_module_cmd(sub)
42
+ register_new_cmd(sub)
43
+ register_upgrade_cmds(sub)
44
+ register_run_cmd(sub)
45
+ register_eval_cmd(sub)
46
+ register_debug_cmd(sub)
47
+ register_db_cmd(sub)
48
+ register_health_cmd(sub)
49
+ register_docs_cmd(sub)
50
+ return parser
51
+
52
+
53
+ def main(argv: Sequence[str] | None = None) -> int:
54
+ """CLI entry point.
55
+
56
+ Args:
57
+ argv: Argument vector (typically `sys.argv[1:]`); pass an
58
+ explicit list from tests.
59
+
60
+ Returns:
61
+ Process exit code.
62
+ """
63
+ parser = build_parser()
64
+ args = parser.parse_args(argv)
65
+ handler = getattr(args, "_handler", None)
66
+ if handler is None:
67
+ parser.print_help()
68
+ return 1
69
+ return int(handler(args) or 0)
70
+
71
+
72
+ def _resolve_version() -> str:
73
+ """Return the installed `agentforge` distribution's version."""
74
+ try:
75
+ return version("agentforge")
76
+ except PackageNotFoundError: # pragma: no cover - unusual at runtime
77
+ return "0.0.0+unknown"
78
+
79
+
80
+ if __name__ == "__main__": # pragma: no cover
81
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,368 @@
1
+ """Manifest applier for `agentforge add/swap/remove module` (feat-010b).
2
+
3
+ Pure-data layer — no `pip`, no subprocess. Operates on:
4
+
5
+ - A `Manifest` (loaded from `<package>/manifest.yaml`).
6
+ - A target directory (typically `Path.cwd()`).
7
+ - An `AppliedManifest` state record (created by `apply`, consumed by
8
+ `reverse`).
9
+
10
+ State files live at `<cwd>/.agentforge-state/manifests/<dist>.yaml`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+ from agentforge_core.production.exceptions import ModuleError
20
+ from agentforge_core.values.manifest import (
21
+ AppliedEnvVar,
22
+ AppliedManifest,
23
+ AppliedTemplate,
24
+ Manifest,
25
+ )
26
+
27
+ _STATE_DIR = Path(".agentforge-state") / "manifests"
28
+ _MARKER = "# AGENTFORGE-MANAGED:"
29
+ _ENV_EXAMPLE = ".env.example"
30
+ _DEFAULT_YAML = "agentforge.yaml"
31
+
32
+
33
+ def apply_manifest(
34
+ manifest: Manifest,
35
+ *,
36
+ distribution: str,
37
+ cwd: Path,
38
+ config_path: Path | None = None,
39
+ package_root: Path | None = None,
40
+ ) -> AppliedManifest:
41
+ """Apply `manifest` to the agent repo at `cwd`.
42
+
43
+ Args:
44
+ manifest: Loaded manifest.
45
+ distribution: The pip distribution name (e.g.
46
+ `agentforge-memory-postgres`) — used as the state-file
47
+ stem and as the file marker.
48
+ cwd: Repo root where edits land.
49
+ config_path: Where `agentforge.yaml` lives. Defaults to
50
+ `cwd / agentforge.yaml`.
51
+
52
+ Returns:
53
+ `AppliedManifest` reflecting what actually landed. Written
54
+ to `<cwd>/.agentforge-state/manifests/<dist>.yaml` before
55
+ return so partial failures are recoverable.
56
+
57
+ Raises:
58
+ ModuleError: a template's destination already exists and
59
+ `overwrite=False`. State is written for whatever landed
60
+ before the failure so `reverse` can clean up.
61
+ """
62
+ yaml_path = config_path if config_path is not None else cwd / _DEFAULT_YAML
63
+ applied = AppliedManifest(
64
+ distribution=distribution,
65
+ category=manifest.category,
66
+ name=manifest.name,
67
+ )
68
+ state_path = _state_path(cwd, distribution)
69
+ state_path.parent.mkdir(parents=True, exist_ok=True)
70
+
71
+ try:
72
+ for env in manifest.env_vars:
73
+ line = _append_env_var(cwd / _ENV_EXAMPLE, env)
74
+ if line is not None:
75
+ applied = applied.model_copy(
76
+ update={
77
+ "env_vars": [*applied.env_vars, AppliedEnvVar(name=env.name, line=line)],
78
+ }
79
+ )
80
+
81
+ for template in manifest.templates:
82
+ written = _copy_template(distribution, template, cwd, package_root=package_root)
83
+ if written:
84
+ applied = applied.model_copy(
85
+ update={
86
+ "templates": [
87
+ *applied.templates,
88
+ AppliedTemplate(destination=template.destination),
89
+ ],
90
+ }
91
+ )
92
+
93
+ if manifest.config_block:
94
+ _merge_into_yaml(yaml_path, manifest.config_block)
95
+ applied = applied.model_copy(update={"config_block_applied": True})
96
+ finally:
97
+ # Always persist whatever landed — partial state is better
98
+ # than no state for `remove` to clean up against.
99
+ _write_state(state_path, applied)
100
+ return applied
101
+
102
+
103
+ def reverse_manifest(
104
+ applied: AppliedManifest,
105
+ *,
106
+ cwd: Path,
107
+ config_block: dict[str, Any] | None = None,
108
+ config_path: Path | None = None,
109
+ ) -> None:
110
+ """Reverse an `AppliedManifest`: un-append env vars, delete copied
111
+ files, un-merge the config block from `agentforge.yaml`.
112
+
113
+ Args:
114
+ applied: State record from a prior `apply_manifest`.
115
+ cwd: Repo root.
116
+ config_block: The same `Manifest.config_block` dict that was
117
+ applied. Reversing the deep-merge requires knowing what
118
+ keys to remove; we accept it as a parameter rather than
119
+ re-reading the package manifest (which may already be
120
+ uninstalled by the time `remove` runs).
121
+ config_path: `agentforge.yaml` location. Defaults to
122
+ `cwd / agentforge.yaml`.
123
+
124
+ Side-effects: removes the state file on success.
125
+ """
126
+ yaml_path = config_path if config_path is not None else cwd / _DEFAULT_YAML
127
+
128
+ for entry in applied.env_vars:
129
+ _strip_env_var_line(cwd / _ENV_EXAMPLE, entry.line)
130
+ for template in applied.templates:
131
+ target = cwd / template.destination
132
+ if target.exists():
133
+ target.unlink()
134
+ if applied.config_block_applied and config_block:
135
+ _strip_from_yaml(yaml_path, config_block)
136
+
137
+ state_path = _state_path(cwd, applied.distribution)
138
+ if state_path.exists():
139
+ state_path.unlink()
140
+
141
+
142
+ def read_applied(cwd: Path, distribution: str) -> AppliedManifest | None:
143
+ """Load the state file for `distribution` if it exists."""
144
+ state_path = _state_path(cwd, distribution)
145
+ if not state_path.exists():
146
+ return None
147
+ with state_path.open() as fh:
148
+ return AppliedManifest.model_validate(yaml.safe_load(fh) or {})
149
+
150
+
151
+ # ----------------------------------------------------------------------
152
+ # Internals
153
+ # ----------------------------------------------------------------------
154
+
155
+
156
+ def _state_path(cwd: Path, distribution: str) -> Path:
157
+ return cwd / _STATE_DIR / f"{distribution}.yaml"
158
+
159
+
160
+ def _write_state(path: Path, applied: AppliedManifest) -> None:
161
+ path.parent.mkdir(parents=True, exist_ok=True)
162
+ with path.open("w") as fh:
163
+ yaml.safe_dump(applied.model_dump(mode="json"), fh, sort_keys=False)
164
+
165
+
166
+ def _append_env_var(env_file: Path, entry: Any) -> str | None:
167
+ """Append `<NAME>=<default>` to `.env.example`. Returns the line
168
+ that was appended, or `None` if it was already present (no-op).
169
+ """
170
+ line = _format_env_line(entry)
171
+ existing = env_file.read_text() if env_file.exists() else ""
172
+ if _env_already_present(existing, entry.name):
173
+ return None
174
+ new = existing
175
+ if new and not new.endswith("\n"):
176
+ new += "\n"
177
+ new += line + "\n"
178
+ env_file.write_text(new)
179
+ return line
180
+
181
+
182
+ def _format_env_line(entry: Any) -> str:
183
+ """Format one env-var entry as a `NAME=value` line with optional
184
+ comment."""
185
+ value = entry.default if entry.default is not None else ""
186
+ if entry.description:
187
+ return f"# {entry.description}\n{entry.name}={value}"
188
+ return f"{entry.name}={value}"
189
+
190
+
191
+ def _env_already_present(text: str, name: str) -> bool:
192
+ for line in text.splitlines():
193
+ stripped = line.strip()
194
+ if stripped.startswith(f"{name}=") or stripped == name:
195
+ return True
196
+ return False
197
+
198
+
199
+ def _strip_env_var_line(env_file: Path, line: str) -> None:
200
+ """Remove the matching env-var line (and its preceding `# ...`
201
+ description comment) from `.env.example`."""
202
+ if not env_file.exists():
203
+ return
204
+ raw = env_file.read_text()
205
+ lines = raw.splitlines()
206
+ # `line` may be multi-line (`# description\nNAME=value`). Split
207
+ # on newline so we can drop each piece.
208
+ targets = line.split("\n")
209
+ out: list[str] = []
210
+ skip = 0
211
+ for current in lines:
212
+ if skip > 0:
213
+ skip -= 1
214
+ continue
215
+ if current == targets[0] and len(targets) > 1:
216
+ window = lines[lines.index(current) : lines.index(current) + len(targets)]
217
+ if window == targets:
218
+ skip = len(targets) - 1
219
+ continue
220
+ if current in targets:
221
+ continue
222
+ out.append(current)
223
+ env_file.write_text("\n".join(out) + ("\n" if raw.endswith("\n") else ""))
224
+
225
+
226
+ def _copy_template(
227
+ distribution: str,
228
+ template: Any,
229
+ cwd: Path,
230
+ *,
231
+ package_root: Path | None = None,
232
+ ) -> bool:
233
+ """Copy `template.source` (inside the installed package) to
234
+ `template.destination` (in `cwd`). Returns `True` if a file was
235
+ written, `False` if the destination already exists with a matching
236
+ marker (idempotent).
237
+
238
+ Args:
239
+ package_root: When provided, read templates from this directory
240
+ instead of `importlib.resources`. Used in tests to point at
241
+ a fake module dir.
242
+
243
+ Raises:
244
+ ModuleError: destination exists without the framework marker
245
+ and `overwrite=False`.
246
+ """
247
+ source_text = _read_template_source(distribution, template.source, package_root)
248
+
249
+ dest = cwd / template.destination
250
+ marker = _marker_for(dest.suffix, distribution)
251
+ if dest.exists():
252
+ existing = dest.read_text(encoding="utf-8")
253
+ if marker and marker in existing:
254
+ return False # idempotent — same module already wrote it
255
+ if not template.overwrite:
256
+ raise ModuleError(
257
+ f"Refusing to overwrite {dest} (no framework marker; "
258
+ f"set `overwrite: true` in the manifest if intentional)."
259
+ )
260
+
261
+ dest.parent.mkdir(parents=True, exist_ok=True)
262
+ body = f"{marker}\n{source_text}" if marker else source_text
263
+ dest.write_text(body, encoding="utf-8")
264
+ return True
265
+
266
+
267
+ def _read_template_source(
268
+ distribution: str,
269
+ source: str,
270
+ package_root: Path | None,
271
+ ) -> str:
272
+ """Read template file contents from `package_root` (tests) or via
273
+ `importlib.resources` against the installed distribution
274
+ (production)."""
275
+ if package_root is not None:
276
+ source_path = package_root / source
277
+ if not source_path.exists():
278
+ raise ModuleError(f"Manifest template {source!r} not found in package_root.")
279
+ return source_path.read_text(encoding="utf-8")
280
+
281
+ from importlib import resources # noqa: PLC0415 — lazy import
282
+
283
+ package_name = distribution.replace("-", "_")
284
+ try:
285
+ package_files = resources.files(package_name)
286
+ except (ModuleNotFoundError, TypeError) as exc:
287
+ raise ModuleError(
288
+ f"Cannot locate package files for {package_name!r}: {exc}. "
289
+ f"Was `pip install {distribution}` actually run?"
290
+ ) from exc
291
+
292
+ target = package_files.joinpath(source)
293
+ try:
294
+ return target.read_text(encoding="utf-8")
295
+ except FileNotFoundError as exc:
296
+ raise ModuleError(
297
+ f"Manifest template {source!r} not found in package {package_name}."
298
+ ) from exc
299
+
300
+
301
+ def _marker_for(suffix: str, distribution: str) -> str | None:
302
+ """Pick a comment-marker prefix for the given file extension."""
303
+ if suffix in {".py", ".sh", ".yaml", ".yml", ".toml", ".ini", ".env", ".sql"}:
304
+ return f"# AGENTFORGE-MANAGED: {distribution}"
305
+ if suffix in {".js", ".ts", ".tsx", ".jsx", ".css"}:
306
+ return f"// AGENTFORGE-MANAGED: {distribution}"
307
+ if suffix in {".html", ".xml", ".md"}:
308
+ return f"<!-- AGENTFORGE-MANAGED: {distribution} -->"
309
+ return None
310
+
311
+
312
+ def _merge_into_yaml(path: Path, block: dict[str, Any]) -> None:
313
+ """Deep-merge `block` into `agentforge.yaml`.
314
+
315
+ Round-tripping plain pyyaml loses comments and reorders keys —
316
+ documented trade-off. Users who care can edit `agentforge.yaml`
317
+ by hand after `add`.
318
+ """
319
+ existing: dict[str, Any] = {}
320
+ if path.exists():
321
+ with path.open() as fh:
322
+ existing = yaml.safe_load(fh) or {}
323
+ if not isinstance(existing, dict):
324
+ raise ModuleError(
325
+ f"{path} must be a mapping at the top level; got {type(existing).__name__}."
326
+ )
327
+ merged = _deep_merge(existing, block)
328
+ with path.open("w") as fh:
329
+ yaml.safe_dump(merged, fh, sort_keys=False)
330
+
331
+
332
+ def _strip_from_yaml(path: Path, block: dict[str, Any]) -> None:
333
+ """Remove keys present in `block` from `agentforge.yaml`. Conservative:
334
+ only strips leaf values that match, then prunes empty parent dicts."""
335
+ if not path.exists():
336
+ return
337
+ with path.open() as fh:
338
+ existing = yaml.safe_load(fh) or {}
339
+ if not isinstance(existing, dict):
340
+ return
341
+ pruned = _deep_strip(existing, block)
342
+ with path.open("w") as fh:
343
+ yaml.safe_dump(pruned, fh, sort_keys=False)
344
+
345
+
346
+ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
347
+ out: dict[str, Any] = dict(base)
348
+ for key, value in overlay.items():
349
+ if key in out and isinstance(out[key], dict) and isinstance(value, dict):
350
+ out[key] = _deep_merge(out[key], value)
351
+ else:
352
+ out[key] = value
353
+ return out
354
+
355
+
356
+ def _deep_strip(base: dict[str, Any], to_remove: dict[str, Any]) -> dict[str, Any]:
357
+ out: dict[str, Any] = {}
358
+ for key, value in base.items():
359
+ if key not in to_remove:
360
+ out[key] = value
361
+ continue
362
+ removal = to_remove[key]
363
+ if isinstance(value, dict) and isinstance(removal, dict):
364
+ pruned = _deep_strip(value, removal)
365
+ if pruned:
366
+ out[key] = pruned
367
+ # else: leaf matches — drop it entirely
368
+ return out