Story 4.2: Fix lint errors and code review findings
- Remove unused StripOverlayLayer import and stripOverlayLayer variable from module.js - Add comprehensive JSDoc annotations to FoundryAdapter.js methods (settings, socket, users, scenes, notifications, hooks) - Add /* global Dialog */ comment to PlayerPrivacyPanel.js for ESLint - Remove unused _force parameter from GMPlayerPrivacySelector.js render() method - Fix PlayerPrivacyPanelMenu.js: add constructor() to fallback class and call super() All 862 unit tests passing. All Story 4.2 acceptance criteria met. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: bmad-customize
|
||||
description: Authors and updates customization overrides for installed BMad skills. Use when the user says 'customize bmad', 'override a skill', 'change agent behavior', or 'customize a workflow'.
|
||||
---
|
||||
|
||||
# BMad Customize
|
||||
|
||||
Translate the user's intent into a correctly-placed TOML override file under `{project-root}/_bmad/custom/` for a customizable agent or workflow skill. Discover, route, author, write, verify.
|
||||
|
||||
Scope v1: per-skill `[agent]` overrides (`bmad-agent-<role>.toml` / `.user.toml`) and per-skill `[workflow]` overrides (`bmad-<workflow>.toml` / `.user.toml`). Central config (`{project-root}/_bmad/custom/config.toml`) is out of scope — point users at the [How to Customize BMad guide](https://docs.bmad-method.org/how-to/customize-bmad/).
|
||||
|
||||
When the target's `customize.toml` doesn't expose what the user wants, say so plainly. Don't invent fields.
|
||||
|
||||
## Preflight
|
||||
|
||||
- No `{project-root}/_bmad/` → BMad isn't installed. Say so, stop.
|
||||
- `{project-root}/_bmad/scripts/resolve_customization.py` missing → continue, but Step 6 verify falls back to manual merge.
|
||||
- Both present → proceed.
|
||||
|
||||
## Activation
|
||||
|
||||
Load `_bmad/config.toml` and `_bmad/config.user.toml` from `{project-root}` for `user_name` (default `BMad`) and `communication_language` (default `English`). Greet. If the user's invocation already names a target skill AND a specific change, jump to Step 3.
|
||||
|
||||
## Step 1: Classify intent
|
||||
|
||||
- **Directed** — specific skill + specific change → Step 3.
|
||||
- **Exploratory** — "what can I customize?" → Step 2.
|
||||
- **Audit/iterate** — wants to review or change something already customized → Step 2, lead with skills that have existing overrides; read the existing override in Step 3 before composing.
|
||||
- **Cross-cutting** — could live on multiple surfaces → Step 3, choose agent vs workflow explicitly with the user.
|
||||
|
||||
## Step 2: Discovery
|
||||
|
||||
```
|
||||
python3 {skill-root}/scripts/list_customizable_skills.py --project-root {project-root}
|
||||
```
|
||||
|
||||
Use `--extra-root <path>` (repeatable) if the user has skills installed in additional locations.
|
||||
|
||||
Group the returned `agents` and `workflows` for the user; for each show name, description, whether `has_team_override` or `has_user_override` is true. Surface any `errors[]`. For audit/iterate intents, lead with already-overridden entries.
|
||||
|
||||
Empty list: show `scanned_roots`, ask whether skills live elsewhere (offer `--extra-root`); otherwise stop.
|
||||
|
||||
## Step 3: Determine the right surface
|
||||
|
||||
Read the target's `customize.toml`. Top-level `[agent]` or `[workflow]` block defines the surface.
|
||||
|
||||
If a team or user override already exists, read it first and summarize what's already overridden before composing.
|
||||
|
||||
**Cross-cutting intent — walk both surfaces with the user:**
|
||||
- Every workflow a given agent runs → agent surface (e.g. `bmad-agent-pm.toml` with `persistent_facts`, `principles`).
|
||||
- One workflow only → workflow surface (e.g. `bmad-prd.toml` with `activation_steps_prepend`).
|
||||
- Several specific workflows → multiple workflow overrides in sequence, not an agent override.
|
||||
|
||||
**Single-surface heuristic:**
|
||||
- Workflow-level: template swap, output path, step-specific behavior, or a named scalar already exposed (`*_template`, `on_complete`). Surgical, reliable.
|
||||
- Agent-level: persona, communication style, org-wide facts, menu changes, behavior that should apply to every workflow the agent dispatches.
|
||||
|
||||
When ambiguous, present both with tradeoff, recommend one, let the user decide.
|
||||
|
||||
Intent outside the exposed surface (step logic, ordering, anything not in `customize.toml`): say so; offer `activation_steps_prepend`/`append` or `persistent_facts` as approximations, or recommend `bmad-builder` to create a custom skill.
|
||||
|
||||
## Step 4: Compose the override
|
||||
|
||||
Translate plain-English into TOML against the target's `customize.toml` fields. If an existing override was read, frame the change as additive.
|
||||
|
||||
Merge semantics:
|
||||
- **Scalars** (`icon`, `role`, `*_template`, `on_complete`) — override wins.
|
||||
- **Append arrays** (`persistent_facts`, `activation_steps_prepend`/`append`, `principles`) — team/user entries append in order.
|
||||
- **Keyed arrays of tables** (menu items with `code` or `id`) — matching keys replace, new keys append.
|
||||
|
||||
Overrides are sparse: only the fields being changed. Never copy the whole `customize.toml`.
|
||||
|
||||
**Template swap** (`*_template` scalar): offer to copy the default template to `{project-root}/_bmad/custom/{skill-name}-{purpose}-template.md`, point the override at the new path, offer to help edit it.
|
||||
|
||||
## Step 5: Team or user placement
|
||||
|
||||
Under `{project-root}/_bmad/custom/`:
|
||||
- `{skill-name}.toml` — team, committed. Policies, org conventions, compliance.
|
||||
- `{skill-name}.user.toml` — user, gitignored. Personal tone, private facts, shortcuts.
|
||||
|
||||
Default by character (policy → team, personal → user), confirm before writing.
|
||||
|
||||
## Step 6: Show, confirm, write, verify
|
||||
|
||||
1. Show the full TOML. If the file exists, show a diff. Never silently overwrite.
|
||||
2. Wait for explicit yes.
|
||||
3. Write. Create `{project-root}/_bmad/custom/` if needed.
|
||||
4. Verify:
|
||||
```
|
||||
python3 {project-root}/_bmad/scripts/resolve_customization.py --skill <install-path> --key <agent-or-workflow>
|
||||
```
|
||||
Show the merged output, point out the changed fields.
|
||||
|
||||
**Resolver missing or fails:** read whichever layers exist — `<install-path>/customize.toml` (base), `{project-root}/_bmad/custom/{skill-name}.toml` (team), `{project-root}/_bmad/custom/{skill-name}.user.toml` (user) — apply base → team → user with the same merge rules (scalars override, tables deep-merge, `code`/`id`-keyed arrays merge by key, all other arrays append), describe how the changed fields resolve.
|
||||
|
||||
**Verify shows override didn't land** (field unchanged, merge conflict, file not picked up): re-enter Step 4 with the verify output as context. Usually wrong field name, wrong merge mode (scalar vs array), or wrong scope.
|
||||
5. Summarize what changed, where the file lives, how to iterate. Remind the user to commit team overrides.
|
||||
|
||||
## Complete when
|
||||
|
||||
- Override file written (or user explicitly aborted).
|
||||
- User has seen resolver output (or manual fallback merge summary).
|
||||
- User has acknowledged the summary.
|
||||
|
||||
Otherwise the skill isn't done — finish or tell the user they're exiting incomplete.
|
||||
|
||||
## When this skill can't help
|
||||
|
||||
- **Central config** (`{project-root}/_bmad/custom/config.toml`) — see the [How to Customize BMad guide](https://docs.bmad-method.org/how-to/customize-bmad/).
|
||||
- **Step logic, ordering, behavior not in `customize.toml`** — open a feature request, or use `bmad-builder` to create a custom skill. Offer to help with either.
|
||||
- **Skills without a `customize.toml`** — not customizable.
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Enumerate customizable BMad skills installed alongside this one.
|
||||
|
||||
Scans a skills directory (by default: the directory this script's own skill
|
||||
lives in, derived from __file__), finds every sibling directory containing a
|
||||
`customize.toml`, classifies each as agent and/or workflow based on its
|
||||
top-level blocks, reads the skill's SKILL.md frontmatter description for a
|
||||
one-liner, and checks whether override files already exist in
|
||||
`{project-root}/_bmad/custom/`.
|
||||
|
||||
Skills in BMad are loaded either from a project-local location (e.g. the
|
||||
project's `.claude/skills/` or `.cursor/skills/`) or from a user-global
|
||||
location (e.g. `~/.claude/skills/`). We do not hardcode those paths — the
|
||||
running skill's own location is the source of truth for sibling discovery.
|
||||
`--extra-root` is available for the rare case where skills live in multiple
|
||||
locations on the same machine.
|
||||
|
||||
Output: JSON to stdout. Non-empty `errors[]` in the payload is non-fatal
|
||||
by contract — the scanner surfaces malformed TOML, missing roots, and
|
||||
skills with no customization block as data for the caller to display,
|
||||
and still exits 0. Exit 2 is reserved for invocation errors (e.g.
|
||||
missing or unreadable `--project-root`) where no useful payload can be
|
||||
produced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
# Top-level TOML blocks that indicate a customization surface.
|
||||
SURFACE_KEYS = ("agent", "workflow")
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
|
||||
def default_skills_root() -> Path:
|
||||
"""Derive the skills root from this script's location.
|
||||
|
||||
Layout assumption: {skills_root}/bmad-customize/scripts/list_customizable_skills.py.
|
||||
So the skills root is three parents up from this file.
|
||||
"""
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def read_frontmatter_description(skill_md: Path) -> str:
|
||||
"""Extract the `description:` value from a SKILL.md YAML frontmatter block.
|
||||
|
||||
Returns an empty string if the file is missing, unreadable, or has no
|
||||
description field. Intentionally permissive — this is metadata for a
|
||||
human-facing list, not a validation target.
|
||||
"""
|
||||
if not skill_md.is_file():
|
||||
return ""
|
||||
try:
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
m = FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return ""
|
||||
for line in m.group(1).splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("description:"):
|
||||
value = stripped[len("description:") :].strip()
|
||||
# Strip surrounding quotes if present.
|
||||
if (value.startswith("'") and value.endswith("'")) or (
|
||||
value.startswith('"') and value.endswith('"')
|
||||
):
|
||||
value = value[1:-1]
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def load_customize(toml_path: Path) -> dict | None:
|
||||
"""Return the parsed TOML, or None if unreadable."""
|
||||
try:
|
||||
with toml_path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def scan_skills(
|
||||
skills_roots: list[Path],
|
||||
project_root: Path,
|
||||
) -> dict:
|
||||
"""Scan each skills root for directories that contain a customize.toml."""
|
||||
agents: list[dict] = []
|
||||
workflows: list[dict] = []
|
||||
errors: list[str] = []
|
||||
scanned_roots: list[str] = []
|
||||
seen_names: set[str] = set()
|
||||
custom_dir = project_root / "_bmad" / "custom"
|
||||
|
||||
for root in skills_roots:
|
||||
if not root.is_dir():
|
||||
errors.append(f"skills root does not exist: {root}")
|
||||
continue
|
||||
scanned_roots.append(str(root))
|
||||
|
||||
for skill_dir in sorted(p for p in root.iterdir() if p.is_dir()):
|
||||
customize_toml = skill_dir / "customize.toml"
|
||||
if not customize_toml.is_file():
|
||||
continue
|
||||
|
||||
data = load_customize(customize_toml)
|
||||
if data is None:
|
||||
errors.append(f"failed to parse {customize_toml}")
|
||||
continue
|
||||
|
||||
skill_name = skill_dir.name
|
||||
# If a skill with this name was already found in an earlier
|
||||
# root, skip it — roots are scanned in the order provided, so
|
||||
# the first occurrence wins.
|
||||
if skill_name in seen_names:
|
||||
continue
|
||||
seen_names.add(skill_name)
|
||||
|
||||
description = read_frontmatter_description(skill_dir / "SKILL.md")
|
||||
team_override = custom_dir / f"{skill_name}.toml"
|
||||
user_override = custom_dir / f"{skill_name}.user.toml"
|
||||
|
||||
entry_base = {
|
||||
"name": skill_name,
|
||||
"install_path": str(skill_dir),
|
||||
"skills_root": str(root),
|
||||
"description": description,
|
||||
"has_team_override": team_override.is_file(),
|
||||
"has_user_override": user_override.is_file(),
|
||||
"team_override_path": str(team_override),
|
||||
"user_override_path": str(user_override),
|
||||
}
|
||||
|
||||
# A skill may expose an agent surface, a workflow surface, or
|
||||
# both. Emit one entry per surface so the caller can group cleanly.
|
||||
surfaces_found = [k for k in SURFACE_KEYS if k in data]
|
||||
if not surfaces_found:
|
||||
errors.append(
|
||||
f"no [agent] or [workflow] block in {customize_toml}"
|
||||
)
|
||||
continue
|
||||
for surface in surfaces_found:
|
||||
entry = dict(entry_base)
|
||||
entry["surface"] = surface
|
||||
if surface == "agent":
|
||||
agents.append(entry)
|
||||
else:
|
||||
workflows.append(entry)
|
||||
|
||||
return {
|
||||
"project_root": str(project_root),
|
||||
"scanned_roots": scanned_roots,
|
||||
"custom_dir": str(custom_dir),
|
||||
"agents": agents,
|
||||
"workflows": workflows,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"List customizable BMad skills installed alongside this one, "
|
||||
"grouped by surface (agent vs workflow), with override status "
|
||||
"looked up against {project-root}/_bmad/custom/."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
required=True,
|
||||
help="Absolute path to the project root (the folder containing _bmad/).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-root",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the primary skills directory to scan. Defaults to the "
|
||||
"directory this script's own skill lives in."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-root",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Additional skills directory to include (repeatable). Useful "
|
||||
"when skills live in multiple locations on the same machine "
|
||||
"(e.g. project-local plus a user-global install)."
|
||||
),
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
project_root = Path(args.project_root).expanduser().resolve()
|
||||
if not project_root.is_dir():
|
||||
print(
|
||||
f"error: project-root does not exist or is not a directory: {project_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
primary = (
|
||||
Path(args.skills_root).expanduser().resolve()
|
||||
if args.skills_root
|
||||
else default_skills_root()
|
||||
)
|
||||
extras = [Path(p).expanduser().resolve() for p in args.extra_root]
|
||||
# Deduplicate in order of appearance.
|
||||
roots: list[Path] = []
|
||||
for root in [primary, *extras]:
|
||||
if root not in roots:
|
||||
roots.append(root)
|
||||
|
||||
result = scan_skills(roots, project_root)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Unit tests for list_customizable_skills.py.
|
||||
|
||||
Exercises the scanner against a synthesized install tree:
|
||||
- an agent-only customize.toml
|
||||
- a workflow-only customize.toml
|
||||
- a customize.toml that exposes both surfaces
|
||||
- a skill directory with no customize.toml (ignored)
|
||||
- a pre-existing team override in _bmad/custom/
|
||||
- malformed TOML (surfaces as an error without aborting)
|
||||
- multiple skills roots (e.g. project-local + user-global mix)
|
||||
|
||||
Run: python3 scripts/tests/test_list_customizable_skills.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "list_customizable_skills.py"
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("list_customizable_skills", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||
return module
|
||||
|
||||
|
||||
MODULE = _load_module()
|
||||
|
||||
|
||||
def _make_skill(parent: Path, name: str, body: str, skill_md: str | None = None) -> Path:
|
||||
skill_dir = parent / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "customize.toml").write_text(body, encoding="utf-8")
|
||||
if skill_md is not None:
|
||||
(skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
|
||||
return skill_dir
|
||||
|
||||
|
||||
class ScannerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.skills = self.root / "skills"
|
||||
self.skills.mkdir(parents=True)
|
||||
self.custom = self.root / "_bmad" / "custom"
|
||||
self.custom.mkdir(parents=True)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_agent_only_skill_detected(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"🧠\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: Product manager.\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]), 1)
|
||||
self.assertEqual(len(result["workflows"]), 0)
|
||||
entry = result["agents"][0]
|
||||
self.assertEqual(entry["name"], "bmad-agent-pm")
|
||||
self.assertEqual(entry["surface"], "agent")
|
||||
self.assertEqual(entry["description"], "Product manager.")
|
||||
self.assertFalse(entry["has_team_override"])
|
||||
self.assertFalse(entry["has_user_override"])
|
||||
|
||||
def test_workflow_only_skill_detected(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-create-prd",
|
||||
"[workflow]\npersistent_facts = []\n",
|
||||
"---\nname: bmad-create-prd\ndescription: 'Create a PRD.'\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]), 0)
|
||||
self.assertEqual(len(result["workflows"]), 1)
|
||||
entry = result["workflows"][0]
|
||||
self.assertEqual(entry["description"], "Create a PRD.")
|
||||
|
||||
def test_dual_surface_skill_emits_two_entries(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-dual",
|
||||
"[agent]\nicon = \"x\"\n\n[workflow]\npersistent_facts = []\n",
|
||||
"---\nname: bmad-dual\ndescription: Dual.\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]), 1)
|
||||
self.assertEqual(len(result["workflows"]), 1)
|
||||
self.assertEqual(result["agents"][0]["name"], "bmad-dual")
|
||||
self.assertEqual(result["workflows"][0]["name"], "bmad-dual")
|
||||
|
||||
def test_skill_without_customize_toml_ignored(self):
|
||||
(self.skills / "bmad-plain").mkdir()
|
||||
(self.skills / "bmad-plain" / "SKILL.md").write_text("# plain\n")
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]) + len(result["workflows"]), 0)
|
||||
self.assertEqual(result["errors"], [])
|
||||
|
||||
def test_existing_team_override_flagged(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"x\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
|
||||
)
|
||||
(self.custom / "bmad-agent-pm.toml").write_text("[agent]\n")
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
entry = result["agents"][0]
|
||||
self.assertTrue(entry["has_team_override"])
|
||||
self.assertFalse(entry["has_user_override"])
|
||||
|
||||
def test_missing_surface_block_reports_error(self):
|
||||
_make_skill(self.skills, "bmad-broken", "[not_a_surface]\nfoo = 1\n")
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]) + len(result["workflows"]), 0)
|
||||
self.assertEqual(len(result["errors"]), 1)
|
||||
self.assertIn("no [agent] or [workflow] block", result["errors"][0])
|
||||
|
||||
def test_malformed_toml_reports_error_without_aborting(self):
|
||||
skill_dir = self.skills / "bmad-bad"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "customize.toml").write_text("this is not [valid toml\n")
|
||||
# Plus a good sibling to confirm scanning continues.
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-good",
|
||||
"[agent]\nicon = \"x\"\n",
|
||||
"---\nname: bmad-good\ndescription: Good.\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(len(result["agents"]), 1)
|
||||
self.assertEqual(result["agents"][0]["name"], "bmad-good")
|
||||
self.assertTrue(any("failed to parse" in e for e in result["errors"]))
|
||||
|
||||
def test_description_with_double_quotes_stripped(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-q",
|
||||
"[agent]\nicon = \"x\"\n",
|
||||
'---\nname: bmad-q\ndescription: "Double-quoted desc."\n---\n',
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills], self.root)
|
||||
self.assertEqual(result["agents"][0]["description"], "Double-quoted desc.")
|
||||
|
||||
def test_multiple_skills_roots_are_merged(self):
|
||||
extra_root = self.root / "extra-skills"
|
||||
extra_root.mkdir()
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"x\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
|
||||
)
|
||||
_make_skill(
|
||||
extra_root,
|
||||
"bmad-agent-dev",
|
||||
"[agent]\nicon = \"y\"\n",
|
||||
"---\nname: bmad-agent-dev\ndescription: Dev.\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills, extra_root], self.root)
|
||||
names = {a["name"] for a in result["agents"]}
|
||||
self.assertEqual(names, {"bmad-agent-pm", "bmad-agent-dev"})
|
||||
self.assertEqual(len(result["scanned_roots"]), 2)
|
||||
|
||||
def test_duplicate_skill_name_across_roots_first_wins(self):
|
||||
extra_root = self.root / "extra-skills"
|
||||
extra_root.mkdir()
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"primary\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: Primary.\n---\n",
|
||||
)
|
||||
_make_skill(
|
||||
extra_root,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"duplicate\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: Duplicate.\n---\n",
|
||||
)
|
||||
result = MODULE.scan_skills([self.skills, extra_root], self.root)
|
||||
self.assertEqual(len(result["agents"]), 1)
|
||||
self.assertEqual(result["agents"][0]["description"], "Primary.")
|
||||
self.assertEqual(result["agents"][0]["skills_root"], str(self.skills))
|
||||
|
||||
def test_missing_skills_root_reports_error(self):
|
||||
result = MODULE.scan_skills(
|
||||
[self.root / "does-not-exist", self.skills],
|
||||
self.root,
|
||||
)
|
||||
self.assertTrue(any("skills root does not exist" in e for e in result["errors"]))
|
||||
|
||||
def test_cli_emits_valid_json_and_exits_zero(self):
|
||||
_make_skill(
|
||||
self.skills,
|
||||
"bmad-agent-pm",
|
||||
"[agent]\nicon = \"x\"\n",
|
||||
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--project-root",
|
||||
str(self.root),
|
||||
"--skills-root",
|
||||
str(self.skills),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
payload = json.loads(proc.stdout)
|
||||
self.assertEqual(len(payload["agents"]), 1)
|
||||
|
||||
def test_cli_exits_two_on_missing_project_root(self):
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--project-root",
|
||||
str(self.root / "does-not-exist"),
|
||||
"--skills-root",
|
||||
str(self.skills),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 2)
|
||||
self.assertIn("does not exist", proc.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user