multi-harness 0.1.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ from .antigravity import SPEC as _ANTIGRAVITY
2
+ from .claude import SPEC as _CLAUDE
3
+ from .codex import SPEC as _CODEX
4
+ from .copilot import SPEC as _COPILOT
5
+ from .opencode import SPEC as _OPENCODE
6
+ from .spec import AgentSpec
7
+
8
+ AGENT_REGISTRY: dict[str, AgentSpec] = {
9
+ spec.name: spec
10
+ for spec in (_CLAUDE, _CODEX, _OPENCODE, _COPILOT, _ANTIGRAVITY)
11
+ }
12
+
13
+ __all__ = ["AGENT_REGISTRY", "AgentSpec"]
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .spec import AgentSpec
4
+
5
+ SPEC = AgentSpec(
6
+ name="antigravity",
7
+ display_name="Google Antigravity",
8
+ instructions_path=None,
9
+ skills_path=Path(".agents/skills"),
10
+ subagents_path=Path(".subagents"),
11
+ detection_paths=(Path(".agents"), Path(".subagents")),
12
+ )
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .spec import AgentSpec
4
+
5
+ SPEC = AgentSpec(
6
+ name="claude",
7
+ display_name="Claude Code",
8
+ instructions_path=Path("CLAUDE.md"),
9
+ skills_path=Path(".claude/skills"),
10
+ subagents_path=Path(".claude/agents"),
11
+ detection_paths=(Path("CLAUDE.md"), Path(".claude")),
12
+ )
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .spec import AgentSpec
4
+
5
+ SPEC = AgentSpec(
6
+ name="codex",
7
+ display_name="OpenAI Codex",
8
+ instructions_path=None,
9
+ skills_path=Path(".codex/skills"),
10
+ subagents_path=Path(".codex/agents"),
11
+ detection_paths=(Path(".codex"),),
12
+ )
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .spec import AgentSpec
4
+
5
+ SPEC = AgentSpec(
6
+ name="copilot",
7
+ display_name="GitHub Copilot",
8
+ instructions_path=Path(".github/copilot-instructions.md"),
9
+ skills_path=Path(".github/skills"),
10
+ subagents_path=Path(".github/agents"),
11
+ detection_paths=(Path(".github/copilot-instructions.md"),),
12
+ )
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .spec import AgentSpec
4
+
5
+ SPEC = AgentSpec(
6
+ name="opencode",
7
+ display_name="OpenCode",
8
+ instructions_path=None,
9
+ skills_path=Path(".opencode/skills"),
10
+ subagents_path=Path(".opencode/agent"),
11
+ detection_paths=(Path(".opencode"),),
12
+ )
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AgentSpec:
9
+ name: str
10
+ display_name: str
11
+ instructions_path: Path | None
12
+ skills_path: Path
13
+ subagents_path: Path
14
+ detection_paths: tuple[Path, ...]
multi_harness/cli.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from .agents import AGENT_REGISTRY
10
+ from .config import ConfigError, read_agent_names
11
+ from .harness import HarnessError, add as harness_add, init as harness_init, remove as harness_remove
12
+ from .status import check_all
13
+
14
+ app = typer.Typer(
15
+ no_args_is_help=True,
16
+ help="Manage projects targeting multiple coding agents from a shared harness.",
17
+ )
18
+
19
+
20
+ @app.callback()
21
+ def _callback() -> None:
22
+ """multi-harness — shared harness for multi-agent coding projects."""
23
+
24
+
25
+ def _parse_agents(value: Optional[str]) -> list[str]:
26
+ if value is None:
27
+ return list(AGENT_REGISTRY)
28
+ names = [n.strip() for n in value.split(",") if n.strip()]
29
+ if not names:
30
+ raise typer.BadParameter("--agents must list at least one agent.")
31
+ return names
32
+
33
+
34
+ @app.command()
35
+ def init(
36
+ path: Annotated[
37
+ Path,
38
+ typer.Argument(
39
+ help="Project directory to initialize. Defaults to the current dir.",
40
+ file_okay=False,
41
+ dir_okay=True,
42
+ exists=True,
43
+ resolve_path=True,
44
+ ),
45
+ ] = Path("."),
46
+ agents: Annotated[
47
+ Optional[str],
48
+ typer.Option(
49
+ "--agents",
50
+ help=(
51
+ "Comma-separated list of agents to register. "
52
+ f"Defaults to all supported: {','.join(AGENT_REGISTRY)}."
53
+ ),
54
+ ),
55
+ ] = None,
56
+ template: Annotated[
57
+ Optional[Path],
58
+ typer.Option(
59
+ "--template",
60
+ help="Path to a file used to seed AGENTS.md when it doesn't already exist.",
61
+ file_okay=True,
62
+ dir_okay=False,
63
+ exists=True,
64
+ readable=True,
65
+ resolve_path=True,
66
+ ),
67
+ ] = None,
68
+ force: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--force",
72
+ help="Re-link symlinks even if .harness/ already exists. Never overwrites real files.",
73
+ ),
74
+ ] = False,
75
+ ) -> None:
76
+ """Bootstrap or migrate a project into the multi-harness layout."""
77
+ selected = _parse_agents(agents)
78
+ try:
79
+ report = harness_init(path, selected, template=template, force=force)
80
+ except HarnessError as exc:
81
+ typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
82
+ raise typer.Exit(code=1)
83
+
84
+ if report.detected:
85
+ names = ", ".join(s.display_name for s in report.detected)
86
+ typer.echo(f"Detected existing agent: {names}")
87
+ for src, dst in report.moved:
88
+ typer.echo(f" moved {src.relative_to(path)} -> {dst.relative_to(path)}")
89
+ for created in report.created_files:
90
+ typer.echo(f" created {created.relative_to(path)}")
91
+ for link, result in report.symlinks:
92
+ typer.echo(f" link {link.relative_to(path)} ({result})")
93
+ typer.echo("Done.")
94
+
95
+
96
+ @app.command()
97
+ def add(
98
+ agents: Annotated[
99
+ list[str],
100
+ typer.Argument(help="Agent name(s) to register."),
101
+ ],
102
+ path: Annotated[
103
+ Path,
104
+ typer.Option(
105
+ "--path",
106
+ help="Project directory. Defaults to the current dir.",
107
+ file_okay=False,
108
+ dir_okay=True,
109
+ exists=True,
110
+ resolve_path=True,
111
+ ),
112
+ ] = Path("."),
113
+ ) -> None:
114
+ """Register one or more new agents to an existing harness."""
115
+ try:
116
+ report = harness_add(path, agents)
117
+ except HarnessError as exc:
118
+ typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
119
+ raise typer.Exit(code=1)
120
+
121
+ for link, result in report.symlinks:
122
+ typer.echo(f" link {link.relative_to(path)} ({result})")
123
+ typer.echo("Done.")
124
+
125
+
126
+ @app.command()
127
+ def remove(
128
+ agents: Annotated[
129
+ list[str],
130
+ typer.Argument(help="Agent name(s) to remove."),
131
+ ],
132
+ path: Annotated[
133
+ Path,
134
+ typer.Option(
135
+ "--path",
136
+ help="Project directory. Defaults to the current dir.",
137
+ file_okay=False,
138
+ dir_okay=True,
139
+ exists=True,
140
+ resolve_path=True,
141
+ ),
142
+ ] = Path("."),
143
+ ) -> None:
144
+ """Remove one or more agents from the harness."""
145
+ try:
146
+ report = harness_remove(path, agents)
147
+ except HarnessError as exc:
148
+ typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
149
+ raise typer.Exit(code=1)
150
+
151
+ for link in report.removed_symlinks:
152
+ typer.echo(f" unlinked {link.relative_to(path)}")
153
+ for d in report.removed_dirs:
154
+ typer.echo(f" removed {d.relative_to(path)}")
155
+ for d in report.nonempty_dirs:
156
+ rel = d.relative_to(path)
157
+ if typer.confirm(f" {rel} is not empty. Remove it?", default=False):
158
+ shutil.rmtree(d)
159
+ typer.echo(f" removed {rel}")
160
+ typer.echo("Done.")
161
+
162
+
163
+ @app.command()
164
+ def status(
165
+ path: Annotated[
166
+ Path,
167
+ typer.Argument(
168
+ help="Project directory to check. Defaults to the current dir.",
169
+ file_okay=False,
170
+ dir_okay=True,
171
+ exists=True,
172
+ resolve_path=True,
173
+ ),
174
+ ] = Path("."),
175
+ ) -> None:
176
+ """Show symlink health for all registered agents."""
177
+ try:
178
+ agent_names = read_agent_names(path)
179
+ except ConfigError as exc:
180
+ typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
181
+ raise typer.Exit(code=1)
182
+
183
+ statuses = check_all(path, agent_names)
184
+ if not statuses:
185
+ typer.echo("No agents registered.")
186
+ return
187
+
188
+ _STATUS_COLOR = {
189
+ "ok": typer.colors.GREEN,
190
+ "missing": typer.colors.YELLOW,
191
+ "broken": typer.colors.RED,
192
+ "detached": typer.colors.RED,
193
+ }
194
+ max_label = max(len(r.label) for s in statuses for r in s.rows)
195
+ for agent_status in statuses:
196
+ typer.echo(agent_status.spec.display_name)
197
+ for row in agent_status.rows:
198
+ typer.echo(f" {row.label.ljust(max_label + 2)}", nl=False)
199
+ typer.secho(row.status, fg=_STATUS_COLOR[row.status])
200
+ typer.echo("")
201
+
202
+ if not all(s.is_ok() for s in statuses):
203
+ raise typer.Exit(code=1)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ app()
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ _HARNESS_DIR = Path(".harness")
7
+ HARNESS_CONFIG = _HARNESS_DIR / "config.toml"
8
+
9
+
10
+ class ConfigError(Exception):
11
+ """Raised when .harness/config.toml is absent or has unexpected structure."""
12
+
13
+
14
+ def write_config(root: Path, agent_names: list[str]) -> None:
15
+ agents_repr = ", ".join(f'"{n}"' for n in agent_names)
16
+ (root / HARNESS_CONFIG).write_text(
17
+ f"[harness]\nversion = 1\nagents = [{agents_repr}]\n"
18
+ )
19
+
20
+
21
+ def read_agent_names(root: Path) -> list[str]:
22
+ config_path = root / HARNESS_CONFIG
23
+ if not config_path.exists():
24
+ raise ConfigError(
25
+ f"{HARNESS_CONFIG} not found. Run `mh init` to register agents."
26
+ )
27
+ try:
28
+ with config_path.open("rb") as fh:
29
+ data = tomllib.load(fh)
30
+ except tomllib.TOMLDecodeError as exc:
31
+ raise ConfigError(f"{HARNESS_CONFIG} is malformed: {exc}") from exc
32
+
33
+ try:
34
+ agents = data["harness"]["agents"]
35
+ except KeyError as exc:
36
+ raise ConfigError(
37
+ f"{HARNESS_CONFIG} is missing key {exc}. Re-run `mh init`."
38
+ ) from exc
39
+
40
+ if not isinstance(agents, list) or not all(isinstance(n, str) for n in agents):
41
+ raise ConfigError(
42
+ f"{HARNESS_CONFIG}: harness.agents must be a list of strings."
43
+ )
44
+
45
+ return agents
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from .agents import AGENT_REGISTRY, AgentSpec
7
+ from .config import ConfigError, read_agent_names, write_config
8
+ from .symlinks import SymlinkResult, ensure_symlink
9
+
10
+ HARNESS_DIR = Path(".harness")
11
+ HARNESS_SKILLS = HARNESS_DIR / "skills"
12
+ HARNESS_AGENTS = HARNESS_DIR / "agents"
13
+ AGENTS_MD = Path("AGENTS.md")
14
+
15
+
16
+ class HarnessError(Exception):
17
+ """Raised when `mh init` cannot safely proceed."""
18
+
19
+
20
+ @dataclass
21
+ class InitReport:
22
+ detected: list[AgentSpec] = field(default_factory=list)
23
+ moved: list[tuple[Path, Path]] = field(default_factory=list)
24
+ created_files: list[Path] = field(default_factory=list)
25
+ symlinks: list[tuple[Path, SymlinkResult]] = field(default_factory=list)
26
+
27
+
28
+ @dataclass
29
+ class AddReport:
30
+ symlinks: list[tuple[Path, SymlinkResult]] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class RemoveReport:
35
+ removed_symlinks: list[Path] = field(default_factory=list)
36
+ removed_dirs: list[Path] = field(default_factory=list)
37
+ nonempty_dirs: list[Path] = field(default_factory=list)
38
+
39
+
40
+ def detect_configured_agents(root: Path) -> list[AgentSpec]:
41
+ """Return registered agents whose real detection paths exist under ``root``."""
42
+ detected: list[AgentSpec] = []
43
+ for spec in AGENT_REGISTRY.values():
44
+ for p in spec.detection_paths:
45
+ candidate = root / p
46
+ if candidate.exists() and not candidate.is_symlink():
47
+ detected.append(spec)
48
+ break
49
+ return detected
50
+
51
+
52
+ def _move(src: Path, dst: Path, report: InitReport) -> None:
53
+ if dst.exists() or dst.is_symlink():
54
+ raise HarnessError(
55
+ f"Cannot migrate {src} -> {dst}: destination already exists. "
56
+ f"Resolve the conflict and re-run."
57
+ )
58
+ dst.parent.mkdir(parents=True, exist_ok=True)
59
+ src.rename(dst)
60
+ report.moved.append((src, dst))
61
+
62
+
63
+ def _migrate(root: Path, spec: AgentSpec, report: InitReport) -> None:
64
+ if spec.instructions_path is not None:
65
+ src = root / spec.instructions_path
66
+ dst = root / AGENTS_MD
67
+ if src.exists() and not src.is_symlink() and src != dst:
68
+ _move(src, dst, report)
69
+
70
+ src_skills = root / spec.skills_path
71
+ if src_skills.is_dir() and not src_skills.is_symlink():
72
+ _move(src_skills, root / HARNESS_SKILLS, report)
73
+
74
+ src_subagents = root / spec.subagents_path
75
+ if src_subagents.is_dir() and not src_subagents.is_symlink():
76
+ _move(src_subagents, root / HARNESS_AGENTS, report)
77
+
78
+
79
+ def init(
80
+ root: Path,
81
+ agent_names: list[str],
82
+ template: Path | None = None,
83
+ force: bool = False,
84
+ ) -> InitReport:
85
+ """Initialize (or re-initialize) the harness layout at ``root``.
86
+
87
+ Raises :class:`HarnessError` if the project is in a state that requires manual
88
+ resolution (e.g. multiple agents already configured, or `.harness/` already
89
+ exists without ``--force``).
90
+ """
91
+ unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
92
+ if unknown:
93
+ raise HarnessError(
94
+ f"Unknown agent(s): {', '.join(unknown)}. "
95
+ f"Supported: {', '.join(AGENT_REGISTRY)}."
96
+ )
97
+
98
+ report = InitReport()
99
+ harness_existed = (root / HARNESS_DIR).exists()
100
+
101
+ if harness_existed and not force:
102
+ raise HarnessError(
103
+ f"{HARNESS_DIR}/ already exists. Pass --force to re-link symlinks idempotently."
104
+ )
105
+
106
+ if not harness_existed:
107
+ report.detected = detect_configured_agents(root)
108
+ if len(report.detected) > 1:
109
+ names = ", ".join(s.display_name for s in report.detected)
110
+ raise HarnessError(
111
+ f"More than one agent is already configured ({names}). "
112
+ f"Resolve manually (remove all but one) and re-run."
113
+ )
114
+ if len(report.detected) == 1:
115
+ _migrate(root, report.detected[0], report)
116
+
117
+ (root / HARNESS_SKILLS).mkdir(parents=True, exist_ok=True)
118
+ (root / HARNESS_AGENTS).mkdir(parents=True, exist_ok=True)
119
+
120
+ agents_md = root / AGENTS_MD
121
+ if not agents_md.exists() and not agents_md.is_symlink():
122
+ if template is not None:
123
+ agents_md.write_text(template.read_text())
124
+ else:
125
+ agents_md.touch()
126
+ report.created_files.append(agents_md)
127
+
128
+ for name in agent_names:
129
+ _create_agent_symlinks(root, AGENT_REGISTRY[name], report.symlinks)
130
+
131
+ write_config(root, agent_names)
132
+ return report
133
+
134
+
135
+ def _create_agent_symlinks(
136
+ root: Path,
137
+ spec: AgentSpec,
138
+ out: list[tuple[Path, SymlinkResult]],
139
+ ) -> None:
140
+ if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
141
+ link = root / spec.instructions_path
142
+ try:
143
+ result = ensure_symlink(link, root / AGENTS_MD)
144
+ except FileExistsError as exc:
145
+ raise HarnessError(str(exc)) from exc
146
+ out.append((link, result))
147
+ for link_rel, target_rel in (
148
+ (spec.skills_path, HARNESS_SKILLS),
149
+ (spec.subagents_path, HARNESS_AGENTS),
150
+ ):
151
+ link = root / link_rel
152
+ try:
153
+ result = ensure_symlink(link, root / target_rel)
154
+ except FileExistsError as exc:
155
+ raise HarnessError(str(exc)) from exc
156
+ out.append((link, result))
157
+
158
+
159
+ def add(root: Path, agent_names: list[str]) -> AddReport:
160
+ """Register new agents to an already-initialized harness.
161
+
162
+ Raises :class:`HarnessError` if the harness is absent, any name is unknown,
163
+ or any agent is already registered.
164
+ """
165
+ unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
166
+ if unknown:
167
+ raise HarnessError(
168
+ f"Unknown agent(s): {', '.join(unknown)}. "
169
+ f"Supported: {', '.join(AGENT_REGISTRY)}."
170
+ )
171
+
172
+ if not (root / HARNESS_DIR).exists():
173
+ raise HarnessError(f"{HARNESS_DIR}/ not found. Run `mh init` first.")
174
+
175
+ try:
176
+ current = read_agent_names(root)
177
+ except ConfigError as exc:
178
+ raise HarnessError(str(exc)) from exc
179
+
180
+ already = [n for n in agent_names if n in current]
181
+ if already:
182
+ raise HarnessError(
183
+ f"Agent(s) already registered: {', '.join(already)}. "
184
+ f"Run `mh remove` first if you want to re-add."
185
+ )
186
+
187
+ report = AddReport()
188
+ for name in agent_names:
189
+ _create_agent_symlinks(root, AGENT_REGISTRY[name], report.symlinks)
190
+
191
+ write_config(root, current + agent_names)
192
+ return report
193
+
194
+
195
+ def remove(root: Path, agent_names: list[str]) -> RemoveReport:
196
+ """Unlink symlinks for the given agents and clean up empty parent directories.
197
+
198
+ Raises :class:`HarnessError` if the harness is absent, any name is unknown,
199
+ or any agent is not currently registered.
200
+
201
+ Non-empty parent directories are reported in ``RemoveReport.nonempty_dirs``
202
+ and left for the caller to handle (e.g. interactive prompt).
203
+ """
204
+ unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
205
+ if unknown:
206
+ raise HarnessError(
207
+ f"Unknown agent(s): {', '.join(unknown)}. "
208
+ f"Supported: {', '.join(AGENT_REGISTRY)}."
209
+ )
210
+
211
+ if not (root / HARNESS_DIR).exists():
212
+ raise HarnessError(f"{HARNESS_DIR}/ not found. Run `mh init` first.")
213
+
214
+ try:
215
+ current = read_agent_names(root)
216
+ except ConfigError as exc:
217
+ raise HarnessError(str(exc)) from exc
218
+
219
+ not_registered = [n for n in agent_names if n not in current]
220
+ if not_registered:
221
+ raise HarnessError(
222
+ f"Agent(s) not registered: {', '.join(not_registered)}. "
223
+ f"Run `mh status` to see registered agents."
224
+ )
225
+
226
+ report = RemoveReport()
227
+ parent_dirs: set[Path] = set()
228
+
229
+ for name in agent_names:
230
+ spec = AGENT_REGISTRY[name]
231
+ links: list[Path] = []
232
+ if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
233
+ links.append(root / spec.instructions_path)
234
+ links.append(root / spec.skills_path)
235
+ links.append(root / spec.subagents_path)
236
+
237
+ for link in links:
238
+ if link.is_symlink():
239
+ parent = link.parent
240
+ link.unlink()
241
+ report.removed_symlinks.append(link)
242
+ if parent != root:
243
+ parent_dirs.add(parent)
244
+
245
+ for d in sorted(parent_dirs):
246
+ if not any(d.iterdir()):
247
+ d.rmdir()
248
+ report.removed_dirs.append(d)
249
+ else:
250
+ report.nonempty_dirs.append(d)
251
+
252
+ remaining = [n for n in current if n not in set(agent_names)]
253
+ write_config(root, remaining)
254
+ return report
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from .agents import AGENT_REGISTRY, AgentSpec
9
+ from .harness import AGENTS_MD, HARNESS_AGENTS, HARNESS_SKILLS
10
+
11
+ LinkStatus = Literal["ok", "missing", "broken", "detached"]
12
+
13
+
14
+ def check_symlink(link: Path, expected_target: Path) -> LinkStatus:
15
+ if link.is_symlink():
16
+ resolved = (link.parent / os.readlink(link)).resolve()
17
+ return "ok" if resolved == expected_target.resolve() else "broken"
18
+ if link.exists():
19
+ return "detached"
20
+ return "missing"
21
+
22
+
23
+ @dataclass
24
+ class AgentStatusRow:
25
+ label: str
26
+ status: LinkStatus
27
+
28
+
29
+ @dataclass
30
+ class AgentStatus:
31
+ spec: AgentSpec
32
+ rows: list[AgentStatusRow]
33
+
34
+ def is_ok(self) -> bool:
35
+ return all(r.status == "ok" for r in self.rows)
36
+
37
+
38
+ def check_agent(root: Path, spec: AgentSpec) -> AgentStatus:
39
+ rows: list[AgentStatusRow] = []
40
+
41
+ if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
42
+ rows.append(AgentStatusRow(
43
+ label=str(spec.instructions_path),
44
+ status=check_symlink(root / spec.instructions_path, root / AGENTS_MD),
45
+ ))
46
+
47
+ rows.append(AgentStatusRow(
48
+ label=str(spec.skills_path),
49
+ status=check_symlink(root / spec.skills_path, root / HARNESS_SKILLS),
50
+ ))
51
+ rows.append(AgentStatusRow(
52
+ label=str(spec.subagents_path),
53
+ status=check_symlink(root / spec.subagents_path, root / HARNESS_AGENTS),
54
+ ))
55
+
56
+ return AgentStatus(spec=spec, rows=rows)
57
+
58
+
59
+ def check_all(root: Path, agent_names: list[str]) -> list[AgentStatus]:
60
+ return [check_agent(root, AGENT_REGISTRY[name]) for name in agent_names]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ SymlinkResult = Literal["created", "ok", "replaced"]
8
+
9
+
10
+ def ensure_symlink(link: Path, target: Path) -> SymlinkResult:
11
+ """Create or update a relative symlink at ``link`` pointing at ``target``.
12
+
13
+ Returns ``"created"`` if newly made, ``"ok"`` if already correct, or
14
+ ``"replaced"`` if an existing symlink was repointed. Raises ``FileExistsError``
15
+ if ``link`` exists as a real file or directory (we never overwrite user data).
16
+ """
17
+ rel = Path(os.path.relpath(target, start=link.parent))
18
+
19
+ if link.is_symlink():
20
+ current = Path(os.readlink(link))
21
+ if current == rel:
22
+ return "ok"
23
+ link.unlink()
24
+ link.symlink_to(rel)
25
+ return "replaced"
26
+
27
+ if link.exists():
28
+ raise FileExistsError(
29
+ f"{link} already exists as a real file or directory; refusing to overwrite. "
30
+ f"Move or remove it manually, then re-run `mh init`."
31
+ )
32
+
33
+ link.parent.mkdir(parents=True, exist_ok=True)
34
+ link.symlink_to(rel)
35
+ return "created"
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: multi-harness
3
+ Version: 0.1.0
4
+ Summary: Manage projects that target multiple coding agents from a single shared harness.
5
+ Author-email: jslarraz <jslarraz@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: typer>=0.12
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # multi-harness 🔗
14
+
15
+ > One project. Many coding agents. Zero duplication.
16
+
17
+ `mh` is a CLI that keeps your skills, subagents, and project instructions in a single
18
+ canonical `.harness/` directory and materialises per-agent symlinks so every agent sees
19
+ the paths it expects — no copy-paste, no drift.
20
+
21
+ **Supported agents:** Claude Code · OpenAI Codex CLI · OpenCode · GitHub Copilot · Google Antigravity
22
+
23
+ ---
24
+
25
+ ## ✨ Why multi-harness?
26
+
27
+ Each coding agent looks for instructions, skills, and subagents in different places:
28
+ `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `.claude/skills/`, `.codex/skills/` …
29
+
30
+ Without `mh` you either duplicate these files across every agent-specific location or
31
+ keep them in sync by hand. `mh` solves this by:
32
+
33
+ 1. Creating one real `.harness/` directory with the canonical files.
34
+ 2. Writing relative symlinks everywhere each agent expects to look.
35
+ 3. Detecting an existing single-agent project and **migrating** it automatically.
36
+
37
+ ---
38
+
39
+ ## 📦 Installation
40
+
41
+ ```bash
42
+ pip install multi-harness
43
+ ```
44
+
45
+ Verify the install:
46
+
47
+ ```bash
48
+ mh --help
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 🚀 Quick start
54
+
55
+ ```bash
56
+ cd my-project
57
+ mh init
58
+ ```
59
+
60
+ That's it. `mh` detects which agents are already configured, migrates their files into
61
+ `.harness/`, and creates symlinks for all five supported agents.
62
+
63
+ ---
64
+
65
+ ## 📖 Commands
66
+
67
+ ### `mh init` — bootstrap or migrate a project
68
+
69
+ ```bash
70
+ mh init # all five agents, current directory
71
+ mh init --agents claude,codex # register only two agents
72
+ mh init --template ./AGENTS_TEMPLATE.md # seed AGENTS.md from a template file
73
+ mh init --force # re-create symlinks idempotently (safe)
74
+ mh init /path/to/project # target a different directory
75
+ ```
76
+
77
+ On first run `mh init`:
78
+ - Creates `AGENTS.md` (or uses your `--template`) as the shared project instructions file.
79
+ - Creates `.harness/skills/` and `.harness/agents/` as the shared canonical directories.
80
+ - Writes relative symlinks for every registered agent (see layout below).
81
+ - Records the registered agents in `.harness/config.toml`.
82
+
83
+ If exactly **one** agent is already configured, `mh init` migrates its files into
84
+ `.harness/` before creating symlinks — your existing work is preserved, not overwritten.
85
+
86
+ ---
87
+
88
+ ### `mh add` — register new agents
89
+
90
+ ```bash
91
+ mh add copilot # add a single agent
92
+ mh add opencode antigravity # add multiple agents at once
93
+ ```
94
+
95
+ Creates the symlinks for the new agent(s) and updates `.harness/config.toml`. Requires
96
+ `mh init` to have been run first.
97
+
98
+ ---
99
+
100
+ ### `mh remove` — unregister agents
101
+
102
+ ```bash
103
+ mh remove codex # remove one agent
104
+ mh remove opencode copilot # remove several at once
105
+ ```
106
+
107
+ Removes the agent's symlinks (instructions, skills, subagents) without touching any file
108
+ inside `.harness/`. Your shared content is never deleted.
109
+
110
+ ---
111
+
112
+ ### `mh status` — inspect symlink health
113
+
114
+ ```bash
115
+ mh status # check current directory
116
+ mh status /path/to/project # check another directory
117
+ ```
118
+
119
+ Reports the state of every symlink for every registered agent:
120
+
121
+ | Status | Meaning |
122
+ |--------|---------|
123
+ | ✅ `ok` | Symlink exists and resolves correctly |
124
+ | ❌ `missing` | Symlink is absent |
125
+ | 💔 `broken` | Symlink exists but target doesn't resolve |
126
+ | ⚠️ `detached` | A real file/dir sits where a symlink should be |
127
+
128
+ ---
129
+
130
+ ## 🗂️ Layout produced by `mh init`
131
+
132
+ ```
133
+ my-project/
134
+ ├── AGENTS.md ← canonical shared instructions (real file)
135
+ ├── CLAUDE.md → AGENTS.md
136
+ ├── .harness/
137
+ │ ├── config.toml ← registered agents list
138
+ │ ├── skills/ ← shared skills (real dir)
139
+ │ └── agents/ ← shared subagents (real dir)
140
+ ├── .claude/
141
+ │ ├── skills → ../.harness/skills
142
+ │ └── agents → ../.harness/agents
143
+ ├── .codex/
144
+ │ ├── skills → ../.harness/skills
145
+ │ └── agents → ../.harness/agents
146
+ ├── .opencode/
147
+ │ ├── skills → ../.harness/skills
148
+ │ └── agent → ../.harness/agents
149
+ ├── .github/
150
+ │ ├── copilot-instructions.md → ../AGENTS.md
151
+ │ ├── skills → ../.harness/skills
152
+ │ └── agents → ../.harness/agents
153
+ ├── .agents/
154
+ │ └── skills → ../.harness/skills
155
+ └── .subagents → .harness/agents
156
+ ```
157
+
158
+ All symlinks are **relative**, so the tree is fully portable across machines and paths.
159
+
160
+ ---
161
+
162
+ ## 🔄 Typical workflow
163
+
164
+ ```bash
165
+ # 1. Bootstrap a new project
166
+ mh init --agents claude,codex
167
+
168
+ # 2. Add an agent later
169
+ mh add copilot
170
+
171
+ # 3. Check everything is wired up correctly
172
+ mh status
173
+
174
+ # 4. Remove an agent you no longer use
175
+ mh remove antigravity
176
+ ```
177
+
178
+ Write your instructions once in `AGENTS.md`, drop skills into `.harness/skills/`, and
179
+ place shared subagents in `.harness/agents/` — every registered agent picks them up
180
+ automatically.
181
+
182
+ ---
183
+
184
+ ## 🛡️ Safety guarantees
185
+
186
+ - **Symlinks never overwrite real files.** If a real file or directory already exists
187
+ where a symlink should go, `mh` aborts with an error rather than deleting your data.
188
+ - **`--force` is safe.** It re-creates symlinks but still refuses to touch real files.
189
+ - **Migration is non-destructive.** Files are moved into `.harness/`; nothing is deleted.
@@ -0,0 +1,17 @@
1
+ multi_harness/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ multi_harness/cli.py,sha256=SVtmA_f54VarZVaLjebLiA3L0Lt6TG2wNPG08e6mjes,6146
3
+ multi_harness/config.py,sha256=27V5UA7u8PTCBkjvzsF3qeuth4C27V02_r3MzdnBkPw,1358
4
+ multi_harness/harness.py,sha256=rmQc-kxDZb7sCyZxOZlIgcjr0Qk-OFTLj_bH-i9pr5g,8575
5
+ multi_harness/status.py,sha256=6NFiv7FNfzvpKEUymnfDhhhqnh8vhDbdkYX8Iy0sW1o,1720
6
+ multi_harness/symlinks.py,sha256=T8AisxDGW1HyWnoWwIAjTe74QYpuUKHE0LEUU6HTasc,1119
7
+ multi_harness/agents/__init__.py,sha256=jqH76KDGwwn7t2GEcSBHdORYKCMnhWomm1_Gl-rDLaM,398
8
+ multi_harness/agents/antigravity.py,sha256=BSUBmzEsquJ8zH6ICr_nQJPWauVkxVpeNiplz5w7Q_I,304
9
+ multi_harness/agents/claude.py,sha256=RHmukKe5TnjpniyF2GNFCMYFT68P2Eyb8irhZf5DuQE,308
10
+ multi_harness/agents/codex.py,sha256=X_xDHYT5IbCdLsKMPI0D6xpfneN0FNRXNNX6aHj9bhE,274
11
+ multi_harness/agents/copilot.py,sha256=u0LgCST2ByI9QLQqRvMJItZoLTT4hijQ2uOralBUJaU,340
12
+ multi_harness/agents/opencode.py,sha256=27iCFSc1WiwLQ1tPo9NVrFPL7A9Wlx8GzNV0Zb9jLlU,281
13
+ multi_harness/agents/spec.py,sha256=hqqXGLW43fVBxR7s1On-Jw-WvElQYQjx3QELC2_Rm5Q,294
14
+ multi_harness-0.1.0.dist-info/METADATA,sha256=G7dGzOkm5JsdbUJi4egjxhFTVwIwKt30pK9RSf-frqE,6084
15
+ multi_harness-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ multi_harness-0.1.0.dist-info/entry_points.txt,sha256=uSRPViv2-Oilz9mOBvAk_n8W1r9Zr_3drrGEX10ZQeE,45
17
+ multi_harness-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mh = multi_harness.cli:app