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
247 changes: 156 additions & 91 deletions src/sc_cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
list
info <package>
install <package> --dest <path/to/.claude> [--force] [--no-expand]
install <package> --global [--force] [--no-expand]
install <package> --local [--force] [--no-expand]
install <package> --user [--force] [--no-expand]
install <package> --project [--force] [--no-expand]
install <package> --global [--claude|--codex] [--force] [--no-expand]
install <package> --local [--claude|--codex] [--force] [--no-expand]
install <package> --user [--claude|--codex] [--force] [--no-expand]
install <package> --project [--claude|--codex] [--force] [--no-expand]
uninstall <package> --dest <path/to/.claude>
registry add <name> <url> [--path <path>]
registry list
Expand All @@ -36,7 +36,12 @@
- Local-install templating: any artifact with a sibling <path>.local.j2 file (any
category: commands, skills, agents, scripts, assets, plugin) is rendered via
sc-compose (auto-installed on demand) instead of copied verbatim, for any
install that isn't --global/--user. Never used for global/user installs.
install that isn't --global/--user, and never for the .codex target (Codex
has no install-time repo-specific customization).
- --claude/--codex select which target(s) get installed under the chosen
scope (--global/--local/--user/--project): symmetric flags, either alone
means only that target, neither means both .claude and .codex are
installed. An explicit --dest is always .claude-only.
- Scripts are made executable on install (artifacts under scripts/*)
- Config file manages marketplace registries with metadata (url, path, status, added_date)
- Phase 1: Basic registry commands (add, list, remove) and config persistence
Expand Down Expand Up @@ -908,8 +913,15 @@ def _git_repo_basename(dest_dir: Path) -> str:
return ""


def _iter_artifacts(m: Manifest) -> Iterable[str]:
order = ["commands", "skills", "agents", "scripts", "assets", "plugin"]
def _iter_artifacts(m: Manifest, *, codex: bool = False) -> Iterable[str]:
# Codex has no equivalent of Claude Code's slash-commands, subagents, or
# plugin manifests, and no registry.yaml, so only skills/scripts/assets
# are meaningful there.
order = (
["skills", "scripts", "assets"]
if codex
else ["commands", "skills", "agents", "scripts", "assets", "plugin"]
)
for key in order:
for item in m.artifacts.get(key, []):
yield item
Expand Down Expand Up @@ -979,21 +991,26 @@ def _parse_frontmatter_simple(md_path: Path) -> Dict[str, Any]:
return out


def _resolve_install_dest(
def _resolve_install_scope(
global_flag: bool = False,
local_flag: bool = False,
user_flag: bool = False,
project_flag: bool = False,
dest: Optional[str] = None,
) -> Optional[Path]:
"""Resolve installation destination from --global/--local/--user/--project/--dest flags.
"""Resolve the install *scope* (global/user/local/project/dest) to a base path.

Phase 1: Support for --global, --local, --user, and --project flags.

Phase 1: Support for --global, --local, --user, and --project flags
For --global/--user/--local/--project this is the *parent* directory that
a `.claude` and/or `.codex` subdirectory is installed into (see
`_resolve_install_targets`). For --dest it is the exact, single directory
to install into (dest is always claude-only; there is no sibling `.codex`
to mirror into, since the path was explicitly chosen by the caller).

Returns:
Path to .claude directory, or None if invalid combination
Path, or None if invalid combination (an error has already been reported).
"""
# Count how many flags are set
flags_set = sum([global_flag, local_flag, user_flag, project_flag, dest is not None])

if flags_set == 0:
Expand All @@ -1005,10 +1022,10 @@ def _resolve_install_dest(
return None

if global_flag or user_flag:
return Path.home() / ".claude"
return Path.home()

if local_flag or project_flag:
return Path.cwd() / ".claude"
return Path.cwd()

# dest flag
if dest:
Expand All @@ -1020,6 +1037,25 @@ def _resolve_install_dest(
return None


def _resolve_install_targets(
claude_flag: bool, codex_flag: bool, *, dest: Optional[str] = None
) -> List[str]:
"""Resolve which of "claude"/"codex" to install, mirroring --global/--local
symmetry: --claude or --codex alone installs only that target; with
neither given, both install. An explicit --dest is always claude-only,
since it names one exact directory rather than a `.claude`/`.codex`-suffixed
base.
"""
if dest:
return ["claude"]
targets = []
if claude_flag:
targets.append("claude")
if codex_flag:
targets.append("codex")
return targets or ["claude", "codex"]


def _parse_skill_metadata(skill_md_path: Path) -> Dict[str, Any]:
"""Parse skill metadata from SKILL.md frontmatter.

Expand Down Expand Up @@ -1186,24 +1222,30 @@ def cmd_install(
local_flag: bool = False,
user_flag: bool = False,
project_flag: bool = False,
claude_flag: bool = False,
codex_flag: bool = False,
registry: Optional[str] = None,
) -> int:
"""Install a package to a .claude directory.
"""Install a package to a .claude and/or .codex directory.

Phase 3 Enhancement: Remote Registry Support
- Support --registry flag to install from remote registry
- Prefer local packages (backward compatible)
- Fall back to remote if not found locally

Args:
pkg: Package name
dest: Explicit destination path
dest: Explicit destination path (always .claude-only; no .codex mirror)
force: Overwrite existing files
expand: Perform token expansion
global_flag: Install to ~/.claude
local_flag: Install to ./.claude
global_flag: Install under ~/ (i.e. ~/.claude and/or ~/.codex)
local_flag: Install under ./ (i.e. ./.claude and/or ./.codex)
user_flag: Alias for --global
project_flag: Alias for --local
claude_flag: Install the .claude target (default: both, if neither
--claude nor --codex is given)
codex_flag: Install the .codex target (skills/scripts/assets only;
no commands, agents, or registry.yaml)
registry: Optional registry name to install from
"""
# Check local package first (backward compatible)
Expand Down Expand Up @@ -1246,14 +1288,17 @@ def cmd_install(
error(f"Package not found: {pkg}")
return 1

# Resolve destination (Phase 1: Support --global/--local/--user/--project)
dest_path = _resolve_install_dest(global_flag, local_flag, user_flag, project_flag, dest)
if dest_path is None:
if dest and codex_flag:
error("--dest does not support --codex (an explicit --dest path is always .claude-only)")
return 1

# track installed artifact files relative to dest_path
installed_artifacts: List[str] = []
dest_path.mkdir(parents=True, exist_ok=True)
# Resolve scope (Phase 1: --global/--local/--user/--project/--dest) and
# targets (--claude/--codex, symmetric: either alone means only that one,
# neither means both).
base_path = _resolve_install_scope(global_flag, local_flag, user_flag, project_flag, dest)
if base_path is None:
return 1
targets = _resolve_install_targets(claude_flag, codex_flag, dest=dest)

manifest = _parse_manifest(pkg_dir)

Expand All @@ -1262,77 +1307,91 @@ def cmd_install(
declares_repo_name_var = manifest.variables.get("REPO_NAME", {}).get("auto") == "git-repo-basename"

# .local.j2 templating (sc-compose): available for any artifact, in any
# category, for any install that isn't --global/--user. Never consulted
# for global/user installs, since the plugin marketplace can't run
# install-time templating there.
use_local_templates = not (global_flag or user_flag)

repo_name = ""
if use_local_templates or (expand and declares_repo_name_var):
repo_name = _git_repo_basename(dest_path)

info(f"Installing {pkg} to {dest_path}")
if repo_name:
info(f"REPO_NAME={repo_name}")

def install_one(rel_file: str) -> bool:
local_template = pkg_dir / f"{rel_file}.local.j2"
use_template = use_local_templates and local_template.exists()
src = (local_template if use_template else (pkg_dir / rel_file)).resolve()
dst = (dest_path / rel_file).resolve()
if not src.exists():
warn(f"Source not found: {src}")
return True
if dst.exists() and not force:
warn(f"Skip (exists): {dst}")
# category, for any install that isn't --global/--user, and never for
# codex (codex has no install-time repo-specific customization).
allow_local_templates = not (global_flag or user_flag)

def install_target(dest_path: Path, is_codex: bool) -> int:
dest_path.mkdir(parents=True, exist_ok=True)
installed_artifacts: List[str] = []
use_local_templates = allow_local_templates and not is_codex

repo_name = ""
if use_local_templates or (expand and declares_repo_name_var):
repo_name = _git_repo_basename(dest_path)

info(f"Installing {pkg} to {dest_path}")
if repo_name:
info(f"REPO_NAME={repo_name}")

def install_one(rel_file: str) -> bool:
local_template = pkg_dir / f"{rel_file}.local.j2"
use_template = use_local_templates and local_template.exists()
src = (local_template if use_template else (pkg_dir / rel_file)).resolve()
dst = (dest_path / rel_file).resolve()
if not src.exists():
warn(f"Source not found: {src}")
return True
if dst.exists() and not force:
warn(f"Skip (exists): {dst}")
return True
dst.parent.mkdir(parents=True, exist_ok=True)

if use_template:
try:
render_template = _get_render_template()
except RuntimeError as ex:
error(f"Cannot install {rel_file}: {ex}")
return False
text = local_template.read_text(encoding="utf-8", errors="ignore")
rendered = render_template(text, {"REPO_NAME": repo_name})
dst.write_text(rendered, encoding="utf-8")
else:
shutil.copy2(src, dst)
# legacy token expansion
if expand and declares_repo_name_var and repo_name:
try:
text = dst.read_text(encoding="utf-8", errors="ignore")
text = text.replace("{{REPO_NAME}}", repo_name)
dst.write_text(text, encoding="utf-8")
except Exception:
# Ignore binary/non-text failures
pass

# executable for scripts/*
if rel_file.startswith("scripts/"):
_ensure_executable(dst)
# track agents and skills for registry
if rel_file.startswith("agents/") or rel_file.startswith("skills/"):
# store relative to .claude (dest_path)
installed_artifacts.append(rel_file)
info(f"Installed: {rel_file}")
return True
dst.parent.mkdir(parents=True, exist_ok=True)

if use_template:
try:
render_template = _get_render_template()
except RuntimeError as ex:
error(f"Cannot install {rel_file}: {ex}")
return False
text = local_template.read_text(encoding="utf-8", errors="ignore")
rendered = render_template(text, {"REPO_NAME": repo_name})
dst.write_text(rendered, encoding="utf-8")
else:
shutil.copy2(src, dst)
# legacy token expansion
if expand and declares_repo_name_var and repo_name:
try:
text = dst.read_text(encoding="utf-8", errors="ignore")
text = text.replace("{{REPO_NAME}}", repo_name)
dst.write_text(text, encoding="utf-8")
except Exception:
# Ignore binary/non-text failures
pass

# executable for scripts/*
if rel_file.startswith("scripts/"):
_ensure_executable(dst)
# track agents and skills for registry
if rel_file.startswith("agents/") or rel_file.startswith("skills/"):
# store relative to .claude (dest_path)
installed_artifacts.append(rel_file)
info(f"Installed: {rel_file}")
return True
for rel in _iter_artifacts(manifest, codex=is_codex):
if not install_one(rel):
return 1

for rel in _iter_artifacts(manifest):
if not install_one(rel):
return 1
# Codex has no registry.yaml concept (no agents/subagent roster to track)
if not is_codex:
rc = _update_registry(
dest_path,
installed_artifacts,
package_version=manifest.version or None,
)
if rc != 0:
return rc

# Update registry.yaml (agents and skills)
rc = _update_registry(
dest_path,
installed_artifacts,
package_version=manifest.version or None,
)
if rc != 0:
return rc
info(f"Done installing {pkg} to {dest_path}")
return 0

for target in targets:
is_codex = target == "codex"
target_dest = base_path if dest else (base_path / f".{target}")
rc = install_target(target_dest, is_codex)
if rc != 0:
return rc

info(f"Done installing {pkg}")
return 0


Expand Down Expand Up @@ -1390,6 +1449,10 @@ def build_parser() -> argparse.ArgumentParser:
dest_group.add_argument("--user", dest="user_flag", action="store_true")
dest_group.add_argument("--local", dest="local_flag", action="store_true")
dest_group.add_argument("--project", dest="project_flag", action="store_true")
# Target flags: independent of scope, symmetric with each other. Neither
# given means install both; either alone means only that one.
p_install.add_argument("--claude", dest="claude_flag", action="store_true")
p_install.add_argument("--codex", dest="codex_flag", action="store_true")
p_install.add_argument("--force", action="store_true")
p_install.add_argument("--no-expand", action="store_true")
p_install.add_argument("--registry", help="Install from remote registry")
Expand Down Expand Up @@ -1445,6 +1508,8 @@ def main(argv: Optional[list[str]] = None) -> int:
local_flag=getattr(args, 'local_flag', False),
user_flag=getattr(args, 'user_flag', False),
project_flag=getattr(args, 'project_flag', False),
claude_flag=getattr(args, 'claude_flag', False),
codex_flag=getattr(args, 'codex_flag', False),
registry=getattr(args, 'registry', None),
)

Expand Down
10 changes: 5 additions & 5 deletions tests/test_sc_cli_scope_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from sc_cli import skill_integration


def test_resolve_install_dest_supports_user_and_project(tmp_path, monkeypatch):
def test_resolve_install_scope_supports_user_and_project(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(sc_install.Path, "home", lambda: tmp_path / "home")

assert sc_install._resolve_install_dest(local_flag=True) == tmp_path / ".claude"
assert sc_install._resolve_install_dest(project_flag=True) == tmp_path / ".claude"
assert sc_install._resolve_install_dest(global_flag=True) == tmp_path / "home" / ".claude"
assert sc_install._resolve_install_dest(user_flag=True) == tmp_path / "home" / ".claude"
assert sc_install._resolve_install_scope(local_flag=True) == tmp_path
assert sc_install._resolve_install_scope(project_flag=True) == tmp_path
assert sc_install._resolve_install_scope(global_flag=True) == tmp_path / "home"
assert sc_install._resolve_install_scope(user_flag=True) == tmp_path / "home"


def test_install_marketplace_scope_aliases(tmp_path, monkeypatch):
Expand Down
Loading
Loading