twagent 2.2.1__tar.gz → 2.3.0__tar.gz
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.
- {twagent-2.2.1/src/twagent.egg-info → twagent-2.3.0}/PKG-INFO +1 -1
- {twagent-2.2.1 → twagent-2.3.0}/pyproject.toml +2 -2
- twagent-2.3.0/src/twagent/__init__.py +1 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/cli.py +11 -1
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/config.py +75 -14
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/deploy.py +29 -1
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/doctor.py +30 -1
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/plugins.py +16 -2
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/selector.py +21 -1
- {twagent-2.2.1 → twagent-2.3.0/src/twagent.egg-info}/PKG-INFO +1 -1
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_config.py +109 -2
- twagent-2.3.0/tests/test_deploy_plugins.py +222 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_doctor.py +44 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_plugins.py +11 -3
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_selector.py +40 -0
- twagent-2.2.1/src/twagent/__init__.py +0 -1
- twagent-2.2.1/tests/test_deploy_plugins.py +0 -83
- {twagent-2.2.1 → twagent-2.3.0}/LICENSE +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/README.md +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/setup.cfg +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/diff.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/expansion.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/extractor.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/info.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/interpolate.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent/mcp.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/SOURCES.txt +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/dependency_links.txt +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/entry_points.txt +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/requires.txt +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/top_level.txt +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_apply.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_diff.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_doctor.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_extract.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_info.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_listings.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_deploy_render.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_deploy_symlinks.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_expansion.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_extractor.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_info.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_interpolate.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_mcp.py +0 -0
- {twagent-2.2.1 → twagent-2.3.0}/tests/test_performance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "twagent"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
description = "twagent CLI"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -86,7 +86,7 @@ skip-magic-trailing-comma = false
|
|
|
86
86
|
line-ending = "auto"
|
|
87
87
|
|
|
88
88
|
[tool.bumpversion]
|
|
89
|
-
current_version = "2.
|
|
89
|
+
current_version = "2.3.0"
|
|
90
90
|
commit = true
|
|
91
91
|
tag = false
|
|
92
92
|
tag_name = "v{new_version}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.3.0"
|
|
@@ -387,7 +387,17 @@ def apply(
|
|
|
387
387
|
f"[green]Applied[/green] ({mode}): {n_written} {label}, "
|
|
388
388
|
f"{len(result.warnings)} warning(s), {len(result.errors)} error(s)."
|
|
389
389
|
)
|
|
390
|
-
if
|
|
390
|
+
if result.skipped_optional:
|
|
391
|
+
summary += (
|
|
392
|
+
f" {len(result.skipped_optional)} optional source(s) absent "
|
|
393
|
+
f"— run `twagent doctor` to list them."
|
|
394
|
+
)
|
|
395
|
+
if (
|
|
396
|
+
n_written == 0
|
|
397
|
+
and not result.warnings
|
|
398
|
+
and not result.errors
|
|
399
|
+
and not result.skipped_optional
|
|
400
|
+
):
|
|
391
401
|
summary = (
|
|
392
402
|
f"[yellow]No-op[/yellow] ({mode}): nothing to deploy. "
|
|
393
403
|
f"Check `twagent agents` for paths.project.* coverage, "
|
|
@@ -23,7 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
from typing import Final, Literal, overload
|
|
24
24
|
|
|
25
25
|
from twagent.interpolate import contains_variable_default
|
|
26
|
-
from twagent.plugins import discover_plugin
|
|
26
|
+
from twagent.plugins import PluginSourceMissing, discover_plugin
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
@@ -87,6 +87,9 @@ class FileArtifact:
|
|
|
87
87
|
name: str
|
|
88
88
|
source: Path
|
|
89
89
|
description: str | None = None
|
|
90
|
+
#: Declared absent on some machines. A missing source is then a silent
|
|
91
|
+
#: skip at deploy rather than an error (one config, many machines).
|
|
92
|
+
optional: bool = False
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
@dataclass(frozen=True)
|
|
@@ -130,6 +133,12 @@ class Plugin:
|
|
|
130
133
|
subagents: list[str] = field(default_factory=list)
|
|
131
134
|
prompts: list[str] = field(default_factory=list)
|
|
132
135
|
servers: list[str] = field(default_factory=list)
|
|
136
|
+
#: See FileArtifact.optional.
|
|
137
|
+
optional: bool = False
|
|
138
|
+
#: False when the source dir is absent on this machine. The name stays
|
|
139
|
+
#: registered (profile refs and --select must still resolve) but the
|
|
140
|
+
#: member lists are empty, so it contributes nothing to any expansion.
|
|
141
|
+
available: bool = True
|
|
133
142
|
|
|
134
143
|
|
|
135
144
|
@dataclass(frozen=True)
|
|
@@ -240,10 +249,10 @@ def _build(raw: dict, base_dir: Path) -> Configuration:
|
|
|
240
249
|
|
|
241
250
|
common = _build_common(raw.get("common", {}))
|
|
242
251
|
agents = _build_agents(raw.get("agents", {}))
|
|
243
|
-
instructions = _build_artifacts(raw.get("instructions", {}))
|
|
244
|
-
skills = _build_artifacts(raw.get("skills", {}))
|
|
245
|
-
subagents = _build_artifacts(raw.get("subagents", {}))
|
|
246
|
-
prompts = _build_artifacts(raw.get("prompts", {}))
|
|
252
|
+
instructions = _build_artifacts("instructions", raw.get("instructions", {}))
|
|
253
|
+
skills = _build_artifacts("skills", raw.get("skills", {}))
|
|
254
|
+
subagents = _build_artifacts("subagents", raw.get("subagents", {}))
|
|
255
|
+
prompts = _build_artifacts("prompts", raw.get("prompts", {}))
|
|
247
256
|
servers = _build_servers(raw.get("servers", {}))
|
|
248
257
|
profiles = _build_profiles(raw.get("profiles", {}))
|
|
249
258
|
plugins = _build_plugins(
|
|
@@ -326,17 +335,27 @@ def _build_paths_section(raw: dict) -> dict[str, list[Path]]:
|
|
|
326
335
|
return {cap: [Path(p).expanduser() for p in paths] for cap, paths in raw.items()}
|
|
327
336
|
|
|
328
337
|
|
|
329
|
-
def _build_artifacts(raw: dict) -> dict[str, FileArtifact]:
|
|
338
|
+
def _build_artifacts(kind: str, raw: dict) -> dict[str, FileArtifact]:
|
|
339
|
+
for name, blob in raw.items():
|
|
340
|
+
_check_unknown_keys(f"{kind}.{name}", blob, _ARTIFACT_KEYS)
|
|
330
341
|
return {
|
|
331
342
|
name: FileArtifact(
|
|
332
343
|
name=name,
|
|
333
|
-
source=
|
|
344
|
+
source=_require_source(f"{kind}.{name}", blob),
|
|
334
345
|
description=blob.get("description"),
|
|
346
|
+
optional=bool(blob.get("optional", False)),
|
|
335
347
|
)
|
|
336
348
|
for name, blob in raw.items()
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
|
|
352
|
+
def _require_source(where: str, blob: dict) -> Path:
|
|
353
|
+
"""Read the mandatory `source` key as a ConfigError, not a bare KeyError."""
|
|
354
|
+
if "source" not in blob:
|
|
355
|
+
raise ConfigError(f"{where}: missing required key 'source'")
|
|
356
|
+
return Path(blob["source"]).expanduser()
|
|
357
|
+
|
|
358
|
+
|
|
340
359
|
def _build_server(name: str, blob: dict) -> Server:
|
|
341
360
|
server = Server(
|
|
342
361
|
name=name,
|
|
@@ -374,6 +393,10 @@ def _build_servers(raw: dict) -> dict[str, Server]:
|
|
|
374
393
|
# Keys a [profiles.<name>] block may contain. A misspelled key (e.g.
|
|
375
394
|
# `pluings`) was previously dropped silently — turning a typo into a silent
|
|
376
395
|
# no-op. Reject unknown keys so the failure is loud, per fail-fast.
|
|
396
|
+
_ARTIFACT_KEYS: Final[frozenset[str]] = frozenset({"source", "description", "optional"})
|
|
397
|
+
|
|
398
|
+
_PLUGIN_KEYS: Final[frozenset[str]] = frozenset({"source", "description", "optional"})
|
|
399
|
+
|
|
377
400
|
_PROFILE_KEYS: Final[frozenset[str]] = frozenset(
|
|
378
401
|
{
|
|
379
402
|
"description",
|
|
@@ -446,9 +469,33 @@ def _build_plugins(
|
|
|
446
469
|
out: dict[str, Plugin] = {}
|
|
447
470
|
for plugin_name in sorted(raw):
|
|
448
471
|
blob = raw[plugin_name]
|
|
449
|
-
|
|
472
|
+
_check_unknown_keys(f"plugins.{plugin_name}", blob, _PLUGIN_KEYS)
|
|
473
|
+
source = _require_source(f"plugins.{plugin_name}", blob)
|
|
474
|
+
optional = bool(blob.get("optional", False))
|
|
450
475
|
try:
|
|
451
476
|
contents = discover_plugin(plugin_name, source)
|
|
477
|
+
except PluginSourceMissing as exc:
|
|
478
|
+
# The dir simply isn't on this machine. Register a placeholder so
|
|
479
|
+
# profile refs and --select still resolve, but inject nothing.
|
|
480
|
+
# A present-but-broken plugin still falls through to the hard
|
|
481
|
+
# error below — that's a real defect, not a machine difference.
|
|
482
|
+
out[plugin_name] = Plugin(
|
|
483
|
+
name=plugin_name,
|
|
484
|
+
source=source,
|
|
485
|
+
description=blob.get("description"),
|
|
486
|
+
optional=optional,
|
|
487
|
+
available=False,
|
|
488
|
+
)
|
|
489
|
+
if optional:
|
|
490
|
+
logger.debug(
|
|
491
|
+
"config._build_plugins: %s optional and absent, skipped",
|
|
492
|
+
plugin_name,
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
warnings.warn(
|
|
496
|
+
f"plugins.{plugin_name}: {exc}", UserWarning, stacklevel=3
|
|
497
|
+
)
|
|
498
|
+
continue
|
|
452
499
|
except (FileNotFoundError, ValueError) as exc:
|
|
453
500
|
raise ConfigError(f"plugins.{plugin_name}: {exc}")
|
|
454
501
|
|
|
@@ -468,6 +515,7 @@ def _build_plugins(
|
|
|
468
515
|
out[plugin_name] = Plugin(
|
|
469
516
|
name=plugin_name,
|
|
470
517
|
source=source,
|
|
518
|
+
optional=optional,
|
|
471
519
|
description=description,
|
|
472
520
|
skills=skill_names,
|
|
473
521
|
subagents=subagent_names,
|
|
@@ -662,7 +710,12 @@ def _validate_no_name_shadow(config: Configuration) -> None:
|
|
|
662
710
|
|
|
663
711
|
|
|
664
712
|
def _check_artifact_sources(config: Configuration) -> None:
|
|
665
|
-
"""Missing source = warning, not hard error (FR-005).
|
|
713
|
+
"""Missing source = warning, not hard error (FR-005).
|
|
714
|
+
|
|
715
|
+
`optional = true` entries are exempt: they are declared absent on some
|
|
716
|
+
machines, so their absence is expected rather than a warning. `doctor`
|
|
717
|
+
reports them instead.
|
|
718
|
+
"""
|
|
666
719
|
logger.debug("config._check_artifact_sources: scanning registries")
|
|
667
720
|
for registry, kind in (
|
|
668
721
|
(config.instructions, "instructions"),
|
|
@@ -671,9 +724,17 @@ def _check_artifact_sources(config: Configuration) -> None:
|
|
|
671
724
|
(config.prompts, "prompts"),
|
|
672
725
|
):
|
|
673
726
|
for name, art in registry.items():
|
|
674
|
-
if
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
727
|
+
if art.source.exists():
|
|
728
|
+
continue
|
|
729
|
+
if art.optional:
|
|
730
|
+
logger.debug(
|
|
731
|
+
"config._check_artifact_sources: %s.%s optional and absent",
|
|
732
|
+
kind,
|
|
733
|
+
name,
|
|
679
734
|
)
|
|
735
|
+
continue
|
|
736
|
+
warnings.warn(
|
|
737
|
+
f"{kind}.{name}: source does not exist: {art.source}",
|
|
738
|
+
UserWarning,
|
|
739
|
+
stacklevel=3,
|
|
740
|
+
)
|
|
@@ -62,6 +62,10 @@ class ApplyResult:
|
|
|
62
62
|
warnings: list[str] = field(default_factory=list)
|
|
63
63
|
errors: list[str] = field(default_factory=list)
|
|
64
64
|
dry_run_log: list[str] = field(default_factory=list)
|
|
65
|
+
#: Distinct artifact names declared `optional = true` whose source is
|
|
66
|
+
#: absent on this machine — one entry per artifact, however many agents
|
|
67
|
+
#: wanted it. Expected, so deliberately NOT part of `has_errors`.
|
|
68
|
+
skipped_optional: list[str] = field(default_factory=list)
|
|
65
69
|
|
|
66
70
|
@property
|
|
67
71
|
def has_errors(self) -> bool:
|
|
@@ -538,7 +542,31 @@ def _deploy_file_artifacts(
|
|
|
538
542
|
if ctx.dry_run:
|
|
539
543
|
result.dry_run_log.append(f"dedup {label}")
|
|
540
544
|
logger.debug("deploy.dedup: %s", label)
|
|
541
|
-
sources
|
|
545
|
+
sources: dict[str, Path] = {}
|
|
546
|
+
for name in members:
|
|
547
|
+
art = registry.get(name)
|
|
548
|
+
if art is None:
|
|
549
|
+
continue
|
|
550
|
+
if art.optional and not art.source.exists():
|
|
551
|
+
# Leave it out of `sources` entirely: link_artifacts reaps target
|
|
552
|
+
# symlinks it doesn't see, which also clears a stale link synced
|
|
553
|
+
# in from a machine where this artifact did exist.
|
|
554
|
+
#
|
|
555
|
+
# Recorded by artifact name, not per agent/capability slot: one
|
|
556
|
+
# absent skill wanted by four agents is one absent source, and
|
|
557
|
+
# that is the count the summary line reports. The slot detail
|
|
558
|
+
# stays in the debug log.
|
|
559
|
+
if name not in result.skipped_optional:
|
|
560
|
+
result.skipped_optional.append(name)
|
|
561
|
+
logger.debug(
|
|
562
|
+
"deploy.optional_absent: %s/%s/%s (%s)",
|
|
563
|
+
agent.id,
|
|
564
|
+
capability,
|
|
565
|
+
name,
|
|
566
|
+
art.source,
|
|
567
|
+
)
|
|
568
|
+
continue
|
|
569
|
+
sources[name] = art.source
|
|
542
570
|
for target_dir in targets:
|
|
543
571
|
link_result = link_artifacts(sources, target_dir, dry_run=ctx.dry_run)
|
|
544
572
|
for name in link_result.created + link_result.relinked:
|
|
@@ -26,6 +26,7 @@ def check(config: Configuration) -> DoctorReport:
|
|
|
26
26
|
logger.debug("doctor.check: starting")
|
|
27
27
|
report = DoctorReport()
|
|
28
28
|
_check_artifact_sources(config, report)
|
|
29
|
+
_check_plugin_sources(config, report)
|
|
29
30
|
_check_dangling_symlinks(config, report)
|
|
30
31
|
_check_capability_mismatches(config, report)
|
|
31
32
|
_check_agents_without_global_profile(config, report)
|
|
@@ -46,12 +47,40 @@ def _check_artifact_sources(config: Configuration, report: DoctorReport) -> None
|
|
|
46
47
|
("prompts", config.prompts),
|
|
47
48
|
):
|
|
48
49
|
for name, art in registry.items():
|
|
49
|
-
if
|
|
50
|
+
if art.source.exists():
|
|
51
|
+
continue
|
|
52
|
+
if art.optional:
|
|
53
|
+
report.info.append(
|
|
54
|
+
f"{kind}.{name}: expected absent on this machine "
|
|
55
|
+
f"(optional): {art.source}"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
50
58
|
report.errors.append(
|
|
51
59
|
f"{kind}.{name}: source does not exist: {art.source}"
|
|
52
60
|
)
|
|
53
61
|
|
|
54
62
|
|
|
63
|
+
def _check_plugin_sources(config: Configuration, report: DoctorReport) -> None:
|
|
64
|
+
"""Plugins whose source dir isn't on this machine.
|
|
65
|
+
|
|
66
|
+
`_build_plugins` degrades these to placeholders so the config still
|
|
67
|
+
loads; doctor is where they surface.
|
|
68
|
+
"""
|
|
69
|
+
logger.debug("doctor._check_plugin_sources")
|
|
70
|
+
for name, plugin in config.plugins.items():
|
|
71
|
+
if plugin.available:
|
|
72
|
+
continue
|
|
73
|
+
if plugin.optional:
|
|
74
|
+
report.info.append(
|
|
75
|
+
f"plugins.{name}: expected absent on this machine "
|
|
76
|
+
f"(optional): {plugin.source}"
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
report.errors.append(
|
|
80
|
+
f"plugins.{name}: source dir does not exist: {plugin.source}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
55
84
|
def _check_dangling_symlinks(config: Configuration, report: DoctorReport) -> None:
|
|
56
85
|
"""Walk every per-agent capability directory and warn on dangling links."""
|
|
57
86
|
logger.debug("doctor._check_dangling_symlinks")
|
|
@@ -20,6 +20,16 @@ from pathlib import Path
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
class PluginSourceMissing(FileNotFoundError):
|
|
24
|
+
"""The plugin's source directory is not present on this machine.
|
|
25
|
+
|
|
26
|
+
Distinct from a present-but-broken plugin: an absent directory is the
|
|
27
|
+
expected case when one canonical config is shared across machines that
|
|
28
|
+
don't all check out the same repos. config.py degrades these to a
|
|
29
|
+
placeholder instead of failing the whole load.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
23
33
|
@dataclass(frozen=True)
|
|
24
34
|
class PluginContents:
|
|
25
35
|
"""Pieces discovered in one plugin, keyed by on-disk basename.
|
|
@@ -41,10 +51,14 @@ class PluginContents:
|
|
|
41
51
|
def discover_plugin(name: str, source: Path) -> PluginContents:
|
|
42
52
|
"""Parse `<source>/plugin.json` and discover all declared pieces.
|
|
43
53
|
|
|
44
|
-
Raises
|
|
45
|
-
|
|
54
|
+
Raises PluginSourceMissing if the source dir itself is absent (the
|
|
55
|
+
machine simply doesn't have it), plain FileNotFoundError if the dir is
|
|
56
|
+
there but holds no plugin.json, and ValueError if the manifest is
|
|
57
|
+
malformed or declares a non-existent dir.
|
|
46
58
|
"""
|
|
47
59
|
logger.debug("plugins.discover_plugin: name=%s source=%s", name, source)
|
|
60
|
+
if not source.is_dir():
|
|
61
|
+
raise PluginSourceMissing(f"plugin {name!r}: source dir not found: {source}")
|
|
48
62
|
manifest_path = source / "plugin.json"
|
|
49
63
|
if not manifest_path.exists():
|
|
50
64
|
raise FileNotFoundError(f"plugin {name!r}: no plugin.json at {manifest_path}")
|
|
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING
|
|
|
17
17
|
|
|
18
18
|
from simple_term_menu import TerminalMenu
|
|
19
19
|
|
|
20
|
-
from twagent.config import EXPANSION_KINDS, ProfileExpansion
|
|
20
|
+
from twagent.config import EXPANSION_KINDS, FileArtifact, ProfileExpansion
|
|
21
21
|
from twagent.expansion import expand_profile
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
@@ -118,12 +118,32 @@ def resolve_selection(names: list[str], config: "Configuration") -> ProfileExpan
|
|
|
118
118
|
buckets[kind].append(m)
|
|
119
119
|
elif name in config.plugins:
|
|
120
120
|
plugin = config.plugins[name]
|
|
121
|
+
if not plugin.available:
|
|
122
|
+
# Selected by name, so silence would be wrong even when the
|
|
123
|
+
# entry is marked optional — the user asked for this thing.
|
|
124
|
+
logger.warning(
|
|
125
|
+
"--select %s: plugin source not on this machine (%s); "
|
|
126
|
+
"it contributes nothing",
|
|
127
|
+
name,
|
|
128
|
+
plugin.source,
|
|
129
|
+
)
|
|
121
130
|
for kind in buckets:
|
|
122
131
|
for m in getattr(plugin, kind, []):
|
|
123
132
|
if m not in buckets[kind]:
|
|
124
133
|
buckets[kind].append(m)
|
|
125
134
|
elif name in artifact_kind_of:
|
|
126
135
|
kind = artifact_kind_of[name]
|
|
136
|
+
art = config.registry(kind)[name]
|
|
137
|
+
if (
|
|
138
|
+
isinstance(art, FileArtifact)
|
|
139
|
+
and art.optional
|
|
140
|
+
and not art.source.exists()
|
|
141
|
+
):
|
|
142
|
+
logger.warning(
|
|
143
|
+
"--select %s: source not on this machine (%s); it will be skipped",
|
|
144
|
+
name,
|
|
145
|
+
art.source,
|
|
146
|
+
)
|
|
127
147
|
if name not in buckets[kind]:
|
|
128
148
|
buckets[kind].append(name)
|
|
129
149
|
else:
|
|
@@ -4,6 +4,8 @@ Schema v2: scopes are gone. Global deployment is per-agent `global_profile`.
|
|
|
4
4
|
Polymorphic --select forces a name-shadow validation rule across registries.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
7
9
|
import pytest
|
|
8
10
|
|
|
9
11
|
from twagent.config import (
|
|
@@ -12,6 +14,7 @@ from twagent.config import (
|
|
|
12
14
|
ConfigError,
|
|
13
15
|
load,
|
|
14
16
|
)
|
|
17
|
+
from twagent.expansion import expand_profile
|
|
15
18
|
|
|
16
19
|
# ─── Helpers ────────────────────────────────────────────────────────────
|
|
17
20
|
|
|
@@ -565,14 +568,118 @@ source = "{FIXTURE_PLUGINS / "beta"}"
|
|
|
565
568
|
_write_config(tmp_path, body)
|
|
566
569
|
|
|
567
570
|
|
|
568
|
-
def
|
|
571
|
+
def test_plugin_missing_source_dir_warns_and_degrades(tmp_path):
|
|
572
|
+
"""One config, many machines: an absent plugin dir must not brick load."""
|
|
573
|
+
body = f"""\
|
|
574
|
+
schema_version = 3
|
|
575
|
+
|
|
576
|
+
[plugins.ghost]
|
|
577
|
+
source = "{tmp_path / "nonexistent"}"
|
|
578
|
+
"""
|
|
579
|
+
with pytest.warns(UserWarning, match="ghost"):
|
|
580
|
+
config = _write_config(tmp_path, body)
|
|
581
|
+
assert config.plugins["ghost"].available is False
|
|
582
|
+
assert config.plugins["ghost"].optional is False
|
|
583
|
+
assert config.plugins["ghost"].skills == []
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def test_plugin_missing_source_dir_optional_is_silent(tmp_path):
|
|
587
|
+
body = f"""\
|
|
588
|
+
schema_version = 3
|
|
589
|
+
|
|
590
|
+
[plugins.ghost]
|
|
591
|
+
source = "{tmp_path / "nonexistent"}"
|
|
592
|
+
optional = true
|
|
593
|
+
"""
|
|
594
|
+
with warnings.catch_warnings():
|
|
595
|
+
warnings.simplefilter("error")
|
|
596
|
+
config = _write_config(tmp_path, body)
|
|
597
|
+
assert config.plugins["ghost"].available is False
|
|
598
|
+
assert config.plugins["ghost"].optional is True
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_plugin_dir_present_but_no_manifest_is_still_hard_error(tmp_path):
|
|
602
|
+
"""A broken plugin is a real defect, not a machine difference."""
|
|
603
|
+
(tmp_path / "broken").mkdir()
|
|
604
|
+
body = f"""\
|
|
605
|
+
schema_version = 3
|
|
606
|
+
|
|
607
|
+
[plugins.broken]
|
|
608
|
+
source = "{tmp_path / "broken"}"
|
|
609
|
+
"""
|
|
610
|
+
with pytest.raises(ConfigError, match="broken"):
|
|
611
|
+
_write_config(tmp_path, body)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_plugin_dir_present_but_malformed_manifest_is_hard_error(tmp_path):
|
|
615
|
+
(tmp_path / "broken").mkdir()
|
|
616
|
+
(tmp_path / "broken" / "plugin.json").write_text("{not json")
|
|
617
|
+
body = f"""\
|
|
618
|
+
schema_version = 3
|
|
619
|
+
|
|
620
|
+
[plugins.broken]
|
|
621
|
+
source = "{tmp_path / "broken"}"
|
|
622
|
+
"""
|
|
623
|
+
with pytest.raises(ConfigError, match="cannot parse"):
|
|
624
|
+
_write_config(tmp_path, body)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def test_profile_referencing_degraded_plugin_still_validates(tmp_path):
|
|
628
|
+
"""Name stays registered, so profile refs and expansion keep working."""
|
|
569
629
|
body = f"""\
|
|
570
630
|
schema_version = 3
|
|
571
631
|
|
|
572
632
|
[plugins.ghost]
|
|
573
633
|
source = "{tmp_path / "nonexistent"}"
|
|
634
|
+
optional = true
|
|
635
|
+
|
|
636
|
+
[profiles.p]
|
|
637
|
+
plugins = ["ghost"]
|
|
574
638
|
"""
|
|
575
|
-
|
|
639
|
+
config = _write_config(tmp_path, body)
|
|
640
|
+
expanded = expand_profile(config, "p")
|
|
641
|
+
assert expanded.skills == []
|
|
642
|
+
assert expanded.servers == []
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_optional_artifact_missing_source_does_not_warn(tmp_path):
|
|
646
|
+
body = (
|
|
647
|
+
MINIMAL_OK
|
|
648
|
+
+ """
|
|
649
|
+
[skills.s]
|
|
650
|
+
source = "/nonexistent/path"
|
|
651
|
+
optional = true
|
|
652
|
+
"""
|
|
653
|
+
)
|
|
654
|
+
body = body.replace("[profiles.empty]\n", '[profiles.empty]\nskills = ["s"]\n')
|
|
655
|
+
with warnings.catch_warnings():
|
|
656
|
+
warnings.simplefilter("error")
|
|
657
|
+
config = _write_config(tmp_path, body)
|
|
658
|
+
assert config.skills["s"].optional is True
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_artifact_unknown_key_is_rejected_with_suggestion(tmp_path):
|
|
662
|
+
body = (
|
|
663
|
+
MINIMAL_OK
|
|
664
|
+
+ """
|
|
665
|
+
[skills.s]
|
|
666
|
+
source = "/nonexistent/path"
|
|
667
|
+
optinal = true
|
|
668
|
+
"""
|
|
669
|
+
)
|
|
670
|
+
with pytest.raises(ConfigError, match="optinal.*did you mean 'optional'"):
|
|
671
|
+
_write_config(tmp_path, body)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_artifact_missing_source_key_is_config_error(tmp_path):
|
|
675
|
+
body = (
|
|
676
|
+
MINIMAL_OK
|
|
677
|
+
+ """
|
|
678
|
+
[skills.s]
|
|
679
|
+
description = "no source"
|
|
680
|
+
"""
|
|
681
|
+
)
|
|
682
|
+
with pytest.raises(ConfigError, match="skills.s: missing required key 'source'"):
|
|
576
683
|
_write_config(tmp_path, body)
|
|
577
684
|
|
|
578
685
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Integration: a plugin-bearing profile deploys + reaps via the existing
|
|
2
|
+
deploy machinery (Task 8). No production deploy code changes — if these pass,
|
|
3
|
+
the "zero deploy changes" design claim holds.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import textwrap
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from twagent.config import load
|
|
11
|
+
from twagent.deploy import apply_here
|
|
12
|
+
|
|
13
|
+
FIXTURE_PLUGINS = Path(__file__).parent / "fixtures" / "plugins"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _setup(tmp_path: Path):
|
|
17
|
+
"""A config with one agent, the alpha plugin, and a plain `keeper` skill.
|
|
18
|
+
|
|
19
|
+
Profile `p` pulls the plugin atomically; profile `q` engages the skills
|
|
20
|
+
capability with a non-plugin skill, so switching p -> q exercises orphan
|
|
21
|
+
reaping of the plugin's deployed skill.
|
|
22
|
+
"""
|
|
23
|
+
keeper = tmp_path / "keeper_skill"
|
|
24
|
+
keeper.mkdir()
|
|
25
|
+
(keeper / "SKILL.md").write_text("---\ndescription: keeper skill\n---\n")
|
|
26
|
+
|
|
27
|
+
cfg = tmp_path / "config.toml"
|
|
28
|
+
cfg.write_text(
|
|
29
|
+
textwrap.dedent(
|
|
30
|
+
f"""\
|
|
31
|
+
schema_version = 3
|
|
32
|
+
|
|
33
|
+
[agents.claude-code]
|
|
34
|
+
capabilities = ["skills", "subagents"]
|
|
35
|
+
|
|
36
|
+
[agents.claude-code.paths.global]
|
|
37
|
+
skills = ["~/.claude/skills"]
|
|
38
|
+
subagents = ["~/.claude/agents"]
|
|
39
|
+
|
|
40
|
+
[agents.claude-code.paths.project]
|
|
41
|
+
skills = [".claude/skills"]
|
|
42
|
+
subagents = [".claude/agents"]
|
|
43
|
+
|
|
44
|
+
[skills.keeper]
|
|
45
|
+
source = "{keeper}"
|
|
46
|
+
|
|
47
|
+
[plugins.alpha]
|
|
48
|
+
source = "{FIXTURE_PLUGINS / "alpha"}"
|
|
49
|
+
|
|
50
|
+
[profiles.p]
|
|
51
|
+
plugins = ["alpha"]
|
|
52
|
+
|
|
53
|
+
[profiles.q]
|
|
54
|
+
skills = ["keeper"]
|
|
55
|
+
"""
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return load(cfg)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_apply_here_symlinks_plugin_pieces(tmp_path):
|
|
62
|
+
config = _setup(tmp_path)
|
|
63
|
+
cwd = tmp_path / "work"
|
|
64
|
+
cwd.mkdir()
|
|
65
|
+
|
|
66
|
+
apply_here(config, cwd, select=["p"])
|
|
67
|
+
|
|
68
|
+
assert (cwd / ".claude" / "skills" / "greet").is_symlink()
|
|
69
|
+
assert (cwd / ".claude" / "agents" / "helper.agent.md").is_symlink()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_switching_profile_reaps_plugin_skill(tmp_path):
|
|
73
|
+
config = _setup(tmp_path)
|
|
74
|
+
cwd = tmp_path / "work"
|
|
75
|
+
cwd.mkdir()
|
|
76
|
+
|
|
77
|
+
apply_here(config, cwd, select=["p"])
|
|
78
|
+
assert (cwd / ".claude" / "skills" / "greet").is_symlink()
|
|
79
|
+
|
|
80
|
+
# Switch to a profile that still engages the skills capability but does
|
|
81
|
+
# not include the plugin: the plugin's skill is reaped as an orphan.
|
|
82
|
+
apply_here(config, cwd, select=["q"])
|
|
83
|
+
assert not (cwd / ".claude" / "skills" / "greet").exists()
|
|
84
|
+
assert (cwd / ".claude" / "skills" / "keeper").is_symlink()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─── portability: sources absent on this machine ────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _setup_optional(tmp_path: Path, *, optional: bool):
|
|
91
|
+
"""`keeper` exists; `workonly` and plugin `ghost` point nowhere.
|
|
92
|
+
|
|
93
|
+
Mirrors one canonical config synced to a machine that doesn't check out
|
|
94
|
+
the work repos.
|
|
95
|
+
"""
|
|
96
|
+
keeper = tmp_path / "keeper_skill"
|
|
97
|
+
keeper.mkdir()
|
|
98
|
+
(keeper / "SKILL.md").write_text("---\ndescription: keeper skill\n---\n")
|
|
99
|
+
|
|
100
|
+
flag = "\noptional = true" if optional else ""
|
|
101
|
+
cfg = tmp_path / "config.toml"
|
|
102
|
+
cfg.write_text(
|
|
103
|
+
textwrap.dedent(
|
|
104
|
+
f"""\
|
|
105
|
+
schema_version = 3
|
|
106
|
+
|
|
107
|
+
[agents.claude-code]
|
|
108
|
+
capabilities = ["skills"]
|
|
109
|
+
|
|
110
|
+
[agents.claude-code.paths.global]
|
|
111
|
+
skills = ["~/.claude/skills"]
|
|
112
|
+
|
|
113
|
+
[agents.claude-code.paths.project]
|
|
114
|
+
skills = [".claude/skills"]
|
|
115
|
+
|
|
116
|
+
[skills.keeper]
|
|
117
|
+
source = "{keeper}"
|
|
118
|
+
|
|
119
|
+
[skills.workonly]
|
|
120
|
+
source = "{tmp_path / "nope"}"{flag}
|
|
121
|
+
|
|
122
|
+
[plugins.ghost]
|
|
123
|
+
source = "{tmp_path / "no-plugin"}"{flag}
|
|
124
|
+
|
|
125
|
+
[profiles.p]
|
|
126
|
+
skills = ["keeper", "workonly"]
|
|
127
|
+
plugins = ["ghost"]
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
with warnings.catch_warnings():
|
|
132
|
+
warnings.simplefilter("ignore")
|
|
133
|
+
return load(cfg)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_optional_absent_source_is_skipped_not_an_error(tmp_path):
|
|
137
|
+
config = _setup_optional(tmp_path, optional=True)
|
|
138
|
+
cwd = tmp_path / "work"
|
|
139
|
+
cwd.mkdir()
|
|
140
|
+
|
|
141
|
+
result = apply_here(config, cwd, select=["p"])
|
|
142
|
+
|
|
143
|
+
assert not result.has_errors
|
|
144
|
+
assert any("workonly" in s for s in result.skipped_optional)
|
|
145
|
+
assert (cwd / ".claude" / "skills" / "keeper").is_symlink()
|
|
146
|
+
assert not (cwd / ".claude" / "skills" / "workonly").exists()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_non_optional_absent_source_still_errors(tmp_path):
|
|
150
|
+
config = _setup_optional(tmp_path, optional=False)
|
|
151
|
+
cwd = tmp_path / "work"
|
|
152
|
+
cwd.mkdir()
|
|
153
|
+
|
|
154
|
+
result = apply_here(config, cwd, select=["p"])
|
|
155
|
+
|
|
156
|
+
assert result.has_errors
|
|
157
|
+
assert any("workonly" in e for e in result.errors)
|
|
158
|
+
assert result.skipped_optional == []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_optional_absent_source_reaps_stale_symlink(tmp_path):
|
|
162
|
+
"""A broken link synced in from the machine where it did exist."""
|
|
163
|
+
config = _setup_optional(tmp_path, optional=True)
|
|
164
|
+
cwd = tmp_path / "work"
|
|
165
|
+
target = cwd / ".claude" / "skills"
|
|
166
|
+
target.mkdir(parents=True)
|
|
167
|
+
(target / "workonly").symlink_to(tmp_path / "gone-with-the-other-machine")
|
|
168
|
+
|
|
169
|
+
apply_here(config, cwd, select=["p"])
|
|
170
|
+
|
|
171
|
+
assert not (target / "workonly").is_symlink()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_optional_skips_are_counted_per_artifact_not_per_agent(tmp_path):
|
|
175
|
+
"""One absent skill wanted by three agents is ONE absent source.
|
|
176
|
+
|
|
177
|
+
The summary line reports what the user thinks in — artifacts — not the
|
|
178
|
+
agent x capability slots the deploy loop happens to iterate.
|
|
179
|
+
"""
|
|
180
|
+
cfg = tmp_path / "config.toml"
|
|
181
|
+
cfg.write_text(
|
|
182
|
+
textwrap.dedent(
|
|
183
|
+
f"""\
|
|
184
|
+
schema_version = 3
|
|
185
|
+
|
|
186
|
+
[agents.a]
|
|
187
|
+
capabilities = ["skills"]
|
|
188
|
+
[agents.a.paths.global]
|
|
189
|
+
skills = ["~/.a/skills"]
|
|
190
|
+
[agents.a.paths.project]
|
|
191
|
+
skills = [".a/skills"]
|
|
192
|
+
|
|
193
|
+
[agents.b]
|
|
194
|
+
capabilities = ["skills"]
|
|
195
|
+
[agents.b.paths.global]
|
|
196
|
+
skills = ["~/.b/skills"]
|
|
197
|
+
[agents.b.paths.project]
|
|
198
|
+
skills = [".b/skills"]
|
|
199
|
+
|
|
200
|
+
[agents.c]
|
|
201
|
+
capabilities = ["skills"]
|
|
202
|
+
[agents.c.paths.global]
|
|
203
|
+
skills = ["~/.c/skills"]
|
|
204
|
+
[agents.c.paths.project]
|
|
205
|
+
skills = [".c/skills"]
|
|
206
|
+
|
|
207
|
+
[skills.workonly]
|
|
208
|
+
source = "{tmp_path / "nope"}"
|
|
209
|
+
optional = true
|
|
210
|
+
|
|
211
|
+
[profiles.p]
|
|
212
|
+
skills = ["workonly"]
|
|
213
|
+
"""
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
config = load(cfg)
|
|
217
|
+
cwd = tmp_path / "work"
|
|
218
|
+
cwd.mkdir()
|
|
219
|
+
|
|
220
|
+
result = apply_here(config, cwd, select=["p"])
|
|
221
|
+
|
|
222
|
+
assert result.skipped_optional == ["workonly"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""US3: doctor reports introduced failures + clean state passes (v2)."""
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -106,3 +107,46 @@ subagents = ["reviewer"]
|
|
|
106
107
|
config = load(config_path)
|
|
107
108
|
report = check(config)
|
|
108
109
|
assert any("subagents" in i and "lacks" in i for i in report.info)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ─── portability: doctor is where per-machine gaps surface ──────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _portable_config(tmp_path, *, optional: bool):
|
|
116
|
+
flag = "\noptional = true" if optional else ""
|
|
117
|
+
config_path = tmp_path / "config.toml"
|
|
118
|
+
config_path.write_text(
|
|
119
|
+
f"""\
|
|
120
|
+
schema_version = 3
|
|
121
|
+
[skills.workonly]
|
|
122
|
+
source = "{tmp_path / "nope"}"{flag}
|
|
123
|
+
[plugins.ghost]
|
|
124
|
+
source = "{tmp_path / "no-plugin"}"{flag}
|
|
125
|
+
[profiles.p]
|
|
126
|
+
skills = ["workonly"]
|
|
127
|
+
plugins = ["ghost"]
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
with warnings.catch_warnings():
|
|
131
|
+
warnings.simplefilter("ignore")
|
|
132
|
+
return load(config_path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_optional_absent_sources_are_info_not_errors(tmp_path):
|
|
136
|
+
report = check(_portable_config(tmp_path, optional=True))
|
|
137
|
+
assert not report.has_errors
|
|
138
|
+
assert any("skills.workonly" in i and "expected absent" in i for i in report.info)
|
|
139
|
+
assert any("plugins.ghost" in i and "expected absent" in i for i in report.info)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_non_optional_absent_sources_are_errors(tmp_path):
|
|
143
|
+
report = check(_portable_config(tmp_path, optional=False))
|
|
144
|
+
assert report.has_errors
|
|
145
|
+
assert any("skills.workonly" in e for e in report.errors)
|
|
146
|
+
assert any("plugins.ghost" in e for e in report.errors)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_doctor_loads_despite_absent_plugin_dir(tmp_path):
|
|
150
|
+
"""Regression: an absent plugin dir used to make doctor itself unusable."""
|
|
151
|
+
report = check(_portable_config(tmp_path, optional=True))
|
|
152
|
+
assert report.info # it ran at all
|
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from twagent.plugins import PluginContents, discover_plugin
|
|
5
|
+
from twagent.plugins import PluginContents, PluginSourceMissing, discover_plugin
|
|
6
6
|
|
|
7
7
|
FIXTURES = Path(__file__).parent / "fixtures" / "plugins"
|
|
8
8
|
|
|
@@ -32,11 +32,19 @@ def test_discover_plugin_reads_all_kinds_from_manifest():
|
|
|
32
32
|
assert contents.servers["alpha-server"]["command"] == "echo"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def
|
|
36
|
-
|
|
35
|
+
def test_discover_plugin_absent_source_dir_raises_source_missing():
|
|
36
|
+
"""Absent dir is its own error: config degrades it instead of failing."""
|
|
37
|
+
with pytest.raises(PluginSourceMissing):
|
|
37
38
|
discover_plugin("nope", FIXTURES / "does-not-exist")
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def test_discover_plugin_missing_manifest_raises(tmp_path):
|
|
42
|
+
"""Dir present but no plugin.json — broken, NOT PluginSourceMissing."""
|
|
43
|
+
with pytest.raises(FileNotFoundError) as exc:
|
|
44
|
+
discover_plugin("nope", tmp_path)
|
|
45
|
+
assert not isinstance(exc.value, PluginSourceMissing)
|
|
46
|
+
|
|
47
|
+
|
|
40
48
|
def test_discover_plugin_declared_dir_absent_raises(tmp_path):
|
|
41
49
|
(tmp_path / "plugin.json").write_text('{"name": "x", "skills": "skills/"}')
|
|
42
50
|
with pytest.raises(ValueError, match="declares skills dir"):
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""US5: selection across all four list-shaped artifact types + polymorphic --select."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import warnings
|
|
3
5
|
from types import SimpleNamespace
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
@@ -336,3 +338,41 @@ class TestResolveSelectionPlugins:
|
|
|
336
338
|
config = self._config_with_plugin()
|
|
337
339
|
with pytest.raises(ValueError, match="Unknown name"):
|
|
338
340
|
resolve_selection(["nope"], config)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ─── portability: naming an absent thing explicitly stays loud ───────────
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _absent_config(tmp_path):
|
|
347
|
+
config_path = tmp_path / "config.toml"
|
|
348
|
+
config_path.write_text(
|
|
349
|
+
f"""\
|
|
350
|
+
schema_version = 3
|
|
351
|
+
[skills.workonly]
|
|
352
|
+
source = "{tmp_path / "nope"}"
|
|
353
|
+
optional = true
|
|
354
|
+
[plugins.ghost]
|
|
355
|
+
source = "{tmp_path / "no-plugin"}"
|
|
356
|
+
optional = true
|
|
357
|
+
[profiles.p]
|
|
358
|
+
"""
|
|
359
|
+
)
|
|
360
|
+
with warnings.catch_warnings():
|
|
361
|
+
warnings.simplefilter("ignore")
|
|
362
|
+
return load(config_path)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_select_absent_optional_skill_warns(tmp_path, caplog):
|
|
366
|
+
config = _absent_config(tmp_path)
|
|
367
|
+
with caplog.at_level(logging.WARNING):
|
|
368
|
+
expanded = resolve_selection(["workonly"], config)
|
|
369
|
+
assert expanded.skills == ["workonly"]
|
|
370
|
+
assert "not on this machine" in caplog.text
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_select_degraded_plugin_warns(tmp_path, caplog):
|
|
374
|
+
config = _absent_config(tmp_path)
|
|
375
|
+
with caplog.at_level(logging.WARNING):
|
|
376
|
+
expanded = resolve_selection(["ghost"], config)
|
|
377
|
+
assert expanded.skills == []
|
|
378
|
+
assert "not on this machine" in caplog.text
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.2.1"
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Integration: a plugin-bearing profile deploys + reaps via the existing
|
|
2
|
-
deploy machinery (Task 8). No production deploy code changes — if these pass,
|
|
3
|
-
the "zero deploy changes" design claim holds.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import textwrap
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from twagent.config import load
|
|
10
|
-
from twagent.deploy import apply_here
|
|
11
|
-
|
|
12
|
-
FIXTURE_PLUGINS = Path(__file__).parent / "fixtures" / "plugins"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _setup(tmp_path: Path):
|
|
16
|
-
"""A config with one agent, the alpha plugin, and a plain `keeper` skill.
|
|
17
|
-
|
|
18
|
-
Profile `p` pulls the plugin atomically; profile `q` engages the skills
|
|
19
|
-
capability with a non-plugin skill, so switching p -> q exercises orphan
|
|
20
|
-
reaping of the plugin's deployed skill.
|
|
21
|
-
"""
|
|
22
|
-
keeper = tmp_path / "keeper_skill"
|
|
23
|
-
keeper.mkdir()
|
|
24
|
-
(keeper / "SKILL.md").write_text("---\ndescription: keeper skill\n---\n")
|
|
25
|
-
|
|
26
|
-
cfg = tmp_path / "config.toml"
|
|
27
|
-
cfg.write_text(
|
|
28
|
-
textwrap.dedent(
|
|
29
|
-
f"""\
|
|
30
|
-
schema_version = 3
|
|
31
|
-
|
|
32
|
-
[agents.claude-code]
|
|
33
|
-
capabilities = ["skills", "subagents"]
|
|
34
|
-
|
|
35
|
-
[agents.claude-code.paths.global]
|
|
36
|
-
skills = ["~/.claude/skills"]
|
|
37
|
-
subagents = ["~/.claude/agents"]
|
|
38
|
-
|
|
39
|
-
[agents.claude-code.paths.project]
|
|
40
|
-
skills = [".claude/skills"]
|
|
41
|
-
subagents = [".claude/agents"]
|
|
42
|
-
|
|
43
|
-
[skills.keeper]
|
|
44
|
-
source = "{keeper}"
|
|
45
|
-
|
|
46
|
-
[plugins.alpha]
|
|
47
|
-
source = "{FIXTURE_PLUGINS / "alpha"}"
|
|
48
|
-
|
|
49
|
-
[profiles.p]
|
|
50
|
-
plugins = ["alpha"]
|
|
51
|
-
|
|
52
|
-
[profiles.q]
|
|
53
|
-
skills = ["keeper"]
|
|
54
|
-
"""
|
|
55
|
-
)
|
|
56
|
-
)
|
|
57
|
-
return load(cfg)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_apply_here_symlinks_plugin_pieces(tmp_path):
|
|
61
|
-
config = _setup(tmp_path)
|
|
62
|
-
cwd = tmp_path / "work"
|
|
63
|
-
cwd.mkdir()
|
|
64
|
-
|
|
65
|
-
apply_here(config, cwd, select=["p"])
|
|
66
|
-
|
|
67
|
-
assert (cwd / ".claude" / "skills" / "greet").is_symlink()
|
|
68
|
-
assert (cwd / ".claude" / "agents" / "helper.agent.md").is_symlink()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_switching_profile_reaps_plugin_skill(tmp_path):
|
|
72
|
-
config = _setup(tmp_path)
|
|
73
|
-
cwd = tmp_path / "work"
|
|
74
|
-
cwd.mkdir()
|
|
75
|
-
|
|
76
|
-
apply_here(config, cwd, select=["p"])
|
|
77
|
-
assert (cwd / ".claude" / "skills" / "greet").is_symlink()
|
|
78
|
-
|
|
79
|
-
# Switch to a profile that still engages the skills capability but does
|
|
80
|
-
# not include the plugin: the plugin's skill is reaped as an orphan.
|
|
81
|
-
apply_here(config, cwd, select=["q"])
|
|
82
|
-
assert not (cwd / ".claude" / "skills" / "greet").exists()
|
|
83
|
-
assert (cwd / ".claude" / "skills" / "keeper").is_symlink()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|