Skip to content

API Reference

Technical reference for the jup package modules.

Core

main_callback(ctx, version=typer.Option(None, '--version', '-v', callback=version_callback, is_eager=True, help='Show version and exit'))

jup - Agent Skills Manager

Source code in src/jup/main.py
@app.callback(invoke_without_command=True)
def main_callback(
    ctx: typer.Context,
    version: bool = typer.Option(
        None,
        "--version",
        "-v",
        callback=version_callback,
        is_eager=True,
        help="Show version and exit",
    ),
):
    """jup - Agent Skills Manager"""
    if ctx.invoked_subcommand is None:
        print(BANNER)
        print("[bold]jup - Agent Skills Manager[/bold]")
        print()
        print(ctx.get_help())
        raise typer.Exit()

version_callback(value)

Callback to show the version of the application.

Source code in src/jup/main.py
def version_callback(value: bool):
    """Callback to show the version of the application."""
    if value:
        print(BANNER)
        try:
            v = get_version("jup")
        except Exception:
            v = "unknown"
        print(f"[bold]jup[/bold] version: [magenta]{v}[/magenta]")
        raise typer.Exit()

get_all_harnesses(config)

Merge default harnesses with custom providers from config.

Source code in src/jup/config.py
def get_all_harnesses(config: JupConfig) -> dict[str, HarnessConfig]:
    """Merge default harnesses with custom providers from config."""
    all_harnesses = DEFAULT_HARNESSES.copy()
    if config.custom_harnesses:
        all_harnesses.update(config.custom_harnesses)
    return all_harnesses

get_scope_dir(config, harness_name=None)

Return the skills directory for the given config and optional harness.

Source code in src/jup/config.py
def get_scope_dir(config: JupConfig, harness_name: str | None = None) -> Path:
    """
    Return the skills directory for the given config and optional harness.
    """
    all_harnesses = get_all_harnesses(config)
    harness_key = (
        harness_name if harness_name and harness_name in all_harnesses else ".agents"
    )
    harness_config = all_harnesses[harness_key]

    if config.scope == "local":
        return Path(harness_config.local_location).expanduser().resolve()

    return Path(harness_config.global_location).expanduser().resolve()

get_skills_storage_dir()

Internal storage for all downloaded skills globally.

Source code in src/jup/config.py
def get_skills_storage_dir() -> Path:
    """Internal storage for all downloaded skills globally."""
    storage = JUP_CONFIG_DIR / "skills"
    storage.mkdir(parents=True, exist_ok=True)
    return storage

skills_lock_session(config)

Context manager that yields a SkillsLock and automatically saves it on exit. Handles file locking for the entire session.

Source code in src/jup/config.py
@contextlib.contextmanager
def skills_lock_session(config: JupConfig):
    """
    Context manager that yields a SkillsLock and automatically saves it on exit.
    Handles file locking for the entire session.
    """
    try:
        lm = get_lock_manager(config)
        with lm.lock(write=True):
            lock = get_skills_lock(config)
            yield lock
            save_skills_lock(config, lock)
    except Exception:
        raise

Commands

add_skill(repo=typer.Argument(..., help='GitHub repository (owner/repo), URL, or local directory. Can include @version.'), category=typer.Option('misc', '--category', help='Category for the skill (e.g., productivity/custom)'), path=typer.Option(None, '--path', help='[GitHub only] Path to skills directory in the repo.'), skills=typer.Option(None, '--skills', help='[GitHub only] Comma-separated list of skill names to add (default: all)'), agent=typer.Option(None, '--agent', '-a', help='Comma-separated agents to install to (overrides config.harnesses)'), scope=typer.Option(None, '--scope', help='Target scope (user or local)'), custom_dir=typer.Option(None, '--dir', help='Install to a custom directory (overrides --agent and --scope)'), verbose=False)

Install skills from a GitHub repository or a local directory.

Source code in src/jup/commands/add.py
def add_skill(
    repo: str = typer.Argument(
        ...,
        help="GitHub repository (owner/repo), URL, or local directory. Can include @version.",
    ),
    category: str = typer.Option(
        "misc", "--category", help="Category for the skill (e.g., productivity/custom)"
    ),
    path: str = typer.Option(
        None,
        "--path",
        help="[GitHub only] Path to skills directory in the repo.",
    ),
    skills: str = typer.Option(
        None,
        "--skills",
        help="[GitHub only] Comma-separated list of skill names to add (default: all)",
    ),
    agent: str = typer.Option(
        None,
        "--agent",
        "-a",
        help="Comma-separated agents to install to (overrides config.harnesses)",
    ),
    scope: ScopeType = typer.Option(
        None,
        "--scope",
        help="Target scope (user or local)",
    ),
    custom_dir: str = typer.Option(
        None,
        "--dir",
        help="Install to a custom directory (overrides --agent and --scope)",
    ),
    verbose: bool = False,
):
    """Install skills from a GitHub repository or a local directory."""
    verbose_state.verbose = verbose
    source_type = GITHUB_SOURCE_TYPE
    source_layout = None
    source_key = repo
    source_display = repo
    found_skills: list[Path] = []
    target_dir: Path | None = None
    config = get_config()

    if scope:
        config.scope = scope
    if agent:
        config.harnesses = [a.strip() for a in agent.split(",")]

    parsed_repo = parse_repo_arg(repo)
    if not parsed_repo:
        local_path = Path(repo).expanduser()
        if not local_path.exists() or not local_path.is_dir():
            print(
                f"[red]Local source must be an existing directory or invalid repo format: {repo}[/red]"
            )
            raise typer.Exit(code=1)

        resolved_local = local_path.resolve()
        harness_name = is_path_in_harness_dir(resolved_local, config)
        if harness_name:
            print(
                f"[yellow]Source is inside a harness directory ({harness_name}).[/yellow]"
            )
            if typer.confirm(
                "Move to central storage? (Recommended for management)", default=True
            ):
                storage_base = get_skills_storage_dir()
                new_path = storage_base / category / resolved_local.name
                if new_path.exists():
                    print(
                        f"[red]Destination {rel_home(new_path)} already exists. Aborting move.[/red]"
                    )
                else:
                    print(f"Moving to [cyan]{rel_home(new_path)}[/cyan]...")
                    new_path.parent.mkdir(parents=True, exist_ok=True)
                    shutil.move(str(resolved_local), str(new_path))
                    resolved_local = new_path

        source_type = LOCAL_SOURCE_TYPE
        source_key = str(resolved_local)

        if (resolved_local / "SKILL.md").exists():
            source_layout = "single"
            found_skills = [resolved_local]
        else:
            source_layout = "collection"
            for item in resolved_local.iterdir():
                if item.is_dir() and (item / "SKILL.md").exists():
                    found_skills.append(item)

        if verbose_state.verbose:
            print(f"Found {len(found_skills)} local skills.")
        if not found_skills:
            print("[red]No skills found.[/red]")
            raise typer.Exit(code=1)
        version_resolved = None
        repo_url = None
    else:
        owner, repo_name, parsed_path, version_resolved, is_url = parsed_repo
        repo_url = f"https://github.com/{owner}/{repo_name}.git"

        if parsed_path and not path:
            path = parsed_path

        # Normalize case for keys to avoid duplicates
        source_key = f"{owner.lower()}/{repo_name.lower()}"
        if path:
            source_key += f"/{path}"
        if version_resolved:
            source_key += f"@{version_resolved}"

        with tempfile.TemporaryDirectory() as temp_dir:
            temp_path = Path(temp_dir)
            print(f"Cloning {repo_url}...")
            try:
                if version_resolved:
                    run_git_clone(repo_url, temp_path, branch=version_resolved, depth=1)
                else:
                    run_git_clone(repo_url, temp_path, depth=1)
            except subprocess.CalledProcessError:
                raise typer.Exit(code=1)

            skills_dir = temp_path / path if path else temp_path / "skills"
            fallback_skills_dir = temp_path / ".claude" / "skills"

            if not skills_dir.exists() or not skills_dir.is_dir():
                if (
                    (not path or path == "skills/")
                    and fallback_skills_dir.exists()
                    and fallback_skills_dir.is_dir()
                ):
                    skills_dir = fallback_skills_dir
                elif (temp_path / "SKILL.md").exists() and not path:
                    # The root of the repo is the skill
                    skills_dir = temp_path
                else:
                    print(
                        f"[red]Skills directory not found at {path or 'skills/'}[/red]"
                    )
                    raise typer.Exit(code=1)

            storage_base = get_skills_storage_dir()
            target_dir = storage_base / category / GH_PREFIX / owner / repo_name
            if version_resolved:
                target_dir = target_dir.with_name(f"{repo_name}-{version_resolved}")

            # CRITICAL: Validate path AFTER all modifications (including @version)
            validate_path(target_dir, storage_base)

            if (skills_dir / "SKILL.md").exists():
                all_skills = [skills_dir]
                source_layout = "single"
            else:
                source_layout = "collection"
                # Support skills/*/SKILL.md discovery
                all_skills = [item for item in skills_dir.glob("*/SKILL.md")]
                all_skills = [item.parent for item in all_skills]

            if skills:
                selected = set(s.strip() for s in skills.split(",") if s.strip())
                found_skills = [item for item in all_skills if item.name in selected]
            else:
                found_skills = all_skills

            if not found_skills:
                print("[red]No skills found matching selection.[/red]")
                raise typer.Exit(code=1)

            if target_dir.exists():
                shutil.rmtree(target_dir)
            target_dir.mkdir(parents=True, exist_ok=True)

            for skill in found_skills:
                dest_skill_dir = (
                    target_dir / skill.name
                    if source_layout == "collection"
                    else target_dir
                )
                if source_layout == "single" and target_dir.exists():
                    shutil.rmtree(target_dir)  # clear if exist
                shutil.copytree(skill, dest_skill_dir)
                inject_metadata(dest_skill_dir / "SKILL.md", repo_url, version_resolved)

            if source_layout == "single":
                # Ensure found_skills points to the destination target_dir so lockfile stores correct name
                found_skills = [target_dir]

    with skills_lock_session(config) as lock:
        lock.sources[source_key] = SkillSource(
            repo=f"{owner.lower()}/{repo_name.lower()}"
            if source_type == GITHUB_SOURCE_TYPE
            else None,
            source_type=source_type,
            source_path=source_key if source_type == LOCAL_SOURCE_TYPE else None,
            source_layout=source_layout,
            category=category,
            skills=[skill.name for skill in found_skills],
            version=version_resolved,
            source=repo_url if source_type == GITHUB_SOURCE_TYPE else source_key,
            last_updated=datetime.now(timezone.utc).isoformat(timespec="seconds"),
        )

    if source_type == LOCAL_SOURCE_TYPE:
        print(
            f"✅ Successfully added {len(found_skills)} local skills from {source_display}"
        )
    else:
        assert target_dir is not None
        print(
            f"✅ Successfully added {len(found_skills)} skills from {owner}/{repo_name} to [green]{rel_home(target_dir)}[/green]"
        )

    # Trigger sync with potential custom_dir
    from ..core.sync import sync_logic

    sync_logic(
        verbose=verbose_state.verbose,
        custom_dir=custom_dir,
        config=config,
        logger=print,
    )

parse_repo_arg(repo_arg)

Parses the repo argument. Supports: - https://github.com/owner/repo - https://github.com/owner/repo/tree/main/path/to/skill - owner/repo - owner/repo/path/to/skill - @version suffix on any of the above Returns (owner, repo, path, version, is_url) or None if local.

Source code in src/jup/commands/add.py
def parse_repo_arg(repo_arg: str):
    """
    Parses the repo argument.
    Supports:
    - https://github.com/owner/repo
    - https://github.com/owner/repo/tree/main/path/to/skill
    - owner/repo
    - owner/repo/path/to/skill
    - @version suffix on any of the above
    Returns (owner, repo, path, version, is_url) or None if local.
    """
    if not repo_arg:
        return None

    # 1. Detect and ignore git SSH URLs (e.g. git@github.com:owner/repo.git)
    if (
        "@" in repo_arg
        and ":" in repo_arg
        and not repo_arg.startswith(("http", "ssh://"))
    ):
        return None

    # 2. Extract version suffix (if any)
    # We split only the LAST @ to avoid confusion with usernames in URLs
    # and to handle cases like owner/repo@v1 correctly.
    version = None
    if "@" in repo_arg and not repo_arg.startswith("@"):
        is_url = "://" in repo_arg
        # If it's a URL, only extract if it's NOT a /tree/ URL (where branch is already present)
        # OR if the @ is at the very end (explicit version override)
        if not is_url or "/tree/" not in repo_arg:
            parts = repo_arg.rsplit("@", 1)
            # If the part after @ contains /, it's probably not a version suffix but part of a path
            if "/" not in parts[1]:
                repo_arg = parts[0]
                version = parts[1]

    # 3. Handle SSH and HTTP URLs
    if repo_arg.startswith(("http://", "https://", "ssh://")):
        parsed = urllib.parse.urlparse(repo_arg)
        # Handle userinfo in netloc (e.g. git@github.com)
        netloc = parsed.netloc
        if "@" in netloc:
            netloc = netloc.split("@")[-1]
        # Handle port in netloc (e.g. github.com:443)
        if ":" in netloc:
            netloc = netloc.split(":")[0]

        if netloc == "github.com":
            # Normalize path: remove redundant slashes and ..
            path_parts = [p for p in parsed.path.split("/") if p and p != ".."]
            if len(path_parts) >= 2:
                owner = path_parts[0]
                repo = path_parts[1]
                if repo.endswith(".git"):
                    repo = repo[:-4]
                subpath = None
                if len(path_parts) > 4 and path_parts[2] == "tree":
                    subpath = "/".join(path_parts[4:])
                    if not version:
                        version = path_parts[3]
                return owner, repo, subpath, version, True
        return None  # Not a supported GitHub URL

    # 4. Handle shorthand owner/repo or local paths
    # Normalize: strip only trailing slashes and collapse multiple slashes and strip ..
    repo_arg_norm = "/".join(p for p in repo_arg.split("/") if p and p != "..")
    if repo_arg.startswith("/"):
        return None  # Absolute path is always local

    parts = repo_arg_norm.split("/")
    if len(parts) >= 2:
        # Check if it exists locally FIRST to allow local shadowing if intended
        # but only if it's a valid local path.
        if Path(repo_arg).expanduser().exists():
            return None

        owner = parts[0]
        repo = parts[1]
        subpath = "/".join(parts[2:]) if len(parts) > 2 else None
        return owner, repo, subpath, version, False

    return None

sync_skills(update=False, interactive=False, verbose=False, custom_dir=None)

Update all links/copies in default-lib and for other harnesses.

Source code in src/jup/commands/sync.py
def sync_skills(
    update: Annotated[
        bool,
        typer.Option("--update", "-u", help="Update GitHub sources before syncing"),
    ] = False,
    interactive: Annotated[
        bool,
        typer.Option(
            "--interactive", "-i", help="Select which skills to sync interactively"
        ),
    ] = False,
    verbose: bool = False,
    custom_dir: Optional[str] = None,
):
    """Update all links/copies in default-lib and for other harnesses."""
    verbose_state.verbose = verbose

    def interactive_callback(all_skills: List[str]) -> Optional[List[str]]:
        values = [(s, s) for s in all_skills]
        return checkboxlist_dialog(
            title="Interactive Sync",
            text="Select skills to sync (Space to toggle, Enter to confirm):",
            values=values,
            default_values=all_skills,
        ).run()

    sync_logic(
        update=update,
        verbose=verbose,
        interactive_callback=interactive_callback if interactive else None,
        custom_dir=custom_dir,
        logger=print,
    )

up_shortcut(verbose=False)

Shortcut for jup sync --update

Source code in src/jup/commands/sync.py
def up_shortcut(verbose: bool = False):
    """Shortcut for jup sync --update"""
    verbose_state.verbose = verbose
    sync_logic(update=True, verbose=verbose)

list_skills(target=typer.Argument(None, hidden=True), only_local=typer.Option(False, '--only-local', help='Show only local skills (from local path source)'), remote=typer.Option(False, '--remote', help='Show only remote (GitHub) skills'), scope=typer.Option(None, '--scope', help='Filter by scope (global or local)'), as_json=typer.Option(False, '--json', help='Output in JSON format'))

List installed skills as a table or JSON.

