Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -168,7 +169,8 @@ Use `idac <command> --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

Expand Down Expand Up @@ -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 <dir>` 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 <dir>` to scaffold a workspace containing `prompts/recovery-pass.md`.

## Development

Expand Down
14 changes: 0 additions & 14 deletions src/idac/cli2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
2 changes: 2 additions & 0 deletions src/idac/cli2/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
python_exec,
search,
segment,
setup,
targets,
top_level,
type_commands,
Expand All @@ -28,6 +29,7 @@
"python_exec",
"search",
"segment",
"setup",
"targets",
"top_level",
"type_commands",
Expand Down
101 changes: 2 additions & 99 deletions src/idac/cli2/commands/misc.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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")
Expand All @@ -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
)
159 changes: 159 additions & 0 deletions src/idac/cli2/commands/setup.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading