Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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╔══════════════════════════════════════════════════════════╗
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
173 changes: 173 additions & 0 deletions nitrostack/cli/skills.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 19 additions & 2 deletions nitrostack/cli/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading