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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ htmlcov/

# Ruff
.ruff_cache/

# Tool config dirs (generated by apc sync)
.cursor/
.claude/
.gemini/
.codeium/
7 changes: 3 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ build-backend = "setuptools.build_meta"
package-dir = {"" = "src"}
py-modules = [
"cache", "collect", "config", "export_import",
"frontmatter_parser", "llm_client", "llm_config",
"main", "marketplace", "mcp", "memory",
"secrets_manager", "share", "skill", "status",
"sync_helpers", "ui",
"frontmatter_parser", "install", "llm_client", "llm_config",
"main", "mcp", "memory", "secrets_manager",
"skill", "skills", "status", "sync_helpers", "ui",
]
packages = ["extractors", "appliers"]

Expand Down
52 changes: 30 additions & 22 deletions src/appliers/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@
from appliers.manifest import ToolManifest
from frontmatter_parser import render_frontmatter

CURSOR_DIR = Path.home() / ".cursor"
CURSOR_RULES_DIR = Path(".cursor") / "rules"
CURSOR_MCP_JSON = CURSOR_DIR / "mcp.json"

CURSOR_MEMORY_SCHEMA = """
Cursor uses Project Rules in .cursor/rules/ to provide persistent context to its AI.
Rules are markdown files (.md or .mdc). Files with .mdc extension support YAML frontmatter.
Expand Down Expand Up @@ -65,17 +61,30 @@
"""


def _cursor_dir() -> Path:
return Path.home() / ".cursor"


def _cursor_rules_dir() -> Path:
return Path.home() / ".cursor" / "rules"


def _cursor_mcp_json() -> Path:
return Path.home() / ".cursor" / "mcp.json"


class CursorApplier(BaseApplier):
SKILL_DIR = CURSOR_RULES_DIR
TOOL_NAME = "cursor"
MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA

@property
def SKILL_DIR(self) -> Path: # type: ignore[override]
return _cursor_rules_dir()

def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManifest) -> int:
"""Cursor uses flat .mdc files, so symlink SKILL.md as <name>.mdc."""
if self.SKILL_DIR is None:
return 0

self.SKILL_DIR.mkdir(parents=True, exist_ok=True)
rules_dir = _cursor_rules_dir()
rules_dir.mkdir(parents=True, exist_ok=True)
count = 0

for skill in skills:
Expand All @@ -84,7 +93,7 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife
if not source.exists():
continue

link_path = self.SKILL_DIR / f"{name}.mdc"
link_path = rules_dir / f"{name}.mdc"

if link_path.is_symlink() or link_path.exists():
link_path.unlink()
Expand All @@ -100,7 +109,8 @@ def link_skills(self, skills: List[Dict], source_dir: Path, manifest: ToolManife
return count

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
CURSOR_RULES_DIR.mkdir(parents=True, exist_ok=True)
rules_dir = _cursor_rules_dir()
rules_dir.mkdir(parents=True, exist_ok=True)
count = 0
for skill in skills:
name = skill.get("name", "unnamed")
Expand All @@ -111,7 +121,7 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
metadata["tags"] = skill["tags"]

content = render_frontmatter(metadata, skill.get("body", ""))
path = CURSOR_RULES_DIR / f"{name}.mdc"
path = rules_dir / f"{name}.mdc"
path.write_text(content, encoding="utf-8")
manifest.record_skill(name, file_path=str(path.resolve()), content=content)
count += 1
Expand All @@ -124,9 +134,10 @@ def apply_mcp_servers(
manifest: ToolManifest,
override: bool = False,
) -> int:
if CURSOR_MCP_JSON.exists():
mcp_json = _cursor_mcp_json()
if mcp_json.exists():
try:
data = json.loads(CURSOR_MCP_JSON.read_text(encoding="utf-8"))
data = json.loads(mcp_json.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
else:
Expand All @@ -136,8 +147,6 @@ def apply_mcp_servers(
mcp_servers = {}
else:
mcp_servers = data.get("mcpServers", {})

# Prune orphaned MCP servers
if not manifest.is_first_sync:
current_names = {s.get("name", "unnamed") for s in servers}
for orphan in set(manifest.managed_mcp_names()) - current_names:
Expand All @@ -147,7 +156,6 @@ def apply_mcp_servers(
count = 0
for server in servers:
name = server.get("name", "unnamed")

env = server.get("env", {}).copy()
for key, value in env.items():
if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
Expand All @@ -166,15 +174,15 @@ def apply_mcp_servers(
count += 1

data["mcpServers"] = mcp_servers
CURSOR_MCP_JSON.parent.mkdir(parents=True, exist_ok=True)
CURSOR_MCP_JSON.write_text(json.dumps(data, indent=2), encoding="utf-8")
mcp_json.parent.mkdir(parents=True, exist_ok=True)
mcp_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
return count

def _read_existing_memory_files(self) -> Dict[str, str]:
"""Return {file_path: content} for Cursor's rule files."""
result = {}
if CURSOR_RULES_DIR.exists():
for path in CURSOR_RULES_DIR.rglob("*.md*"):
rules_dir = _cursor_rules_dir()
if rules_dir.exists():
for path in rules_dir.rglob("*.md*"):
if path.is_file():
try:
result[str(path)] = path.read_text(encoding="utf-8")
Expand Down
6 changes: 5 additions & 1 deletion src/appliers/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
return count

def apply_mcp_servers(
self, servers: List[Dict], secrets: Dict[str, str], manifest: ToolManifest
self,
servers: List[Dict],
secrets: Dict[str, str],
manifest: ToolManifest,
override: bool = False,
) -> int:
# OpenClaw does not support MCP servers — it uses its own skill/tool system
return 0
Expand Down
2 changes: 1 addition & 1 deletion src/export_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
save_skills,
)
from config import get_config_dir
from marketplace import get_skills_dir
from secrets_manager import retrieve_secret, store_secrets_batch
from skills import get_skills_dir
from ui import error, header, info, success, warning

SCHEMA_VERSION = 1
Expand Down
197 changes: 197 additions & 0 deletions src/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""apc install command — install skills from a GitHub repository.

Handles the `apc install owner/repo` command and all its options.
"""

from typing import List

import click

from appliers import get_applier
from cache import load_skills, merge_skills, save_skills
from extractors import detect_installed_tools
from skills import fetch_skill_from_repo, list_skills_in_repo, save_skill_file

_AGENTS = ["claude-code", "cursor", "gemini-cli", "github-copilot", "openclaw", "windsurf"]


def _resolve_targets(target_args: tuple, yes: bool) -> List[str]:
"""Resolve target targets from -a flags, '*', or interactive selection."""
if not target_args:
detected = detect_installed_tools()
if not detected:
click.echo("No AI tools detected on this machine.", err=True)
return []
if yes:
return detected
click.echo("\nDetected tools:")
for i, t in enumerate(detected, 1):
click.echo(f" {i}. {t}")
raw = click.prompt("Install to (e.g. 1,3 or 'all')", default="all")
if raw.strip().lower() == "all":
return detected
indices = []
for part in raw.split(","):
part = part.strip()
if "-" in part:
a, b = part.split("-", 1)
indices.extend(range(int(a) - 1, int(b)))
elif part.isdigit():
indices.append(int(part) - 1)
return [detected[i] for i in indices if 0 <= i < len(detected)]

targets = list(target_args)
if "*" in targets:
return detect_installed_tools()
return targets


def _apply_skill_to_targets(skill: dict, target_list: list) -> int:
"""Write a skill directly to each target's skill directory. Returns applied count."""

count = 0
for target_name in target_list:
try:
applier = get_applier(target_name)
manifest = applier.get_manifest()
applied = applier.apply_skills([skill], manifest)
manifest.save()
count += applied
except Exception as e:
click.echo(f" ! {target_name}: {e}", err=True)
return count


@click.command()
@click.argument("repo")
@click.option(
"--skill", "-s", "skills", multiple=True, help="Skill name(s) to install. Use '*' for all."
)
@click.option("--all", "install_all", is_flag=True, help="Install all skills from the repo.")
@click.option(
"--target",
"-t",
"targets",
multiple=True,
help="Target tool(s) to install to. Use '*' for all detected.",
)
@click.option("--branch", default="main", show_default=True, help="Git branch to fetch from.")
@click.option(
"--list",
"list_only",
is_flag=True,
help="List available skills in the repo without installing.",
)
@click.option("-y", "--yes", is_flag=True, help="Non-interactive: skip all confirmation prompts.")
def install(repo, skills, install_all, targets, branch, list_only, yes):
"""Install skills from a GitHub repository.

\b
Examples:
apc install owner/repo --list
apc install owner/repo --skill frontend-design
apc install owner/repo --skill frontend-design --skill skill-creator
apc install owner/repo --skill '*'
apc install owner/repo --all
apc install owner/repo --skill frontend-design -t claude-code -t cursor
apc install owner/repo --all -t claude-code -y
"""
# Validate: repo must look like owner/repo
if "/" not in repo or repo.startswith("http"):
raise click.UsageError(
"REPO must be a GitHub repository name in owner/repo format"
" (e.g. vercel-labs/target-skills)"
)

# --list: just show available skills and exit
if list_only:
click.echo(f"Fetching skill list from {repo}...")
available = list_skills_in_repo(repo, branch)
if not available:
click.echo(f"No skills found in {repo} (branch: {branch}).", err=True)
return
click.echo(f"\nAvailable skills in {repo}:\n")
for name in available:
click.echo(f" • {name}")
click.echo(f"\n{len(available)} skill(s) found.")
return

# Resolve which skills to install
if install_all or ("*" in skills):
click.echo(f"Fetching skill list from {repo}...")
skill_names = list_skills_in_repo(repo, branch)
if not skill_names:
click.echo(f"No skills found in {repo}.", err=True)
return
elif skills:
skill_names = list(skills)
else:
# No --skill or --all: show list and prompt
click.echo(f"Fetching skill list from {repo}...")
available = list_skills_in_repo(repo, branch)
if not available:
click.echo(f"No skills found in {repo}.", err=True)
return
click.echo(f"\nAvailable skills in {repo}:\n")
for i, name in enumerate(available, 1):
click.echo(f" {i}. {name}")
raw = click.prompt("\nWhich skills? (e.g. 1,3 or 'all')", default="all")
if raw.strip().lower() == "all":
skill_names = available
else:
indices = []
for part in raw.split(","):
part = part.strip()
if "-" in part:
a, b = part.split("-", 1)
indices.extend(range(int(a) - 1, int(b)))
elif part.isdigit():
indices.append(int(part) - 1)
skill_names = [available[i] for i in indices if 0 <= i < len(available)]

if not skill_names:
click.echo("No skills selected.", err=True)
return

# Resolve target targets
target_list = _resolve_targets(targets, yes)
if not target_list:
return

# Confirm plan
if not yes:
click.echo(f"\nInstall {len(skill_names)} skill(s) from {repo}")
click.echo(f" Skills: {', '.join(skill_names)}")
click.echo(f" To: {', '.join(target_list)}")
if not click.confirm("\nProceed?", default=True):
click.echo("Cancelled.")
return

# Fetch and install
installed_skills = []
for skill_name in skill_names:
click.echo(f" Fetching {skill_name}...", nl=False)
skill = fetch_skill_from_repo(repo, skill_name, branch)
if not skill:
click.echo(f" not found in {repo}")
continue

# Save to ~/.apc/skills/<name>/SKILL.md
raw_content = skill.pop("_raw_content", skill.get("body", ""))
save_skill_file(skill["name"], raw_content)

# Apply directly to each target target
_apply_skill_to_targets(skill, target_list)

# Save metadata to local cache
existing = load_skills()
merged = merge_skills(existing, [skill])
save_skills(merged)

installed_skills.append(skill["name"])
click.echo(" ✓")

if installed_skills:
click.echo(f"\n✓ Installed {len(installed_skills)} skill(s) to {', '.join(target_list)}")
else:
click.echo("\nNo skills were installed.")
3 changes: 2 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from cache import load_local_bundle
from collect import collect
from export_import import export_cmd, import_cmd
from install import install
from llm_config import configure_cmd, models_cmd
from mcp import mcp
from memory import memory
from share import install
from skill import skill
from status import status
from sync_helpers import count_installed_skills, resolve_target_tools, sync_all
Expand Down Expand Up @@ -53,6 +53,7 @@ def cli():
# Memory
cli.add_command(memory)


# Install
cli.add_command(install)

Expand Down
Loading