Skip to content
Open
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
23 changes: 23 additions & 0 deletions news/agentify-list.rst
Original file line number Diff line number Diff line change
@@ -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:**

* <news item>

**Deprecated:**

* <news item>

**Removed:**

* <news item>

**Fixed:**

* <news item>

**Security:**

* <news item>
165 changes: 138 additions & 27 deletions src/diffpy/apps/app_agentify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use sparse-checkout to only download part of the repo

"set",
"--no-cone",
"/skills.json",
],
check=True,
capture_output=True,
)
yield tmp_path


def list_skills(args):
console = Console(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same prompt package that cookiecuter and scikit-package use.

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}")
35 changes: 34 additions & 1 deletion src/diffpy/apps/apps.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)

Expand Down
23 changes: 14 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import subprocess
from pathlib import Path

import numpy
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading