katalyst-plugin-harness 0.1.1a1__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.
- katalyst_plugin_harness/__init__.py +7 -0
- katalyst_plugin_harness/adapters/__init__.py +1 -0
- katalyst_plugin_harness/adapters/jinja_settings.py +61 -0
- katalyst_plugin_harness/adapters/opkg.py +214 -0
- katalyst_plugin_harness/adapters/packages.py +62 -0
- katalyst_plugin_harness/adapters/taxonomy_catalog.py +206 -0
- katalyst_plugin_harness/cli.py +138 -0
- katalyst_plugin_harness/composition.py +80 -0
- katalyst_plugin_harness/creation/agent.yaml.j2 +20 -0
- katalyst_plugin_harness/creation/agent_skill.yaml.j2 +13 -0
- katalyst_plugin_harness/creation/harness.yaml.j2 +18 -0
- katalyst_plugin_harness/domain/__init__.py +41 -0
- katalyst_plugin_harness/domain/errors.py +17 -0
- katalyst_plugin_harness/domain/model.py +153 -0
- katalyst_plugin_harness/domain/package.py +238 -0
- katalyst_plugin_harness/domain/reports.py +128 -0
- katalyst_plugin_harness/domain/wiring.py +140 -0
- katalyst_plugin_harness/linkml/__init__.py +15 -0
- katalyst_plugin_harness/linkml/harness.yaml +225 -0
- katalyst_plugin_harness/linkml/harness_relationships.yaml +57 -0
- katalyst_plugin_harness/linkml/released/0.1.0/SHA256SUMS +2 -0
- katalyst_plugin_harness/linkml/released/0.1.0/harness.yaml +225 -0
- katalyst_plugin_harness/linkml/released/0.1.0/harness_relationships.yaml +57 -0
- katalyst_plugin_harness/lint.py +48 -0
- katalyst_plugin_harness/mcp_tools.py +71 -0
- katalyst_plugin_harness/plugin.py +48 -0
- katalyst_plugin_harness/ports/__init__.py +8 -0
- katalyst_plugin_harness/ports/catalog.py +15 -0
- katalyst_plugin_harness/ports/openpackage.py +39 -0
- katalyst_plugin_harness/ports/packages.py +22 -0
- katalyst_plugin_harness/ports/templates.py +19 -0
- katalyst_plugin_harness/py.typed +0 -0
- katalyst_plugin_harness/schema.py +123 -0
- katalyst_plugin_harness/service.py +217 -0
- katalyst_plugin_harness-0.1.1a1.dist-info/METADATA +15 -0
- katalyst_plugin_harness-0.1.1a1.dist-info/RECORD +38 -0
- katalyst_plugin_harness-0.1.1a1.dist-info/WHEEL +4 -0
- katalyst_plugin_harness-0.1.1a1.dist-info/entry_points.txt +11 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The harness plugin's adapters: OpenPackage, the package cache, Jinja, the taxonomy."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Jinja: a harness's settings for one agent or skill, rendered and read as YAML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateError
|
|
11
|
+
|
|
12
|
+
from katalyst_plugin_harness.domain.errors import HarnessError
|
|
13
|
+
|
|
14
|
+
__all__ = ["JinjaSettingsRenderer"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JinjaSettingsRenderer:
|
|
18
|
+
"""Implements :class:`~katalyst_plugin_harness.ports.SettingsRenderer`.
|
|
19
|
+
|
|
20
|
+
Undefined names are errors (``StrictUndefined``): a template naming a field the node
|
|
21
|
+
does not have fails instead of rendering an empty value. An empty rendering is no
|
|
22
|
+
settings.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._environments: dict[Path, Environment] = {}
|
|
27
|
+
|
|
28
|
+
def _environment(self, directory: Path) -> Environment:
|
|
29
|
+
environment = self._environments.get(directory)
|
|
30
|
+
if environment is None:
|
|
31
|
+
environment = Environment(
|
|
32
|
+
loader=FileSystemLoader(str(directory)),
|
|
33
|
+
undefined=StrictUndefined,
|
|
34
|
+
keep_trailing_newline=True,
|
|
35
|
+
autoescape=False,
|
|
36
|
+
)
|
|
37
|
+
self._environments[directory] = environment
|
|
38
|
+
return environment
|
|
39
|
+
|
|
40
|
+
def render(self, template: Path, context: Mapping[str, object]) -> Mapping[str, object]:
|
|
41
|
+
if not template.is_file():
|
|
42
|
+
raise HarnessError(f"harness template not found: {template}")
|
|
43
|
+
try:
|
|
44
|
+
text = (
|
|
45
|
+
self._environment(template.parent)
|
|
46
|
+
.get_template(template.name)
|
|
47
|
+
.render(**dict(context))
|
|
48
|
+
)
|
|
49
|
+
except TemplateError as exc:
|
|
50
|
+
raise HarnessError(f"{template}: {exc}") from exc
|
|
51
|
+
try:
|
|
52
|
+
loaded: object = yaml.safe_load(text)
|
|
53
|
+
except yaml.YAMLError as exc:
|
|
54
|
+
raise HarnessError(f"{template} did not render YAML: {exc}") from exc
|
|
55
|
+
if loaded is None:
|
|
56
|
+
return {}
|
|
57
|
+
if not isinstance(loaded, dict):
|
|
58
|
+
raise HarnessError(
|
|
59
|
+
f"{template} must render a mapping of settings, got {type(loaded).__name__}"
|
|
60
|
+
)
|
|
61
|
+
return cast("dict[str, Any]", loaded)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""OpenPackage (``opkg``) run as a subprocess: the pinned CLI, fetched with npm on first use.
|
|
2
|
+
|
|
3
|
+
Where ``opkg`` comes from, in order: ``KATALYST_OPKG`` (a path to an executable — for
|
|
4
|
+
machines whose endpoint security only allows an approved binary, and for offline use),
|
|
5
|
+
then the pinned version installed in the Katalyst cache
|
|
6
|
+
(``<cache home>/harness/opkg/<version>``), fetched there with npm the first time. An
|
|
7
|
+
``opkg`` on ``PATH`` is deliberately not used: the pinned one is what is tested.
|
|
8
|
+
|
|
9
|
+
What OpenPackage installed is read from its index (``.openpackage/openpackage.index.yml``
|
|
10
|
+
in the workspace, or in the home directory for a global install), which it documents as
|
|
11
|
+
the record of every file a package put where.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, cast
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from katalyst_plugin_harness.domain.errors import (
|
|
26
|
+
OpenPackageError,
|
|
27
|
+
OpenPackageUnavailableError,
|
|
28
|
+
)
|
|
29
|
+
from katalyst_plugin_harness.domain.model import Scope
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OPKG_ENV",
|
|
33
|
+
"OPKG_VERSION",
|
|
34
|
+
"SubprocessOpenPackage",
|
|
35
|
+
"opkg_cache_dir",
|
|
36
|
+
"resolve_opkg",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
#: The OpenPackage CLI the harness plugin is built and tested against.
|
|
40
|
+
OPKG_VERSION = "0.9.5"
|
|
41
|
+
|
|
42
|
+
#: Overrides where ``opkg`` comes from: a path to the executable.
|
|
43
|
+
OPKG_ENV = "KATALYST_OPKG"
|
|
44
|
+
|
|
45
|
+
_INDEX = Path(".openpackage") / "openpackage.index.yml"
|
|
46
|
+
|
|
47
|
+
#: How much of a failed run's output an error carries.
|
|
48
|
+
_TAIL_LINES = 30
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def opkg_cache_dir(version: str = OPKG_VERSION) -> Path:
|
|
52
|
+
"""Where the pinned ``opkg`` is installed: ``<cache home>/harness/opkg/<version>``."""
|
|
53
|
+
from katalyst_taxonomy.domain.homes import resolve_home
|
|
54
|
+
|
|
55
|
+
return resolve_home().cache_dir / "harness" / "opkg" / version
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _opkg_in(prefix: Path) -> Path | None:
|
|
59
|
+
binary = prefix / "node_modules" / ".bin" / "opkg"
|
|
60
|
+
return binary if binary.is_file() else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fetch_opkg(
|
|
64
|
+
prefix: Path, version: str, echo: Callable[[str], None], which: Callable[[str], str | None]
|
|
65
|
+
) -> Path:
|
|
66
|
+
npm = which("npm")
|
|
67
|
+
if npm is None:
|
|
68
|
+
raise OpenPackageUnavailableError(
|
|
69
|
+
f"OpenPackage (opkg {version}) is needed to install into a harness, and npm, "
|
|
70
|
+
f"which fetches it, is not on PATH (it comes with Node.js 20+). Install Node.js, "
|
|
71
|
+
f"or set {OPKG_ENV} to an opkg executable."
|
|
72
|
+
)
|
|
73
|
+
echo(f"Fetching OpenPackage {version} into {prefix} (first run only)…")
|
|
74
|
+
prefix.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
[
|
|
77
|
+
npm,
|
|
78
|
+
"install",
|
|
79
|
+
"--prefix",
|
|
80
|
+
str(prefix),
|
|
81
|
+
"--no-save",
|
|
82
|
+
"--no-audit",
|
|
83
|
+
"--no-fund",
|
|
84
|
+
f"opkg@{version}",
|
|
85
|
+
],
|
|
86
|
+
check=False,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
stdin=subprocess.DEVNULL,
|
|
90
|
+
)
|
|
91
|
+
binary = _opkg_in(prefix)
|
|
92
|
+
if result.returncode != 0 or binary is None:
|
|
93
|
+
raise OpenPackageUnavailableError(
|
|
94
|
+
f"Fetching OpenPackage {version} failed (npm exit {result.returncode}): "
|
|
95
|
+
f"{_tail(result.stdout + result.stderr)}\n"
|
|
96
|
+
f"Set {OPKG_ENV} to an opkg executable to use one you already have."
|
|
97
|
+
)
|
|
98
|
+
return binary
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_opkg(
|
|
102
|
+
*,
|
|
103
|
+
env: Mapping[str, str] | None = None,
|
|
104
|
+
echo: Callable[[str], None] = print,
|
|
105
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
106
|
+
cache_dir: Path | None = None,
|
|
107
|
+
) -> Path:
|
|
108
|
+
"""The ``opkg`` executable to run: ``KATALYST_OPKG``, else the pinned one (fetched).
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
OpenPackageUnavailableError: ``KATALYST_OPKG`` names no file, or there is no
|
|
112
|
+
cached copy and no npm to fetch one with.
|
|
113
|
+
"""
|
|
114
|
+
environ = os.environ if env is None else env
|
|
115
|
+
explicit = environ.get(OPKG_ENV, "").strip()
|
|
116
|
+
if explicit:
|
|
117
|
+
chosen = Path(explicit).expanduser()
|
|
118
|
+
if not chosen.is_file():
|
|
119
|
+
raise OpenPackageUnavailableError(f"{OPKG_ENV} names no file: {chosen}")
|
|
120
|
+
return chosen
|
|
121
|
+
prefix = cache_dir if cache_dir is not None else opkg_cache_dir()
|
|
122
|
+
cached = _opkg_in(prefix)
|
|
123
|
+
if cached is not None:
|
|
124
|
+
return cached
|
|
125
|
+
return _fetch_opkg(prefix, OPKG_VERSION, echo, which)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tail(output: str) -> str:
|
|
129
|
+
lines = [line for line in output.strip().splitlines() if line.strip()]
|
|
130
|
+
return "\n".join(lines[-_TAIL_LINES:])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SubprocessOpenPackage:
|
|
134
|
+
"""Implements :class:`~katalyst_plugin_harness.ports.OpenPackage` with the ``opkg`` CLI.
|
|
135
|
+
|
|
136
|
+
*resolve* finds the executable; it is called on the first install or uninstall, so
|
|
137
|
+
reading what is installed (``status``, a dry run) never fetches anything. *home* is
|
|
138
|
+
where a global install goes (default: the user's home).
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
resolve: Callable[[], Path] = resolve_opkg,
|
|
144
|
+
*,
|
|
145
|
+
home: Path | None = None,
|
|
146
|
+
env: Mapping[str, str] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._resolve = resolve
|
|
149
|
+
self._executable: Path | None = None
|
|
150
|
+
self._home = home
|
|
151
|
+
self._env = env
|
|
152
|
+
|
|
153
|
+
def _home_dir(self) -> Path:
|
|
154
|
+
return self._home if self._home is not None else Path.home()
|
|
155
|
+
|
|
156
|
+
def _run(self, args: list[str], *, scope: Scope, workspace: Path) -> None:
|
|
157
|
+
if self._executable is None:
|
|
158
|
+
self._executable = self._resolve()
|
|
159
|
+
command = [str(self._executable), *args, *(["--global"] if scope == "global" else [])]
|
|
160
|
+
environ = {**(os.environ if self._env is None else self._env), "NO_COLOR": "1"}
|
|
161
|
+
if self._home is not None:
|
|
162
|
+
environ["HOME"] = str(self._home)
|
|
163
|
+
try:
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
command,
|
|
166
|
+
cwd=workspace,
|
|
167
|
+
env=environ,
|
|
168
|
+
check=False,
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
stdin=subprocess.DEVNULL,
|
|
172
|
+
)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise OpenPackageError(f"could not run {command[0]}: {exc}") from exc
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
raise OpenPackageError(
|
|
177
|
+
f"opkg {args[0]} failed (exit {result.returncode}):\n"
|
|
178
|
+
f"{_tail(result.stdout + result.stderr)}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def install(self, package_dir: Path, *, platform: str, scope: Scope, workspace: Path) -> None:
|
|
182
|
+
self._run(
|
|
183
|
+
["install", str(package_dir), "--platforms", platform, "--force"],
|
|
184
|
+
scope=scope,
|
|
185
|
+
workspace=workspace,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def uninstall(self, package: str, *, scope: Scope, workspace: Path) -> None:
|
|
189
|
+
self._run(["uninstall", package], scope=scope, workspace=workspace)
|
|
190
|
+
|
|
191
|
+
def installed(
|
|
192
|
+
self, package: str, *, scope: Scope, workspace: Path
|
|
193
|
+
) -> Mapping[str, tuple[str, ...]] | None:
|
|
194
|
+
index = (self._home_dir() if scope == "global" else workspace) / _INDEX
|
|
195
|
+
try:
|
|
196
|
+
loaded: object = yaml.safe_load(index.read_text(encoding="utf-8"))
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
return None
|
|
199
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
200
|
+
raise OpenPackageError(f"cannot read OpenPackage's index {index}: {exc}") from exc
|
|
201
|
+
packages = _mapping(_mapping(loaded).get("packages"))
|
|
202
|
+
if package not in packages:
|
|
203
|
+
return None
|
|
204
|
+
files = _mapping(_mapping(packages[package]).get("files"))
|
|
205
|
+
return {
|
|
206
|
+
str(source): tuple(str(t) for t in cast("list[Any]", targets))
|
|
207
|
+
if isinstance(targets, list)
|
|
208
|
+
else ()
|
|
209
|
+
for source, targets in files.items()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _mapping(value: object) -> dict[str, Any]:
|
|
214
|
+
return cast("dict[str, Any]", value) if isinstance(value, dict) else {}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Packages written to the Katalyst cache, one directory per harness of one repository.
|
|
2
|
+
|
|
3
|
+
``<cache home>/harness/packages/<repository key>/<harness>/``: the key is derived from
|
|
4
|
+
the repository's path, so two repositories with a harness of the same name do not share a
|
|
5
|
+
package. A markdown file is its generated frontmatter followed by the body file's text;
|
|
6
|
+
every other file is copied as it is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from katalyst_plugin_harness.domain.package import MANIFEST, Package
|
|
18
|
+
|
|
19
|
+
__all__ = ["CachePackageStore", "markdown", "package_root"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def package_root(workspace: Path) -> Path:
|
|
23
|
+
"""Where the packages of the repository at *workspace* are built."""
|
|
24
|
+
from katalyst_taxonomy.domain.homes import resolve_home
|
|
25
|
+
|
|
26
|
+
key = hashlib.sha256(str(workspace.resolve()).encode()).hexdigest()[:16]
|
|
27
|
+
return resolve_home().cache_dir / "harness" / "packages" / key
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _yaml(data: object) -> str:
|
|
31
|
+
return yaml.safe_dump(data, sort_keys=False, width=100, allow_unicode=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def markdown(frontmatter: object, body: str) -> str:
|
|
35
|
+
"""A markdown file: ``---`` frontmatter ``---``, a blank line, then *body*."""
|
|
36
|
+
return f"---\n{_yaml(frontmatter)}---\n\n{body.lstrip(chr(10))}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CachePackageStore:
|
|
40
|
+
"""Implements :class:`~katalyst_plugin_harness.ports.PackageStore` under *root*."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, root: Path) -> None:
|
|
43
|
+
self._root = root
|
|
44
|
+
|
|
45
|
+
def location(self, package: Package) -> Path:
|
|
46
|
+
return self._root / package.harness.name
|
|
47
|
+
|
|
48
|
+
def write(self, package: Package) -> Path:
|
|
49
|
+
directory = self.location(package)
|
|
50
|
+
if directory.exists():
|
|
51
|
+
shutil.rmtree(directory)
|
|
52
|
+
directory.mkdir(parents=True)
|
|
53
|
+
(directory / MANIFEST).write_text(_yaml(dict(package.manifest)), encoding="utf-8")
|
|
54
|
+
for item in package.files:
|
|
55
|
+
target = directory / item.path
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
if item.frontmatter is None:
|
|
58
|
+
shutil.copy2(item.source, target)
|
|
59
|
+
else:
|
|
60
|
+
body = item.source.read_text(encoding="utf-8")
|
|
61
|
+
target.write_text(markdown(dict(item.frontmatter), body), encoding="utf-8")
|
|
62
|
+
return directory
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The catalog of one repository, read from its loaded ``harness``, ``agent`` and
|
|
2
|
+
``agent_skill`` nodes.
|
|
3
|
+
|
|
4
|
+
A harness's agents and skills are read through the fields the schema declares carry them
|
|
5
|
+
(the ``harness`` kind's references to ``agent`` and ``agent_skill``), not by field name.
|
|
6
|
+
Each node's settings are every field its kind declares, an unset one as its default, so a
|
|
7
|
+
template can test any of them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from katalyst_engine.model.node import Node
|
|
18
|
+
from katalyst_engine.schema.registry import SchemaRegistry
|
|
19
|
+
|
|
20
|
+
from katalyst_plugin_harness.domain.model import (
|
|
21
|
+
AGENT_KIND,
|
|
22
|
+
HARNESS_KIND,
|
|
23
|
+
SKILL_KIND,
|
|
24
|
+
Agent,
|
|
25
|
+
Catalog,
|
|
26
|
+
Harness,
|
|
27
|
+
Scope,
|
|
28
|
+
Skill,
|
|
29
|
+
)
|
|
30
|
+
from katalyst_plugin_harness.domain.package import SKILL_FILE
|
|
31
|
+
|
|
32
|
+
__all__ = ["TaxonomyCatalog", "catalog_from"]
|
|
33
|
+
|
|
34
|
+
#: Files in a skill directory that are not part of the skill.
|
|
35
|
+
_JUNK = frozenset({"__pycache__", ".DS_Store", ".pytest_cache", ".ruff_cache"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _settings(registry: SchemaRegistry, doc: Node) -> dict[str, object]:
|
|
39
|
+
definition = registry.get_node_definition(doc.kind)
|
|
40
|
+
declared: dict[str, object] = (
|
|
41
|
+
{f.name: doc.spec.get(f.name, f.default) for f in definition.fields}
|
|
42
|
+
if definition is not None
|
|
43
|
+
else {}
|
|
44
|
+
)
|
|
45
|
+
return {**doc.spec, **declared}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _description(doc: Node) -> str:
|
|
49
|
+
value = doc.spec.get("description")
|
|
50
|
+
return str(value).strip() if value else doc.description.strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _string(value: object) -> str | None:
|
|
54
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _skill_files(directory: Path) -> tuple[str, ...]:
|
|
58
|
+
return tuple(
|
|
59
|
+
sorted(
|
|
60
|
+
path.relative_to(directory).as_posix()
|
|
61
|
+
for path in directory.rglob("*")
|
|
62
|
+
if path.is_file() and not _JUNK.intersection(path.relative_to(directory).parts)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _carried(registry: SchemaRegistry, doc: Node, target_kind: str) -> tuple[str, ...]:
|
|
68
|
+
from katalyst_taxonomy.engine.relationships import carried_values, carriers_of
|
|
69
|
+
|
|
70
|
+
values = (
|
|
71
|
+
value
|
|
72
|
+
for carrier in carriers_of(registry, doc.kind)
|
|
73
|
+
if carrier.target_kinds == frozenset({target_kind})
|
|
74
|
+
for value in carried_values(doc, carrier)
|
|
75
|
+
)
|
|
76
|
+
return tuple(dict.fromkeys(values))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _tool_names(value: object) -> Mapping[str, str | None] | None:
|
|
80
|
+
if not isinstance(value, dict):
|
|
81
|
+
return None
|
|
82
|
+
raw = cast("dict[Any, Any]", value)
|
|
83
|
+
return {str(k): (None if v is None else str(v)) for k, v in raw.items()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _mcp_servers(value: object) -> Mapping[str, str | Mapping[str, object]] | None:
|
|
87
|
+
"""``mcp_servers``: each server a name (a string) or a definition (a map)."""
|
|
88
|
+
if not isinstance(value, dict):
|
|
89
|
+
return None
|
|
90
|
+
raw = cast("dict[Any, Any]", value)
|
|
91
|
+
servers: dict[str, str | Mapping[str, object]] = {}
|
|
92
|
+
for key, entry in raw.items():
|
|
93
|
+
if isinstance(entry, dict):
|
|
94
|
+
servers[str(key)] = {str(k): v for k, v in cast("dict[Any, Any]", entry).items()}
|
|
95
|
+
elif entry is not None and str(entry).strip():
|
|
96
|
+
servers[str(key)] = str(entry)
|
|
97
|
+
return servers
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _with_additions(
|
|
101
|
+
harnesses: Mapping[str, Harness], additions: Mapping[str, tuple[str, Harness]]
|
|
102
|
+
) -> tuple[list[Harness], tuple[tuple[str, str], ...]]:
|
|
103
|
+
"""Each harness with what the nodes that add to it carry (theirs after its own, by
|
|
104
|
+
node name), and the ``(node, target)`` pairs whose target is not a harness."""
|
|
105
|
+
merged = dict(harnesses)
|
|
106
|
+
stray: list[tuple[str, str]] = []
|
|
107
|
+
for name, (target, node) in sorted(additions.items()):
|
|
108
|
+
base = merged.get(target)
|
|
109
|
+
if base is None:
|
|
110
|
+
stray.append((name, target))
|
|
111
|
+
continue
|
|
112
|
+
merged[target] = replace(
|
|
113
|
+
base,
|
|
114
|
+
agents=tuple(dict.fromkeys((*base.agents, *node.agents))),
|
|
115
|
+
skills=tuple(dict.fromkeys((*base.skills, *node.skills))),
|
|
116
|
+
added_by=(*base.added_by, name),
|
|
117
|
+
)
|
|
118
|
+
return list(merged.values()), tuple(stray)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def catalog_from(documents: Iterable[Node], registry: SchemaRegistry) -> Catalog:
|
|
122
|
+
"""The harnesses, agents and skills among *documents*; the first of a name wins.
|
|
123
|
+
|
|
124
|
+
A harness node that adds to another (its reference to a ``harness``) is not a harness
|
|
125
|
+
of its own: what it carries is added to that one's.
|
|
126
|
+
"""
|
|
127
|
+
from katalyst_taxonomy.engine.accessors import get_source_path
|
|
128
|
+
|
|
129
|
+
harnesses: dict[str, Harness] = {}
|
|
130
|
+
additions: dict[str, tuple[str, Harness]] = {}
|
|
131
|
+
agents: dict[str, Agent] = {}
|
|
132
|
+
skills: dict[str, Skill] = {}
|
|
133
|
+
for doc in documents:
|
|
134
|
+
source = get_source_path(doc)
|
|
135
|
+
if source is None:
|
|
136
|
+
continue
|
|
137
|
+
here = source.parent
|
|
138
|
+
if doc.kind == AGENT_KIND and doc.name not in agents:
|
|
139
|
+
prompt = here / (_string(doc.spec.get("prompt_file")) or f"{doc.name}.md")
|
|
140
|
+
agents[doc.name] = Agent(
|
|
141
|
+
name=doc.name,
|
|
142
|
+
description=_description(doc),
|
|
143
|
+
node=source,
|
|
144
|
+
prompt=prompt if prompt.is_file() else None,
|
|
145
|
+
settings=_settings(registry, doc),
|
|
146
|
+
)
|
|
147
|
+
elif doc.kind == SKILL_KIND and doc.name not in skills:
|
|
148
|
+
directory = here / (_string(doc.spec.get("skill_dir")) or doc.name)
|
|
149
|
+
present = (directory / SKILL_FILE).is_file()
|
|
150
|
+
skills[doc.name] = Skill(
|
|
151
|
+
name=doc.name,
|
|
152
|
+
description=_description(doc),
|
|
153
|
+
node=source,
|
|
154
|
+
directory=directory if present else None,
|
|
155
|
+
files=_skill_files(directory) if present else (),
|
|
156
|
+
settings=_settings(registry, doc),
|
|
157
|
+
)
|
|
158
|
+
elif doc.kind == HARNESS_KIND and doc.name not in harnesses and doc.name not in additions:
|
|
159
|
+
spec = doc.spec
|
|
160
|
+
scope: Scope = "global" if spec.get("scope") == "global" else "project"
|
|
161
|
+
agent_template = _string(spec.get("agent_template"))
|
|
162
|
+
skill_template = _string(spec.get("skill_template"))
|
|
163
|
+
harness = Harness(
|
|
164
|
+
name=doc.name,
|
|
165
|
+
platform=_string(spec.get("platform")) or doc.name,
|
|
166
|
+
node=source,
|
|
167
|
+
scope=scope,
|
|
168
|
+
agents=_carried(registry, doc, AGENT_KIND),
|
|
169
|
+
skills=_carried(registry, doc, SKILL_KIND),
|
|
170
|
+
tool_names=_tool_names(spec.get("tool_names")),
|
|
171
|
+
mcp_tool_name=_string(spec.get("mcp_tool_name")),
|
|
172
|
+
mcp_servers=_mcp_servers(spec.get("mcp_servers")),
|
|
173
|
+
agent_template=here / agent_template if agent_template else None,
|
|
174
|
+
skill_template=here / skill_template if skill_template else None,
|
|
175
|
+
)
|
|
176
|
+
target = next(iter(_carried(registry, doc, HARNESS_KIND)), None)
|
|
177
|
+
if target is None:
|
|
178
|
+
harnesses[doc.name] = harness
|
|
179
|
+
else:
|
|
180
|
+
additions[doc.name] = (target, harness)
|
|
181
|
+
merged, stray = _with_additions(harnesses, additions)
|
|
182
|
+
return Catalog(
|
|
183
|
+
harnesses=tuple(sorted(merged, key=lambda h: h.name)),
|
|
184
|
+
agents=agents,
|
|
185
|
+
skills=skills,
|
|
186
|
+
stray=stray,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TaxonomyCatalog:
|
|
191
|
+
"""Implements :class:`~katalyst_plugin_harness.ports.CatalogSource` over loaded nodes.
|
|
192
|
+
|
|
193
|
+
*load* reads the repository's nodes; *registry* gives the schema (both called once,
|
|
194
|
+
on :meth:`load`).
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
load: Callable[[], Sequence[Node]],
|
|
200
|
+
registry: Callable[[], SchemaRegistry],
|
|
201
|
+
) -> None:
|
|
202
|
+
self._load = load
|
|
203
|
+
self._registry = registry
|
|
204
|
+
|
|
205
|
+
def load(self) -> Catalog:
|
|
206
|
+
return catalog_from(self._load(), self._registry())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# pyright: reportUnusedFunction=false
|
|
2
|
+
#
|
|
3
|
+
# The commands are nested functions registered by their decorators and never referenced by
|
|
4
|
+
# name; discovery calls create_group() for a configured group.
|
|
5
|
+
"""``kata tax harness sync | status`` — the taxonomy's agents and skills in each harness."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from katalyst_taxonomy_cli.context import CliContext
|
|
16
|
+
|
|
17
|
+
from katalyst_plugin_harness.domain.reports import HarnessState, HarnessSync
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sync_lines(result: HarnessSync) -> list[str]:
|
|
21
|
+
head = (
|
|
22
|
+
f"{result.harness} ({result.platform}, {result.scope}): {len(result.agents)} agent(s), "
|
|
23
|
+
f"{len(result.skills)} skill(s) as {result.package}"
|
|
24
|
+
)
|
|
25
|
+
lines = [head]
|
|
26
|
+
if result.dry_run:
|
|
27
|
+
lines.append(f" would install {len(result.installed)} package file(s)")
|
|
28
|
+
if result.reinstalled:
|
|
29
|
+
lines.append(" would uninstall the package first (files were unwired)")
|
|
30
|
+
lines.extend(f" would remove {path}" for path in result.removed)
|
|
31
|
+
else:
|
|
32
|
+
lines.append(f" installed {len(result.installed)} file(s)")
|
|
33
|
+
lines.extend(f" removed {path}" for path in result.removed)
|
|
34
|
+
return lines
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _status_lines(state: HarnessState) -> list[str]:
|
|
38
|
+
agents = f"{len(state.wired_agents) - len(state.missing_agents)}/{len(state.wired_agents)}"
|
|
39
|
+
skills = f"{len(state.wired_skills) - len(state.missing_skills)}/{len(state.wired_skills)}"
|
|
40
|
+
verdict = "in sync" if state.in_sync else "out of sync (run kata tax harness sync)"
|
|
41
|
+
lines = [
|
|
42
|
+
f"{state.harness} ({state.platform}, {state.scope}): {verdict}; "
|
|
43
|
+
f"{agents} agents, {skills} skills installed"
|
|
44
|
+
]
|
|
45
|
+
if not state.package_installed and (state.wired_agents or state.wired_skills):
|
|
46
|
+
lines.append(f" {state.package} is not installed")
|
|
47
|
+
for label, names in (
|
|
48
|
+
("missing agent", state.missing_agents),
|
|
49
|
+
("missing skill", state.missing_skills),
|
|
50
|
+
("unwired agent", state.extra_agents),
|
|
51
|
+
("unwired skill", state.extra_skills),
|
|
52
|
+
):
|
|
53
|
+
lines.extend(f" {label}: {name}" for name in names)
|
|
54
|
+
return lines
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_group() -> click.Group:
|
|
58
|
+
"""Entry point for ``katalyst_taxonomy.cli``: contributes ``kata tax harness``."""
|
|
59
|
+
from katalyst_taxonomy_cli.options import repository_option
|
|
60
|
+
|
|
61
|
+
@click.group("harness")
|
|
62
|
+
def harness() -> None:
|
|
63
|
+
"""Coding-agent harnesses: install the agents and skills each is wired to."""
|
|
64
|
+
|
|
65
|
+
@harness.command("sync")
|
|
66
|
+
@click.option("--harness", "name", default=None, help="Only this harness (its node's name).")
|
|
67
|
+
@click.option(
|
|
68
|
+
"--dry-run", is_flag=True, help="Show what would be installed and removed; change nothing."
|
|
69
|
+
)
|
|
70
|
+
@repository_option
|
|
71
|
+
@click.pass_obj
|
|
72
|
+
def sync(ctx: CliContext, name: str | None, dry_run: bool, backend: str | None) -> None:
|
|
73
|
+
"""Install each harness's agents and skills with OpenPackage; remove unwired ones.
|
|
74
|
+
|
|
75
|
+
Builds one OpenPackage package per harness node from the agents and skills it
|
|
76
|
+
carries (their settings from the nodes' fields), installs it for the harness's
|
|
77
|
+
platform, and uninstalls what the harness no longer carries.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
|
|
81
|
+
kata tax harness sync
|
|
82
|
+
|
|
83
|
+
kata tax harness sync --harness claude --dry-run --backend work
|
|
84
|
+
"""
|
|
85
|
+
from katalyst_plugin_harness.composition import harness_service
|
|
86
|
+
from katalyst_plugin_harness.domain.errors import HarnessError
|
|
87
|
+
|
|
88
|
+
repository = ctx.repository(backend)
|
|
89
|
+
service = harness_service(
|
|
90
|
+
repository.root, profile=ctx.profile, echo=lambda m: click.echo(m, err=True)
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
results = service.sync(harness=name, dry_run=dry_run)
|
|
94
|
+
except HarnessError as exc:
|
|
95
|
+
raise click.ClickException(str(exc)) from exc
|
|
96
|
+
|
|
97
|
+
if ctx.output_format == "json":
|
|
98
|
+
click.echo(json.dumps([r.as_dict() for r in results], indent=2))
|
|
99
|
+
elif not results:
|
|
100
|
+
click.echo(
|
|
101
|
+
"No harness nodes in this repository. katalyst-taxonomy's taxonomy-agents "
|
|
102
|
+
"bundle ships opencode and claude: kata tax add --source <katalyst-taxonomy> "
|
|
103
|
+
"--bundle taxonomy-agents"
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
for result in results:
|
|
107
|
+
for line in _sync_lines(result):
|
|
108
|
+
click.echo(line)
|
|
109
|
+
for problem in result.problems:
|
|
110
|
+
click.echo(f"{problem.severity}: {problem.message}", err=True)
|
|
111
|
+
if any(p.severity == "error" for r in results for p in r.problems):
|
|
112
|
+
raise SystemExit(1)
|
|
113
|
+
|
|
114
|
+
@harness.command("status")
|
|
115
|
+
@click.option("--harness", "name", default=None, help="Only this harness (its node's name).")
|
|
116
|
+
@repository_option
|
|
117
|
+
@click.pass_obj
|
|
118
|
+
def status(ctx: CliContext, name: str | None, backend: str | None) -> None:
|
|
119
|
+
"""Show, per harness, what it carries against what OpenPackage has installed."""
|
|
120
|
+
from katalyst_plugin_harness.composition import harness_service
|
|
121
|
+
from katalyst_plugin_harness.domain.errors import HarnessError
|
|
122
|
+
|
|
123
|
+
service = harness_service(ctx.repository(backend).root, profile=ctx.profile)
|
|
124
|
+
try:
|
|
125
|
+
states = service.status(harness=name)
|
|
126
|
+
except HarnessError as exc:
|
|
127
|
+
raise click.ClickException(str(exc)) from exc
|
|
128
|
+
if ctx.output_format == "json":
|
|
129
|
+
click.echo(json.dumps([s.as_dict() for s in states], indent=2))
|
|
130
|
+
return
|
|
131
|
+
if not states:
|
|
132
|
+
click.echo("No harness nodes in this repository.")
|
|
133
|
+
return
|
|
134
|
+
for state in states:
|
|
135
|
+
for line in _status_lines(state):
|
|
136
|
+
click.echo(line)
|
|
137
|
+
|
|
138
|
+
return harness
|