harnessweaver-cli 0.7.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 (84) hide show
  1. harnessweaver_cli/__init__.py +7 -0
  2. harnessweaver_cli/_load.py +119 -0
  3. harnessweaver_cli/_yaml.py +49 -0
  4. harnessweaver_cli/add.py +238 -0
  5. harnessweaver_cli/dev.py +141 -0
  6. harnessweaver_cli/doctor.py +185 -0
  7. harnessweaver_cli/evals.py +276 -0
  8. harnessweaver_cli/gateway.py +301 -0
  9. harnessweaver_cli/main.py +62 -0
  10. harnessweaver_cli/plugins.py +105 -0
  11. harnessweaver_cli/policy.py +143 -0
  12. harnessweaver_cli/py.typed +0 -0
  13. harnessweaver_cli/replay.py +75 -0
  14. harnessweaver_cli/scaffold.py +158 -0
  15. harnessweaver_cli/sidecar.py +48 -0
  16. harnessweaver_cli/spec.py +160 -0
  17. harnessweaver_cli/templates/_dev/compose.yaml +183 -0
  18. harnessweaver_cli/templates/_dev/otel-collector.yaml +25 -0
  19. harnessweaver_cli/templates/_plugin/README.md +26 -0
  20. harnessweaver_cli/templates/_plugin/pyproject.toml +26 -0
  21. harnessweaver_cli/templates/_plugin/src/__ns___harness/__init__.py +20 -0
  22. harnessweaver_cli/templates/_plugin/src/__ns___harness/guards.py +48 -0
  23. harnessweaver_cli/templates/_plugin/src/__ns___harness/py.typed +0 -0
  24. harnessweaver_cli/templates/_plugin/tests/test_guards.py +26 -0
  25. harnessweaver_cli/templates/agent/README.md +18 -0
  26. harnessweaver_cli/templates/agent/app/__init__.py +1 -0
  27. harnessweaver_cli/templates/agent/app/agent.py +12 -0
  28. harnessweaver_cli/templates/agent/app/main.py +19 -0
  29. harnessweaver_cli/templates/agent/app/tools.py +28 -0
  30. harnessweaver_cli/templates/agent/evals/redteam.yaml +17 -0
  31. harnessweaver_cli/templates/agent/harness.offline.yaml +16 -0
  32. harnessweaver_cli/templates/agent/harness.yaml +38 -0
  33. harnessweaver_cli/templates/agent/pyproject.toml +13 -0
  34. harnessweaver_cli/templates/agent/tests/test_agent.py +54 -0
  35. harnessweaver_cli/templates/agent/tools/lookup_order.tool.yaml +15 -0
  36. harnessweaver_cli/templates/agent/tools/refund_payment.tool.yaml +17 -0
  37. harnessweaver_cli/templates/chat/README.md +15 -0
  38. harnessweaver_cli/templates/chat/app/__init__.py +1 -0
  39. harnessweaver_cli/templates/chat/app/chat.py +32 -0
  40. harnessweaver_cli/templates/chat/app/main.py +33 -0
  41. harnessweaver_cli/templates/chat/evals/golden.yaml +10 -0
  42. harnessweaver_cli/templates/chat/harness.offline.yaml +12 -0
  43. harnessweaver_cli/templates/chat/harness.yaml +39 -0
  44. harnessweaver_cli/templates/chat/pyproject.toml +13 -0
  45. harnessweaver_cli/templates/chat/tests/test_chat.py +63 -0
  46. harnessweaver_cli/templates/extraction/README.md +16 -0
  47. harnessweaver_cli/templates/extraction/app/__init__.py +1 -0
  48. harnessweaver_cli/templates/extraction/app/main.py +21 -0
  49. harnessweaver_cli/templates/extraction/app/models.py +20 -0
  50. harnessweaver_cli/templates/extraction/evals/golden.yaml +12 -0
  51. harnessweaver_cli/templates/extraction/harness.offline.yaml +18 -0
  52. harnessweaver_cli/templates/extraction/harness.yaml +33 -0
  53. harnessweaver_cli/templates/extraction/prompts/extract@1.md +7 -0
  54. harnessweaver_cli/templates/extraction/pyproject.toml +13 -0
  55. harnessweaver_cli/templates/extraction/tests/test_extract.py +56 -0
  56. harnessweaver_cli/templates/rag/README.md +17 -0
  57. harnessweaver_cli/templates/rag/app/__init__.py +1 -0
  58. harnessweaver_cli/templates/rag/app/flow.py +29 -0
  59. harnessweaver_cli/templates/rag/app/main.py +19 -0
  60. harnessweaver_cli/templates/rag/data/kb.yaml +13 -0
  61. harnessweaver_cli/templates/rag/evals/golden.yaml +14 -0
  62. harnessweaver_cli/templates/rag/harness.offline.yaml +13 -0
  63. harnessweaver_cli/templates/rag/harness.yaml +50 -0
  64. harnessweaver_cli/templates/rag/prompts/answer@1.md +9 -0
  65. harnessweaver_cli/templates/rag/pyproject.toml +13 -0
  66. harnessweaver_cli/templates/rag/tests/test_answer.py +50 -0
  67. harnessweaver_cli/templates/workflow/README.md +16 -0
  68. harnessweaver_cli/templates/workflow/app/__init__.py +1 -0
  69. harnessweaver_cli/templates/workflow/app/main.py +20 -0
  70. harnessweaver_cli/templates/workflow/app/steps.py +49 -0
  71. harnessweaver_cli/templates/workflow/evals/golden.yaml +9 -0
  72. harnessweaver_cli/templates/workflow/harness.offline.yaml +12 -0
  73. harnessweaver_cli/templates/workflow/harness.yaml +39 -0
  74. harnessweaver_cli/templates/workflow/prompts/classify@1.md +4 -0
  75. harnessweaver_cli/templates/workflow/prompts/reply@1.md +5 -0
  76. harnessweaver_cli/templates/workflow/prompts/summarize@1.md +3 -0
  77. harnessweaver_cli/templates/workflow/pyproject.toml +13 -0
  78. harnessweaver_cli/templates/workflow/tests/test_pipeline.py +32 -0
  79. harnessweaver_cli/tools.py +72 -0
  80. harnessweaver_cli-0.7.1.dist-info/METADATA +83 -0
  81. harnessweaver_cli-0.7.1.dist-info/RECORD +84 -0
  82. harnessweaver_cli-0.7.1.dist-info/WHEEL +4 -0
  83. harnessweaver_cli-0.7.1.dist-info/entry_points.txt +2 -0
  84. harnessweaver_cli-0.7.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,7 @@
