From 7a662b3b6b7f1aef768c049fc1f66ad39ce3ed02 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Tue, 8 Sep 2026 15:43:04 +0530 Subject: [PATCH] Install agent skills from skills-python-sdk on init and upgrade. --- nitrostack/cli/main.py | 11 ++- nitrostack/cli/skills.py | 173 ++++++++++++++++++++++++++++++++++++++ nitrostack/cli/upgrade.py | 21 ++++- tests/conftest.py | 15 ++++ tests/test_cli.py | 1 + tests/test_cli_skills.py | 156 ++++++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 nitrostack/cli/skills.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli_skills.py diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 89725e7..fdf82bd 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -10,6 +10,7 @@ from nitrostack.cli.generate import generate_component, generate_module as generate_module_from_template from nitrostack.cli.install import install_dependencies from nitrostack.cli.pack import pack_project +from nitrostack.cli.skills import run_skills_flow from nitrostack.cli.upgrade import upgrade_project from nitrostack.cli.validators import format_report, validate_project @@ -1242,7 +1243,7 @@ def _add_port_flags(parser): ) -def init_project(name: str = None, template: str = None, skip_install: bool = False, port=None, widget=None): +def init_project(name: str = None, template: str = None, skip_install: bool = False, port=None, widget=None, force: bool = False): print_banner() # 1. Project name — optional CLI arg, otherwise the next readline @@ -1360,6 +1361,8 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa elif not install_deps: print("\033[32m✓\033[0m Skipped dependency install") + run_skills_flow(os.path.abspath(name), force=force) + # Success Card abs_path = os.path.abspath(name) success_box = f"""\033[36m╔══════════════════════════════════════════════════════════╗ @@ -1725,6 +1728,11 @@ def main(): help="Template to use: python-starter, python-pizzaz, python-oauth (default: interactive prompt)", ) init_parser.add_argument("--skip-install", action="store_true", help="Skip installing widget npm dependencies") + init_parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing agent skill directories when installing", + ) _add_port_flags(init_parser) # dev command @@ -1833,6 +1841,7 @@ def _run_command(fn, *fn_args, **fn_kwargs): skip_install=args.skip_install, port=args.port, widget=args.widget, + force=args.force, ) elif args.command == "dev": run_dev(port=args.port, widget=args.widget) diff --git a/nitrostack/cli/skills.py b/nitrostack/cli/skills.py new file mode 100644 index 0000000..a0a1b3d --- /dev/null +++ b/nitrostack/cli/skills.py @@ -0,0 +1,173 @@ +"""Clone nitrocloudofficial/skills-python-sdk and copy skills into agent folders.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile + +SKILLS_REPO_URL = "https://github.com/nitrocloudofficial/skills-python-sdk.git" +SKILLS_SUBDIR = "skills" +STAMP_FILENAME = ".nitrostack.json" +CLONE_TIMEOUT_S = 60 + +AGENT_SKILL_DIRS = ( + ".cursor/skills", + ".codex/skills", + ".claude/skills", + ".gemini/skills", + ".antigravity/skills", + ".copilot/skills", + ".opencode/skills", + ".agents/skills", +) + + +class SkillsCloneError(Exception): + pass + + +def clone_skills_repo() -> str: + temp_dir = os.path.join(tempfile.gettempdir(), f"nitrostack-skills-{os.urandom(6).hex()}") + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + try: + subprocess.run( + ["git", "clone", "--depth", "1", SKILLS_REPO_URL, temp_dir], + check=True, + capture_output=True, + text=True, + timeout=CLONE_TIMEOUT_S, + env=env, + ) + except FileNotFoundError as exc: + shutil.rmtree(temp_dir, ignore_errors=True) + raise SkillsCloneError( + "Git is not installed or not in PATH. Install Git from https://git-scm.com and try again." + ) from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + shutil.rmtree(temp_dir, ignore_errors=True) + if isinstance(exc, subprocess.CalledProcessError): + detail = (exc.stderr or exc.stdout or str(exc)).split("\n")[0] + else: + detail = str(exc) + raise SkillsCloneError(f"Failed to clone skills repository: {detail}") from exc + return temp_dir + + +def discover_skills(clone_dir: str) -> list[tuple[str, str]]: + root = os.path.join(clone_dir, SKILLS_SUBDIR) + if not os.path.isdir(root): + return [] + skills = [] + for name in sorted(os.listdir(root)): + if name.startswith("."): + continue + source = os.path.join(root, name) + if os.path.isdir(source): + skills.append((name, source)) + return skills + + +def read_skills_version(clone_dir: str) -> str: + path = os.path.join(clone_dir, "package.json") + if not os.path.isfile(path): + return "1.0.0" + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + return str(data.get("version") or "1.0.0") + except (OSError, json.JSONDecodeError): + return "1.0.0" + + +def stamp_path(project_dir: str) -> str: + return os.path.join(project_dir, STAMP_FILENAME) + + +def read_local_skills_version(project_dir: str) -> str: + path = stamp_path(project_dir) + if not os.path.isfile(path): + return "0.0.0" + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + return str(data.get("skillsVersion") or "0.0.0") + except (OSError, json.JSONDecodeError): + return "0.0.0" + + +def write_local_skills_version(project_dir: str, version: str) -> None: + with open(stamp_path(project_dir), "w", encoding="utf-8") as fh: + json.dump({"skillsVersion": version}, fh, indent=2) + fh.write("\n") + + +def _version_key(value: str) -> tuple: + parts = [] + for piece in value.split("."): + try: + parts.append(int(piece)) + except ValueError: + parts.append(0) + return tuple(parts) + + +def install_skills(project_dir: str, skills: list[tuple[str, str]], force: bool) -> None: + for rel in AGENT_SKILL_DIRS: + dest_root = os.path.join(project_dir, rel) + os.makedirs(dest_root, exist_ok=True) + for name, source in skills: + dest = os.path.join(dest_root, name) + if os.path.exists(dest) and not force: + continue + if os.path.exists(dest): + shutil.rmtree(dest) + shutil.copytree(source, dest) + + +def run_skills_flow(project_dir: str, force: bool = False) -> None: + print("\nInstalling agent skills...") + try: + clone_dir = clone_skills_repo() + except SkillsCloneError as err: + print(f"Warning: {err}") + print("Skipped agent skills. Init continues.\n") + return + try: + skills = discover_skills(clone_dir) + if not skills: + print("Warning: No skills found in the repository.\n") + return + install_skills(project_dir, skills, force) + write_local_skills_version(project_dir, read_skills_version(clone_dir)) + print("\033[32m✓\033[0m Agent skills installed\n") + finally: + shutil.rmtree(clone_dir, ignore_errors=True) + + +def upgrade_agent_skills(project_dir: str, *, dry_run: bool = False) -> dict: + """Reinstall project skills when the remote package.json version is newer.""" + try: + clone_dir = clone_skills_repo() + except SkillsCloneError as err: + print(f"Warning: Could not refresh agent skills: {err}") + return {"upgraded": False, "error": str(err)} + try: + remote = read_skills_version(clone_dir) + local = read_local_skills_version(project_dir) + if _version_key(local) >= _version_key(remote): + print(f"Skills: already up to date ({local})") + return {"upgraded": False, "local": local, "remote": remote, "dry_run": dry_run} + if dry_run: + print(f"Skills: updates available ({local} → {remote})") + return {"upgraded": False, "local": local, "remote": remote, "dry_run": True} + skills = discover_skills(clone_dir) + install_skills(project_dir, skills, force=True) + write_local_skills_version(project_dir, remote) + print(f"Skills: upgraded {local} → {remote}") + return {"upgraded": True, "local": local, "remote": remote, "dry_run": False} + finally: + shutil.rmtree(clone_dir, ignore_errors=True) diff --git a/nitrostack/cli/upgrade.py b/nitrostack/cli/upgrade.py index 55c77c1..12a312a 100644 --- a/nitrostack/cli/upgrade.py +++ b/nitrostack/cli/upgrade.py @@ -18,6 +18,7 @@ read_text, write_text_atomic, ) +from nitrostack.cli.skills import upgrade_agent_skills PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" PYPI_VERSION_JSON = "https://pypi.org/pypi/nitrostack/{version}/json" @@ -243,10 +244,26 @@ def upgrade_project( if dry_run: print("\nDry run — no files modified.") - return {"version": target, "spec": new_spec, "changes": changes, "dry_run": True, "written": []} + skills = upgrade_agent_skills(root, dry_run=True) + return { + "version": target, + "spec": new_spec, + "changes": changes, + "dry_run": True, + "written": [], + "skills": skills, + } written = _commit_file_changes(changes) print(f"\nUpgrade complete. nitrostack dependency is now {new_spec}.") print("Run `nitrostack-py install` to install the new version.") - return {"version": target, "spec": new_spec, "changes": changes, "dry_run": False, "written": written} + skills = upgrade_agent_skills(root, dry_run=False) + return { + "version": target, + "spec": new_spec, + "changes": changes, + "dry_run": False, + "written": written, + "skills": skills, + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..316b0d8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from nitrostack.cli.skills import SkillsCloneError + + +@pytest.fixture(autouse=True) +def disable_live_skills_clone(monkeypatch): + """Keep CLI tests off GitHub; test_cli_skills.py overrides this fixture.""" + + def _blocked(): + raise SkillsCloneError("skills clone disabled in tests") + + monkeypatch.setattr("nitrostack.cli.skills.clone_skills_repo", _blocked) diff --git a/tests/test_cli.py b/tests/test_cli.py index a2117bb..d734365 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -806,6 +806,7 @@ def test_main_dispatches_commands(): kwargs = init.call_args.kwargs assert init.call_args.args[0] == "demo" assert kwargs["skip_install"] is True + assert kwargs["force"] is False with patch("sys.argv", ["nitrostack-py", "dev", "--port", "4000", "--widget", "4001"]), patch( "nitrostack.cli.main.run_dev" diff --git a/tests/test_cli_skills.py b/tests/test_cli_skills.py new file mode 100644 index 0000000..25c2a06 --- /dev/null +++ b/tests/test_cli_skills.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nitrostack.cli.skills import ( + AGENT_SKILL_DIRS, + SkillsCloneError, + clone_skills_repo as _real_clone_skills_repo, + discover_skills, + install_skills, + read_local_skills_version, + run_skills_flow, + upgrade_agent_skills, + write_local_skills_version, +) +from nitrostack.cli.upgrade import upgrade_project + + +def _fake_clone(tmp_path: Path, version: str = "1.0.0", names: tuple[str, ...] = ("mcp-app-architecture",)) -> Path: + clone = tmp_path / "clone" + (clone / "skills").mkdir(parents=True) + (clone / "package.json").write_text(json.dumps({"version": version}), encoding="utf-8") + for name in names: + skill = clone / "skills" / name + skill.mkdir() + (skill / "SKILL.md").write_text(f"# {name}\nfrom nitrostack import module\n", encoding="utf-8") + return clone + + +@pytest.fixture +def disable_live_skills_clone(): + """Override autouse block so this module can install from a fake clone.""" + yield + + +def test_discover_skills_lists_skill_dirs_only(tmp_path: Path): + clone = _fake_clone(tmp_path, names=("auth-security", "mcp-app-architecture")) + (clone / "skills" / ".hidden").mkdir() + (clone / "skills" / "README.md").write_text("nope", encoding="utf-8") + found = discover_skills(str(clone)) + assert [name for name, _ in found] == ["auth-security", "mcp-app-architecture"] + + +def test_install_skills_fans_out_and_skips_without_force(tmp_path: Path): + clone = _fake_clone(tmp_path) + project = tmp_path / "proj" + project.mkdir() + skills = discover_skills(str(clone)) + install_skills(str(project), skills, force=False) + for rel in AGENT_SKILL_DIRS: + dest = project / rel / "mcp-app-architecture" / "SKILL.md" + assert dest.is_file() + marker = project / ".cursor" / "skills" / "mcp-app-architecture" / "SKILL.md" + marker.write_text("keep-me", encoding="utf-8") + install_skills(str(project), skills, force=False) + assert marker.read_text(encoding="utf-8") == "keep-me" + install_skills(str(project), skills, force=True) + assert "from nitrostack import module" in marker.read_text(encoding="utf-8") + + +def test_run_skills_flow_clone_error_does_not_raise(tmp_path: Path, capsys): + project = tmp_path / "proj" + project.mkdir() + + def boom(): + raise SkillsCloneError("no git") + + with patch("nitrostack.cli.skills.clone_skills_repo", side_effect=boom): + run_skills_flow(str(project), force=False) + out = capsys.readouterr().out + assert "Skipped agent skills" in out + assert not (project / ".nitrostack.json").exists() + + +def test_run_skills_flow_writes_stamp(tmp_path: Path): + clone = _fake_clone(tmp_path, version="1.2.3") + project = tmp_path / "proj" + project.mkdir() + with patch("nitrostack.cli.skills.clone_skills_repo", return_value=str(clone)): + run_skills_flow(str(project), force=False) + assert read_local_skills_version(str(project)) == "1.2.3" + assert (project / ".claude" / "skills" / "mcp-app-architecture" / "SKILL.md").is_file() + + +def test_init_mocked_clone_writes_nitrostack_json(tmp_path: Path, monkeypatch): + from nitrostack.cli.main import init_project + + clone = _fake_clone(tmp_path / "remote", version="1.0.0") + monkeypatch.setattr("nitrostack.cli.skills.clone_skills_repo", lambda: str(clone)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", io.StringIO("desc\nauthor\n")) + init_project("skills-demo", template="python-starter", skip_install=True) + project = tmp_path / "skills-demo" + stamp = json.loads((project / ".nitrostack.json").read_text(encoding="utf-8")) + assert stamp["skillsVersion"] == "1.0.0" + assert (project / ".cursor" / "skills" / "mcp-app-architecture" / "SKILL.md").is_file() + + +def test_upgrade_reinstalls_when_remote_newer(tmp_path: Path): + clone = _fake_clone(tmp_path / "v2", version="2.0.0", names=("mcp-app-architecture", "ui-widgets")) + project = tmp_path / "proj" + project.mkdir() + write_local_skills_version(str(project), "1.0.0") + (project / ".cursor" / "skills" / "mcp-app-architecture").mkdir(parents=True) + (project / ".cursor" / "skills" / "mcp-app-architecture" / "SKILL.md").write_text("old", encoding="utf-8") + with patch("nitrostack.cli.skills.clone_skills_repo", return_value=str(clone)): + result = upgrade_agent_skills(str(project), dry_run=False) + assert result["upgraded"] is True + assert read_local_skills_version(str(project)) == "2.0.0" + body = (project / ".cursor" / "skills" / "mcp-app-architecture" / "SKILL.md").read_text(encoding="utf-8") + assert body != "old" + assert (project / ".agents" / "skills" / "ui-widgets" / "SKILL.md").is_file() + + +def test_upgrade_dry_run_does_not_install(tmp_path: Path): + clone = _fake_clone(tmp_path / "v2", version="2.0.0") + project = tmp_path / "proj" + project.mkdir() + write_local_skills_version(str(project), "1.0.0") + with patch("nitrostack.cli.skills.clone_skills_repo", return_value=str(clone)): + result = upgrade_agent_skills(str(project), dry_run=True) + assert result["dry_run"] is True + assert result["upgraded"] is False + assert not (project / ".cursor" / "skills").exists() + + +def test_upgrade_project_refreshes_skills(tmp_path: Path): + clone = _fake_clone(tmp_path / "v2", version="1.1.0") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "[project]\n" + 'name = "demo"\n' + 'version = "0.1.0"\n' + "dependencies = [\n" + ' "nitrostack>=0.1.0",\n' + "]\n", + encoding="utf-8", + ) + write_local_skills_version(str(tmp_path), "1.0.0") + with patch("nitrostack.cli.upgrade.fetch_latest_nitrostack_version", return_value="9.9.9"): + with patch("nitrostack.cli.skills.clone_skills_repo", return_value=str(clone)): + result = upgrade_project(str(tmp_path), dry_run=True, verify=False) + assert result["skills"]["remote"] == "1.1.0" + assert result["skills"]["dry_run"] is True + + +def test_clone_skills_repo_wraps_git_errors(): + with patch("nitrostack.cli.skills.subprocess.run", side_effect=FileNotFoundError("git")): + with pytest.raises(SkillsCloneError, match="Git is not installed"): + _real_clone_skills_repo()