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.
Files changed (46) hide show
  1. {twagent-2.2.1/src/twagent.egg-info → twagent-2.3.0}/PKG-INFO +1 -1
  2. {twagent-2.2.1 → twagent-2.3.0}/pyproject.toml +2 -2
  3. twagent-2.3.0/src/twagent/__init__.py +1 -0
  4. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/cli.py +11 -1
  5. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/config.py +75 -14
  6. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/deploy.py +29 -1
  7. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/doctor.py +30 -1
  8. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/plugins.py +16 -2
  9. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/selector.py +21 -1
  10. {twagent-2.2.1 → twagent-2.3.0/src/twagent.egg-info}/PKG-INFO +1 -1
  11. {twagent-2.2.1 → twagent-2.3.0}/tests/test_config.py +109 -2
  12. twagent-2.3.0/tests/test_deploy_plugins.py +222 -0
  13. {twagent-2.2.1 → twagent-2.3.0}/tests/test_doctor.py +44 -0
  14. {twagent-2.2.1 → twagent-2.3.0}/tests/test_plugins.py +11 -3
  15. {twagent-2.2.1 → twagent-2.3.0}/tests/test_selector.py +40 -0
  16. twagent-2.2.1/src/twagent/__init__.py +0 -1
  17. twagent-2.2.1/tests/test_deploy_plugins.py +0 -83
  18. {twagent-2.2.1 → twagent-2.3.0}/LICENSE +0 -0
  19. {twagent-2.2.1 → twagent-2.3.0}/README.md +0 -0
  20. {twagent-2.2.1 → twagent-2.3.0}/setup.cfg +0 -0
  21. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/diff.py +0 -0
  22. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/expansion.py +0 -0
  23. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/extractor.py +0 -0
  24. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/info.py +0 -0
  25. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/interpolate.py +0 -0
  26. {twagent-2.2.1 → twagent-2.3.0}/src/twagent/mcp.py +0 -0
  27. {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/SOURCES.txt +0 -0
  28. {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/dependency_links.txt +0 -0
  29. {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/entry_points.txt +0 -0
  30. {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/requires.txt +0 -0
  31. {twagent-2.2.1 → twagent-2.3.0}/src/twagent.egg-info/top_level.txt +0 -0
  32. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli.py +0 -0
  33. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_apply.py +0 -0
  34. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_diff.py +0 -0
  35. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_doctor.py +0 -0
  36. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_extract.py +0 -0
  37. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_info.py +0 -0
  38. {twagent-2.2.1 → twagent-2.3.0}/tests/test_cli_listings.py +0 -0
  39. {twagent-2.2.1 → twagent-2.3.0}/tests/test_deploy_render.py +0 -0
  40. {twagent-2.2.1 → twagent-2.3.0}/tests/test_deploy_symlinks.py +0 -0
  41. {twagent-2.2.1 → twagent-2.3.0}/tests/test_expansion.py +0 -0
  42. {twagent-2.2.1 → twagent-2.3.0}/tests/test_extractor.py +0 -0
  43. {twagent-2.2.1 → twagent-2.3.0}/tests/test_info.py +0 -0
  44. {twagent-2.2.1 → twagent-2.3.0}/tests/test_interpolate.py +0 -0
  45. {twagent-2.2.1 → twagent-2.3.0}/tests/test_mcp.py +0 -0
  46. {twagent-2.2.1 → twagent-2.3.0}/tests/test_performance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twagent
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: twagent CLI
5
5
  Author-email: sysid <sysid@gmx.de>
6
6
  License-Expression: BSD-3-Clause
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "twagent"
3
- version = "2.2.1"
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.2.1"
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 n_written == 0 and not result.warnings and not result.errors:
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=Path(blob["source"]).expanduser(),
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
- source = Path(blob["source"]).expanduser()
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 not art.source.exists():
675
- warnings.warn(
676
- f"{kind}.{name}: source does not exist: {art.source}",
677
- UserWarning,
678
- stacklevel=3,
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 = {name: registry[name].source for name in members if name in registry}
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 not art.source.exists():
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 FileNotFoundError if the source dir or plugin.json is missing,
45
- ValueError if the manifest is malformed or declares a non-existent dir.
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twagent
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: twagent CLI
5
5
  Author-email: sysid <sysid@gmx.de>
6
6
  License-Expression: BSD-3-Clause
@@ -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 test_plugin_missing_source_dir_is_hard_error(tmp_path):
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
- with pytest.raises(ConfigError, match="ghost"):
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 test_discover_plugin_missing_manifest_raises():
36
- with pytest.raises(FileNotFoundError):
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