Skip to content

Commit 3e24897

Browse files
feat(集成): 为 OpenCode 与 Hermes 挂接第一方 Skill 分身 (#57)
Add fail-closed Skill host seats so install/sync/status attach avatars under ~/.config/opencode/skills and ~/.hermes/skills only when those homes already exist. Absent homes stay silent; Dyro does not create them. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Dandre Yang <Dandre126@users.noreply.github.com>
1 parent 78f8e64 commit 3e24897

4 files changed

Lines changed: 148 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
- Attach first-party Skill avatars to OpenCode and Hermes when those host
6+
homes already exist (`~/.config/opencode/skills/<skill>`,
7+
`~/.hermes/skills/<skill>`). Detection stays fail-closed: absent homes
8+
stay silent, and Dyro does not create OpenCode or Hermes directories.
9+
`OPENCODE_CONFIG_DIR` and `HERMES_HOME` remain the existing overrides.
10+
Pi (`PI_CODING_AGENT_DIR`, `~/.pi/agent`) is unchanged.
11+
512
## 0.7.10 - 2026-08-21
613

714
- `dyro next` no longer reports `ready` when doctor has FAIL findings,

docs/updates.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ dyro integration sync skill --yes # upgrade-only; skips absent installs
7474

7575
(`codex` is an alias for `skill`.)
7676

77+
Avatars attach only to already-present agent homes. OpenCode uses
78+
`~/.config/opencode/skills` (`OPENCODE_CONFIG_DIR` when set) and Hermes uses
79+
`~/.hermes/skills` (`HERMES_HOME` when set). Absent homes stay silent; Dyro
80+
does not create those directories.
81+
7782
The outbound `dyro-dispatch`, executor, and board Skills keep independent
7883
ownership state from the read-only control-plane Skill. One `dyro setup` opt-in
7984
installs the first-batch seat bundle. An existing managed control-plane

src/dyro/integrations/manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ class HostSpec:
160160
HostSpec("grok", "GROK_HOME", ".grok"),
161161
HostSpec("pi", "PI_CODING_AGENT_DIR", ".pi/agent"),
162162
HostSpec("dsh", "DSH_HOME", ".dsh"),
163+
HostSpec("opencode", "OPENCODE_CONFIG_DIR", ".config/opencode"),
164+
HostSpec("hermes", "HERMES_HOME", ".hermes"),
163165
)
164166

165167

tests/test_integrations.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,8 @@ def test_pi_skill_host_uses_coding_agent_dir(self) -> None:
12591259
avatar = pi_home / "skills" / "dyro-control-plane"
12601260
self.assertTrue(avatar.is_symlink() or avatar.is_dir())
12611261
self.assertTrue((avatar / "SKILL.md").is_file())
1262+
pi_status = integration_status("skill", host_homes={"pi": pi_home})
1263+
self.assertIn("pi", {row.host for row in pi_status.avatars})
12621264

12631265
def test_dsh_skill_host_uses_dsh_home(self) -> None:
12641266
spec = next(host for host in manager.HOSTS if host.host_id == "dsh")
@@ -1271,6 +1273,138 @@ def test_dsh_skill_host_uses_dsh_home(self) -> None:
12711273
self.assertTrue(avatar.is_symlink() or avatar.is_dir())
12721274
self.assertTrue((avatar / "SKILL.md").is_file())
12731275