1
+ """Developer tooling for HarnessWeaver: the ``harnessweaver`` command."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ __version__: str = _version("harnessweaver-cli")
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,119 @@
1
+ """Finding and loading specs for the CLI, including ``git:<ref>:<path>`` sources."""
2
+
3
+ import io
4
+ import subprocess
5
+ import sys
6
+ import tarfile
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from harnessweaver import Harness
13
+ from harnessweaver.core.errors import HarnessError, SpecError
14
+ from harnessweaver.core.ports import StateStore
15
+ from harnessweaver.spec import EffectiveSpec, load_spec
16
+
17
+ __all__ = ["fail", "load_effective", "load_harness", "spec_path"]
18
+
19
+ DEFAULT_SPEC = Path("harness.yaml")
20
+
21
+
22
+ def fail(error: HarnessError | str, code: int = 1) -> typer.Exit:
23
+ """Print an error the way every command does and return the exit to raise.
24
+
25
+ Commands that report checks (``eval run``, ``policy test``, ``replay``) exit 1
26
+ when a check fails and 2 when they can't run, so CI can tell the two apart.
27
+ """
28
+ message = error.message if isinstance(error, HarnessError) else error
29
+ typer.echo(f"error: {message}", err=True)
30
+ return typer.Exit(code=code)
31
+
32
+
33
+ def spec_path(path: Path | None, code: int = 1) -> Path:
34
+ """The spec to load. Its directory becomes importable, as when the app runs from
35
+ there, so ``tasks.*.input`` and handler paths resolve."""
36
+ found = path or DEFAULT_SPEC
37
+ if not found.is_file():
38
+ raise fail(f"no spec at {found}; pass the path to harness.yaml", code)
39
+ directory = str(found.resolve().parent)
40
+ if directory not in sys.path:
41
+ sys.path.insert(0, directory)
42
+ return found
43
+
44
+
45
+ def load_harness(
46
+ path: Path | None,
47
+ env: str | None,
48
+ profile_dirs: list[Path],
49
+ *,
50
+ state: StateStore | None = None,
51
+ code: int = 1,
52
+ ) -> Harness:
53
+ """Load and build the harness, the same way an app does (every check runs).
54
+ ``state`` replaces the spec's state store (evals keep their runs in memory)."""
55
+ try:
56
+ effective = load_spec(spec_path(path, code), env=env, profile_dirs=profile_dirs)
57
+ return Harness.from_spec(effective, state=state)
58
+ except HarnessError as exc:
59
+ raise fail(exc, code) from None
60
+
61
+
62
+ def _git(*args: str) -> bytes | None:
63
+ """Run git and return its output, or ``None`` if it fails or isn't installed."""
64
+ try:
65
+ done = subprocess.run(["git", *args], capture_output=True, check=False)
66
+ except OSError:
67
+ return None
68
+ return done.stdout if done.returncode == 0 else None
69
+
70
+
71
+ def _load_from_git(ref: str, raw_path: str, env: str | None, dirs: list[Path]) -> EffectiveSpec:
72
+ """Load a spec as it stands at ``ref``, with the profiles that were committed with it.
73
+
74
+ The whole tree at the ref is extracted, so ``.harness/profiles`` and file references come
75
+ from the ref and not the working tree: otherwise a pull request that weakens only a
76
+ profile would show no difference. A ``--profile-dir`` inside the repository is read from
77
+ the ref too. One outside it, like ``HARNESSWEAVER_PROFILE_PATH``, isn't in git, so both
78
+ sides of a diff see the same files.
79
+
80
+ Raises:
81
+ SpecError: If the ref or the path can't be read.
82
+ """
83
+ missing = SpecError(f"can't read {raw_path} at {ref} from git")
84
+ # --end-of-options keeps a ref from being read as an option of git's.
85
+ commit = _git("rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}")
86
+ top = _git("rev-parse", "--show-toplevel")
87
+ if commit is None or top is None:
88
+ raise missing
89
+ root = Path(top.decode().strip()).resolve()
90
+ archive = _git("-C", str(root), "archive", "--format=tar", commit.decode().strip())
91
+ if archive is None:
92
+ raise missing
93
+
94
+ def inside(tree: Path, path: Path) -> Path | None:
95
+ """Where ``path`` is in the extracted tree, if it's in the repository at all."""
96
+ resolved = path.resolve()
97
+ return tree / resolved.relative_to(root) if resolved.is_relative_to(root) else None
98
+
99
+ with tempfile.TemporaryDirectory(prefix="harnessweaver-git-") as temporary:
100
+ tree = Path(temporary).resolve()
101
+ with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
102
+ tar.extractall(tree, filter="data") # the data filter refuses paths out of the tree
103
+ path = inside(tree, Path(raw_path))
104
+ if path is None or not path.is_file():
105
+ raise missing
106
+ return load_spec(path, env=env, profile_dirs=[inside(tree, d) or d for d in dirs])
107
+
108
+
109
+ def load_effective(source: str, env: str | None, profile_dirs: list[Path]) -> EffectiveSpec:
110
+ """Load a spec from a file, or from git as ``git:<ref>:<path>`` (for ``spec diff`` in CI)."""
111
+ try:
112
+ if not source.startswith("git:"):
113
+ return load_spec(spec_path(Path(source)), env=env, profile_dirs=profile_dirs)
114
+ ref, _, raw_path = source.removeprefix("git:").partition(":")
115
+ if not ref or not raw_path:
116
+ raise SpecError(f"{source!r} must look like git:<ref>:<path>")
117
+ return _load_from_git(ref, raw_path, env, profile_dirs)
118
+ except HarnessError as exc:
119
+ raise fail(exc) from None
@@ -0,0 +1,49 @@
1
+ """Comment-preserving reads and writes of the YAML files the CLI edits (ruamel.yaml, ADR-0016).
2
+
3
+ The spec loader reads YAML 1.2 with PyYAML (ADR-0017). Editing commands
4
+ (``add guard``, ``add tool``, ``tools pin``) use ruamel.yaml's round-trip mode
5
+ instead, so the comments, key order and flow style a person wrote survive the
6
+ edit. ruamel.yaml reads YAML 1.2 too, so both see the same values.
7
+ """
8
+
9
+ import io
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ruamel.yaml import YAML
14
+ from ruamel.yaml.comments import CommentedMap
15
+
16
+ __all__ = ["dump", "flow", "read", "write"]
17
+
18
+
19
+ def _yaml() -> YAML:
20
+ yaml = YAML() # round-trip mode: comments, order and quoting are kept
21
+ yaml.preserve_quotes = True
22
+ yaml.width = 4096 # never re-wrap a long line someone wrote on purpose
23
+ # The repository's style: list items indented two spaces under their key.
24
+ yaml.indent(mapping=2, sequence=4, offset=2)
25
+ return yaml
26
+
27
+
28
+ def read(path: Path) -> Any:
29
+ """The document at ``path``, as ruamel's comment-keeping maps and lists."""
30
+ return _yaml().load(path.read_text(encoding="utf-8"))
31
+
32
+
33
+ def dump(document: Any) -> str:
34
+ """``document`` as YAML text, with its comments."""
35
+ out = io.StringIO()
36
+ _yaml().dump(document, out)
37
+ return out.getvalue()
38
+
39
+
40
+ def write(path: Path, document: Any) -> None:
41
+ """Write ``document`` to ``path``, comments included."""
42
+ path.write_text(dump(document), encoding="utf-8")
43
+
44
+
45
+ def flow(**items: Any) -> CommentedMap:
46
+ """A map written on one line, ``{ id: x, use: y }``, as spec list items usually are."""
47
+ found = CommentedMap(items)
48
+ found.fa.set_flow_style()
49
+ return found
@@ -0,0 +1,238 @@
1
+ """``harnessweaver add guard`` and ``harnessweaver add tool``: grow a spec without hand-editing it.
2
+
3
+ Both edit files in place with comments kept (``_yaml``), then load the spec the
4
+ way the app would. A guard whose edit doesn't load is taken back out, so a
5
+ failed ``add`` leaves the spec as it was.
6
+ """
7
+
8
+ import inspect
9
+ import re
10
+ import typing
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Literal
13
+
14
+ import typer
15
+ from pydantic import Field, ValidationError, create_model
16
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
17
+
18
+ from harnessweaver import Harness, Risk, SideEffect, ToolManifest
19
+ from harnessweaver.core.errors import HarnessError
20
+ from harnessweaver.core.jsonschema import SchemaError, check_schema
21
+ from harnessweaver.spec import load_spec
22
+ from harnessweaver.spec.yaml import load_yaml
23
+ from harnessweaver_cli import _yaml
24
+ from harnessweaver_cli._load import fail, spec_path
25
+ from harnessweaver_cli.spec import Env, Path_, ProfileDirs
26
+
27
+ __all__ = ["add_app", "input_schema"]
28
+
29
+ add_app = typer.Typer(help="Add a guard or a tool to the spec.", no_args_is_help=True)
30
+
31
+ Stage = Literal["input", "context", "tool_result", "output", "stream"]
32
+
33
+ _PARAM = re.compile(r"^(\w+)\s*(?:\([^)]*\))?\s*:\s*(.*)$")
34
+ """A docstring ``Args:`` entry: ``name: text`` or ``name (type): text``."""
35
+
36
+ _IDEMPOTENCY_KEY = "idempotency_key"
37
+ """A native tool's parameter the governor fills in; the model never sees it (DESIGNS No. 17)."""
38
+
39
+
40
+ def _settings(items: list[str]) -> dict[str, Any]:
41
+ """``--set KEY=VALUE`` options as provider settings, each value read as YAML."""
42
+ found: dict[str, Any] = {}
43
+ for item in items:
44
+ key, sep, raw = item.partition("=")
45
+ if not sep or not key:
46
+ raise fail(f"--set {item!r} must look like KEY=VALUE")
47
+ found[key] = load_yaml(raw, f"--set {key}") if raw else ""
48
+ return found
49
+
50
+
51
+ def _check(path: Path, env: str | None, profile_dirs: list[Path]) -> None:
52
+ """Load and build the harness from ``path``, as ``spec validate`` does."""
53
+ Harness.from_spec(load_spec(path, env=env, profile_dirs=profile_dirs))
54
+
55
+
56
+ @add_app.command("guard")
57
+ def add_guard(
58
+ use: Annotated[str, typer.Argument(help="The guard provider, such as pii.regex or acme.pii.")],
59
+ path: Path_ = None,
60
+ stage: Annotated[Stage, typer.Option("--stage", help="Where it checks content.")] = "input",
61
+ guard_id: Annotated[
62
+ str | None, typer.Option("--id", help="Its id (default: the provider name with dashes).")
63
+ ] = None,
64
+ action: Annotated[
65
+ str | None, typer.Option("--action", help="What it does when it fires (overrides its own).")
66
+ ] = None,
67
+ settings: Annotated[
68
+ list[str] | None, typer.Option("--set", help="A provider setting, KEY=VALUE; repeatable.")
69
+ ] = None,
70
+ env: Env = None,
71
+ profile_dir: ProfileDirs = None,
72
+ ) -> None:
73
+ """Add a guard in shadow mode (``mode: log_only``): it records what it would do and
74
+ changes nothing until a reviewed edit promotes it to ``enforce`` (R8)."""
75
+ target = spec_path(path)
76
+ original = target.read_text(encoding="utf-8")
77
+ document = _yaml.read(target)
78
+ if not isinstance(document, CommentedMap):
79
+ raise fail(f"{target} must be a YAML map")
80
+ name = guard_id or use.replace(".", "-")
81
+ guards = document.setdefault("guardrails", CommentedMap()).setdefault(stage, CommentedSeq())
82
+ if any(isinstance(g, dict) and g.get("id") == name for g in guards):
83
+ raise fail(f"guardrails.{stage} already has a guard {name!r}; pick another --id")
84
+ item = _yaml.flow(id=name, use=use, mode="log_only")
85
+ if action is not None:
86
+ item["action"] = action
87
+ item.update(_settings(settings or []))
88
+ guards.append(item)
89
+ _yaml.write(target, document)
90
+ try:
91
+ _check(target, env, profile_dir or [])
92
+ except HarnessError as exc:
93
+ target.write_text(original, encoding="utf-8") # leave the spec as it was
94
+ raise fail(exc) from None
95
+ typer.echo(f"added guardrails.{stage}[{name}] ({use}) in shadow mode (mode: log_only)")
96
+ typer.echo(" promote it with mode: enforce once the eval shadow report backs it (R8)")
97
+
98
+
99
+ # --- add tool ----------------------------------------------------------------------------------
100
+
101
+
102
+ def _docs(function: Any) -> tuple[str, dict[str, str]]:
103
+ """A function's summary and its parameter descriptions, from a Google-style docstring.
104
+
105
+ The summary is the text before the first section header (``Args:``,
106
+ ``Returns:``, ...). Parameter descriptions are the ``name: text`` lines under
107
+ ``Args:``, with indented continuation lines joined on.
108
+ """
109
+ lines = (inspect.getdoc(function) or "").splitlines()
110
+ summary: list[str] = []
111
+ params: dict[str, str] = {}
112
+ section: str | None = None
113
+ current: str | None = None
114
+ for line in lines:
115
+ stripped = line.strip()
116
+ if stripped.endswith(":") and stripped[:-1] in ("Args", "Arguments", "Returns", "Raises"):
117
+ section, current = stripped[:-1], None
118
+ continue
119
+ if section is None:
120
+ summary.append(stripped)
121
+ elif section in ("Args", "Arguments") and stripped:
122
+ # Entries sit one indent under "Args:"; deeper lines continue the last one.
123
+ entry = None if line.startswith(" ") else _PARAM.match(stripped)
124
+ if entry is not None:
125
+ current = str(entry[1])
126
+ params[current] = str(entry[2]).strip()
127
+ elif current is not None:
128
+ params[current] = f"{params[current]} {stripped}".strip()
129
+ return " ".join(s for s in summary if s).strip(), params
130
+
131
+
132
+ def input_schema(function: Any) -> dict[str, Any]:
133
+ """The JSON Schema of a function's parameters, as a tool manifest's ``input_schema``.
134
+
135
+ Pydantic builds it from the type hints and defaults; ``idempotency_key``
136
+ is left out because the governor supplies it. Titles are dropped, since
137
+ only the property names and descriptions mean anything to a model.
138
+
139
+ Raises:
140
+ typer.Exit: For ``*args`` or ``**kwargs``, which a schema can't describe.
141
+ """
142
+ _, described = _docs(function)
143
+ hints = typing.get_type_hints(function)
144
+ fields: dict[str, Any] = {}
145
+ for name, parameter in inspect.signature(function).parameters.items():
146
+ if name == _IDEMPOTENCY_KEY:
147
+ continue
148
+ if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
149
+ raise fail(f"{function.__name__}: *args and **kwargs can't be described as a tool")
150
+ default = ... if parameter.default is parameter.empty else parameter.default
151
+ fields[name] = (hints.get(name, Any), Field(default, description=described.get(name)))
152
+ schema = create_model(f"{function.__name__}_args", **fields).model_json_schema()
153
+ schema.pop("title", None)
154
+ for prop in schema.get("properties", {}).values():
155
+ prop.pop("title", None)
156
+ return schema
157
+
158
+
159
+ def _manifest_globs(document: CommentedMap) -> CommentedSeq:
160
+ """The spec's ``tools.manifests`` as a list, made one if it was a single glob or missing."""
161
+ tools = document.setdefault("tools", CommentedMap())
162
+ globs = tools.get("manifests")
163
+ if isinstance(globs, CommentedSeq):
164
+ return globs
165
+ found = CommentedSeq([globs] if isinstance(globs, str) else list(globs or []))
166
+ tools["manifests"] = found
167
+ return found
168
+
169
+
170
+ @add_app.command("tool")
171
+ def add_tool(
172
+ handler: Annotated[str, typer.Argument(help="The tool's function, 'package.module:function'.")],
173
+ path: Path_ = None,
174
+ directory: Annotated[
175
+ Path, typer.Option("--dir", help="Where manifests live, relative to the spec.")
176
+ ] = Path("tools"),
177
+ risk: Annotated[Risk, typer.Option("--risk", help="How much harm a misused call does.")] = (
178
+ Risk.LOW
179
+ ),
180
+ side_effect: Annotated[
181
+ SideEffect, typer.Option("--side-effect", help="What a call changes.")
182
+ ] = SideEffect.NONE,
183
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing manifest.")] = False,
184
+ ) -> None:
185
+ """Write a tool manifest from a function's signature and docstring (S2).
186
+
187
+ The name is the function's, the description its docstring summary, and the
188
+ input schema comes from its parameters. Review it, then pin it with
189
+ ``harnessweaver tools pin``.
190
+ """
191
+ target = spec_path(path)
192
+ module, _, attribute = handler.partition(":")
193
+ if not module or not attribute:
194
+ raise fail(f"{handler!r} isn't 'package.module:function'")
195
+ try:
196
+ function = getattr(__import__(module, fromlist=[attribute]), attribute)
197
+ except (ImportError, AttributeError) as exc:
198
+ raise fail(f"can't import {handler!r} ({type(exc).__name__})") from None
199
+ if not callable(function):
200
+ raise fail(f"{handler!r} isn't a function")
201
+ description, _ = _docs(function)
202
+ if not description:
203
+ raise fail(f"{handler} has no docstring; the model needs a description of the tool")
204
+ name = function.__name__
205
+ manifest = {
206
+ "apiVersion": "harnessweaver/v1",
207
+ "kind": "Tool",
208
+ "metadata": {"name": name, "version": "1.0.0"},
209
+ "spec": {
210
+ "transport": "native",
211
+ "handler": handler,
212
+ "description": description,
213
+ "input_schema": input_schema(function),
214
+ "side_effect": side_effect.value,
215
+ "risk": risk.value,
216
+ },
217
+ }
218
+ try:
219
+ check_schema(ToolManifest.model_validate(manifest).spec.input_schema)
220
+ except (ValidationError, SchemaError) as exc:
221
+ raise fail(f"{handler}: its manifest isn't valid: {exc}") from None
222
+ base = target.resolve().parent
223
+ file = base / directory / f"{name}.tool.yaml"
224
+ if file.exists() and not force:
225
+ raise fail(f"{file} already exists; use --force to overwrite")
226
+ file.parent.mkdir(parents=True, exist_ok=True)
227
+ header = f"# {name}: written by harnessweaver add tool. Review it, then pin it (tools pin).\n"
228
+ file.write_text(header + _yaml.dump(manifest), encoding="utf-8")
229
+ typer.echo(f"wrote {file.relative_to(base)}")
230
+ # Make sure the spec reads the new manifest; add the directory's glob if it doesn't.
231
+ document = _yaml.read(target)
232
+ globs = _manifest_globs(document)
233
+ relative = file.relative_to(base)
234
+ if not any(relative.match(str(g)) for g in globs):
235
+ globs.append(f"{directory.as_posix()}/*.tool.yaml")
236
+ _yaml.write(target, document)
237
+ typer.echo(f" added {directory.as_posix()}/*.tool.yaml to tools.manifests")
238
+ typer.echo(" next: review the description and schema, then run harnessweaver tools pin")
@@ -0,0 +1,141 @@
1
+ """``harnessweaver dev``: a local stack for the app, pre-wired to its spec (ADR-0039).
2
+
3
+ It writes ``.harness/dev/`` next to the spec: the LiteLLM config ``gateway
4
+ litellm`` generates, an OpenTelemetry collector config, a Docker Compose file
5
+ with LiteLLM, the collector, Langfuse and harnessd, and a ``.env`` of local
6
+ secrets. The ``.env`` is written once and then kept, so its secrets and the
7
+ values the developer fills in survive the next run; the other files are
8
+ regenerated each time. ``--up`` starts the stack.
9
+ """
10
+
11
+ import base64
12
+ import secrets
13
+ import shutil
14
+ import subprocess
15
+ from importlib import resources
16
+ from pathlib import Path
17
+ from typing import Annotated
18
+
19
+ import typer
20
+
21
+ from harnessweaver.core.errors import HarnessError
22
+ from harnessweaver.spec import EffectiveSpec, load_spec
23
+ from harnessweaver_cli._load import fail, spec_path
24
+ from harnessweaver_cli.gateway import gateway_tools, litellm_config, render_litellm, upstream_env
25
+ from harnessweaver_cli.spec import Env, Path_, ProfileDirs
26
+
27
+ __all__ = ["compose_file", "dev", "env_file"]
28
+
29
+ PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY")
30
+ """Provider keys LiteLLM reads from its environment; only the gateway holds them (S1)."""
31
+
32
+
33
+ def _token(prefix: str = "") -> str:
34
+ return prefix + secrets.token_hex(16)
35
+
36
+
37
+ def env_file(effective: EffectiveSpec) -> str:
38
+ """A ``.env`` for the stack: random local secrets, and empty lines for what only
39
+ the developer knows (the provider model behind each gateway model, and the keys).
40
+
41
+ The app's own key variable (``models.api_key_env``) gets LiteLLM's master key,
42
+ so the app, harnessd and the gateway agree without further setup.
43
+ """
44
+ names = [
45
+ entry["model_name"]
46
+ for entry in litellm_config(effective, gateway_tools(effective))["model_list"]
47
+ ]
48
+ master = _token("sk-dev-")
49
+ public, secret = _token("pk-lf-"), _token("sk-lf-")
50
+ app_key = effective.spec.models.provider_settings().get("api_key_env", "LITELLM_API_KEY")
51
+ lines = [
52
+ "# Local secrets for `harnessweaver dev`, generated once. Never commit this file.",
53
+ "#",
54
+ "# The provider model behind each gateway model, as LiteLLM names it (provider/model):",
55
+ *[f"{upstream_env(name)}=" for name in names],
56
+ "# Provider keys, which only the gateway holds (S1):",
57
+ *[f"{key}=" for key in PROVIDER_KEYS],
58
+ "",
59
+ f"LITELLM_MASTER_KEY={master}",
60
+ f"{app_key}={master}",
61
+ f"HARNESSD_TOKEN={_token()}",
62
+ f"POSTGRES_PASSWORD={_token()}",
63
+ f"CLICKHOUSE_PASSWORD={_token()}",
64
+ f"REDIS_AUTH={_token()}",
65
+ f"MINIO_ROOT_PASSWORD={_token()}",
66
+ f"SALT={_token()}",
67
+ f"ENCRYPTION_KEY={secrets.token_hex(32)}",
68
+ f"NEXTAUTH_SECRET={_token()}",
69
+ f"LANGFUSE_PUBLIC_KEY={public}",
70
+ f"LANGFUSE_SECRET_KEY={secret}",
71
+ f"LANGFUSE_OTLP_AUTH={base64.b64encode(f'{public}:{secret}'.encode()).decode()}",
72
+ "LANGFUSE_USER_EMAIL=dev@example.com",
73
+ f"LANGFUSE_USER_PASSWORD={_token()}",
74
+ ]
75
+ return "\n".join(lines) + "\n"
76
+
77
+
78
+ def compose_file(spec: Path, env: str | None, profile_dirs: list[Path]) -> str:
79
+ """The Compose file, for this spec and overlay. Each ``--profile-dir`` folder is
80
+ mounted read-only into harnessd's container and put on its profile path, so it
81
+ loads the spec as the developer's machine does."""
82
+ mounts = [f"/profiles/{index}" for index in range(len(profile_dirs))]
83
+ volumes = "".join(
84
+ f" - {folder.resolve()}:{mount}:ro\n"
85
+ for folder, mount in zip(profile_dirs, mounts, strict=True)
86
+ )
87
+ return (
88
+ _template("compose.yaml")
89
+ .replace("__spec__", spec.name)
90
+ .replace("__env__", env or "")
91
+ .replace("__profile_path__", ":".join(mounts))
92
+ .replace("__profile_volumes__", volumes)
93
+ )
94
+
95
+
96
+ def _template(name: str) -> str:
97
+ return (
98
+ resources.files("harnessweaver_cli")
99
+ .joinpath("templates", "_dev", name)
100
+ .read_text(encoding="utf-8")
101
+ )
102
+
103
+
104
+ def dev(
105
+ path: Path_ = None,
106
+ env: Env = None,
107
+ profile_dir: ProfileDirs = None,
108
+ up: Annotated[bool, typer.Option("--up", help="Start it: docker compose up -d.")] = False,
109
+ ) -> None:
110
+ """Write a local stack: LiteLLM (from the spec), an OTel collector, Langfuse and harnessd."""
111
+ found = spec_path(path)
112
+ try:
113
+ effective = load_spec(found, env=env, profile_dirs=profile_dir or [])
114
+ litellm = render_litellm(effective)
115
+ except HarnessError as exc:
116
+ raise fail(exc) from None
117
+ directory = found.resolve().parent / ".harness" / "dev"
118
+ directory.mkdir(parents=True, exist_ok=True)
119
+ (directory / ".gitignore").write_text("*\n", encoding="utf-8") # nothing here is committed
120
+ (directory / "litellm.yaml").write_text(litellm, encoding="utf-8")
121
+ (directory / "otel-collector.yaml").write_text(_template("otel-collector.yaml"), "utf-8")
122
+ (directory / "compose.yaml").write_text(compose_file(found, env, profile_dir or []), "utf-8")
123
+ secrets_file = directory / ".env"
124
+ if not secrets_file.exists():
125
+ secrets_file.write_text(env_file(effective), encoding="utf-8")
126
+ typer.echo(f"wrote {secrets_file}: fill in the provider models and keys")
127
+ typer.echo(f"wrote the dev stack to {directory}")
128
+ if not up:
129
+ typer.echo(
130
+ f"start it with: harnessweaver dev --up (or docker compose up -d in {directory})"
131
+ )
132
+ return
133
+ docker = shutil.which("docker")
134
+ if docker is None:
135
+ raise fail("docker isn't installed; install Docker to start the stack")
136
+ done = subprocess.run([docker, "compose", "up", "-d"], cwd=directory, check=False)
137
+ if done.returncode != 0:
138
+ raise typer.Exit(code=done.returncode)
139
+ typer.echo(
140
+ "LiteLLM http://127.0.0.1:4000, harnessd http://127.0.0.1:7070, Langfuse http://127.0.0.1:3000"
141
+ )