From a954aff8000704ea8ec5a484c1e164e8f9288f64 Mon Sep 17 00:00:00 2001 From: Yuchen Xiao Date: Fri, 13 Nov 2026 10:47:30 -0500 Subject: [PATCH 1/3] feat: add `agentify list` Co-Authored-By: Claude Sonnet 5.5 --- src/diffpy/apps/app_agentify.py | 136 +++++++++++++++++++++++++------- src/diffpy/apps/apps.py | 14 +++- tests/conftest.py | 23 +++--- tests/test_agentify.py | 9 ++- 4 files changed, 144 insertions(+), 38 deletions(-) diff --git a/src/diffpy/apps/app_agentify.py b/src/diffpy/apps/app_agentify.py index a117940..d404593 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,125 @@ 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()) + 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) + + +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()) + for skill_name in skills_brief_dict: + 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..a8d4a31 100644 --- a/src/diffpy/apps/apps.py +++ b/src/diffpy/apps/apps.py @@ -1,6 +1,6 @@ import argparse -from diffpy.apps.app_agentify import agentify +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 +69,22 @@ def main(): action="store_true", help="When set, deploy the agentic skill to the system directory.", ) + 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.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: + agentify_parser.print_help() 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..66a0c5f 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( @@ -67,6 +67,13 @@ def test_agentify(args, expected_scope, expected_skill_dir, mock_clone_skills): assert expected_path.exists() +def test_list_skills(mock_clone_skills, capsys): + # C1: diffpy.apps agentify list + # Expect the available skills are listed. + list_skills(SimpleNamespace()) + assert "diffpy-playbook: Playbook skill." in capsys.readouterr().out + + def test_agentify_update(mock_clone_skills): tmp = mock_clone_skills # C1: Deploy again without --update flag when skill already exists. From cee56ce296f35601502a4910be08b8143580b5cb Mon Sep 17 00:00:00 2001 From: Yuchen Xiao Date: Fri, 13 Nov 2026 10:47:30 -0500 Subject: [PATCH 2/3] feat: support `--skill` for both `agentify` and `agentify list` Co-Authored-By: Claude Sonnet 5.5 --- news/agentify-list.rst | 23 ++++++++++ src/diffpy/apps/app_agentify.py | 39 ++++++++++++++--- src/diffpy/apps/apps.py | 18 ++++++++ tests/test_agentify.py | 78 +++++++++++++++++++++++++++++++-- 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 news/agentify-list.rst 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 d404593..9e47ff9 100644 --- a/src/diffpy/apps/app_agentify.py +++ b/src/diffpy/apps/app_agentify.py @@ -89,11 +89,23 @@ def print_skill(name, skills_brief_dict): with cloned_skills() as tmp_path: skills_brief_dict = json.loads((tmp_path / "skills.json").read_text()) - 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) + 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): @@ -116,7 +128,24 @@ def agentify(args): # 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", diff --git a/src/diffpy/apps/apps.py b/src/diffpy/apps/apps.py index a8d4a31..4ebd39d 100644 --- a/src/diffpy/apps/apps.py +++ b/src/diffpy/apps/apps.py @@ -69,6 +69,15 @@ 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", @@ -78,6 +87,15 @@ def main(): "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() diff --git a/tests/test_agentify.py b/tests/test_agentify.py index 66a0c5f..7f80310 100644 --- a/tests/test_agentify.py +++ b/tests/test_agentify.py @@ -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,11 +84,63 @@ def test_agentify(args, expected_scope, expected_skill_dir, mock_clone_skills): assert expected_path.exists() -def test_list_skills(mock_clone_skills, capsys): - # C1: diffpy.apps agentify list - # Expect the available skills are listed. - list_skills(SimpleNamespace()) +@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): @@ -83,6 +152,7 @@ def test_agentify_update(mock_clone_skills): agent="claude", system=False, update=False, + requested_skill_names=None, ) agentify(args) skill_path = ( From f613d437811236175f7c51063119aa588f201b2f Mon Sep 17 00:00:00 2001 From: Yuchen Xiao Date: Fri, 13 Nov 2026 11:01:52 -0500 Subject: [PATCH 3/3] chore: confirm before deploy skills --- src/diffpy/apps/apps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/diffpy/apps/apps.py b/src/diffpy/apps/apps.py index 4ebd39d..ead22c2 100644 --- a/src/diffpy/apps/apps.py +++ b/src/diffpy/apps/apps.py @@ -1,5 +1,7 @@ import argparse +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 @@ -102,7 +104,8 @@ def main(): if args.application is None: parser.print_help() elif args.application == "agentify" and args.agentify_command is None: - agentify_parser.print_help() + if Confirm.ask("Deploy all agentic skills to the environment?"): + args.func(args) else: args.func(args)