Source code in src/jup/commands/list.py
def list_skills(
    target: Optional[str] = typer.Argument(None, hidden=True),
    only_local: bool = typer.Option(
        False, "--only-local", help="Show only local skills (from local path source)"
    ),
    remote: bool = typer.Option(
        False, "--remote", help="Show only remote (GitHub) skills"
    ),
    scope: Optional[ScopeType] = typer.Option(
        None, "--scope", help="Filter by scope (global or local)"
    ),
    as_json: bool = typer.Option(False, "--json", help="Output in JSON format"),
):
    """List installed skills as a table or JSON."""
    if target:
        if target != "skills":
            if only_local or remote or scope is not None or as_json:
                print(
                    f"[red]Options like --json, --only-local, --remote, --scope are not supported for target '{target}'.[/red]"
                )
                raise typer.Exit(code=1)

        if target == "skills":
            pass
        elif target in ("agents", "agent", "harness", "harnesses"):
            from .harness_cli import harness_list

            harness_list()
            return
        elif target == "config":
            from .config_cli import config_show

            config_show()
            return
        else:
            print(f"[red]Unknown list target: {target}[/red]")
            raise typer.Exit(code=1)

    config = get_config()

    if scope:
        scopes_to_check = [scope]
    else:
        # Show both by default if not specified
        scopes_to_check = [ScopeType.USER, ScopeType.LOCAL]

    installed_skills_data = []
    managed_skill_names = set()
    unmanaged_skills_data = []

    all_harnesses = get_all_harnesses(config)

    for current_scope in scopes_to_check:
        temp_config = config.model_copy()
        temp_config.scope = current_scope
        lock = get_skills_lock(temp_config)

        # Determine targets (where skills should be installed)
        configured_harnesses = []

        # 1. Default scope directory
        default_dir = get_scope_dir(temp_config)
        configured_harnesses.append((".agents", default_dir))

        # 2. Configured harnesses
        for harness_name in config.harnesses:
            if harness_name in all_harnesses:
                harness = all_harnesses[harness_name]
                loc = (
                    harness.local_location
                    if current_scope == ScopeType.LOCAL
                    else harness.global_location
                )
                p = Path(loc).expanduser().resolve()
                configured_harnesses.append((harness_name, p))

        # Filter sources
        sources_to_show = []
        for source_key, source in lock.sources.items():
            source_type = source.source_type or GITHUB_SOURCE_TYPE
            if only_local and source_type != LOCAL_SOURCE_TYPE:
                continue
            if remote and source_type == LOCAL_SOURCE_TYPE:
                continue
            sources_to_show.append((source_key, source))

        def format_location_path(p: Path) -> str:
            if p.name == "skills":
                p = p.parent
            return rel_home(p)

        # Cache harness directory contents to avoid O(N*M) exists() calls
        harness_contents = {}
        for h_name, h_dir in configured_harnesses:
            if h_dir.exists():
                try:
                    # We store a dict of name -> (is_symlink, is_broken, exists)
                    contents = {}
                    for item in h_dir.iterdir():
                        info = {
                            "is_symlink": item.is_symlink(),
                            "is_broken": item.is_symlink() and not item.exists(),
                            "exists": item.exists(),
                            "is_dir": item.is_dir(),
                            "has_skill_md": (item / "SKILL.md").exists()
                            if item.is_dir()
                            else False,
                        }
                        contents[item.name] = info
                    harness_contents[h_name] = contents
                except Exception:
                    harness_contents[h_name] = {}
            else:
                harness_contents[h_name] = {}

        for source_key, source in sources_to_show:
            source_type = source.source_type or GITHUB_SOURCE_TYPE
            repo_ref = source.repo or source_key

            # Calculate source storage dir for this source
            storage_dir: Path | None = None
            local_source_root: Path | None = None

            if source_type == LOCAL_SOURCE_TYPE:
                local_path_str = source.source_path or source_key
                local_source_root = Path(local_path_str).expanduser().resolve()
            else:
                if source.source_path:
                    storage_dir = Path(source.source_path).expanduser().resolve()
                else:
                    if "/" in repo_ref:
                        owner, repo_name = repo_ref.split("/", 1)
                        storage_dir = (
                            get_skills_storage_dir()
                            / str(source.category or "misc")
                            / GH_PREFIX
                            / owner
                            / repo_name
                        )

            for skill_name in source.skills:
                managed_skill_names.add(skill_name)

                # Check source existence
                skill_src_dir = None
                if source_type == LOCAL_SOURCE_TYPE:
                    if local_source_root:
                        skill_src_dir = (
                            local_source_root
                            if source.source_layout == "single"
                            else local_source_root / skill_name
                        )
                elif storage_dir:
                    skill_src_dir = storage_dir / skill_name

                source_exists = skill_src_dir.exists() if skill_src_dir else False

                status = {}
                for harness_name, harness_dir in configured_harnesses:
                    info = {
                        "path": format_location_path(harness_dir),
                        "exists": False,
                        "is_symlink": False,
                        "is_broken": False,
                    }

                    h_contents = harness_contents.get(h_name, {})
                    if skill_name in h_contents:
                        item_info = h_contents[skill_name]
                        info["is_symlink"] = item_info["is_symlink"]
                        info["is_broken"] = item_info["is_broken"]
                        info["exists"] = item_info["exists"]

                    status[harness_name] = info

                installed_skills_data.append(
                    {
                        "name": skill_name,
                        "repo": repo_ref,
                        "source_type": source_type,
                        "source_exists": source_exists,
                        "source_path": rel_home(skill_src_dir)
                        if skill_src_dir
                        else "unknown",
                        "status": status,
                        "last_updated": source.last_updated or "-",
                        "scope": current_scope.value,
                        "version": source.version,
                        "source": source.source,
                    }
                )

        # Scan for unmanaged skills in this scope
        for harness_name, harness_dir in configured_harnesses:
            h_contents = harness_contents.get(h_name, {})
            for item_name, info in h_contents.items():
                if info["is_dir"] and item_name not in managed_skill_names:
                    # Check if it has a SKILL.md to be considered a skill
                    if info["has_skill_md"]:
                        unmanaged_skills_data.append(
                            {
                                "name": item_name,
                                "harness": harness_name,
                                "path": rel_home(harness_dir / item_name),
                                "scope": current_scope.value,
                            }
                        )

    if as_json:
        output = {
            "installed": installed_skills_data,
            "unmanaged": unmanaged_skills_data,
        }
        # Use standard print for JSON to avoid rich colorization
        import sys

        sys.stdout.write(json.dumps(output, indent=2) + "\n")
        return

    if not installed_skills_data and not unmanaged_skills_data:
        print("No skills installed.")
        return

    # Render Table
    table = Table(title="Installed Skills")
    table.add_column("Scope", style="yellow")
    table.add_column("Skill Name", style="magenta")
    table.add_column("Repo/Origin", style="cyan")
    table.add_column("Other Locations", style="green")
    table.add_column("Last Updated", style="white")

    for skill in installed_skills_data:
        skill_display = skill["name"]
        if skill.get("version"):
            skill_display += f" [dim]@{skill['version']}[/dim]"

        status_lines = []
        for harness_name, info in skill["status"].items():
            symbol = "🔗" if info["is_symlink"] else "📁"
            if info["is_broken"]:
                status_lines.append(f"[red]⛓️‍💥 {harness_name} ({info['path']})[/red]")
            elif info["exists"]:
                status_lines.append(
                    f"[green]{symbol} {harness_name}[/green] [dim]({info['path']})[/dim]"
                )
            else:
                status_lines.append(
                    f"[red]{symbol} {harness_name} ({info['path']})[/red] [bold red]❌[/bold red]"
                )

        status_str = "\n".join(status_lines)

        last_updated = skill["last_updated"]
        if last_updated != "-" and "T" in last_updated:
            last_updated = last_updated.split("T")[0]

        repo_display = skill["repo"]
        source_gone_symbol = (
            " [bold red]⚠️[/bold red]" if not skill["source_exists"] else ""
        )
        if skill["source_type"] == GITHUB_SOURCE_TYPE:
            repo_text = Text("🌐 ", style="white")
            url = skill.get("source") or f"https://github.com/{repo_display}"
            repo_text.append(repo_display, style=f"cyan link {url}")
            if source_gone_symbol:
                repo_text.append(" ⚠️", style="bold red")
            repo_display = repo_text
        else:
            repo_text = Text("🏠 ", style="white")
            repo_text.append(
                rel_home(Path(repo_display).expanduser().resolve()), style="cyan"
            )
            if source_gone_symbol:
                repo_text.append(" ⚠️", style="bold red")
            repo_display = repo_text

        table.add_row(
            skill["scope"].capitalize(),
            skill_display,
            repo_display,
            status_str,
            str(last_updated),
        )

    if installed_skills_data:
        print(table)
        print(
            "\n[dim]Legend: 🏠 Local | 🌐 GitHub | 🔗 Symlink | 📁 Directory | ❌ Missing | ⛓️‍💥 Broken | ⚠️ Source Gone[/dim]"
        )
    elif not only_local and not remote:
        print("No managed skills installed.")

    # Render Unmanaged Table
    if unmanaged_skills_data:
        print("\n[yellow]Unmanaged Skills (not in lockfile):[/yellow]")
        un_table = Table()
        un_table.add_column("Scope", style="yellow")
        un_table.add_column("Skill Name", style="magenta")
        un_table.add_column("Harness", style="cyan")
        un_table.add_column("Path", style="green")

        for un in unmanaged_skills_data:
            un_table.add_row(
                un["scope"].capitalize(), un["name"], un["harness"], un["path"]
            )
        print(un_table)

    # Render Tips
    print("\n[bold blue]Tips:[/bold blue]")
    print("  - Manage unmanaged: [cyan]jup add <path>[/cyan]")
    print("  - Fix missing source: [cyan]jup mv <skill> <new-path> --ref-only[/cyan]")
    print("  - Fix broken/missing link: [cyan]jup sync[/cyan]")

find_skills(query=typer.Argument(..., help='Search query for the skills registry'), limit=typer.Option(None, '--limit', '-n', help='Limit the number of results shown'), min_installs=typer.Option(0, '--min-installs', '-i', help='Minimum number of installs to filter results'), interactive=typer.Option(False, '--interactive', '-it', help='Run in interactive mode to install skills'), verbose=False)

Search for skills in the skills.sh registry.

