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.
Files changed (38) hide show
  1. katalyst_plugin_harness/__init__.py +7 -0
  2. katalyst_plugin_harness/adapters/__init__.py +1 -0
  3. katalyst_plugin_harness/adapters/jinja_settings.py +61 -0
  4. katalyst_plugin_harness/adapters/opkg.py +214 -0
  5. katalyst_plugin_harness/adapters/packages.py +62 -0
  6. katalyst_plugin_harness/adapters/taxonomy_catalog.py +206 -0
  7. katalyst_plugin_harness/cli.py +138 -0
  8. katalyst_plugin_harness/composition.py +80 -0
  9. katalyst_plugin_harness/creation/agent.yaml.j2 +20 -0
  10. katalyst_plugin_harness/creation/agent_skill.yaml.j2 +13 -0
  11. katalyst_plugin_harness/creation/harness.yaml.j2 +18 -0
  12. katalyst_plugin_harness/domain/__init__.py +41 -0
  13. katalyst_plugin_harness/domain/errors.py +17 -0
  14. katalyst_plugin_harness/domain/model.py +153 -0
  15. katalyst_plugin_harness/domain/package.py +238 -0
  16. katalyst_plugin_harness/domain/reports.py +128 -0
  17. katalyst_plugin_harness/domain/wiring.py +140 -0
  18. katalyst_plugin_harness/linkml/__init__.py +15 -0
  19. katalyst_plugin_harness/linkml/harness.yaml +225 -0
  20. katalyst_plugin_harness/linkml/harness_relationships.yaml +57 -0
  21. katalyst_plugin_harness/linkml/released/0.1.0/SHA256SUMS +2 -0
  22. katalyst_plugin_harness/linkml/released/0.1.0/harness.yaml +225 -0
  23. katalyst_plugin_harness/linkml/released/0.1.0/harness_relationships.yaml +57 -0
  24. katalyst_plugin_harness/lint.py +48 -0
  25. katalyst_plugin_harness/mcp_tools.py +71 -0
  26. katalyst_plugin_harness/plugin.py +48 -0
  27. katalyst_plugin_harness/ports/__init__.py +8 -0
  28. katalyst_plugin_harness/ports/catalog.py +15 -0
  29. katalyst_plugin_harness/ports/openpackage.py +39 -0
  30. katalyst_plugin_harness/ports/packages.py +22 -0
  31. katalyst_plugin_harness/ports/templates.py +19 -0
  32. katalyst_plugin_harness/py.typed +0 -0
  33. katalyst_plugin_harness/schema.py +123 -0
  34. katalyst_plugin_harness/service.py +217 -0
  35. katalyst_plugin_harness-0.1.1a1.dist-info/METADATA +15 -0
  36. katalyst_plugin_harness-0.1.1a1.dist-info/RECORD +38 -0
  37. katalyst_plugin_harness-0.1.1a1.dist-info/WHEEL +4 -0
  38. katalyst_plugin_harness-0.1.1a1.dist-info/entry_points.txt +11 -0
@@ -0,0 +1,7 @@
1
+ """Katalyst harness plugin: the taxonomy's agents and skills, installed into each
2
+ coding-agent harness (OpenCode, Claude Code, ...) with OpenPackage.
3
+ """
4
+
5
+ from katalyst_plugin_harness.plugin import HarnessPlugin
6
+
7
+ __all__ = ["HarnessPlugin"]
@@ -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