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.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- 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
|