Source code in src/jup/commands/find.py
def find_skills(
    query: str = typer.Argument(..., help="Search query for the skills registry"),
    limit: int = typer.Option(
        None, "--limit", "-n", help="Limit the number of results shown"
    ),
    min_installs: int = typer.Option(
        0, "--min-installs", "-i", help="Minimum number of installs to filter results"
    ),
    interactive: bool = typer.Option(
        False, "--interactive", "-it", help="Run in interactive mode to install skills"
    ),
    verbose: bool = False,
):
    """Search for skills in the skills.sh registry."""
    verbose_state.verbose = verbose
    api_url = f"https://skills.sh/api/search?q={urllib.parse.quote(query)}"

    if verbose_state.verbose:
        print(f"Searching registry: [cyan]{api_url}[/cyan]")

    try:
        req = urllib.request.Request(api_url)
        req.add_header("User-Agent", "jup-cli")
        with urllib.request.urlopen(req) as response:
            data = json.loads(response.read().decode())
    except Exception as e:
        print(f"[red]Failed to query the registry: {e}[/red]")
        raise typer.Exit(code=1)

    skills = data.get("skills", [])

    # Filter by min_installs
    if min_installs > 0:
        skills = [s for s in skills if s.get("installs", 0) >= min_installs]

    # Limit results
    if limit is not None:
        skills = skills[:limit]

    if not skills:
        print(f"No skills found for '[yellow]{query}[/yellow]' matching filters.")
        return

    if not interactive:
        table = Table(title=f"Search Results for '{query}'")
        table.add_column("#", style="dim", width=4)
        table.add_column("Skill / Name", style="magenta")
        table.add_column("Source / Repo", style="cyan")
        table.add_column("Installs", style="green", justify="right")

        for i, skill in enumerate(skills, 1):
            name = skill.get("name", skill.get("skillId", "Unknown"))
            source_id = skill.get("id", "")
            repo = (
                source_id.replace("github/", "")
                if source_id.startswith("github/")
                else source_id
            )
            installs = skill.get("installs", 0)
            table.add_row(str(i), name, repo, f"{installs:,}")
        print(table)
        return

    from prompt_toolkit import Application
    from prompt_toolkit.formatted_text import HTML
    from prompt_toolkit.key_binding import KeyBindings
    from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
    from prompt_toolkit.layout.containers import WindowAlign
    from prompt_toolkit.layout.controls import FormattedTextControl

    kb = KeyBindings()
    state: Dict[str, Any] = {
        "index": 0,
        "selected": set(),
        "preview_content": "Select a skill and press [Right] to preview.",
        "skills_to_install": [],
        "view": "list",  # "list" or "preview"
    }

    def get_skill_at(idx):
        return skills[idx]

    def get_repo_and_path(skill):
        source_id = skill.get("id", "")
        full_path = (
            source_id.replace("github/", "")
            if source_id.startswith("github/")
            else source_id
        )
        parts = full_path.split("/")
        if len(parts) >= 2:
            repo = f"{parts[0]}/{parts[1]}"
            internal_path = "/".join(parts[2:]) if len(parts) > 2 else ""
        else:
            repo = full_path
            internal_path = ""
        return repo, internal_path

    def update_preview(event=None):
        skill = get_skill_at(state["index"])
        repo, internal_path = get_repo_and_path(skill)
        state["preview_content"] = f"Fetching SKILL.md for {repo}..."
        if event:
            event.app.invalidate()

        md_content = fetch_remote_skill_md(repo, skill.get("name"), internal_path)
        state["preview_content"] = md_content

    @kb.add("up")
    def _(event):
        state["index"] = (state["index"] - 1) % len(skills)
        if state["view"] == "preview":
            update_preview(event)

    @kb.add("down")
    def _(event):
        state["index"] = (state["index"] + 1) % len(skills)
        if state["view"] == "preview":
            update_preview(event)

    @kb.add("right")
    def _(event):
        if state["view"] == "list":
            update_preview(event)
            state["view"] = "preview"

    @kb.add("left")
    @kb.add("escape")
    def _(event):
        if state["view"] == "preview":
            state["view"] = "list"
        else:
            event.app.exit()

    @kb.add("space")
    def _(event):
        if state["view"] == "list":
            if state["index"] in state["selected"]:
                state["selected"].remove(state["index"])
            else:
                state["selected"].add(state["index"])

    @kb.add("enter")
    def _(event):
        state["skills_to_install"] = [skills[i] for i in state["selected"]]
        if not state["skills_to_install"] and state["view"] == "list":
            # If nothing selected, install the current one
            state["skills_to_install"] = [skills[state["index"]]]
        event.app.exit()

    @kb.add("c-c")
    def _(event):
        event.app.exit()

    def get_list_text():
        lines = []
        list_width = 48  # Total width for the list entries
        for i, skill in enumerate(skills):
            prefix = "[x]" if i in state["selected"] else "[ ]"
            pointer = ">" if i == state["index"] else " "
            name = skill.get("name", "Unknown")
            source_id = skill.get("id", "")
            repo = (
                source_id.replace("github/", "")
                if source_id.startswith("github/")
                else source_id
            )
            installs = skill.get("installs", 0)
            formatted_installs = f"[{installs:,}]"

            # Main label: name (repo)
            label = f"{name} ({repo})"

            # Calculate how much space we have for the label
            # pointer(2) + prefix(4) + padding(min 1) + installs(len)
            fixed_parts_len = 2 + 4 + 1 + len(formatted_installs)
            available_for_label = list_width - fixed_parts_len

            if len(label) > available_for_label:
                label = label[: available_for_label - 3] + "..."

            padding_len = list_width - (2 + 4 + len(label) + len(formatted_installs))
            padding = " " * padding_len

            import xml.sax.saxutils as saxutils

            safe_label = saxutils.escape(label)
            safe_installs = saxutils.escape(formatted_installs)

            # Construct the line with HTML tags
            content = f"{pointer} {prefix} <b>{safe_label}</b>{padding}<ansigreen>{safe_installs}</ansigreen>"

            if i == state["index"]:
                lines.append(f"<reverse>{content}</reverse>")
            else:
                lines.append(content)
        return HTML("\n".join(lines) + "\n")

    def get_preview_text():
        content = state["preview_content"]
        if state["view"] != "preview" and not content.startswith("#"):
            return "Press [Right] to preview SKILL.md"

        from prompt_toolkit.formatted_text import PygmentsTokens
        from pygments.lexers.markup import MarkdownLexer

        # We can use Pygments for basic MD highlighting in the TUI
        # or just return the text. Since we are in a TUI,
        # actual Rich rendering to the screen is complex.
        # Let's use PygmentsTokens for a nice look.
        return PygmentsTokens(list(MarkdownLexer().get_tokens(content)))

    # We use a simple FormattedTextControl for the preview, but we might want to render it with Rich first
    # For now, let's keep it simple.

    app_ui = Application(
        layout=Layout(
            HSplit(
                [
                    Window(
                        content=FormattedTextControl(
                            HTML(
                                "<b>jup find</b> - Use Up/Down to navigate, Space to toggle, Right to preview, Enter to install, Esc to exit"
                            )
                        ),
                        height=1,
                        align=WindowAlign.CENTER,
                    ),
                    Window(height=1, char="-"),
                    VSplit(
                        [
                            Window(
                                content=FormattedTextControl(get_list_text), width=50
                            ),
                            Window(width=1, char="|"),
                            Window(
                                content=FormattedTextControl(
                                    lambda: get_preview_text()
                                ),
                                wrap_lines=True,
                            ),
                        ]
                    ),
                ]
            )
        ),
        key_bindings=kb,
        full_screen=True,
    )
    app_ui.run()

    if state["skills_to_install"]:
        for skill in state["skills_to_install"]:
            repo, internal_path = get_repo_and_path(skill)
            print(
                f"Installing [magenta]{skill.get('name')}[/magenta] from [cyan]{repo}[/cyan]..."
            )
            if internal_path:
                add_skill(repo=repo, path=internal_path, verbose=verbose)
            else:
                add_skill(repo=repo, verbose=verbose)
    else:
        print("Cancelled.")

