diff --git a/src/sc_cli/install.py b/src/sc_cli/install.py index 22ab1ce95..4b6e734d2 100644 --- a/src/sc_cli/install.py +++ b/src/sc_cli/install.py @@ -6,10 +6,10 @@ list info install --dest [--force] [--no-expand] - install --global [--force] [--no-expand] - install --local [--force] [--no-expand] - install --user [--force] [--no-expand] - install --project [--force] [--no-expand] + install --global [--claude|--codex] [--force] [--no-expand] + install --local [--claude|--codex] [--force] [--no-expand] + install --user [--claude|--codex] [--force] [--no-expand] + install --project [--claude|--codex] [--force] [--no-expand] uninstall --dest registry add [--path ] registry list @@ -36,7 +36,12 @@ - Local-install templating: any artifact with a sibling .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 @@ -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 @@ -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: @@ -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: @@ -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. @@ -1186,10 +1222,12 @@ 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) @@ -1197,13 +1235,17 @@ def cmd_install( 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) @@ -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) @@ -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 @@ -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") @@ -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), ) diff --git a/tests/test_sc_cli_scope_aliases.py b/tests/test_sc_cli_scope_aliases.py index 34c10665f..ce4ab1199 100644 --- a/tests/test_sc_cli_scope_aliases.py +++ b/tests/test_sc_cli_scope_aliases.py @@ -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): diff --git a/tests/test_sc_install_phase1_2.py b/tests/test_sc_install_phase1_2.py index cb3a87a0b..ac700a70c 100644 --- a/tests/test_sc_install_phase1_2.py +++ b/tests/test_sc_install_phase1_2.py @@ -115,7 +115,8 @@ def local_template_pkg(tmp_path, monkeypatch): """Synthetic package shipping a `.local.j2` sibling under `scripts/`. Deliberately not under commands/ or agents/, to prove `.local.j2` detection - is generic across every artifact category. + is generic across every artifact category. Also used to prove --codex + skips .local.j2 templating exactly like --global. """ pkg_root = tmp_path / "pkgs" pkg_dir = pkg_root / "fake-local-template-pkg" @@ -255,6 +256,77 @@ def test_install_multiple_flags_error(self, temp_home, capsys): assert "mutually exclusive" in err.lower() or "not allowed" in err.lower() +class TestCodexFlag: + """Test --codex installation target (skills/scripts/assets only, no + commands/agents/registry.yaml). --claude/--codex are symmetric target + flags under a scope (--global/--local/--user/--project): either alone + installs only that target; neither installs both.""" + + def test_install_codex_flag_creates_dir(self, temp_home, capsys): + """Test that --global --codex creates ~/.codex directory.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global", "--codex"]) + assert rc == 0 + assert (temp_home / ".codex").exists() + assert (temp_home / ".codex" / "scripts").exists() + + def test_install_codex_flag_uses_home_directory(self, temp_home, capsys): + """Test that --global --codex installs to ~/.codex, not ~/.claude.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global", "--codex"]) + assert rc == 0 + assert not (temp_home / ".claude").exists() + + out = capsys.readouterr().out + assert str(temp_home / ".codex") in out + + def test_install_codex_skips_commands_and_agents(self, temp_home, capsys): + """Test that --codex installs only skills/scripts, not commands/agents.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global", "--codex"]) + assert rc == 0 + assert (temp_home / ".codex" / "skills").exists() + assert (temp_home / ".codex" / "scripts").exists() + assert not (temp_home / ".codex" / "commands").exists() + assert not (temp_home / ".codex" / "agents").exists() + + def test_install_codex_skips_registry(self, temp_home, capsys): + """Test that --codex does not write agents/registry.yaml.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global", "--codex"]) + assert rc == 0 + assert not (temp_home / ".codex" / "agents" / "registry.yaml").exists() + + def test_install_no_target_flags_installs_both(self, temp_home, capsys): + """With neither --claude nor --codex given, both targets are installed.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global"]) + assert rc == 0 + assert (temp_home / ".claude" / "agents" / "sc-delay-once.md").exists() + assert (temp_home / ".codex" / "scripts").exists() + assert not (temp_home / ".codex" / "commands").exists() + + def test_install_claude_flag_installs_only_claude(self, temp_home, capsys): + """--claude alone installs only .claude, symmetric with --codex alone.""" + rc = sc_install.main(["install", "sc-delay-tasks", "--global", "--claude"]) + assert rc == 0 + assert (temp_home / ".claude" / "agents" / "sc-delay-once.md").exists() + assert not (temp_home / ".codex").exists() + + def test_install_codex_and_dest_conflict_error(self, temp_home, capsys): + """--dest is always .claude-only; combining it with --codex is an error.""" + dest = temp_home / "custom" / ".claude" + rc = sc_install.main(["install", "sc-delay-tasks", "--codex", "--dest", str(dest)]) + assert rc == 1 + err = capsys.readouterr().err + assert "--codex" in err + + def test_install_codex_skips_local_j2_template(self, temp_home, local_template_pkg): + """--codex must never consult `.local.j2` siblings, exactly like --global.""" + rc = sc_install.main(["install", local_template_pkg, "--global", "--codex"]) + assert rc == 0 + + script = temp_home / ".codex" / "scripts" / "run.sh" + assert script.exists() + content = script.read_text(encoding="utf-8") + assert content == "#!/bin/sh\necho generic\n" + + # ============================================================================== # TEST GROUP 2: Registry Commands (18 tests) # ==============================================================================