1276+
def test_opencode_and_hermes_host_specs_use_existing_homes(self) -> None:
1277+
opencode = next(host for host in manager.HOSTS if host.host_id == "opencode")
1278+
hermes = next(host for host in manager.HOSTS if host.host_id == "hermes")
1279+
self.assertEqual(opencode.env_var, "OPENCODE_CONFIG_DIR")
1280+
self.assertEqual(opencode.default_dirname, ".config/opencode")
1281+
self.assertEqual(hermes.env_var, "HERMES_HOME")
1282+
self.assertEqual(hermes.default_dirname, ".hermes")
1283+
1284+
opencode_home = self.root / "opencode-home"
1285+
hermes_home = self.root / "hermes-home"
1286+
homes = {"opencode": opencode_home, "hermes": hermes_home}
1287+
preview = integration_status("skill", host_homes=homes)
1288+
self.assertEqual(
1289+
{row.host: row.state for row in preview.avatars if row.host in homes},
1290+
{"opencode": "missing", "hermes": "missing"},
1291+
)
1292+
self.assertEqual(
1293+
manager._avatar_path(opencode_home, manager.CONTROL_PLANE_SPEC),
1294+
opencode_home / "skills" / "dyro-control-plane",
1295+
)
1296+
self.assertEqual(
1297+
manager._avatar_path(hermes_home, manager.CONTROL_PLANE_SPEC),
1298+
hermes_home / "skills" / "dyro-control-plane",
1299+
)
1300+
1301+
installed = install_integration("skill", yes=True, host_homes=homes)
1302+
self.assertEqual(installed.status.state, IntegrationState.CURRENT)
1303+
opencode_avatar = opencode_home / "skills" / "dyro-control-plane"
1304+
hermes_avatar = hermes_home / "skills" / "dyro-control-plane"
1305+
self.assertTrue(opencode_avatar.is_symlink())
1306+
self.assertTrue(hermes_avatar.is_symlink())
1307+
self.assertEqual(opencode_avatar.resolve(), self.mirror.resolve())
1308+
self.assertEqual(hermes_avatar.resolve(), self.mirror.resolve())
1309+
self.assertTrue((opencode_avatar / "SKILL.md").is_file())
1310+
self.assertTrue((hermes_avatar / "SKILL.md").is_file())
1311+
self.assertEqual(
1312+
{row.host: row.state for row in installed.status.avatars if row.host in homes},
1313+
{"opencode": "current", "hermes": "current"},
1314+
)
1315+
1316+
synced = sync_managed_skill(yes=True, host_homes=homes)
1317+
self.assertIsNone(synced)
1318+
self.assertEqual(
1319+
integration_status("skill", host_homes=homes).state,
1320+
IntegrationState.CURRENT,
1321+
)
1322+
1323+
removed = uninstall_integration("skill", yes=True, host_homes=homes)
1324+
self.assertEqual(removed.status.state, IntegrationState.ABSENT)
1325+
self.assertFalse(opencode_avatar.exists() or opencode_avatar.is_symlink())
1326+
self.assertFalse(hermes_avatar.exists() or hermes_avatar.is_symlink())
1327+
1328+
def test_absent_opencode_and_hermes_homes_stay_silent(self) -> None:
1329+
config_root = self.fake_home / ".config"
1330+
default_opencode = config_root / "opencode"
1331+
default_hermes = self.fake_home / ".hermes"
1332+
1333+
config_root.mkdir()
1334+
status = integration_status("skill")
1335+
hosts = {row.host for row in status.avatars}
1336+
self.assertIn("codex", hosts)
1337+
self.assertNotIn("opencode", hosts)
1338+
self.assertNotIn("hermes", hosts)
1339+
self.assertIsNone(
1340+
manager._host_home(
1341+
next(host for host in manager.HOSTS if host.host_id == "opencode"),
1342+
None,
1343+
)
1344+
)
1345+
self.assertIsNone(
1346+
manager._host_home(
1347+
next(host for host in manager.HOSTS if host.host_id == "hermes"),
1348+
None,
1349+
)
1350+
)
1351+
1352+
install_integration("skill", yes=True)
1353+
self.assertFalse(default_opencode.exists())
1354+
self.assertFalse(default_hermes.exists())
1355+
self.assertEqual(
1356+
{row.host for row in integration_status("skill").avatars},
1357+
{"codex"},
1358+
)
1359+
1360+
def test_present_opencode_and_hermes_default_homes_receive_avatars(self) -> None:
1361+
opencode_home = self.fake_home / ".config" / "opencode"
1362+
hermes_home = self.fake_home / ".hermes"
1363+
opencode_home.mkdir(parents=True)
1364+
hermes_home.mkdir()
1365+
1366+
status = integration_status("skill")
1367+
hosts = {row.host: row for row in status.avatars}
1368+
self.assertIn("opencode", hosts)
1369+
self.assertIn("hermes", hosts)
1370+
self.assertIn("codex", hosts)
1371+
self.assertEqual(hosts["opencode"].state, "missing")
1372+
self.assertEqual(hosts["hermes"].state, "missing")
1373+
self.assertEqual(
1374+
hosts["opencode"].path,
1375+
opencode_home / "skills" / "dyro-control-plane",
1376+
)
1377+
self.assertEqual(
1378+
hosts["hermes"].path,
1379+
hermes_home / "skills" / "dyro-control-plane",
1380+
)
1381+
1382+
installed = install_integration("skill", yes=True)
1383+
self.assertEqual(installed.status.state, IntegrationState.CURRENT)
1384+
opencode_avatar = opencode_home / "skills" / "dyro-control-plane"
1385+
hermes_avatar = hermes_home / "skills" / "dyro-control-plane"
1386+
self.assertTrue(opencode_avatar.is_symlink())
1387+
self.assertTrue(hermes_avatar.is_symlink())
1388+
self.assertEqual(
1389+
{row.host: row.state for row in installed.status.avatars},
1390+
{"codex": "current", "opencode": "current", "hermes": "current"},
1391+
)
1392+
1393+
output = StringIO()
1394+
with redirect_stdout(output):
1395+
main(["integration", "status", "skill"])
1396+
main(["integration", "sync", "skill", "--yes"])
1397+
text = output.getvalue()
1398+
self.assertIn("avatar\topencode\tcurrent", text)
1399+
self.assertIn("avatar\thermes\tcurrent", text)
1400+
self.assertIn("无需同步", text)
1401+
1402+
uninstall_integration("skill", yes=True)
1403+
self.assertTrue(opencode_home.is_dir())
1404+
self.assertTrue(hermes_home.is_dir())
1405+
self.assertFalse(opencode_avatar.exists() or opencode_avatar.is_symlink())
1406+
self.assertFalse(hermes_avatar.exists() or hermes_avatar.is_symlink())
1407+
12741408

12751409
if __name__ == "__main__":
12761410
unittest.main()

0 commit comments

Comments
 (0)