move_skill(target=typer.Argument(..., help='Skill name or repository (owner/repo)'), new_destination=typer.Argument(..., help='New category or filesystem path for the skill'), rename=typer.Option(None, '--rename', help='Rename the skill'), ref_only=typer.Option(False, '--ref-only', help='Only update the lockfile reference, do not move any files'), to_remote=typer.Option(None, '--to-remote', help='Convert to GitHub source with specified repo (owner/repo)'), to_local=typer.Option(False, '--to-local', help='Convert GitHub source to local source (keeps current path)'), verbose=False)

Move a skill or repository to a new category or filesystem path.

Source code in src/jup/commands/mv.py
def move_skill(
    target: str = typer.Argument(..., help="Skill name or repository (owner/repo)"),
    new_destination: str = typer.Argument(
        ..., help="New category or filesystem path for the skill"
    ),
    rename: Optional[str] = typer.Option(None, "--rename", help="Rename the skill"),
    ref_only: bool = typer.Option(
        False,
        "--ref-only",
        help="Only update the lockfile reference, do not move any files",
    ),
    to_remote: Optional[str] = typer.Option(
        None,
        "--to-remote",
        help="Convert to GitHub source with specified repo (owner/repo)",
    ),
    to_local: bool = typer.Option(
        False,
        "--to-local",
        help="Convert GitHub source to local source (keeps current path)",
    ),
    verbose: bool = False,
):
    """Move a skill or repository to a new category or filesystem path."""
    verbose_state.verbose = verbose
    config = get_config()
    with skills_lock_session(config) as lock:
        repo_key = None
        skill_name = None

        if target in lock.sources:
            repo_key = target
        else:
            # Search for skill name
            for r, source in lock.sources.items():
                if target in source.skills:
                    repo_key = r
                    skill_name = target
                    break

            if not repo_key:
                # Check for local path
                maybe_local = Path(target).expanduser()
                if maybe_local.exists():
                    resolved = str(maybe_local.resolve())
                    if resolved in lock.sources:
                        repo_key = resolved

        if not repo_key:
            print(f"[red]Could not find '{target}' in installed skills.[/red]")
            raise typer.Exit(code=1)

        source = lock.sources[repo_key]
        old_category = source.category or "misc"
        source_type = source.source_type or GITHUB_SOURCE_TYPE

        # Handle renaming
        if rename:
            if not skill_name and len(source.skills) == 1:
                skill_name = source.skills[0]

            if not skill_name:
                print(
                    f"[red]Cannot rename repository '{target}'. Please specify a specific skill name.[/red]"
                )
                raise typer.Exit(code=1)

            # Check if new name already exists in this source
            if rename in source.skills:
                print(f"[red]Skill '{rename}' already exists in this source.[/red]")
                raise typer.Exit(code=1)

            # If it's NOT a single layout, we should rename the folder in source
            if source.source_layout != "single" and not ref_only:
                # Find old_dir
                if source_type == GITHUB_SOURCE_TYPE:
                    storage_base = get_skills_storage_dir()
                    repo_ref = source.repo or repo_key
                    owner, repo_repo = repo_ref.split("/", 1)
                    base_dir = (
                        storage_base / old_category / GH_PREFIX / owner / repo_repo
                    )
                else:
                    base_dir = (
                        Path(source.source_path or repo_key).expanduser().resolve()
                    )

                old_skill_dir = base_dir / skill_name
                new_skill_dir = base_dir / rename

                if old_skill_dir.exists():
                    if new_skill_dir.exists():
                        print(
                            f"[red]Destination {rel_home(new_skill_dir)} already exists. Cannot rename.[/red]"
                        )
                        raise typer.Exit(code=1)

                    shutil.move(str(old_skill_dir), str(new_skill_dir))
                    if verbose:
                        print(
                            f"Renamed directory [cyan]{rel_home(old_skill_dir)}[/cyan] -> [cyan]{rel_home(new_skill_dir)}[/cyan]"
                        )

            # Update lockfile
            idx = source.skills.index(skill_name)
            source.skills[idx] = rename
            print(
                f"✅ Renamed skill [magenta]{skill_name}[/magenta] to [green]{rename}[/green]"
            )
            target = rename  # For subsequent sync
            skill_name = rename

        # Determine if new_destination is a path or a category
        is_path = "/" in new_destination or new_destination.startswith(".")
        new_category = old_category if is_path else new_destination

        if not is_path and old_category == new_category and not source.source_path:
            print(f"Skill is already in category '[cyan]{new_category}[/cyan]'.")
            return

        if source_type == GITHUB_SOURCE_TYPE:
            storage_base = get_skills_storage_dir()
            repo_ref = source.repo or repo_key
            if "/" not in repo_ref:
                print(f"[red]Invalid repository reference: {repo_ref}[/red]")
                raise typer.Exit(code=1)

            owner, repo_name = repo_ref.split("/", 1)

            if source.source_path:
                old_dir = Path(source.source_path).expanduser().resolve()
            else:
                old_dir = storage_base / old_category / GH_PREFIX / owner / repo_name

            if is_path:
                new_dir = Path(new_destination).expanduser().resolve()
                if not ref_only and new_dir.is_dir():
                    new_dir = new_dir / repo_name
            else:
                new_dir = storage_base / new_category / GH_PREFIX / owner / repo_name
        else:
            # Local source
            if source.source_path:
                old_dir = Path(source.source_path).expanduser().resolve()
            else:
                # Fallback to repo key if source_path is None (shouldn't happen for local)
                old_dir = Path(repo_key).expanduser().resolve()

            if is_path:
                new_dir = Path(new_destination).expanduser().resolve()
                if not ref_only and new_dir.is_dir() and old_dir.name:
                    new_dir = new_dir / old_dir.name
            else:
                # If moving to a category, we just update the category metadata
                new_dir = old_dir

        if not ref_only:
            if old_dir.exists():
                if old_dir.resolve() == new_dir.resolve():
                    if not is_path and source.category != new_category:
                        # Just updating category
                        pass
                    else:
                        print(f"Skill is already at [cyan]{rel_home(new_dir)}[/cyan].")
                        # Even if path is same, we might need to update category
                        source.category = new_category
                        return

                if new_dir.exists() and old_dir.resolve() != new_dir.resolve():
                    print(
                        f"[yellow]Warning: Target directory {rel_home(new_dir)} already exists. Overwriting...[/yellow]"
                    )
                    if new_dir.is_dir():
                        shutil.rmtree(new_dir)
                    else:
                        new_dir.unlink()

                if old_dir.resolve() != new_dir.resolve():
                    new_dir.parent.mkdir(parents=True, exist_ok=True)
                    shutil.move(str(old_dir), str(new_dir))
                    if verbose:
                        print(
                            f"Moved [cyan]{rel_home(old_dir)}[/cyan] to [cyan]{rel_home(new_dir)}[/cyan]"
                        )
            else:
                print(
                    f"[yellow]Warning: Source directory {rel_home(old_dir)} not found. Only updating lockfile.[/yellow]"
                )
        else:
            if verbose:
                print(
                    f"Skipping file move, only updating lockfile to [cyan]{rel_home(new_dir)}[/cyan]"
                )

        # Update source_path
        if is_path:
            source.source_path = str(new_dir)
        elif source_type == GITHUB_SOURCE_TYPE:
            # If moving Github back to a category, clear source_path to use default logic
            source.source_path = None

        # Update lockfile
        source.category = new_category

        # Handle source type conversion
        if to_remote:
            if "/" not in to_remote:
                print(
                    f"[red]Invalid repository for --to-remote: {to_remote}. Expected owner/repo.[/red]"
                )
                raise typer.Exit(code=1)
            source.source_type = GITHUB_SOURCE_TYPE
            source.repo = to_remote
            print(f"✅ Converted source to [cyan]GitHub ({to_remote})[/cyan]")

        if to_local:
            if source.source_type == LOCAL_SOURCE_TYPE:
                print("[yellow]Source is already local.[/yellow]")
            else:
                source.source_type = LOCAL_SOURCE_TYPE
                # Ensure source_path is set to the current location
                if not source.source_path:
                    source.source_path = str(old_dir)
                source.repo = None
                print(
                    f"✅ Converted source to [cyan]local[/cyan] at [magenta]{rel_home(Path(source.source_path))}[/magenta]"
                )

    if is_path:
        print(
            f"✅ Moved [magenta]{target}[/magenta] to [green]{rel_home(Path(new_destination).expanduser())}[/green]"
        )
    else:
        print(
            f"✅ Moved [magenta]{target}[/magenta] from [yellow]{old_category}[/yellow] to [green]{new_category}[/green]"
        )

    # Trigger sync to update symlinks
    from .sync import sync_logic

    sync_logic(verbose=verbose_state.verbose, logger=print)

