"""
OpenMDAO Agent Skills installer.
Installs OpenMDAO Claude (and, in future, other-platform) agent skills that
ship *inside* the installed package, so the skills always match the installed
OpenMDAO version. A re-run of the installer after `pip install -U openmdao`
re-syncs the skills.
Design notes
------------
* Skills are bundled in ``openmdao/skills/`` and copied into a tool's skills
directory on install.
* Skill files may contain ``{{OPENMDAO_PATH}}``, ``{{OPENMDAO_DOCS}}`` and
``{{OPENMDAO_EXAMPLES}}`` placeholders, which are rewritten to absolute paths
on the user's machine at install time.
* ``CLAUDE.md`` is managed via a marker-bracketed section so user-authored
content is preserved across re-installs.
* Each supported AI tool is described by a ``Tool`` object with a detection
function and an install strategy, making it easy to add new platforms later.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
# This file lives at openmdao/utils/cli_skills.py
_PACKAGE_DIR = Path(__file__).resolve().parent.parent # .../openmdao/
_REPO_ROOT = _PACKAGE_DIR.parent # repo root (for Editable/source checkout)
# Editable/source checkout has build files at the repo root; a wheel install
# does not. Used only for an informational message at the end of install.
_IS_EDITABLE = (_REPO_ROOT / "pyproject.toml").exists() or (_REPO_ROOT / "setup.py").exists()
# Placeholders rewritten to absolute paths at install time.
OPENMDAO_PATH_PLACEHOLDER = "{{OPENMDAO_PATH}}"
OPENMDAO_DOCS_PLACEHOLDER = "{{OPENMDAO_DOCS}}"
OPENMDAO_EXAMPLES_PLACEHOLDER = "{{OPENMDAO_EXAMPLES}}"
# TOOL.md managed-section markers.
_TOOL_SECTION_START = (
"<!-- OPENMDAO_SECTION_START - Auto-managed by 'openmdao install-skills' -->"
)
_TOOL_SECTION_END = "<!-- OPENMDAO_SECTION_END -->"
# Skill directory naming convention (only dirs with this prefix are installed).
_SKILL_PREFIX = "openmdao-builtin-"
[docs]
def get_package_path() -> Path:
"""Return the path to the bundled package."""
return _PACKAGE_DIR
[docs]
def get_docs_path() -> Path:
"""Return the path to the bundled documentation (Jupyter Book source)."""
# should work for both editable and wheel installs
return _PACKAGE_DIR / "docs" / "openmdao_book"
[docs]
def get_examples_path() -> Path:
"""Return the path to runnable example components."""
# should work for both editable and wheel installs
return _PACKAGE_DIR / "test_suite" / "test_examples"
[docs]
def get_skills_source_dir() -> Path:
"""Return the directory containing bundled skill sources."""
src = _PACKAGE_DIR / "skills"
if not src.exists():
raise FileNotFoundError(
f"OpenMDAO skills not found at {src}. "
"Reinstall openmdao or run from a source checkout."
)
return src
[docs]
def replace_path_placeholders(content: str) -> str:
"""Replace all path placeholders in *content* with absolute paths."""
return (
content
.replace(OPENMDAO_PATH_PLACEHOLDER, str(get_package_path()))
.replace(OPENMDAO_DOCS_PLACEHOLDER, str(get_docs_path()))
.replace(OPENMDAO_EXAMPLES_PLACEHOLDER, str(get_examples_path()))
)
def _find_skill_dirs(root: Path) -> list[Path]:
"""
Return top-level skill directories starting with ``_SKILL_PREFIX`` and containing a SKILL.md.
"""
if not root.exists():
return []
return sorted(
d
for d in root.iterdir()
if d.is_dir()
and d.name.startswith(_SKILL_PREFIX)
and (d / "SKILL.md").exists()
)
# ---------------------------------------------------------------------------
# Tool definitions and functions
# ---------------------------------------------------------------------------
def _make_tools(use_global: bool) -> dict[str, Tool]:
"""
Build the registry of supported AI tools.
Parameters
----------
use_global : bool
If True, install paths resolve to the user's home directory instead of
the current working directory.
Returns
-------
dict of str to Tool
Mapping of tool key to Tool instance for each supported AI coding tool.
"""
tools: list[Tool] = [
ClaudeTool(use_global),
]
return {t.key: t for t in tools}
# ---------------------------------------------------------------------------
# Command execution and parser setup functions
# ---------------------------------------------------------------------------
[docs]
def cmd_skills_install(args, user_args) -> int:
"""
Execute the ``install-skills`` subcommand.
Parameters
----------
args : argparse.Namespace
Parsed CLI arguments.
Relevant attributes: ``use_global`` (bool) and ``tool`` (str) for the tool key to install.
user_args : list of str
Extra positional arguments passed through by the OpenMDAO CLI harness
(unused; reserved for future extension).
Returns
-------
int
Exit code: 0 on success, 1 on failure.
"""
tools = _make_tools(args.use_global)
if not tools:
print("No OpenMDAO skills are currently supported for installation.", file=sys.stderr)
return 1
tool_key = args.tool
tool = tools.get(tool_key)
if not tool:
print(f"Tool '{tool_key}' is not supported by OpenMDAO skills installer.", file=sys.stderr)
return 1
target_name = f'{_SKILL_PREFIX}{args.tool}'
matches = [d for d in _find_skill_dirs(get_skills_source_dir()) if d.name == target_name]
skill_dir = matches[0] if matches else None
if not skill_dir:
print(f"No OpenMDAO skills found in {get_skills_source_dir()}", file=sys.stderr)
return 1
scope = 'global (home directory)' if args.use_global else 'project (current directory)'
print(f"Installing OpenMDAO skill — scope: {scope}\n")
print(f"Installing {tool.name} into ({tool.install_path})")
try:
tool.install(skill_dir)
except (RuntimeError, OSError) as e:
print(f"Failed to install OpenMDAO skills for {tool.name}: {e}", file=sys.stderr)
return 1
# Report configured paths so users can verify the placeholder substitution.
print("Paths configured:")
print(f" Package: {_PACKAGE_DIR}")
print(f" Docs: {get_docs_path()}")
print(f" Examples: {get_examples_path()}")
if not get_docs_path().exists():
print(
"\n WARNING: the docs path above does not exist on disk. If you "
"installed OpenMDAO from a wheel, the docs may not be packaged; "
"skills referencing {{OPENMDAO_DOCS}} will point at a missing path.",
file=sys.stderr,
)
if _IS_EDITABLE:
print("\n (Detected editable/source install)")
return 0
[docs]
def cmd_skills_list(args, user_args) -> int:
"""
Execute the ``list-skills`` subcommand.
Prints a table of all registered tools, their project and global install
paths, and whether skills are currently installed at each location.
Parameters
----------
args : argparse.Namespace
Parsed CLI arguments (currently unused for this subcommand).
user_args : list of str
Extra positional arguments passed through by the OpenMDAO CLI harness
(unused; reserved for future extension).
Returns
-------
int
Exit code: always 0.
"""
skill_dirs = _find_skill_dirs(get_skills_source_dir())
project_tools = _make_tools(False)
global_tools = _make_tools(True)
print(
f"Available OpenMDAO skills ({len(skill_dirs)}): "
f"{', '.join(d.name for d in skill_dirs) or '(none)'}\n"
)
# Build rows: (name, project_path, global_path, status)
rows: list[tuple[str, str, str, str]] = []
for key, pt in project_tools.items():
gt = global_tools[key]
proj_path = str(pt.install_path)
glob_path = str(gt.install_path)
where: list[str] = []
if pt.is_installed():
where.append("project")
if gt.is_installed():
where.append("global")
if where:
status = "installed (" + ", ".join(where) + ")"
elif pt.detected():
status = "detected"
else:
status = "-"
rows.append((pt.name, proj_path, glob_path, status))
if not rows:
print("No tools registered.")
return 0
# Column widths.
name_w = max(len("Tool"), max(len(r[0]) for r in rows))
proj_w = max(len("Project path"), max(len(r[1]) for r in rows))
glob_w = max(len("Global path"), max(len(r[2]) for r in rows))
stat_w = max(len("Status"), max(len(r[3]) for r in rows))
print(
f" {'Tool':<{name_w}} {'Project path':<{proj_w}} "
f"{'Global path':<{glob_w}} {'Status':<{stat_w}}"
)
print(f" {'-' * name_w} {'-' * proj_w} {'-' * glob_w} {'-' * stat_w}")
for name, proj, glob, status in rows:
print(f" {name:<{name_w}} {proj:<{proj_w}} {glob:<{glob_w}} {status:<{stat_w}}")
print()
return 0
[docs]
def cmd_skills_uninstall(args, user_args) -> int:
"""
Execute the ``uninstall-skills`` subcommand.
Parameters
----------
args : argparse.Namespace
Parsed CLI arguments.
Relevant attributes: ``use_global`` (bool) and ``tool`` (str) for the tool key to uninstall.
user_args : list of str
Extra positional arguments passed through by the OpenMDAO CLI harness
(unused; reserved for future extension).
Returns
-------
int
Exit code: 0 on success, 1 if the specified tool is not supported.
"""
tools = _make_tools(args.use_global)
tool_key = args.tool
tool = tools.get(tool_key)
if not tool:
print(f"Tool '{tool_key}' is not supported by OpenMDAO skills installer.", file=sys.stderr)
return 1
try:
tool.uninstall()
except (RuntimeError, OSError) as e:
print(f"Failed to uninstall OpenMDAO skills for {tool.name}: {e}", file=sys.stderr)
return 1
print("\nUninstall complete.")
return 0
def _setup_skills_install(parser):
"""
Configure the argument parser for the ``install-skills`` subcommand.
Parameters
----------
parser : argparse.ArgumentParser
The subcommand parser to configure.
"""
parser.add_argument('tool', type=str, help='AI coding tool to install skills for (e.g. claude)')
parser.add_argument(
"--global",
dest="use_global",
action="store_true",
help="Use global (home-directory) paths instead of the current project",
)
def _setup_skills_uninstall(parser):
"""
Configure the argument parser for the ``uninstall-skills`` subcommand.
Parameters
----------
parser : argparse.ArgumentParser
The subcommand parser to configure.
"""
parser.add_argument('tool', type=str,
help='AI coding tool to uninstall skills for (e.g. claude)')
parser.add_argument(
"--global",
dest="use_global",
action="store_true",
help="Use global (home-directory) paths instead of the current project",
)
def _setup_skills_list(parser):
"""
Configure the argument parser for the ``list-skills`` subcommand.
Parameters
----------
parser : argparse.ArgumentParser
The subcommand parser to configure.
"""
parser.add_argument(
"--global",
dest="use_global",
action="store_true",
help="Use global (home-directory) paths instead of the current project",
)