Skills › BMAD Method › bmad › setup_check.py
setup_check.py
174 lines · skills/bmad/scripts/setup_check.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Tell a starting skill what setup still owes it.
7A skill can be installed after the last `bmad setup`, without its module
8record, or need a newer hub than the one present. Nothing is recorded to detect
9that: everything here is read from the skill's own `bmod.toml`, its module's
10`bmod.toml` beside it, the skills installed beside it, and `_bmad/`.
12Only what stops a skill from working well is reported, because this runs every
13time a skill starts. Recommended skills belong to setup, status, and help.
15Which skills and module records are installed depends on the host's ordered
16skills roots, and only the caller knows them. Without roots, nothing about
17other skills or the module record is claimed; with them, the check reads the
18same ordered view setup and knowledge read, and the first root holding a skill
19wins it.
20"""
22from __future__ import annotations
24import sys
25from pathlib import Path
27sys.dont_write_bytecode = True
29MANIFEST_NAME = "bmod.toml"
30_MISSING = object()
33def install_command(source: str, skill: str) -> str:
34 import setup as hub
36 command = hub.install_command(source, skill)
37 return f"`{command}`" if command is not None else f"`{skill}` from {source}"
40def owed(skill_dir: Path, project_root: Path | None, roots: tuple[Path, ...] = ()) -> list[str]:
41 """Plain sentences for the agent to relay; empty when nothing is owed."""
42 import setup as hub
43 from config_utils import ConfigError, load_central_config
45 parsed = hub.read_bmod_file(skill_dir / MANIFEST_NAME)
46 if parsed is None or parsed.skill is None:
47 return []
48 skill = parsed.skill
49 notes: list[str] = []
51 record = parsed.bmod
52 viewed = False
53 if roots:
54 # The record that wins the ordered view is the one setup uses, so it is the one read here.
55 try:
56 folders, _ = hub.locate_skills(hub.unique_folders((*roots, skill_dir.parent)), skill_dir.parent)
57 record = record or record_in_view(hub, skill.bmod, folders)
58 notes.extend(dependency_notes(skill_dir, parsed, folders, record))
59 viewed = True
60 except Exception:
61 # An unreadable file in a root says nothing about the dependencies; the local notes still apply.
62 pass
63 if record is None and not viewed:
64 other = hub.read_bmod_file(skill_dir.parent / skill.bmod / MANIFEST_NAME)
65 record = other.bmod if other is not None else None
67 if record is None or project_root is None or not (project_root / "_bmad").is_dir():
68 return notes
70 if record.questions:
71 try:
72 config = load_central_config(project_root)
73 except ConfigError:
74 config = None
75 if config is not None:
76 unanswered = [
77 question.key
78 for question in record.questions
79 if lookup(config, ("modules", question.module, *question.key.split("."))) is _MISSING
80 ]
81 if unanswered:
82 notes.append(
83 f"belongs to module `{record.code}`, whose setup questions were never answered "
84 f"({', '.join(unanswered)}). Offer to run `bmad setup`."
85 )
87 installed_scripts = project_root / "_bmad" / record.code / "scripts"
88 for relative in skill.scripts:
89 packaged = skill_dir.joinpath(*relative.parts)
90 placed = installed_scripts.joinpath(*relative.parts[1:])
91 if not placed.is_file() or (packaged.is_file() and placed.read_bytes() != packaged.read_bytes()):
92 notes.append(
93 f"belongs to module `{record.code}`, whose scripts in `_bmad/{record.code}/scripts/` are "
94 "missing or out of date. Offer to run `bmad setup`."
95 )
96 break
98 return notes
101def record_in_view(hub, name: str, folders: dict[str, Path]):
102 folder = hub.skill_path(folders, name)
103 other = hub.read_bmod_file(folder / MANIFEST_NAME) if folder is not None else None
104 return other.bmod if other is not None else None
107def dependency_notes(skill_dir: Path, parsed, folders: dict[str, Path], record) -> list[str]:
108 """What the skill's module record and required skills lack in the ordered view of the skills roots."""
109 import setup as hub
111 skill = parsed.skill
112 notes: list[str] = []
113 if record is None:
114 notes.append(
115 f"belongs to a module whose record `{skill.bmod}` is not installed in the active skills roots. "
116 f"Offer to install it with {install_command(skill.source, skill.bmod)}."
117 )
119 own_source = skill.source if skill.source is not None else record.update_source if record else ""
120 declared = [(requirement, record.update_source) for requirement in (record.required_skills if record else ())]
121 declared += [(requirement, own_source) for requirement in skill.required_skills]
122 # A skill named by both lists is checked once, against the higher minimum.
123 wanted: dict[str, tuple] = {}
124 for requirement, default_source in declared:
125 if requirement.skill == skill_dir.name:
126 continue
127 kept = wanted.get(requirement.skill)
128 if kept is None or (
129 requirement.version is not None
130 and (kept[0].version is None or (hub.compare_semver(requirement.version, kept[0].version) or 0) > 0)
131 ):
132 wanted[requirement.skill] = (requirement, default_source)
133 for requirement, default_source in wanted.values():
134 state, installed = hub.requirement_check(folders, requirement)
135 if state == "missing":
136 source = requirement.source or default_source
137 notes.append(
138 f"needs the `{requirement.skill}` skill, which is not installed. "
139 f"If you have no `{requirement.skill}` skill, offer to install it with "
140 f"{install_command(source, requirement.skill)}."
141 )
142 elif state == "outdated":
143 notes.append(
144 f"needs `{requirement.skill}` {requirement.version} or later, and {installed} is installed. "
145 "Offer to run `npx skills update`."
146 )
147 elif state == "unknown-version":
148 notes.append(
149 f"needs `{requirement.skill}` {requirement.version} or later, and the installed copy's version "
150 "cannot be read. Offer to run `npx skills update`."
151 )
152 return notes
155def lookup(data: object, keys: tuple[str, ...]) -> object:
156 current = data
157 for key in keys:
158 if not isinstance(current, dict) or key not in current:
159 return _MISSING
160 current = current[key]
161 return current
164def report(skill_dir: Path, project_root: Path | None, roots: tuple[Path, ...] = ()) -> None:
165 """Write what is owed to stderr, worded as an instruction so no skill has to explain it.
167 A failure here must never stop the skill from resolving.
168 """
169 try:
170 notes = owed(skill_dir, project_root, roots)
171 except Exception:
172 return
173 for note in notes:
174 sys.stderr.write(f"setup: before continuing, tell the user that `{skill_dir.name}` {note}\n")