remove_skill(target=typer.Argument(..., help='Skill name or repository (owner/repo)'), yes=typer.Option(False, '--yes', '-y', help='Skip confirmation'), verbose=False)

Remove a skill or all skills from a repository.

Source code in src/jup/commands/remove.py
def remove_skill(
    target: str = typer.Argument(..., help="Skill name or repository (owner/repo)"),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
    verbose: bool = False,
):
    """Remove a skill or all skills from a repository."""
    verbose_state.verbose = verbose
    if not yes:
        typer.confirm(f"Are you sure you want to remove {target}?", abort=True)

    config = get_config()
    with skills_lock_session(config) as lock:
        repo_to_remove = None
        skill_to_remove = None

        if target in lock.sources:
            repo_to_remove = target
        else:
            maybe_local_target = Path(target).expanduser()
            if maybe_local_target.exists():
                resolved_target = str(maybe_local_target.resolve())
                if resolved_target in lock.sources:
                    repo_to_remove = resolved_target

            # Search for skill name
            if not repo_to_remove:
                for repo, source in lock.sources.items():
                    if target in source.skills:
                        skill_to_remove = target
                        repo_to_remove = repo
                        break

        if not repo_to_remove:
            print(f"[red]Could not find {target} in installed skills.[/red]")
            raise typer.Exit(code=1)

        source = lock.sources[repo_to_remove]

        # Remove symlinks/directories for this skill/repo from all targets
        targets = []
        scope_dir = get_scope_dir(config)
        default_skills_dir = scope_dir
        targets.append(default_skills_dir)
        all_harnesses = get_all_harnesses(config)
        for harness_name in config.harnesses:
            if harness_name in all_harnesses:
                harness = all_harnesses[harness_name]
                loc = (
                    harness.local_location
                    if config.scope == "local"
                    else harness.global_location
                )
                targets.append(Path(loc).expanduser().resolve())

        removed_skills = []
        if skill_to_remove:
            # Remove only the specific skill
            for t in targets:
                skill_path = t / skill_to_remove
                if skill_path.exists() or skill_path.is_symlink():
                    if skill_path.is_symlink() or skill_path.is_file():
                        skill_path.unlink()
                    elif skill_path.is_dir():
                        shutil.rmtree(skill_path)
                    if verbose_state.verbose:
                        print(f"Removed skill at [red]{rel_home(skill_path)}[/red]")
            source.skills.remove(skill_to_remove)
            removed_skills.append(skill_to_remove)
            print(
                f"🗑️ Removed skill '[yellow]{skill_to_remove}[/yellow]' from {repo_to_remove}"
            )
            if not source.skills:
                del lock.sources[repo_to_remove]
                print(
                    f"No more skills in [yellow]{repo_to_remove}[/yellow], removed repository reference."
                )
        else:
            # Remove all skills from this repo
            for skill in list(source.skills):
                for t in targets:
                    skill_path = t / skill
                    if skill_path.exists() or skill_path.is_symlink():
                        if skill_path.is_symlink() or skill_path.is_file():
                            skill_path.unlink()
                        elif skill_path.is_dir():
                            shutil.rmtree(skill_path)
                        if verbose_state.verbose:
                            print(f"Removed skill at [red]{rel_home(skill_path)}[/red]")
                removed_skills.append(skill)
            del lock.sources[repo_to_remove]
            print(
                f"🗑️ Removed repository '[yellow]{repo_to_remove}[/yellow]' and all its skills."
            )
    print(
        f"Removed {len(removed_skills)} skills from "
        + ", ".join([f"[yellow]{rel_home(t)}[/yellow]" for t in targets])
    )
    # Trigger sync
    from .sync import sync_logic

    sync_logic(verbose=verbose_state.verbose, logger=print)

show_skill(target=typer.Argument(..., help='GitHub repository (owner/repo) or local skills directory.'), skill=typer.Option(None, '--skill', help='[GitHub only] Specific skill name to show'), verbose=False)

Show the content of SKILL.md and the directory structure of a skill.

