diff --git a/AGENTS.md b/AGENTS.md index 6691cfb..a904c60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ The bundled `idac` skill supports both Claude Code and Codex equally. - `~/.claude/skills/idac` - `~/.codex/skills/idac` - custom install destination: - - `idac misc skill install --dest /custom/path/idac` + - `idac setup install --component skill --skill-dest /custom/path/idac` For fixture-driven class tests, prefer updating and validating: diff --git a/CHANGELOG.md b/CHANGELOG.md index ece7bdb..d924acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ ## Unreleased -This change de-duplicates and tightens the bundled agent guidance, prompts, and reference docs. +This release improves bundled integration setup and tightens the agent guidance, prompts, and reference docs. - Consolidated the three workspace task prompts into one fill-in `prompts/recovery-pass.md` with explicit required fields and `{{...}}` placeholders, and gave the workspace `AGENTS.md` a visible default-target field. - Gave each recurring rule (batch linting, selector calibration, capture strategy, class-evidence caveats) a single canonical home across `SKILL.md`, the references, and the workspace template, and unified the `batch --lint --out` command spelling. -- Documented that `misc rename` is unavailable in `batch`/`preview`, made the "No GUI targets found" recovery diagnose before installing (including the `--force` reinstall flag and the manual plugin-reload handoff), and fixed the misspelled bare `reanalyze` in checklists. +- Added transactional `setup install` and `setup update` commands for the GUI bridge and agent skills, including dry-run planning, custom-destination confirmation, strict path-overlap validation, rollback, and post-commit cleanup warnings; removed the old installer paths under `misc`. +- Documented that `misc rename` is unavailable in `batch`/`preview`, retained the manual plugin-reload handoff in GUI recovery guidance, and fixed the misspelled bare `reanalyze` in checklists. - Made `idac docs templates` print the template files themselves, converted `checkpoint-note.md` into a placeholder-only skeleton, and rewrote `ida-set-types.md` as an idac-owned reference with the spliced examples repaired. ## 0.18.0 diff --git a/README.md b/README.md index e3aa9e8..0241336 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,11 @@ git clone https://github.com/trailofbits/idac.git cd idac uv tool install . # installs the `idac` command on your PATH idac doctor # verify IDA install, license, and bridge -idac misc plugin install # GUI bridge plugin -idac misc skill install # Claude Code + Codex skill +idac setup install # GUI bridge + Claude Code and Codex skills ``` +After upgrading the CLI with the same package manager, run `idac setup update` to refresh the bundled GUI bridge and skills. Replacing an existing custom destination requires confirmation; use `--dry-run` to inspect the plan and `--force` to apply a reviewed custom-path update non-interactively. Setup updates integrations from the currently installed CLI package; it does not upgrade the CLI itself. + Talk to a live GUI session: ```bash @@ -168,7 +169,8 @@ Use `idac --help` for one subcommand, `idac --full-help` for the compl | Batch | `batch`, `batch --lint`, `preview` | | IDAPython | `py exec` | | Workspace | `workspace init` | -| Maintenance | `misc reanalyze`, `database open/save/close`, `targets cleanup`, `misc plugin`, `misc skill` | +| Setup | `setup install`, `setup update` | +| Maintenance | `misc reanalyze`, `database open/save/close`, `targets cleanup` | ### Output @@ -262,10 +264,10 @@ idac py exec --code "result = {'entry': hex(idc.get_inf_attr(idc.INF_START_EA))} A bundled skill in [src/idac/skills/idac](src/idac/skills/idac) teaches Claude Code and Codex to prefer `idac` commands over ad hoc shell or raw IDAPython for RE work. ```bash -idac misc skill install +idac setup install --component skill ``` -This installs into both `~/.claude/skills/idac` and `~/.codex/skills/idac`; both agents auto-discover skills from their `skills/` directories. Once installed, the skill loads automatically when relevant. For a ready-to-fill task prompt covering anything from a light analysis pass to class-family recovery, run `idac workspace init ` to scaffold a workspace containing `prompts/recovery-pass.md`. +This installs into both `~/.claude/skills/idac` and `~/.codex/skills/idac`; both agents auto-discover skills from their `skills/` directories. After upgrading idac, refresh them with `idac setup update --component skill`. Once installed, the skill loads automatically when relevant. For a ready-to-fill task prompt covering anything from a light analysis pass to class-family recovery, run `idac workspace init ` to scaffold a workspace containing `prompts/recovery-pass.md`. ## Development diff --git a/src/idac/cli2/argparse_utils.py b/src/idac/cli2/argparse_utils.py index 103664c..75a6a5a 100644 --- a/src/idac/cli2/argparse_utils.py +++ b/src/idac/cli2/argparse_utils.py @@ -352,17 +352,3 @@ def read_decl_or_type_text( if normalized: return f"{normalized} {placeholder_name};" raise CliUserError("missing declaration or type input") - - -def add_install_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--mode", - choices=("copy", "symlink"), - default="symlink", - help="Installation mode", - ) - parser.add_argument( - "--force", - action="store_true", - help="Replace an existing destination if it already exists", - ) diff --git a/src/idac/cli2/commands/__init__.py b/src/idac/cli2/commands/__init__.py index f10daf6..a3dd017 100644 --- a/src/idac/cli2/commands/__init__.py +++ b/src/idac/cli2/commands/__init__.py @@ -10,6 +10,7 @@ python_exec, search, segment, + setup, targets, top_level, type_commands, @@ -28,6 +29,7 @@ "python_exec", "search", "segment", + "setup", "targets", "top_level", "type_commands", diff --git a/src/idac/cli2/commands/misc.py b/src/idac/cli2/commands/misc.py index e2aff25..41c1765 100644 --- a/src/idac/cli2/commands/misc.py +++ b/src/idac/cli2/commands/misc.py @@ -1,50 +1,16 @@ from __future__ import annotations import argparse -import os -import shutil -from pathlib import Path -from ...paths import ( - plugin_bootstrap_install_path, - plugin_bootstrap_source_path, - plugin_install_dir, - plugin_runtime_package_install_dir, - plugin_runtime_package_source_dir, - plugin_source_dir, - skill_install_dirs, - skill_source_dir, -) from ..argparse_utils import ( add_command, add_context_options, - add_install_options, add_output_options, ) -from ..commands.common import command_result, send_op +from ..commands.common import send_op from ..result import CommandResult -def _install_path(source: Path, dest: Path, *, mode: str, force: bool, is_dir: bool) -> None: - if not source.exists(): - raise OSError(f"source path is missing: {source}") - dest.parent.mkdir(parents=True, exist_ok=True) - if dest.exists() or dest.is_symlink(): - if not force: - raise OSError(f"destination already exists: {dest}") - if dest.is_symlink() or dest.is_file(): - dest.unlink() - else: - shutil.rmtree(dest) - if mode == "copy": - if is_dir: - shutil.copytree(source, dest) - else: - shutil.copy2(source, dest) - return - os.symlink(source, dest, target_is_directory=is_dir) - - def _rename(args: argparse.Namespace) -> CommandResult: params = {"identifier": args.identifier, "new_name": args.new_name} return send_op(args, op="name_set", params=params) @@ -57,52 +23,10 @@ def _reanalyze(args: argparse.Namespace) -> CommandResult: return send_op(args, op="reanalyze", params=params) -def _plugin_install(args: argparse.Namespace) -> CommandResult: - package_source = plugin_source_dir() - bootstrap_source = plugin_bootstrap_source_path() - runtime_source = plugin_runtime_package_source_dir() - custom_dest = args.dest - package_dest = custom_dest or plugin_install_dir() - bootstrap_dest = ( - package_dest.parent / plugin_bootstrap_install_path().name if custom_dest else plugin_bootstrap_install_path() - ) - runtime_dest = package_dest.parent / runtime_source.name if custom_dest else plugin_runtime_package_install_dir() - _install_path(package_source, package_dest, mode=args.mode, force=args.force, is_dir=True) - _install_path(bootstrap_source, bootstrap_dest, mode=args.mode, force=args.force, is_dir=False) - _install_path(runtime_source, runtime_dest, mode=args.mode, force=args.force, is_dir=True) - return command_result( - "plugin_install", - { - "installed": True, - "mode": args.mode, - "package_destination": str(package_dest), - "bootstrap_destination": str(bootstrap_dest), - "runtime_package_destination": str(runtime_dest), - }, - ) - - -def _skill_install(args: argparse.Namespace) -> CommandResult: - source = skill_source_dir() - custom_dest = args.dest - destinations = [custom_dest] if custom_dest else skill_install_dirs(host=args.host) - for dest in destinations: - _install_path(source, dest, mode=args.mode, force=args.force, is_dir=True) - return command_result( - "skill_install", - { - "installed": True, - "mode": args.mode, - "source": str(source), - "destinations": [str(dest) for dest in destinations], - }, - ) - - def register( root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] ) -> None: - parser = add_command(root_parser, subparsers, "misc", help_text="Maintenance, setup, and utility commands") + parser = add_command(root_parser, subparsers, "misc", help_text="Maintenance and utility commands") misc_subparsers = parser.add_subparsers(dest="misc_command") child = add_command(parser, misc_subparsers, "rename", help_text="Rename an item") @@ -122,24 +46,3 @@ def register( child.set_defaults( run=_reanalyze, context_policy="standard", allow_batch=True, allow_preview=False, _mutating_command=True ) - - plugin_parser = add_command(parser, misc_subparsers, "plugin", help_text="Plugin operations") - plugin_subparsers = plugin_parser.add_subparsers(dest="misc_plugin_command") - child = add_command(plugin_parser, plugin_subparsers, "install", help_text="Install the GUI bridge plugin") - add_output_options(child, default_format="json") - add_install_options(child) - child.add_argument("--dest", type=Path, help="Custom installation destination") - child.set_defaults( - run=_plugin_install, context_policy="none", allow_batch=False, allow_preview=False, _mutating_command=False - ) - - skill_parser = add_command(parser, misc_subparsers, "skill", help_text="Skill operations") - skill_subparsers = skill_parser.add_subparsers(dest="misc_skill_command") - child = add_command(skill_parser, skill_subparsers, "install", help_text="Install the bundled idac skill") - add_output_options(child, default_format="json") - add_install_options(child) - child.add_argument("--dest", type=Path, help="Custom installation destination") - child.add_argument("--host", choices=("claude", "codex", "both"), default="both", help="Install target host") - child.set_defaults( - run=_skill_install, context_policy="none", allow_batch=False, allow_preview=False, _mutating_command=False - ) diff --git a/src/idac/cli2/commands/setup.py b/src/idac/cli2/commands/setup.py new file mode 100644 index 0000000..082a5c1 --- /dev/null +++ b/src/idac/cli2/commands/setup.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import cast + +from ...setup import ( + InstallMode, + SetupAction, + SetupComponent, + SetupPlan, + SetupRequest, + SkillAgent, + apply_setup, + plan_setup, + setup_result, +) +from ..argparse_utils import add_command, add_output_options +from ..commands.common import command_result +from ..errors import CliUserError +from ..renderers import render_setup +from ..result import CommandResult + + +def _setup_request(args: argparse.Namespace) -> SetupRequest: + components = cast( + tuple[SetupComponent, ...], + (args.component,) if args.component is not None else ("plugin", "skill"), + ) + if args.plugin_dir is not None and "plugin" not in components: + raise CliUserError("--plugin-dir requires the plugin component") + if args.skill_dest is not None and "skill" not in components: + raise CliUserError("--skill-dest requires the skill component") + if args.agent is not None and "skill" not in components: + raise CliUserError("--agent requires the skill component") + if args.agent is not None and args.skill_dest is not None: + raise CliUserError("--agent cannot be combined with --skill-dest because a custom destination has no agent") + return SetupRequest( + action=cast(SetupAction, args.setup_command), + components=components, + agent=cast(SkillAgent, args.agent or "both"), + mode=cast(InstallMode | None, args.mode), + plugin_directory=args.plugin_dir, + skill_destination=args.skill_dest, + ) + + +def _confirmation_lines(plan: SetupPlan) -> list[str]: + return render_setup(setup_result(plan, phase="planned")).splitlines() + + +def _confirm_setup_update(plan: SetupPlan) -> None: + for line in _confirmation_lines(plan): + print(line, file=sys.stderr) + if not sys.stdin.isatty(): + raise CliUserError( + "setup update requires confirmation in an interactive terminal; " + "inspect with --dry-run and rerun with --force" + ) + print("Continue? [y/N] ", end="", file=sys.stderr, flush=True) + answer = sys.stdin.readline().strip().lower() + if answer not in {"y", "yes"}: + raise CliUserError("setup update cancelled") + + +def _run_setup(args: argparse.Namespace) -> CommandResult: + try: + plan = plan_setup(_setup_request(args)) + except ValueError as exc: + raise CliUserError(str(exc)) from exc + if args.dry_run: + return command_result("setup", setup_result(plan, phase="planned")) + if plan.requires_confirmation and not args.force: + _confirm_setup_update(plan) + + value = apply_setup(plan) + warnings: list[str] = [] + cleanup_warnings = value.get("cleanup_warnings") + if isinstance(cleanup_warnings, list): + warnings.extend(str(item) for item in cleanup_warnings) + if value.get("ida_reload_recommended"): + warnings.append( + "if IDA is running, reload the idac bridge plugin or restart IDA to use the current bridge package" + ) + return command_result("setup", value, warnings=warnings) + + +def _add_setup_options(parser: argparse.ArgumentParser, *, is_update: bool) -> None: + add_output_options(parser, default_format="text") + parser.add_argument( + "--component", + choices=("plugin", "skill"), + help="Limit setup to one component (default: both)", + ) + parser.add_argument( + "--agent", + choices=("claude", "codex", "both"), + default=None, + help="Agent skill target (default: both); cannot be combined with --skill-dest", + ) + parser.add_argument( + "--mode", + choices=("copy", "symlink"), + default=None, + help=( + "Installation mode (default: preserve existing mode; symlink for new targets)" + if is_update + else "Installation mode (default: symlink)" + ), + ) + parser.add_argument( + "--plugin-dir", + type=Path, + help="Custom directory for GUI bridge plugin files", + ) + parser.add_argument("--skill-dest", type=Path, help="Custom skill destination") + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate and show the setup plan without changing files", + ) + if is_update: + parser.add_argument( + "--force", + action="store_true", + help="Skip confirmation when replacing custom destinations", + ) + parser.set_defaults( + run=_run_setup, + force=False, + context_policy="none", + allow_batch=False, + allow_preview=False, + _mutating_command=False, + ) + + +def register( + root_parser: argparse.ArgumentParser, subparsers: argparse._SubParsersAction[argparse.ArgumentParser] +) -> None: + parser = add_command(root_parser, subparsers, "setup", help_text="Install or update bundled integrations") + setup_subparsers = parser.add_subparsers(dest="setup_command") + + child = add_command( + parser, + setup_subparsers, + "install", + help_text="Install the GUI bridge and agent skills", + ) + _add_setup_options(child, is_update=False) + + child = add_command( + parser, + setup_subparsers, + "update", + help_text="Update bundled integrations from this idac installation", + ) + _add_setup_options(child, is_update=True) diff --git a/src/idac/cli2/main.py b/src/idac/cli2/main.py index b21c6cc..8ba96a2 100644 --- a/src/idac/cli2/main.py +++ b/src/idac/cli2/main.py @@ -21,6 +21,7 @@ python_exec, search, segment, + setup, targets, top_level, type_commands, @@ -56,6 +57,7 @@ def build_parser(*, prog: str = "idac") -> argparse.ArgumentParser: python_exec.register(parser, subparsers) preview.register(parser, subparsers) misc.register(parser, subparsers) + setup.register(parser, subparsers) finalize_help_tree(parser) return parser diff --git a/src/idac/cli2/renderers/__init__.py b/src/idac/cli2/renderers/__init__.py index 068368f..ba9aa2f 100644 --- a/src/idac/cli2/renderers/__init__.py +++ b/src/idac/cli2/renderers/__init__.py @@ -770,6 +770,28 @@ def render_workspace_init(value: Any) -> str: return "\n".join(lines) +def render_setup(value: Any) -> str: + if not isinstance(value, dict): + return _fallback(value) + action = str(value.get("action") or "setup") + phase = str(value.get("phase") or "applied") + lines = [f"Setup {action} plan (no changes applied)." if phase == "planned" else f"Setup {action} complete."] + has_updates = False + for item in value.get("targets") or []: + if not isinstance(item, dict): + continue + status = str(item.get("status") or "unknown") + has_updates = has_updates or status == "updated" + component = str(item.get("component") or "setup") + name = str(item.get("name") or "target") + destination = str(item.get("destination") or "") + mode = str(item.get("mode") or "unknown") + lines.append(f" {status:9} {component}.{name}: {destination} ({mode})") + if phase == "planned" and has_updates: + lines.append("Existing destinations listed as updated will be replaced in full.") + return "\n".join(lines) + + TEXT_RENDERERS = build_text_renderers(globals()) diff --git a/src/idac/cli2/renderers/_registry.py b/src/idac/cli2/renderers/_registry.py index 64458e0..76e1001 100644 --- a/src/idac/cli2/renderers/_registry.py +++ b/src/idac/cli2/renderers/_registry.py @@ -70,10 +70,11 @@ "reanalyze": "_fallback", "python_exec": "render_python_exec", "decompile_bulk": "render_decompile_bulk", + "setup": "render_setup", "workspace_init": "render_workspace_init", } -_CLI_ONLY_RENDERERS = {"doctor", "docs", "targets_cleanup", "decompile_bulk", "workspace_init"} +_CLI_ONLY_RENDERERS = {"doctor", "docs", "targets_cleanup", "decompile_bulk", "setup", "workspace_init"} def build_text_renderers(namespace: Mapping[str, object]) -> dict[str, Renderer]: diff --git a/src/idac/doctor.py b/src/idac/doctor.py index 0528c69..9b12f8b 100644 --- a/src/idac/doctor.py +++ b/src/idac/doctor.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any +from .install_payload import relative_install_files from .metadata import BRIDGE_SOCKET_PREFIX, IDALIB_SOCKET_PREFIX from .paths import ( bridge_registry_paths, @@ -48,10 +49,6 @@ def _symlink_target(path: Path) -> Path | None: return None -def _relative_file_set(root: Path) -> set[Path]: - return {path.relative_to(root) for path in root.rglob("*") if path.is_file()} - - def _install_matches_source(install_path: Path, source_path: Path) -> tuple[bool, str, dict[str, Any]]: source_resolved = source_path.resolve() install_resolved = _symlink_target(install_path) @@ -72,8 +69,8 @@ def _install_matches_source(install_path: Path, source_path: Path) -> tuple[bool details, ) - source_files = _relative_file_set(source_path) - install_files = _relative_file_set(install_path) + source_files = relative_install_files(source_path) + install_files = relative_install_files(install_path) if source_files != install_files: details["missing_files"] = sorted(str(path) for path in source_files - install_files)[:10] details["extra_files"] = sorted(str(path) for path in install_files - source_files)[:10] diff --git a/src/idac/install_payload.py b/src/idac/install_payload.py new file mode 100644 index 0000000..13658f0 --- /dev/null +++ b/src/idac/install_payload.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + + +def is_ignored_install_path(path: Path) -> bool: + return "__pycache__" in path.parts or path.suffix in {".pyc", ".pyo"} + + +def copytree_ignore(_directory: str, names: list[str]) -> set[str]: + return {name for name in names if is_ignored_install_path(Path(name))} + + +def relative_install_files(root: Path) -> set[Path]: + return { + relative + for path in root.rglob("*") + if path.is_file() and not is_ignored_install_path(relative := path.relative_to(root)) + } diff --git a/src/idac/paths.py b/src/idac/paths.py index 431f180..224f15a 100644 --- a/src/idac/paths.py +++ b/src/idac/paths.py @@ -241,22 +241,22 @@ def workspace_template_source_dir() -> Path: return package_source_dir() / "workspace_template" / "default" -def skill_install_dir(*, host: str = "codex") -> Path: - if host == "codex": +def skill_install_dir(*, agent: str = "codex") -> Path: + if agent == "codex": return codex_skills_dir() / SKILL_NAME - if host == "claude": + if agent == "claude": return claude_skills_dir() / SKILL_NAME - raise ValueError(f"unsupported skill host: {host}") + raise ValueError(f"unsupported skill agent: {agent}") -def skill_install_dirs(*, host: str = "both") -> list[Path]: - if host == "both": +def skill_install_dirs(*, agent: str = "both") -> list[Path]: + if agent == "both": deduped: list[Path] = [] for candidate in ( - skill_install_dir(host="claude"), - skill_install_dir(host="codex"), + skill_install_dir(agent="claude"), + skill_install_dir(agent="codex"), ): if candidate not in deduped: deduped.append(candidate) return deduped - return [skill_install_dir(host=host)] + return [skill_install_dir(agent=agent)] diff --git a/src/idac/setup.py b/src/idac/setup.py new file mode 100644 index 0000000..15e1180 --- /dev/null +++ b/src/idac/setup.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +import os +import shutil +import sys +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from .install_payload import copytree_ignore +from .paths import ( + plugin_bootstrap_source_path, + plugin_install_dir, + plugin_runtime_package_source_dir, + plugin_source_dir, + skill_install_dir, + skill_install_dirs, + skill_source_dir, +) + +SetupAction = Literal["install", "update"] +SetupComponent = Literal["plugin", "skill"] +InstallMode = Literal["copy", "symlink"] +SkillAgent = Literal["claude", "codex", "both"] +SetupStatus = Literal["installed", "updated", "unchanged"] +SetupPhase = Literal["planned", "applied"] + + +class SetupValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class SetupRequest: + action: SetupAction + components: tuple[SetupComponent, ...] = ("plugin", "skill") + agent: SkillAgent = "both" + mode: InstallMode | None = None + plugin_directory: Path | None = None + skill_destination: Path | None = None + + +@dataclass(frozen=True) +class SetupTarget: + component: SetupComponent + name: str + source: Path + destination: Path + is_dir: bool + custom_destination: bool + + +@dataclass(frozen=True) +class PlannedTarget: + target: SetupTarget + mode: InstallMode + status: SetupStatus + + +@dataclass(frozen=True) +class SetupPlan: + request: SetupRequest + targets: tuple[PlannedTarget, ...] + + @property + def changed(self) -> bool: + return any(item.status != "unchanged" for item in self.targets) + + @property + def requires_confirmation(self) -> bool: + return self.request.action == "update" and any( + item.status == "updated" and item.target.custom_destination for item in self.targets + ) + + +@dataclass(frozen=True) +class PreparedTarget: + planned: PlannedTarget + staging_path: Path + + +def _path_exists(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def _remove_path(path: Path) -> None: + if not _path_exists(path): + return + if path.is_symlink() or path.is_file(): + path.unlink() + return + shutil.rmtree(path) + + +def _temporary_sibling(path: Path, *, label: str) -> Path: + token = uuid.uuid4().hex + return path.parent / f".{path.name}.idac-{label}-{token}" + + +def _plugin_targets(custom_directory: Path | None) -> list[SetupTarget]: + package_source = plugin_source_dir() + bootstrap_source = plugin_bootstrap_source_path() + runtime_source = plugin_runtime_package_source_dir() + directory = custom_directory or plugin_install_dir().parent + custom = custom_directory is not None + return [ + SetupTarget("plugin", "bridge_package", package_source, directory / package_source.name, True, custom), + SetupTarget("plugin", "bootstrap", bootstrap_source, directory / bootstrap_source.name, False, custom), + SetupTarget("plugin", "runtime_package", runtime_source, directory / runtime_source.name, True, custom), + ] + + +def _skill_target_name(destination: Path, *, agent: SkillAgent, custom: bool) -> str: + if custom: + return "skill_custom" + matching_agents = [ + candidate for candidate in ("claude", "codex") if skill_install_dir(agent=candidate) == destination + ] + if matching_agents: + return "skill_" + "_".join(matching_agents) + return f"skill_{agent}" + + +def _skill_targets(agent: SkillAgent, custom_destination: Path | None) -> list[SetupTarget]: + source = skill_source_dir() + custom = custom_destination is not None + destinations = [custom_destination] if custom_destination is not None else skill_install_dirs(agent=agent) + return [ + SetupTarget( + "skill", + _skill_target_name(destination, agent=agent, custom=custom), + source, + destination, + True, + custom, + ) + for destination in destinations + ] + + +def build_setup_targets(request: SetupRequest) -> list[SetupTarget]: + targets: list[SetupTarget] = [] + if "plugin" in request.components: + targets.extend(_plugin_targets(request.plugin_directory)) + if "skill" in request.components: + targets.extend(_skill_targets(request.agent, request.skill_destination)) + return targets + + +def _absolute_path(path: Path) -> Path: + return Path(os.path.abspath(path)) + + +def _destination_views(path: Path) -> tuple[Path, ...]: + lexical = _absolute_path(path) + try: + canonical_parent = lexical.parent.resolve(strict=False) + except (OSError, RuntimeError) as exc: + raise SetupValidationError(f"failed to resolve destination parent for {path}: {exc}") from exc + canonical = canonical_parent / lexical.name + return (lexical,) if canonical == lexical else (lexical, canonical) + + +def _filesystem_is_case_insensitive(path: Path) -> bool: + absolute = _absolute_path(path) + existing = next((candidate for candidate in (absolute, *absolute.parents) if candidate.exists()), None) + if sys.platform == "darwin" and existing is not None: + try: + # Python does not expose macOS's _PC_CASE_SENSITIVE name. + return os.pathconf(existing, 11) == 0 + except (OSError, ValueError): + pass + existing_paths = (existing, *existing.parents) if existing is not None else () + for candidate in existing_paths: + swapped = candidate.name.swapcase() + if not swapped or swapped == candidate.name: + continue + try: + return os.path.samefile(candidate, candidate.with_name(swapped)) + except OSError: + continue + return os.path.normcase("A") == os.path.normcase("a") + + +def _lexical_paths_overlap(first: Path, second: Path, *, ignore_case: bool = False) -> bool: + first_parts = tuple(part.casefold() for part in first.parts) if ignore_case else first.parts + second_parts = tuple(part.casefold() for part in second.parts) if ignore_case else second.parts + return first_parts == second_parts[: len(first_parts)] or second_parts == first_parts[: len(second_parts)] + + +def _paths_overlap(first: Path, second: Path) -> bool: + if _lexical_paths_overlap(first, second): + return True + if (_filesystem_is_case_insensitive(first) or _filesystem_is_case_insensitive(second)) and _lexical_paths_overlap( + first, second, ignore_case=True + ): + return True + if _same_existing_path(first, second): + return True + return any(_same_existing_path(first, parent) for parent in second.parents) or any( + _same_existing_path(second, parent) for parent in first.parents + ) + + +def _same_existing_path(first: Path, second: Path) -> bool: + if first.is_symlink() or second.is_symlink(): + return False + try: + return os.path.samefile(first, second) + except OSError: + return False + + +def _validate_targets(targets: list[SetupTarget], *, action: SetupAction) -> None: + if not targets: + raise SetupValidationError("setup requires at least one component") + + resolved_sources: list[tuple[SetupTarget, Path]] = [] + destination_views: list[tuple[SetupTarget, tuple[Path, ...]]] = [] + for target in targets: + if not target.source.exists(): + raise OSError(f"source path is missing: {target.source}") + if target.source.is_dir() != target.is_dir: + raise OSError(f"source path has the wrong kind: {target.source}") + resolved_sources.append((target, target.source.resolve(strict=True))) + destination_views.append((target, _destination_views(target.destination))) + + for index, (target, views) in enumerate(destination_views): + for previous, previous_views in destination_views[:index]: + if any(_paths_overlap(view, previous_view) for view in views for previous_view in previous_views): + raise SetupValidationError( + "setup destinations must not be equal or nested: " + f"{previous.name} ({previous.destination}) and {target.name} ({target.destination})" + ) + + for target, views in destination_views: + for source_target, source in resolved_sources: + if any(_paths_overlap(view, source) for view in views): + raise SetupValidationError( + f"setup destination {target.name} ({target.destination}) must not overlap bundled source " + f"{source_target.name} ({source_target.source})" + ) + + if action == "install": + existing = [target.destination for target in targets if _path_exists(target.destination)] + if existing: + rendered = ", ".join(str(path) for path in existing) + raise SetupValidationError( + f"setup destination already exists: {rendered} (use `idac setup update` to replace it)" + ) + + +def _existing_mode(path: Path) -> InstallMode | None: + if path.is_symlink(): + return "symlink" + if path.exists(): + return "copy" + return None + + +def _resolved_modes(targets: list[SetupTarget], requested_mode: InstallMode | None) -> dict[Path, InstallMode]: + if requested_mode is not None: + return {target.destination: requested_mode for target in targets} + + component_modes: dict[SetupComponent, set[InstallMode]] = {"plugin": set(), "skill": set()} + for target in targets: + existing = _existing_mode(target.destination) + if existing is not None: + component_modes[target.component].add(existing) + + modes: dict[Path, InstallMode] = {} + for target in targets: + existing = _existing_mode(target.destination) + if existing is not None: + modes[target.destination] = existing + continue + observed = component_modes[target.component] + modes[target.destination] = next(iter(observed)) if len(observed) == 1 else "symlink" + return modes + + +def _symlink_matches_source(target: SetupTarget) -> bool: + if not target.destination.is_symlink(): + return False + try: + return target.destination.resolve(strict=True) == target.source.resolve(strict=True) + except OSError: + return False + + +def plan_setup(request: SetupRequest) -> SetupPlan: + targets = build_setup_targets(request) + _validate_targets(targets, action=request.action) + modes = _resolved_modes(targets, request.mode) + + planned: list[PlannedTarget] = [] + for target in targets: + mode = modes[target.destination] + existed = _path_exists(target.destination) + if request.action == "update" and mode == "symlink" and _symlink_matches_source(target): + status: SetupStatus = "unchanged" + else: + status = "updated" if existed else "installed" + planned.append( + PlannedTarget( + target=target, + mode=mode, + status=status, + ) + ) + return SetupPlan(request=request, targets=tuple(planned)) + + +def setup_result( + plan: SetupPlan, + *, + phase: SetupPhase, + cleanup_warnings: list[str] | None = None, +) -> dict[str, object]: + warnings = list(cleanup_warnings or []) + target_results = [ + { + "component": item.target.component, + "name": item.target.name, + "source": str(item.target.source), + "destination": str(item.target.destination), + "mode": item.mode, + "status": item.status, + } + for item in plan.targets + ] + target_results.sort(key=lambda item: (str(item["component"]), str(item["name"]))) + return { + "phase": phase, + "action": plan.request.action, + "components": list(plan.request.components), + "changed": plan.changed, + "requires_confirmation": phase == "planned" and plan.requires_confirmation, + "ida_reload_recommended": phase == "applied" + and any(item.target.component == "plugin" and item.status != "unchanged" for item in plan.targets), + "cleanup_warnings": warnings, + "targets": target_results, + } + + +def _stage_target(planned: PlannedTarget) -> Path: + target = planned.target + target.destination.parent.mkdir(parents=True, exist_ok=True) + staging_path = _temporary_sibling(target.destination, label="stage") + try: + if planned.mode == "copy": + if target.is_dir: + shutil.copytree(target.source, staging_path, ignore=copytree_ignore) + else: + shutil.copy2(target.source, staging_path) + else: + os.symlink(target.source, staging_path, target_is_directory=target.is_dir) + except BaseException: + _cleanup_paths([staging_path], label="staging path") + raise + return staging_path + + +def _cleanup_paths(paths: list[Path], *, label: str) -> list[str]: + warnings: list[str] = [] + for path in paths: + try: + _remove_path(path) + except OSError as exc: + warnings.append(f"could not remove setup {label} {path}: {exc or exc.__class__.__name__}") + return warnings + + +def _rollback( + prepared: list[PreparedTarget], + *, + committed: set[Path], + backups: dict[Path, Path], +) -> list[str]: + errors: list[str] = [] + for item in reversed(prepared): + destination = item.planned.target.destination + backup = backups.get(destination) + try: + if destination in committed: + _remove_path(destination) + if backup is not None and _path_exists(backup): + if _path_exists(destination): + _remove_path(destination) + os.replace(backup, destination) + except BaseException as exc: + errors.append(f"{destination}: {exc or exc.__class__.__name__}") + return errors + + +def _commit(prepared: list[PreparedTarget]) -> list[str]: + committed: set[Path] = set() + backups: dict[Path, Path] = {} + cleanup_warnings: list[str] = [] + try: + for item in prepared: + destination = item.planned.target.destination + if _path_exists(destination): + backup = _temporary_sibling(destination, label="backup") + backups[destination] = backup + os.replace(destination, backup) + committed.add(destination) + os.replace(item.staging_path, destination) + except BaseException as exc: + rollback_errors = _rollback(prepared, committed=committed, backups=backups) + if isinstance(exc, OSError): + suffix = f"; rollback errors: {'; '.join(rollback_errors)}" if rollback_errors else "" + raise OSError(f"failed to commit setup transaction: {exc}{suffix}") from exc + if rollback_errors: + detail = f"setup rollback errors: {'; '.join(rollback_errors)}" + add_note = getattr(exc, "add_note", None) + if add_note is not None: + add_note(detail) + else: + exc.args = (f"{exc or exc.__class__.__name__}; {detail}",) + raise + finally: + cleanup_warnings.extend(_cleanup_paths([item.staging_path for item in prepared], label="staging path")) + + cleanup_warnings.extend(_cleanup_paths(list(backups.values()), label="backup")) + return cleanup_warnings + + +def apply_setup(plan: SetupPlan) -> dict[str, object]: + prepared: list[PreparedTarget] = [] + try: + for planned in plan.targets: + if planned.status == "unchanged": + continue + prepared.append(PreparedTarget(planned=planned, staging_path=_stage_target(planned))) + except BaseException: + _cleanup_paths([item.staging_path for item in prepared], label="staging path") + raise + + cleanup_warnings = _commit(prepared) + return setup_result(plan, phase="applied", cleanup_warnings=cleanup_warnings) diff --git a/src/idac/skills/idac/references/cli.md b/src/idac/skills/idac/references/cli.md index 0e34609..0fc73e1 100644 --- a/src/idac/skills/idac/references/cli.md +++ b/src/idac/skills/idac/references/cli.md @@ -12,7 +12,7 @@ The command grammar for the `idac` CLI. - `function metadata` and JSON `function list` rows include `display_name` when available; JSON `function list` rows also include `section` - function-taking commands can resolve a unique demangled C++ name such as `ExampleClass::method_1`; if multiple functions match, use a mangled name, full signature, or address - `segment list` lists database segments -- setup, maintenance, and utility commands live under `misc` +- bundled integration lifecycle commands live under `setup`; IDA maintenance and utility commands live under `misc` ## Common reads @@ -93,14 +93,25 @@ function locals rename "sub_08041337" --index 6 --new-name "entry_count" For a full recovery-pass example and the batch authoring rules, read [workflows.md](workflows.md#batch). +## Setup commands + +Setup commands operate on bundled integrations without contacting an IDA target: + +- `setup install` — install the GUI bridge and both agent skills. It fails before changing anything if a destination already exists. +- `setup update` — refresh existing GUI bridge and skill targets and install missing ones. Existing copy/symlink modes are preserved unless `--mode` is passed. Replacing a custom destination requires interactive confirmation. +- `setup update --dry-run` — validate and print the complete plan without changing files. +- `--component plugin|skill` limits either setup command to one component; `--agent claude|codex|both` limits skill targets. +- `--plugin-dir DIR` and `--skill-dest DIR` select custom plugin and skill locations. +- `--force` skips custom-destination update confirmation for non-interactive execution; it never bypasses invalid destination/source overlap checks. + +Both setup commands are rejected from `batch` and `preview`. `--agent` cannot be combined with `--skill-dest`, because a custom skill destination has no agent association. Setup uses integrations bundled with the currently installed idac package and does not upgrade the CLI itself. + ## Misc commands -These setup, maintenance, and utility commands live under `misc`: +Maintenance and utility commands live under `misc`: - `misc rename` — rename a function or global symbol. Not available in `batch` or `preview`; commit symbol renames one-off. - `misc reanalyze` — re-run IDA analysis on a function or range. Batch-safe; place it between type/prototype mutations and local cleanup. -- `misc plugin install` — install the GUI bridge plugin; `--force` replaces an existing install. Setup-only; rejected from `batch`. -- `misc skill install` — install the bundled skill. Setup-only; rejected from `batch`. ## Bundled docs diff --git a/src/idac/skills/idac/references/targets-and-backends.md b/src/idac/skills/idac/references/targets-and-backends.md index c545b3f..3d26c24 100644 --- a/src/idac/skills/idac/references/targets-and-backends.md +++ b/src/idac/skills/idac/references/targets-and-backends.md @@ -32,7 +32,7 @@ idac targets cleanup If multiple GUI instances are open, pass `-c pid:`. `targets list` reports both live GUI bridge targets and already-open headless `idalib` targets. In JSON output, check `backend`: GUI rows use `backend: "gui"` and headless rows use `backend: "idalib"`. If a binary was opened with `database open`, keep using `-c "db:/path/to/binary"` even when no GUI target is present. If the runtime dir contains stale GUI bridge or `idalib` daemon files, run `idac targets cleanup` and then rerun `targets list`. Use `--out ` when you want to keep the full cleanup result. -If the bridge plugin is missing from the current IDA session, run `idac misc plugin install` (`--force` replaces an existing install), then ask the user to reload the plugin in IDA — idac cannot reload it — before retrying discovery. +If the bridge plugin is missing or stale, run `idac setup update --component plugin`. Ask the user to reload the plugin in IDA — idac cannot reload it — before retrying discovery. `setup update` refreshes existing targets and installs any missing targets. ## Database context diff --git a/src/idac/skills/idac/references/troubleshooting.md b/src/idac/skills/idac/references/troubleshooting.md index 5046ab0..3807b6e 100644 --- a/src/idac/skills/idac/references/troubleshooting.md +++ b/src/idac/skills/idac/references/troubleshooting.md @@ -13,7 +13,7 @@ idac targets cleanup idac targets list ``` -If no targets appear, either the `idac_bridge` plugin is not loaded in the current GUI session or stale bridge runtime files were masking it (`targets cleanup` removes those; add `--out ` to keep the full result). If `doctor` reports the plugin missing, run `idac misc plugin install` (add `--force` to replace an existing install). idac cannot reload the plugin itself — ask the user to reload the `idac_bridge` plugin in the IDA GUI, or restart IDA, then rerun `targets list`. +If no targets appear, either the `idac_bridge` plugin is not loaded in the current GUI session or stale bridge runtime files were masking it (`targets cleanup` removes those; add `--out ` to keep the full result). If `doctor` reports the plugin missing or stale, run `idac setup update --component plugin`; the command refreshes existing targets and installs missing ones. idac cannot reload the plugin itself — ask the user to reload the `idac_bridge` plugin in the IDA GUI, or restart IDA, then rerun `targets list`. This section is only about GUI rows. Headless targets opened through `database open` appear in `targets list --json` with `backend: "idalib"` and should be used with `-c "db:/path"`. diff --git a/src/idac/skills/idac/references/workflows.md b/src/idac/skills/idac/references/workflows.md index a9cb03c..700c04f 100644 --- a/src/idac/skills/idac/references/workflows.md +++ b/src/idac/skills/idac/references/workflows.md @@ -206,7 +206,7 @@ preview function prototype set "ExampleDerived__method_1" --decl-file "example_m For `idalib`, `batch` keeps ordered logging while reusing the same open database state for the shared `-c db:` locator. Each step is still a separate request. For larger prototype and local-rename passes, prefer `batch` so the mutation order is explicit and the run leaves behind a stable ordered log. -Setup-only `misc` commands such as `misc plugin install` and `misc skill install` are intentionally rejected from `batch`, and so is `misc rename` — commit symbol renames one-off. `misc reanalyze` is batch-safe and belongs between type/prototype mutations and local cleanup in full recovery batches. +`setup install` and `setup update` are intentionally rejected from `batch`, and so is `misc rename` — commit symbol renames one-off. `misc reanalyze` is batch-safe and belongs between type/prototype mutations and local cleanup in full recovery batches. ## Broad discovery defaults diff --git a/src/idac/workspace.py b/src/idac/workspace.py index 8712afa..7098eb7 100644 --- a/src/idac/workspace.py +++ b/src/idac/workspace.py @@ -203,7 +203,7 @@ def initialize_workspace(dest: Path, *, force: bool = False) -> dict[str, Any]: "repo_root": str(repo_root), }, "next_steps": [ - "Run `idac misc skill install` if you haven't already", + "Run `idac setup install --component skill` if you haven't already", "Edit AGENTS.md to set your default target", "Commit when the workspace looks right", ], diff --git a/tests/test_cli.py b/tests/test_cli.py index 95f9f07..fc661b4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,12 +67,13 @@ def _copied_fixture_db(copy_database, tiny_database: Path) -> str: return f"db:{copy_database(tiny_database)}" -def test_root_help_shows_misc_and_hides_old_names(capsys) -> None: +def test_root_help_shows_setup_and_misc_and_hides_old_names(capsys) -> None: parser = build_parser() help_text = _help_text(parser, capsys=capsys) assert "docs" in help_text assert "misc" in help_text + assert "setup" in help_text assert "decompilemany" in help_text assert "strings" not in help_text @@ -85,7 +86,7 @@ def test_root_help_mentions_global_context_forwarding(capsys) -> None: assert "--timeout TIMEOUT" in help_text -def test_root_full_help_shows_misc_and_hides_old_function_show(capsys) -> None: +def test_root_full_help_shows_setup_and_hides_removed_installers(capsys) -> None: parser = build_parser() with pytest.raises(SystemExit) as exc: @@ -98,8 +99,11 @@ def test_root_full_help_shows_misc_and_hides_old_function_show(capsys) -> None: assert "# idac docs" in help_text assert "# idac misc" in help_text assert "# idac misc reanalyze" in help_text - assert "# idac misc plugin install" in help_text - assert "# idac misc skill install" in help_text + assert "# idac setup" in help_text + assert "# idac setup install" in help_text + assert "# idac setup update" in help_text + assert "# idac misc plugin" not in help_text + assert "# idac misc skill" not in help_text assert "# idac segment list" in help_text assert "# idac targets list" in help_text assert "# idac targets cleanup" in help_text @@ -119,6 +123,22 @@ def test_function_help_uses_metadata_and_prototype(capsys) -> None: assert "prototype" in help_text +def test_setup_help_exposes_supported_options(capsys) -> None: + parser = build_parser() + install_help = _help_text(parser, "setup", "install", capsys=capsys) + update_help = _help_text(parser, "setup", "update", capsys=capsys) + + assert "--dry-run" in install_help + assert "--force" not in install_help + assert "--dry-run" in update_help + assert "--force" in update_help + assert "--agent" in install_help + assert "--host" not in install_help + assert "--skill-dest" in update_help + assert "--plugin-dir" in update_help + assert "--plugin-dest" not in update_help + + def test_function_list_help_mentions_name_filter_regex_and_ignore_case(capsys) -> None: parser = build_parser() help_text = _help_text(parser, "function", "list", capsys=capsys) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ec93224..943c746 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -8,6 +8,7 @@ from types import SimpleNamespace from idac import doctor +from idac.install_payload import copytree_ignore from idac.metadata import ( BRIDGE_PLUGIN_NAME, GUI_BACKEND_NAME, @@ -141,20 +142,22 @@ def test_doctor_warns_for_runtime_package_drift_when_bridge_is_live(monkeypatch, def test_doctor_accepts_copy_install_layout(monkeypatch, tmp_path: Path) -> None: source_dir = tmp_path / "plugin-src" - source_dir.mkdir() + (source_dir / "__pycache__").mkdir(parents=True) (source_dir / "__init__.py").write_text("# package\n", encoding="utf-8") + (source_dir / "__pycache__" / "__init__.pyc").write_bytes(b"cache") bootstrap_source = tmp_path / "idac_bridge_plugin.py" bootstrap_source.write_text("# bootstrap\n", encoding="utf-8") runtime_package_source = tmp_path / "idac-src" runtime_package_source.mkdir() (runtime_package_source / "__init__.py").write_text("# idac\n", encoding="utf-8") + (runtime_package_source / "runtime.pyo").write_bytes(b"cache") install_dir = tmp_path / "plugins" / "idac_bridge" - shutil.copytree(source_dir, install_dir) + shutil.copytree(source_dir, install_dir, ignore=copytree_ignore) install_bootstrap = tmp_path / "plugins" / "idac_bridge_plugin.py" shutil.copy2(bootstrap_source, install_bootstrap) install_runtime_package = tmp_path / "plugins" / "idac" - shutil.copytree(runtime_package_source, install_runtime_package) + shutil.copytree(runtime_package_source, install_runtime_package, ignore=copytree_ignore) monkeypatch.setattr(doctor, "plugin_source_dir", lambda: source_dir) monkeypatch.setattr(doctor, "plugin_bootstrap_source_path", lambda: bootstrap_source) diff --git a/tests/test_installers.py b/tests/test_installers.py index bc842b1..aabe927 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -1,208 +1,435 @@ from __future__ import annotations import importlib +import io import sys from pathlib import Path from types import SimpleNamespace +from typing import Any, cast +import pytest + +from idac.cli import main from tests.helpers import run_cli, run_cli_json -def test_skill_install_symlink(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: - env = dict(idac_env) - env["CLAUDE_HOME"] = str(tmp_path / ".claude") - env["CODEX_HOME"] = str(tmp_path / ".codex") +def _target_rows(result: dict[str, Any], component: str) -> list[dict[str, Any]]: + return [item for item in result["targets"] if item["component"] == component] - result = run_cli_json(idac_cmd, env, "misc", "skill", "install") - destinations = [Path(str(item)) for item in result["destinations"]] - assert result["installed"] is True - assert len(destinations) == 2 - assert all(dest.is_symlink() for dest in destinations) - expected = (Path(__file__).resolve().parents[1] / "src" / "idac" / "skills" / "idac").resolve() - assert all(dest.resolve() == expected for dest in destinations) +def _run_setup_json(idac_cmd: list[str], env: dict[str, str], *args: object) -> dict[str, Any]: + result = run_cli_json(idac_cmd, env, *args) + assert isinstance(result, dict) + return cast(dict[str, Any], result) -def test_skill_install_symlink_single_host(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: +class _InteractiveInput(io.StringIO): + def isatty(self) -> bool: + return True + + +def test_setup_install_symlinks_plugin_and_both_skills( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: env = dict(idac_env) + env["IDAUSR"] = str(tmp_path / ".idapro") env["CLAUDE_HOME"] = str(tmp_path / ".claude") env["CODEX_HOME"] = str(tmp_path / ".codex") - result = run_cli_json(idac_cmd, env, "misc", "skill", "install", "--host", "claude") + result = _run_setup_json(idac_cmd, env, "setup", "install") + + assert result["action"] == "install" + assert result["phase"] == "applied" + assert result["components"] == ["plugin", "skill"] + assert result["changed"] is True + assert result["ida_reload_recommended"] is True + assert len(result["targets"]) == 5 + assert {item["status"] for item in result["targets"]} == {"installed"} + assert all(Path(str(item["destination"])).is_symlink() for item in result["targets"]) + plugin_targets = _target_rows(result, "plugin") + by_name = {item["name"]: Path(str(item["destination"])) for item in plugin_targets} + package = Path(__file__).resolve().parents[1] / "src" / "idac" + assert by_name["bridge_package"].resolve() == (package / "ida_plugin" / "idac_bridge").resolve() + assert by_name["bootstrap"].resolve() == (package / "ida_plugin" / "idac_bridge_plugin.py").resolve() + assert by_name["runtime_package"].resolve() == package.resolve() + + +def test_setup_install_skill_for_single_agent(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: + env = dict(idac_env) + env["CLAUDE_HOME"] = str(tmp_path / ".claude") + env["CODEX_HOME"] = str(tmp_path / ".codex") - destinations = [Path(str(item)) for item in result["destinations"]] - assert result["installed"] is True - assert len(destinations) == 1 - dest = destinations[0] - assert dest == tmp_path / ".claude" / "skills" / "idac" - assert dest.is_symlink() + result = _run_setup_json(idac_cmd, env, "setup", "install", "--component", "skill", "--agent", "claude") + targets = _target_rows(result, "skill") + assert result["components"] == ["skill"] + assert result["ida_reload_recommended"] is False + assert len(targets) == 1 + assert targets[0]["name"] == "skill_claude" + assert targets[0]["destination"] == str(tmp_path / ".claude" / "skills" / "idac") + assert Path(str(targets[0]["destination"])).is_symlink() + assert not (tmp_path / ".codex").exists() -def test_skill_install_copy_custom_dest(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: + +def test_setup_install_skill_copy_at_custom_destination( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: env = dict(idac_env) - dest = tmp_path / "skills" / "idac" + destination = tmp_path / "skills" / "idac" - result = run_cli_json(idac_cmd, env, "misc", "skill", "install", "--mode", "copy", "--dest", str(dest)) + result = _run_setup_json( + idac_cmd, + env, + "setup", + "install", + "--component", + "skill", + "--mode", + "copy", + "--skill-dest", + str(destination), + ) - assert result["installed"] is True - assert result["destinations"] == [str(dest)] - assert dest.exists() - assert not dest.is_symlink() - assert (dest / "SKILL.md").exists() - assert (dest / "agents" / "openai.yaml").exists() + targets = _target_rows(result, "skill") + assert len(targets) == 1 + assert targets[0]["destination"] == str(destination) + assert targets[0]["mode"] == "copy" + assert destination.exists() + assert not destination.is_symlink() + assert (destination / "SKILL.md").exists() + assert (destination / "agents" / "openai.yaml").exists() -def test_skill_install_refuses_existing_destination_without_force( +def test_setup_install_refuses_all_existing_destinations_before_mutating( idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path ) -> None: env = dict(idac_env) - dest = tmp_path / "skills" / "idac" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("keep", encoding="utf-8") + env["CLAUDE_HOME"] = str(tmp_path / ".claude") + env["CODEX_HOME"] = str(tmp_path / ".codex") + claude_destination = tmp_path / ".claude" / "skills" / "idac" + claude_destination.mkdir(parents=True) + (claude_destination / "keep.txt").write_text("keep", encoding="utf-8") - proc = run_cli(idac_cmd, env, "misc", "skill", "install", "--mode", "copy", "--dest", str(dest)) + proc = run_cli(idac_cmd, env, "setup", "install", "--component", "skill") assert proc.returncode == 1 - assert f"destination already exists: {dest}" in proc.stderr - assert (dest / "stale.txt").read_text(encoding="utf-8") == "keep" + assert "setup destination already exists" in proc.stderr + assert "idac setup update" in proc.stderr + assert (claude_destination / "keep.txt").read_text(encoding="utf-8") == "keep" + assert not (tmp_path / ".codex" / "skills" / "idac").exists() -def test_skill_install_force_replaces_existing_custom_copy( +def test_setup_update_replaces_skill_copy_and_preserves_mode( idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path ) -> None: env = dict(idac_env) - dest = tmp_path / "skills" / "idac" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("remove me", encoding="utf-8") + destination = tmp_path / "skills" / "idac" + install_args = ( + "setup", + "install", + "--component", + "skill", + "--mode", + "copy", + "--skill-dest", + str(destination), + ) + _run_setup_json(idac_cmd, env, *install_args) + (destination / "stale.txt").write_text("remove me", encoding="utf-8") - result = run_cli_json( + result = _run_setup_json( idac_cmd, env, - "misc", + "setup", + "update", + "--component", "skill", + "--skill-dest", + str(destination), + "--force", + ) + + target = _target_rows(result, "skill")[0] + assert target["status"] == "updated" + assert target["mode"] == "copy" + assert not destination.is_symlink() + assert not (destination / "stale.txt").exists() + assert (destination / "SKILL.md").exists() + + +def test_setup_update_installs_missing_default_skill_agent_noninteractively( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: + env = dict(idac_env) + env["CLAUDE_HOME"] = str(tmp_path / ".claude") + env["CODEX_HOME"] = str(tmp_path / ".codex") + _run_setup_json( + idac_cmd, + env, + "setup", "install", + "--component", + "skill", + "--agent", + "claude", "--mode", "copy", - "--dest", - str(dest), - "--force", ) - assert result["installed"] is True - assert result["destinations"] == [str(dest)] - assert not (dest / "stale.txt").exists() - assert (dest / "SKILL.md").exists() - assert (dest / "agents" / "openai.yaml").exists() + result = _run_setup_json(idac_cmd, env, "setup", "update", "--component", "skill") + + targets = _target_rows(result, "skill") + assert {item["status"] for item in targets} == {"installed", "updated"} + assert {item["mode"] for item in targets} == {"copy"} + assert not (tmp_path / ".claude" / "skills" / "idac").is_symlink() + assert not (tmp_path / ".codex" / "skills" / "idac").is_symlink() -def test_plugin_install_symlink(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: +def test_setup_update_dry_run_reports_destructive_plan_without_mutating( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: env = dict(idac_env) - env["IDAUSR"] = str(tmp_path / ".idapro") + destination = tmp_path / "skills" + destination.mkdir() + (destination / "important.txt").write_text("keep", encoding="utf-8") - result = run_cli_json(idac_cmd, env, "misc", "plugin", "install") - - package_dest = Path(str(result["package_destination"])) - bootstrap_dest = Path(str(result["bootstrap_destination"])) - runtime_package_dest = Path(str(result["runtime_package_destination"])) - assert package_dest.is_symlink() - assert bootstrap_dest.is_symlink() - assert runtime_package_dest.is_symlink() - assert ( - package_dest.resolve() - == (Path(__file__).resolve().parents[1] / "src" / "idac" / "ida_plugin" / "idac_bridge").resolve() + result = _run_setup_json( + idac_cmd, + env, + "setup", + "update", + "--component", + "skill", + "--skill-dest", + str(destination), + "--dry-run", ) - assert ( - bootstrap_dest.resolve() - == (Path(__file__).resolve().parents[1] / "src" / "idac" / "ida_plugin" / "idac_bridge_plugin.py").resolve() + + assert result["phase"] == "planned" + assert result["requires_confirmation"] is True + assert _target_rows(result, "skill")[0]["status"] == "updated" + assert (destination / "important.txt").read_text(encoding="utf-8") == "keep" + assert not (destination / "SKILL.md").exists() + + +def test_setup_update_requires_force_when_noninteractive( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: + env = dict(idac_env) + destination = tmp_path / "skills" + destination.mkdir() + (destination / "important.txt").write_text("keep", encoding="utf-8") + + proc = run_cli( + idac_cmd, + env, + "setup", + "update", + "--component", + "skill", + "--skill-dest", + str(destination), + input_text="", ) - assert runtime_package_dest.resolve() == (Path(__file__).resolve().parents[1] / "src" / "idac").resolve() + assert proc.returncode == 1 + assert "Setup update plan (no changes applied)." in proc.stderr + assert "Existing destinations listed as updated will be replaced in full." in proc.stderr + assert "inspect with --dry-run and rerun with --force" in proc.stderr + assert (destination / "important.txt").read_text(encoding="utf-8") == "keep" + + +@pytest.mark.parametrize(("answer", "expected_exit_code"), [("yes\n", 0), ("no\n", 1)]) +def test_setup_update_handles_interactive_confirmation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys, + answer: str, + expected_exit_code: int, +) -> None: + destination = tmp_path / "skills" / "idac" + (destination / "agents").mkdir(parents=True) + (destination / "SKILL.md").write_text("old", encoding="utf-8") + (destination / "agents" / "openai.yaml").write_text("old", encoding="utf-8") + monkeypatch.setattr(sys, "stdin", _InteractiveInput(answer)) + + exit_code = main( + [ + "setup", + "update", + "--component", + "skill", + "--skill-dest", + str(destination), + ] + ) -def test_plugin_install_copy_custom_dest(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: + captured = capsys.readouterr() + assert exit_code == expected_exit_code + assert "Setup update plan (no changes applied)." in captured.err + assert "Existing destinations listed as updated will be replaced in full." in captured.err + assert "Continue? [y/N]" in captured.err + skill_text = (destination / "SKILL.md").read_text(encoding="utf-8") + if expected_exit_code == 0: + assert skill_text.startswith("---") + else: + assert "setup update cancelled" in captured.err + assert skill_text == "old" + + +@pytest.mark.parametrize( + ("initial_mode", "update_mode", "expected_symlink"), + [("symlink", "copy", False), ("copy", "symlink", True)], +) +def test_setup_update_changes_installation_mode_when_requested( + idac_cmd: list[str], + idac_env: dict[str, str], + tmp_path: Path, + initial_mode: str, + update_mode: str, + expected_symlink: bool, +) -> None: env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - - result = run_cli_json(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) - - package_dest = Path(str(result["package_destination"])) - bootstrap_dest = Path(str(result["bootstrap_destination"])) - runtime_package_dest = Path(str(result["runtime_package_destination"])) - assert package_dest == dest - assert bootstrap_dest == dest.parent / "idac_bridge_plugin.py" - assert runtime_package_dest == dest.parent / "idac" - assert package_dest.exists() - assert not package_dest.is_symlink() - assert (package_dest / "__init__.py").exists() - assert bootstrap_dest.exists() - assert not bootstrap_dest.is_symlink() - assert runtime_package_dest.exists() - assert not runtime_package_dest.is_symlink() - assert (runtime_package_dest / "cli.py").exists() - - -def test_plugin_install_refuses_existing_destination_without_force( + destination = tmp_path / "skills" / "idac" + _run_setup_json( + idac_cmd, + env, + "setup", + "install", + "--component", + "skill", + "--skill-dest", + str(destination), + "--mode", + initial_mode, + ) + + result = _run_setup_json( + idac_cmd, + env, + "setup", + "update", + "--component", + "skill", + "--skill-dest", + str(destination), + "--mode", + update_mode, + "--force", + ) + + target = _target_rows(result, "skill")[0] + assert target["status"] == "updated" + assert target["mode"] == update_mode + assert destination.is_symlink() is expected_symlink + + +def test_setup_rejects_agent_with_custom_skill_destination( + idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path +) -> None: + proc = run_cli( + idac_cmd, + idac_env, + "setup", + "install", + "--component", + "skill", + "--agent", + "claude", + "--skill-dest", + str(tmp_path / "idac"), + ) + + assert proc.returncode == 1 + assert "--agent cannot be combined with --skill-dest" in proc.stderr + + +def test_setup_update_keeps_matching_plugin_symlinks_without_recommending_ida_reload( idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path ) -> None: env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - dest.mkdir(parents=True) - (dest / "stale.txt").write_text("keep", encoding="utf-8") + env["IDAUSR"] = str(tmp_path / ".idapro") + _run_setup_json(idac_cmd, env, "setup", "install", "--component", "plugin") - proc = run_cli(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) + result = _run_setup_json(idac_cmd, env, "setup", "update", "--component", "plugin") - assert proc.returncode == 1 - assert f"destination already exists: {dest}" in proc.stderr - assert (dest / "stale.txt").read_text(encoding="utf-8") == "keep" - assert not (dest.parent / "idac_bridge_plugin.py").exists() - assert not (dest.parent / "idac").exists() + targets = _target_rows(result, "plugin") + assert result["changed"] is False + assert result["ida_reload_recommended"] is False + assert {item["status"] for item in targets} == {"unchanged"} -def test_plugin_install_force_replaces_existing_copy_targets( +def test_setup_update_replaces_plugin_copy_targets( idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path ) -> None: env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" - bootstrap_dest = dest.parent / "idac_bridge_plugin.py" - runtime_dest = dest.parent / "idac" - dest.mkdir(parents=True) - runtime_dest.mkdir() - (dest / "stale.txt").write_text("remove me", encoding="utf-8") - bootstrap_dest.write_text("stale bootstrap\n", encoding="utf-8") - (runtime_dest / "stale.txt").write_text("remove me", encoding="utf-8") - - result = run_cli_json( - idac_cmd, - env, - "misc", - "plugin", + plugins_dir = tmp_path / "plugins" + destination = plugins_dir / "idac_bridge" + install_args = ( + "setup", "install", + "--component", + "plugin", "--mode", "copy", - "--dest", - str(dest), + "--plugin-dir", + str(plugins_dir), + ) + _run_setup_json(idac_cmd, env, *install_args) + runtime_destination = destination.parent / "idac" + (destination / "stale.txt").write_text("remove me", encoding="utf-8") + (runtime_destination / "stale.txt").write_text("remove me", encoding="utf-8") + + result = _run_setup_json( + idac_cmd, + env, + "setup", + "update", + "--component", + "plugin", + "--plugin-dir", + str(plugins_dir), "--force", ) - assert result["installed"] is True - assert result["package_destination"] == str(dest) - assert result["bootstrap_destination"] == str(bootstrap_dest) - assert result["runtime_package_destination"] == str(runtime_dest) - assert not (dest / "stale.txt").exists() - assert not (runtime_dest / "stale.txt").exists() - assert (dest / "__init__.py").exists() - assert (bootstrap_dest).exists() - assert (runtime_dest / "cli.py").exists() + targets = _target_rows(result, "plugin") + assert {item["status"] for item in targets} == {"updated"} + assert {item["mode"] for item in targets} == {"copy"} + assert result["ida_reload_recommended"] is True + assert not (destination / "stale.txt").exists() + assert not (runtime_destination / "stale.txt").exists() -def test_plugin_install_copy_custom_dest_is_importable_without_repo_root( +def test_setup_plugin_copy_layout_is_importable_without_repo_root( idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path, monkeypatch ) -> None: env = dict(idac_env) - dest = tmp_path / "plugins" / "idac_bridge" + plugins_dir = tmp_path / "plugins" + result = _run_setup_json( + idac_cmd, + env, + "setup", + "install", + "--component", + "plugin", + "--mode", + "copy", + "--plugin-dir", + str(plugins_dir), + ) - run_cli_json(idac_cmd, env, "misc", "plugin", "install", "--mode", "copy", "--dest", str(dest)) + targets = _target_rows(result, "plugin") + by_name = {item["name"]: Path(str(item["destination"])) for item in targets} + assert by_name == { + "bridge_package": plugins_dir / "idac_bridge", + "bootstrap": plugins_dir / "idac_bridge_plugin.py", + "runtime_package": plugins_dir / "idac", + } + assert (by_name["bridge_package"] / "__init__.py").exists() + assert by_name["bootstrap"].is_file() + assert (by_name["runtime_package"] / "cli.py").exists() + assert all(not path.is_symlink() for path in by_name.values()) - plugins_dir = dest.parent saved_modules = { name: sys.modules.pop(name) for name in list(sys.modules) @@ -236,3 +463,24 @@ def test_plugin_install_copy_custom_dest_is_importable_without_repo_root( assert Path(imported_idac.__file__).resolve().is_relative_to((plugins_dir / "idac").resolve()) assert callable(module.PLUGIN_ENTRY) + + +def test_setup_plugin_reports_ida_reload_warning(idac_cmd: list[str], idac_env: dict[str, str], tmp_path: Path) -> None: + env = dict(idac_env) + env["IDAUSR"] = str(tmp_path / ".idapro") + + proc = run_cli(idac_cmd, env, "setup", "install", "--component", "plugin") + + assert proc.returncode == 0 + assert "Setup install complete." in proc.stdout + assert "reload the idac bridge plugin" in proc.stderr + + +def test_removed_misc_installer_commands_are_not_accepted(idac_cmd: list[str], idac_env: dict[str, str]) -> None: + plugin = run_cli(idac_cmd, idac_env, "misc", "plugin", "install") + skill = run_cli(idac_cmd, idac_env, "misc", "skill", "install") + + assert plugin.returncode == 2 + assert skill.returncode == 2 + assert "invalid choice: 'plugin'" in plugin.stderr + assert "invalid choice: 'skill'" in skill.stderr diff --git a/tests/test_paths.py b/tests/test_paths.py index 30e9c61..5e41b86 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -52,7 +52,7 @@ def test_read_only_path_getters_do_not_create_directories(monkeypatch, tmp_path: assert plugin_runtime_package_install_dir() == idausr / "plugins" / "idac" assert claude_skills_dir() == claude_home / "skills" assert codex_skills_dir() == codex_home / "skills" - assert skill_install_dir(host="claude") == claude_home / "skills" / "idac" + assert skill_install_dir(agent="claude") == claude_home / "skills" / "idac" assert skill_install_dir() == codex_home / "skills" / "idac" assert skill_install_dirs() == [ claude_home / "skills" / "idac", diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..a4f72fc --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from idac import setup + + +def _skill_destinations(tmp_path: Path) -> list[Path]: + return [ + tmp_path / ".claude" / "skills" / "idac", + tmp_path / ".codex" / "skills" / "idac", + ] + + +def _seed_original_destinations(destinations: list[Path]) -> None: + for index, destination in enumerate(destinations): + destination.mkdir(parents=True) + (destination / "original.txt").write_text(f"original-{index}", encoding="utf-8") + + +def _assert_original_destinations(destinations: list[Path]) -> None: + for index, destination in enumerate(destinations): + assert (destination / "original.txt").read_text(encoding="utf-8") == f"original-{index}" + assert not (destination / "SKILL.md").exists() + + +def test_setup_update_rolls_back_every_destination_when_commit_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("CLAUDE_HOME", str(tmp_path / ".claude")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex")) + destinations = _skill_destinations(tmp_path) + _seed_original_destinations(destinations) + + original_replace = os.replace + staged_commits = 0 + + def fail_second_staged_commit(source: str | Path, destination: str | Path) -> None: + nonlocal staged_commits + if ".idac-stage-" in str(source): + staged_commits += 1 + if staged_commits == 2: + raise OSError("injected commit failure") + original_replace(source, destination) + + monkeypatch.setattr(setup.os, "replace", fail_second_staged_commit) + plan = setup.plan_setup(setup.SetupRequest(action="update", components=("skill",), mode="copy")) + + with pytest.raises(OSError, match="failed to commit setup transaction"): + setup.apply_setup(plan) + + _assert_original_destinations(destinations) + assert not list(tmp_path.rglob("*.idac-stage-*")) + assert not list(tmp_path.rglob("*.idac-backup-*")) + + +@pytest.mark.parametrize("interrupt_after", ["backup", "staged_commit"]) +def test_setup_update_rolls_back_when_interrupted_after_replace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + interrupt_after: str, +) -> None: + destination = tmp_path / "skills" / "idac" + _seed_original_destinations([destination]) + original_replace = os.replace + interrupted = False + + def interrupt_after_replace(source: str | Path, target: str | Path) -> None: + nonlocal interrupted + should_interrupt = not interrupted and ( + (interrupt_after == "backup" and ".idac-backup-" in Path(target).name) + or (interrupt_after == "staged_commit" and ".idac-stage-" in Path(source).name) + ) + original_replace(source, target) + if should_interrupt: + interrupted = True + raise KeyboardInterrupt + + monkeypatch.setattr(setup.os, "replace", interrupt_after_replace) + plan = setup.plan_setup( + setup.SetupRequest( + action="update", + components=("skill",), + mode="copy", + skill_destination=destination, + ) + ) + + with pytest.raises(KeyboardInterrupt): + setup.apply_setup(plan) + + assert interrupted is True + _assert_original_destinations([destination]) + assert not list(tmp_path.rglob("*.idac-stage-*")) + assert not list(tmp_path.rglob("*.idac-backup-*")) + + +def test_setup_interrupt_reports_rollback_failures(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + destination = tmp_path / "skills" / "idac" + _seed_original_destinations([destination]) + plan = setup.plan_setup( + setup.SetupRequest( + action="update", + components=("skill",), + mode="copy", + skill_destination=destination, + ) + ) + original_replace = os.replace + original_remove_path = setup._remove_path + + def interrupt_after_staged_commit(source: str | Path, target: str | Path) -> None: + original_replace(source, target) + if ".idac-stage-" in Path(source).name: + raise KeyboardInterrupt + + def fail_rollback(path: Path) -> None: + if path == destination: + raise PermissionError("rollback destination is locked") + original_remove_path(path) + + monkeypatch.setattr(setup.os, "replace", interrupt_after_staged_commit) + monkeypatch.setattr(setup, "_remove_path", fail_rollback) + + with pytest.raises(KeyboardInterrupt) as exc_info: + setup.apply_setup(plan) + + diagnostics = "\n".join(getattr(exc_info.value, "__notes__", ())) + str(exc_info.value) + assert "setup rollback errors" in diagnostics + assert "rollback destination is locked" in diagnostics + + +def test_setup_cleans_partial_staging_when_copy_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLAUDE_HOME", str(tmp_path / ".claude")) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex")) + destinations = _skill_destinations(tmp_path) + _seed_original_destinations(destinations) + + original_copytree = setup.shutil.copytree + staged_copies = 0 + + def fail_second_staged_copy(source: str | Path, destination: str | Path, *args, **kwargs): + nonlocal staged_copies + staged_copies += 1 + if staged_copies == 2: + partial = Path(destination) + partial.mkdir() + (partial / "partial.txt").write_text("partial", encoding="utf-8") + raise OSError("injected staging failure") + return original_copytree(source, destination, *args, **kwargs) + + monkeypatch.setattr(setup.shutil, "copytree", fail_second_staged_copy) + plan = setup.plan_setup(setup.SetupRequest(action="update", components=("skill",), mode="copy")) + + with pytest.raises(OSError, match="injected staging failure"): + setup.apply_setup(plan) + + _assert_original_destinations(destinations) + assert not list(tmp_path.rglob("*.idac-stage-*")) + + +@pytest.mark.parametrize( + ("plugin_directory", "skill_destination"), + [ + (Path("integrations/plugins"), Path("integrations/plugins/idac_bridge/skill")), + (Path("integrations"), Path("integrations/idac/skill")), + ], +) +def test_setup_rejects_nested_custom_destinations_before_staging( + tmp_path: Path, + plugin_directory: Path, + skill_destination: Path, +) -> None: + integration_root = tmp_path / "integrations" + request = setup.SetupRequest( + action="update", + components=("plugin", "skill"), + mode="copy", + plugin_directory=tmp_path / plugin_directory, + skill_destination=tmp_path / skill_destination, + ) + + with pytest.raises(setup.SetupValidationError, match="setup destinations must not be equal or nested"): + setup.plan_setup(request) + + assert not integration_root.exists() + + +def test_setup_rejects_destination_aliases_that_resolve_to_the_same_path(tmp_path: Path) -> None: + real_parent = tmp_path / "real" + real_parent.mkdir() + alias_parent = tmp_path / "alias" + alias_parent.symlink_to(real_parent, target_is_directory=True) + request = setup.SetupRequest( + action="update", + components=("plugin", "skill"), + plugin_directory=real_parent, + skill_destination=alias_parent / "idac", + ) + + with pytest.raises(setup.SetupValidationError, match="setup destinations must not be equal or nested"): + setup.plan_setup(request) + + +def test_setup_rejects_case_only_aliases_before_destinations_exist( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(setup, "_filesystem_is_case_insensitive", lambda _path: True) + integration_root = tmp_path / "integrations" + request = setup.SetupRequest( + action="install", + components=("plugin", "skill"), + plugin_directory=integration_root, + skill_destination=integration_root / "IDAC", + ) + + with pytest.raises(setup.SetupValidationError, match="setup destinations must not be equal or nested"): + setup.plan_setup(request) + + assert not integration_root.exists() + + +def test_setup_rejects_case_insensitive_destination_source_alias( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "skills" / "idac" + source.mkdir(parents=True) + (source / "SKILL.md").write_text("skill", encoding="utf-8") + monkeypatch.setattr(setup, "skill_source_dir", lambda: source) + original_samefile = os.path.samefile + + def case_insensitive_samefile(first: str | Path, second: str | Path) -> bool: + if os.fspath(first).casefold() == os.fspath(second).casefold(): + return True + return original_samefile(first, second) + + monkeypatch.setattr(setup.os.path, "samefile", case_insensitive_samefile) + request = setup.SetupRequest( + action="update", + components=("skill",), + skill_destination=source.with_name("IDAC"), + ) + + with pytest.raises(setup.SetupValidationError, match="must not overlap bundled source"): + setup.plan_setup(request) + + +@pytest.mark.parametrize("destination_kind", ["source_parent", "inside_source"]) +def test_setup_rejects_destination_source_overlap_in_both_directions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + destination_kind: str, +) -> None: + source_parent = tmp_path / "bundled-skills" + source = source_parent / "idac" + (source / "agents").mkdir(parents=True) + (source / "SKILL.md").write_text("skill", encoding="utf-8") + (source / "agents" / "openai.yaml").write_text("agent", encoding="utf-8") + (source_parent / "OTHER.md").write_text("keep", encoding="utf-8") + monkeypatch.setattr(setup, "skill_source_dir", lambda: source) + destination = source_parent if destination_kind == "source_parent" else source / "installed" + request = setup.SetupRequest( + action="update", + components=("skill",), + mode="copy", + skill_destination=destination, + ) + + with pytest.raises(setup.SetupValidationError, match="must not overlap bundled source"): + setup.plan_setup(request) + + assert source.exists() + assert (source_parent / "OTHER.md").read_text(encoding="utf-8") == "keep" + + +def test_setup_plan_marks_alternate_symlink_for_replacement(tmp_path: Path) -> None: + old_source = tmp_path / "old-skill" + old_source.mkdir() + destination = tmp_path / "skills" / "idac" + destination.parent.mkdir() + destination.symlink_to(old_source, target_is_directory=True) + request = setup.SetupRequest( + action="update", + components=("skill",), + skill_destination=destination, + ) + + plan = setup.plan_setup(request) + + assert plan.targets[0].status == "updated" + assert plan.targets[0].mode == "symlink" + assert plan.requires_confirmation is True + + +def test_setup_copy_omits_python_cache_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "source-skill" + (source / "agents").mkdir(parents=True) + (source / "__pycache__").mkdir() + (source / "SKILL.md").write_text("skill", encoding="utf-8") + (source / "agents" / "openai.yaml").write_text("agent", encoding="utf-8") + (source / "__pycache__" / "cached.pyc").write_bytes(b"cache") + (source / "loose.pyc").write_bytes(b"cache") + (source / "keep.py").write_text("keep", encoding="utf-8") + monkeypatch.setattr(setup, "skill_source_dir", lambda: source) + destination = tmp_path / "installed-skill" + request = setup.SetupRequest( + action="install", + components=("skill",), + mode="copy", + skill_destination=destination, + ) + + result = setup.apply_setup(setup.plan_setup(request)) + + assert result["phase"] == "applied" + assert (destination / "keep.py").exists() + assert not (destination / "__pycache__").exists() + assert not (destination / "loose.pyc").exists() + + +def test_setup_reports_backup_cleanup_failure_after_successful_commit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + destination = tmp_path / "skills" / "idac" + (destination / "agents").mkdir(parents=True) + (destination / "SKILL.md").write_text("old", encoding="utf-8") + (destination / "agents" / "openai.yaml").write_text("old", encoding="utf-8") + plan = setup.plan_setup( + setup.SetupRequest( + action="update", + components=("skill",), + mode="copy", + skill_destination=destination, + ) + ) + original_remove_path = setup._remove_path + + def fail_backup_cleanup(path: Path) -> None: + if ".idac-backup-" in path.name: + raise PermissionError("backup is locked") + original_remove_path(path) + + monkeypatch.setattr(setup, "_remove_path", fail_backup_cleanup) + + result = setup.apply_setup(plan) + + assert result["phase"] == "applied" + assert (destination / "SKILL.md").read_text(encoding="utf-8").startswith("---") + cleanup_warnings = result["cleanup_warnings"] + assert isinstance(cleanup_warnings, list) + assert len(cleanup_warnings) == 1 + assert "backup is locked" in str(cleanup_warnings[0]) + assert list(destination.parent.glob(".*.idac-backup-*"))