diff --git a/news/agentify-list.rst b/news/agentify-list.rst new file mode 100644 index 0000000..5a05727 --- /dev/null +++ b/news/agentify-list.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add diffpy-app agentify list and support -skill flag for both diffpy-app agentify and diffpy-app agentify list. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/apps/app_agentify.py b/src/diffpy/apps/app_agentify.py index a117940..9e47ff9 100644 --- a/src/diffpy/apps/app_agentify.py +++ b/src/diffpy/apps/app_agentify.py @@ -4,8 +4,13 @@ import subprocess import tempfile import warnings +from contextlib import contextmanager from pathlib import Path +from rich.console import Console +from rich.text import Text +from rich.theme import Theme + REPO_URL = "https://github.com/diffpy/cmi-agent-skills" @@ -22,48 +27,154 @@ def internet_available(): return agentify_available -def agentify(args): +@contextmanager +def cloned_skills(): + """Yield the path of a fresh clone of the skills repo.""" if not ensure_setup(): raise ValueError( "Internet connection or git unavailable. " "Please ensure git is installed and you have an active internet " f"connection to {REPO_URL}." ) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + subprocess.run( + [ + "git", + "clone", + "--depth=1", + "--filter=blob:none", + "--sparse", + REPO_URL, + str(tmp_path), + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "sparse-checkout", + "set", + "--no-cone", + "/skills.json", + ], + check=True, + capture_output=True, + ) + yield tmp_path + + +def list_skills(args): + console = Console( + theme=Theme( + { + "skill_name": "bold cyan", + "description": "green", + } + ) + ) + + def print_skill(name, skills_brief_dict): + line = Text(f"{name}: ", style="skill_name") + if "description" not in skills_brief_dict[name]: + skills_brief_dict[name][ + "description" + ] = "Description not available." + description = skills_brief_dict[name]["description"] + line.append(str(description), style="description") + console.print(line) + + with cloned_skills() as tmp_path: + skills_brief_dict = json.loads((tmp_path / "skills.json").read_text()) + if args.requested_skill_names is None: + # No skills specified, print up to 10 skills + n_skills_to_show = 10 + for i, name in enumerate(skills_brief_dict): + if i >= n_skills_to_show: + break + print_skill(name, skills_brief_dict) + else: + # skills specified by the user. + for name in args.requested_skill_names: + if name not in skills_brief_dict: + warnings.warn( + f"Skill '{name}' not found. " + f"Run 'agentify list' to see available skills." + ) + continue + print_skill(name, skills_brief_dict) + + +def agentify(args): agent = args.agent system_flag = args.system + # determine destination directory based on the agent and scope if agent == "claude": skills_dir = ".claude/skills" elif agent == "codex": skills_dir = ".codex/skills" + else: + raise KeyError( + f"{agent} could not be recognized. " + "Please use either 'claude' or 'codex'." + ) if system_flag: dest_dir = Path().home() / skills_dir else: dest_dir = Path().cwd() / skills_dir - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - subprocess.run( - ["git", "clone", REPO_URL, str(tmp_path)], - check=True, - ) - skills = json.load(open(tmp_path / "skills.json")) - for item in tmp_path.iterdir(): - if item.is_dir(): - destination = dest_dir / item.name - if item.name not in skills: - continue - if destination.exists(): - if args.update: - shutil.rmtree(destination) - shutil.copytree(item, destination, dirs_exist_ok=True) - print(f"Updated existing skill at {destination}") - else: - warnings.warn( - f"Skill at {destination} already exists. " - "Use '--update' flag to overwrite." - ) - continue + # clone the skills repository and deploy requested skills + with cloned_skills() as tmp_path: + skills_brief_dict = json.loads((tmp_path / "skills.json").read_text()) + if args.requested_skill_names is not None: + unknown_names = [ + name + for name in args.requested_skill_names + if name not in skills_brief_dict + ] + if unknown_names: + raise ValueError( + f"Skill(s) {unknown_names} not found. " + "Run 'agentify list' to see available skills." + ) + for skill_name in skills_brief_dict: + if ( + args.requested_skill_names is not None + and skill_name not in args.requested_skill_names + ): + # skip when skill is not requested + continue + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "sparse-checkout", + "set", + "--no-cone", + f"{skill_name}/", + ], + check=True, + capture_output=True, + ) + destination = dest_dir / skill_name + if destination.exists(): + if args.update: + shutil.rmtree(destination) + shutil.copytree( + tmp_path / skill_name, destination, dirs_exist_ok=True + ) + print(f"Updated existing skill at {destination}") else: - shutil.copytree(item, destination, dirs_exist_ok=True) - print( - f"Skill {item.name} has been deployed to {destination}" + warnings.warn( + f"Skill at {destination} already exists. " + "Use '--update' flag to overwrite." ) + continue + else: + shutil.copytree( + tmp_path / skill_name, destination, dirs_exist_ok=True + ) + print(f"Skill {skill_name} has been deployed to {destination}") diff --git a/src/diffpy/apps/apps.py b/src/diffpy/apps/apps.py index 253ede7..ead22c2 100644 --- a/src/diffpy/apps/apps.py +++ b/src/diffpy/apps/apps.py @@ -1,6 +1,8 @@ import argparse -from diffpy.apps.app_agentify import agentify +from rich.prompt import Confirm + +from diffpy.apps.app_agentify import agentify, list_skills from diffpy.apps.app_runmacro import runmacro from diffpy.apps.version import __version__ # noqa @@ -69,10 +71,41 @@ def main(): action="store_true", help="When set, deploy the agentic skill to the system directory.", ) + agentify_parser.add_argument( + "--skill", + nargs="*", + help=( + "Skill name(s) to deploy. " + "If not specified, all skills are deployed." + ), + dest="requested_skill_names", + ) + agentify_subparsers = agentify_parser.add_subparsers( + title="agentify subcommands", + dest="agentify_command", + ) + # agentify list subcommand + list_parser = agentify_subparsers.add_parser( + "list", + help="List available agentic skills.", + ) + list_parser.add_argument( + "--skill", + nargs="*", + help=( + "Skill name(s) to show details for. " + "If not specified, all skills are listed." + ), + dest="requested_skill_names", + ) + list_parser.set_defaults(func=list_skills) agentify_parser.set_defaults(func=agentify) args = parser.parse_args() if args.application is None: parser.print_help() + elif args.application == "agentify" and args.agentify_command is None: + if Confirm.ask("Deploy all agentic skills to the environment?"): + args.func(args) else: args.func(args) diff --git a/tests/conftest.py b/tests/conftest.py index 89a4ba9..0e36630 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import json +import subprocess from pathlib import Path import numpy @@ -47,19 +48,23 @@ def sine_profile(): @pytest.fixture def mock_clone_skills(user_filesystem, mocker): - def fake_clone(cmd, **kwargs): - # the last argument is the temporary directory to clone into - target_dir = Path(cmd[-1]) - (target_dir / "skills.json").write_text( - json.dumps(["diffpy-playbook"]) + skills_brief = {"diffpy-playbook": {"description": "Playbook skill."}} + + def fake_run(cmd, **kwargs): + if "clone" in cmd: + # The last argument is the temporary directory to clone into + target_dir = Path(cmd[-1]) + (target_dir / "skills.json").write_text(json.dumps(skills_brief)) + skill_dir = target_dir / "diffpy-playbook" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("skill") + return subprocess.CompletedProcess( + cmd, 0, stdout=json.dumps(skills_brief), stderr="" ) - skill_dir = target_dir / "diffpy-playbook" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("skill") mocker.patch("diffpy.apps.app_agentify.ensure_setup", return_value=True) mocker.patch( - "diffpy.apps.app_agentify.subprocess.run", side_effect=fake_clone + "diffpy.apps.app_agentify.subprocess.run", side_effect=fake_run ) mocker.patch.object( Path, "home", return_value=user_filesystem / "home_dir" diff --git a/tests/test_agentify.py b/tests/test_agentify.py index 7a0dde4..7f80310 100644 --- a/tests/test_agentify.py +++ b/tests/test_agentify.py @@ -4,7 +4,7 @@ import pytest -from diffpy.apps.app_agentify import agentify +from diffpy.apps.app_agentify import agentify, list_skills @pytest.mark.parametrize( @@ -18,6 +18,7 @@ agent="claude", system=False, update=False, + requested_skill_names=None, ), "cwd", ".claude/skills/diffpy-playbook", @@ -30,6 +31,7 @@ agent="claude", system=True, update=False, + requested_skill_names=None, ), "home", ".claude/skills/diffpy-playbook", @@ -42,6 +44,7 @@ agent="codex", system=False, update=False, + requested_skill_names=None, ), "cwd", ".codex/skills/diffpy-playbook", @@ -54,10 +57,24 @@ agent="codex", system=True, update=False, + requested_skill_names=None, ), "home", ".codex/skills/diffpy-playbook", ), + # C5: diffpy.apps agentify --skill diffpy-playbook + # Deploys the requested workspace claude skill. + # Expect skill folder is created in the current working directory. + ( + SimpleNamespace( + agent="claude", + system=False, + update=False, + requested_skill_names=["diffpy-playbook"], + ), + "cwd", + ".claude/skills/diffpy-playbook", + ), ], ) def test_agentify(args, expected_scope, expected_skill_dir, mock_clone_skills): @@ -67,6 +84,65 @@ def test_agentify(args, expected_scope, expected_skill_dir, mock_clone_skills): assert expected_path.exists() +@pytest.mark.parametrize( + "requested_skill_names", + [ + # C1: diffpy.apps agentify list + # No skill name provided. + # Expect the available skills are listed without warnings. + None, + # C2: diffpy.apps agentify list --skill diffpy-playbook + # A valid skill name provided. + # Expect the requested skill is listed without warnings. + ["diffpy-playbook"], + ], +) +def test_list_skills( + requested_skill_names, mock_clone_skills, capsys, recwarn +): + args = SimpleNamespace(requested_skill_names=requested_skill_names) + list_skills(args) + assert "diffpy-playbook: Playbook skill." in capsys.readouterr().out + assert len(recwarn) == 0 + + +def test_list_skills_bad(mock_clone_skills): + # C1: diffpy.apps agentify list --skill wrong-skill + # An unknown skill name provided. + # Expect a warning that points the user to 'agentify list'. + args = SimpleNamespace(requested_skill_names=["wrong-skill"]) + with pytest.warns( + UserWarning, + match=re.escape( + "Skill 'wrong-skill' not found. " + "Run 'agentify list' to see available skills." + ), + ): + list_skills(args) + + +def test_agentify_bad(mock_clone_skills): + # C1: diffpy.apps agentify --skill wrong-skill + # An unknown skill name provided. + # Expect ValueError that points the user to 'agentify list', + # and no skill is deployed. + args = SimpleNamespace( + agent="claude", + system=False, + update=False, + requested_skill_names=["wrong-skill"], + ) + with pytest.raises( + ValueError, + match=re.escape( + "Skill(s) ['wrong-skill'] not found. " + "Run 'agentify list' to see available skills." + ), + ): + agentify(args) + assert not (mock_clone_skills / "cwd_dir" / ".claude").exists() + + def test_agentify_update(mock_clone_skills): tmp = mock_clone_skills # C1: Deploy again without --update flag when skill already exists. @@ -76,6 +152,7 @@ def test_agentify_update(mock_clone_skills): agent="claude", system=False, update=False, + requested_skill_names=None, ) agentify(args) skill_path = (