Source code in src/jup/commands/show.py
def show_skill(
    target: str = typer.Argument(
        ..., help="GitHub repository (owner/repo) or local skills directory."
    ),
    skill: str = typer.Option(
        None, "--skill", help="[GitHub only] Specific skill name to show"
    ),
    verbose: bool = False,
):
    """Show the content of SKILL.md and the directory structure of a skill."""
    console = Console()

    local_path = Path(target).expanduser()
    if local_path.exists():
        # Local source
        if local_path.is_file():
            if local_path.name == "SKILL.md":
                content = local_path.read_text()
                console.print(Markdown(content))
            else:
                print(f"[red]{target} is a file but not SKILL.md[/red]")
        else:
            skill_md = local_path / "SKILL.md"
            if skill_md.exists():
                content = skill_md.read_text()
                console.print(Markdown(content))
            else:
                print(f"[yellow]No SKILL.md found in {target}[/yellow]")

            # Show tree
            def add_to_tree(path: Path, tree: Tree):
                for item in sorted(path.iterdir()):
                    if item.name.startswith(".") or item.name == "__pycache__":
                        continue
                    if item.is_dir():
                        branch = tree.add(f"[bold blue]{item.name}/[/bold blue]")
                        add_to_tree(item, branch)
                    else:
                        tree.add(item.name)

            tree = Tree(f"[bold cyan]{rel_home(local_path)}[/bold cyan]")
            add_to_tree(local_path, tree)
            console.print(tree)
    else:
        # Remote source
        if "/" not in target:
            print("[red]Target must be a local path or 'owner/repo'[/red]")
            raise typer.Exit(code=1)

        repo = target
        print(f"Fetching information for [cyan]{repo}[/cyan]...")

        content = fetch_remote_skill_md(repo, skill)
        console.print(Markdown(content))

        # Show remote tree using GitHub API
        try:
            api_url = f"https://api.github.com/repos/{repo}/git/trees/main?recursive=1"
            req = urllib.request.Request(api_url)
            # Add User-Agent to avoid 403
            req.add_header("User-Agent", "jup-cli")
            with urllib.request.urlopen(req) as response:
                tree_data = json.loads(response.read().decode())

            tree = Tree(f"[bold cyan]github.com/{repo}[/bold cyan]")
            nodes = {"": tree}

            # GitHub returns flat list of paths
            for item in tree_data.get("tree", []):
                path = item["path"]
                if path.startswith(".") or "/." in path or "__pycache__" in path:
                    continue

                parts = path.split("/")
                parent_path = "/".join(parts[:-1])
                name = parts[-1]

                if parent_path in nodes:
                    if item["type"] == "tree":
                        nodes[path] = nodes[parent_path].add(
                            f"[bold blue]{name}/[/bold blue]"
                        )
                    else:
                        nodes[parent_path].add(name)

            console.print(tree)
        except Exception as e:
            if verbose:
                print(f"[yellow]Could not fetch remote tree: {e}[/yellow]")
            else:
                print(
                    "[yellow]Could not fetch remote tree (GitHub API rate limit or private repo?)[/yellow]"
                )

fetch_remote_skill_md(repo, skill_name=None, internal_path='')

Fetch SKILL.md content from GitHub.

Source code in src/jup/commands/utils.py
def fetch_remote_skill_md(
    repo: str, skill_name: Optional[str] = None, internal_path: str = ""
) -> str:
    """Fetch SKILL.md content from GitHub."""
    # Try different common paths for SKILL.md
    paths_to_try = []
    base_path = internal_path.strip("/")

    if skill_name:
        # Standard location
        paths_to_try.append(f"skills/{skill_name}/SKILL.md")

        if base_path:
            # If internal_path points to the skill directory itself
            paths_to_try.append(f"{base_path}/SKILL.md")
            # If internal_path points to a parent directory
            p_full = f"{base_path}/{skill_name}/SKILL.md"
            if p_full not in paths_to_try:
                paths_to_try.append(p_full)

        # Root location fallback
        if "SKILL.md" not in paths_to_try:
            paths_to_try.append("SKILL.md")
    else:
        if base_path:
            paths_to_try.append(f"{base_path}/SKILL.md")
        if "SKILL.md" not in paths_to_try:
            paths_to_try.append("SKILL.md")

    for p in paths_to_try:
        url = f"https://raw.githubusercontent.com/{repo}/main/{p}"
        try:
            req = urllib.request.Request(url)
            req.add_header("User-Agent", "jup-cli")
            with urllib.request.urlopen(req) as response:
                return response.read().decode()
        except Exception:
            # Try master branch if main fails
            url = f"https://raw.githubusercontent.com/{repo}/master/{p}"
            try:
                req = urllib.request.Request(url)
                req.add_header("User-Agent", "jup-cli")
                with urllib.request.urlopen(req) as response:
                    return response.read().decode()
            except Exception:
                continue

    # Fallback: Recursive search using GitHub API
    if skill_name:
        try:
            api_url = f"https://api.github.com/repos/{repo}/git/trees/main?recursive=1"
            req = urllib.request.Request(api_url)
            req.add_header("User-Agent", "jup-cli")
            with urllib.request.urlopen(req) as response:
                tree_data = json.loads(response.read().decode())
                for item in tree_data.get("tree", []):
                    path = item["path"]
                    # Search for repo/**/skill-name/SKILL.md
                    if (
                        path.endswith(f"/{skill_name}/SKILL.md")
                        or path == f"{skill_name}/SKILL.md"
                    ):
                        if path in paths_to_try:
                            continue  # Already tried

                        # Fetch the found path
                        url = f"https://raw.githubusercontent.com/{repo}/main/{path}"
                        try:
                            req = urllib.request.Request(url)
                            req.add_header("User-Agent", "jup-cli")
                            with urllib.request.urlopen(req) as response:
                                return response.read().decode()
                        except Exception:
                            # Try master as well
                            url = f"https://raw.githubusercontent.com/{repo}/master/{path}"
                            try:
                                req = urllib.request.Request(url)
                                req.add_header("User-Agent", "jup-cli")
                                with urllib.request.urlopen(req) as response:
                                    return response.read().decode()
                            except Exception:
                                continue
        except Exception:
            # Fallback to master branch tree if main fails
            try:
                api_url = (
                    f"https://api.github.com/repos/{repo}/git/trees/master?recursive=1"
                )
                req = urllib.request.Request(api_url)
                req.add_header("User-Agent", "jup-cli")
                with urllib.request.urlopen(req) as response:
                    tree_data = json.loads(response.read().decode())
                    for item in tree_data.get("tree", []):
                        path = item["path"]
                        if (
                            path.endswith(f"/{skill_name}/SKILL.md")
                            or path == f"{skill_name}/SKILL.md"
                        ):
                            if path in paths_to_try:
                                continue
                            url = f"https://raw.githubusercontent.com/{repo}/master/{path}"
                            req = urllib.request.Request(url)
                            req.add_header("User-Agent", "jup-cli")
                            with urllib.request.urlopen(req) as response:
                                return response.read().decode()
            except Exception:
                pass

    return f"SKILL.md not found in {repo}.\nTried paths:\n- " + "\n- ".join(
        paths_to_try
    )