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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/CLI_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ In workspace mode, adds: repo scanning, per-repo indexing, cross-repo analysis (
| `--commit-limit` | Max commits to analyze per file (default: 500). |
| `--follow-renames` | Track file renames in git history. |
| `--no-claude-md` | Don't generate `CLAUDE.md`. |
| `--distill-hook / --no-distill-hook` | Install or skip the Distill command-rewrite hook (Claude Code PreToolUse). Strictly opt-in: interactive runs prompt (default No); `--no-distill-hook` also gates this repo off in config so a globally installed hook stays inert here. See [DISTILL.md](DISTILL.md). |
| `--distill-hook / --no-distill-hook` | Install or skip the Distill command-rewrite hook (Claude Code PreToolUse). Strictly opt-in: interactive runs prompt (default No); `--no-distill-hook` also gates the repo off in config so a globally installed hook stays inert here. In workspace mode the verdict (prompt or flag) applies to every selected repo. See [DISTILL.md](DISTILL.md). |
| `--agents / --no-agents` | Generate or skip managed `AGENTS.md` for Codex. Persists the preference. |
| `--codex / --no-codex` | Generate or skip project-local Codex MCP/hooks setup. Interactive runs prompt when Codex CLI is installed and logged in; non-interactive runs require `--codex`. |
| `--yes / -y` | Skip confirmation prompts. |
Expand Down Expand Up @@ -516,12 +516,15 @@ approval by default — so the agent sees a compact, errors-first rendering.

```bash
repowise hook rewrite install # writes ~/.claude/settings.json (idempotent)
repowise hook rewrite install -w # also re-enable every workspace repo
repowise hook rewrite status
repowise hook rewrite uninstall # removes only the repowise entry
```

`install` also re-enables the current repo's `distill.commands` config if a
prior `repowise init` opt-out had gated it off. `uninstall` is global
`install` also re-enables the target's `distill.commands` config if a prior
`repowise init` opt-out had gated it off — the target repo by default, or
every workspace repo with `--workspace`/`-w` (accepts an optional `PATH` and
`--no-workspace`, like `repowise hook install`). `uninstall` is global
(settings.json) and leaves per-repo config untouched. Per-repo posture
(`permission: ask | allow`, per-family overrides) lives under
`distill.commands` in `.repowise/config.yaml` — see
Expand Down
6 changes: 4 additions & 2 deletions docs/DISTILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ The hook is deliberately conservative. It never rewrites:
Per-repo behavior is configured under `distill.commands` in
`.repowise/config.yaml` — see [Configuration](#configuration). Declining the
`repowise init` prompt writes `distill.commands.enabled: false`, so a hook
installed globally from another repo stays inert in this one. The hook answers
in well under 100 ms (stdlib-only hot path, no database).
installed globally from another repo stays inert in this one. A multi-repo
workspace `init` asks once and records the verdict in **every selected
repo**; `repowise hook rewrite install -w` re-enables them all later. The
hook answers in well under 100 ms (stdlib-only hot path, no database).

`repowise init` also adds a short "Output Distillation" section to the managed
`CLAUDE.md`, teaching the agent to prefer `repowise distill <cmd>` voluntarily
Expand Down
65 changes: 46 additions & 19 deletions packages/cli/src/repowise/cli/commands/hook_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,28 +109,55 @@ def rewrite_group() -> None:


@rewrite_group.command("install")
def rewrite_install() -> None:
"""Install the rewrite hook into ~/.claude/settings.json."""
@click.argument("path", required=False, default=None)
@click.option(
"--workspace",
"-w",
is_flag=True,
default=False,
help="Force workspace mode (re-enable distill rewrites for every repo in the workspace).",
)
@click.option(
"--no-workspace",
is_flag=True,
default=False,
help="Force single-repo mode even when invoked from a workspace.",
)
def rewrite_install(path: str | None, workspace: bool, no_workspace: bool) -> None:
"""Install the rewrite hook into ~/.claude/settings.json.

The hook itself is user-level (one install covers every repo); this
command additionally re-enables ``distill.commands.enabled`` for the
target — every workspace repo in workspace mode, the target repo
otherwise — since a prior ``repowise init`` opt-out may have gated
repos off.
"""
from repowise.cli.agent_adapters.claude_code import ClaudeCodeAdapter
from repowise.cli.helpers import save_distill_commands_enabled

path = ClaudeCodeAdapter().install_rewrite_hook()
if path:
console.print(f"Rewrite hook: [green]installed[/green] ({path})")
console.print(
" [dim]Per-repo behavior is configured under `distill.commands` "
"in .repowise/config.yaml (permission: ask | allow).[/dim]"
)
# A prior init opt-out may have gated this repo off; installing
# explicitly re-enables it here.
from pathlib import Path

from repowise.cli.helpers import save_distill_commands_enabled

cwd = Path.cwd()
if (cwd / ".repowise").is_dir():
save_distill_commands_enabled(cwd, enabled=True)
else:
target = _hook_target(path, workspace, no_workspace)

hook_path = ClaudeCodeAdapter().install_rewrite_hook()
if not hook_path:
console.print("Rewrite hook: [red]install failed[/red]")
return
console.print(f"Rewrite hook: [green]installed[/green] ({hook_path})")
console.print(
" [dim]Per-repo behavior is configured under `distill.commands` "
"in .repowise/config.yaml (permission: ask | allow).[/dim]"
)

if target.is_workspace:
assert target.ws_root is not None and target.ws_config is not None
for entry in target.ws_config.repos:
abs_path = (target.ws_root / entry.path).resolve()
if (abs_path / ".repowise").is_dir():
save_distill_commands_enabled(abs_path, enabled=True)
console.print(f" {entry.alias}: [green]enabled[/green]")
else:
assert target.repo_path is not None
if (target.repo_path / ".repowise").is_dir():
save_distill_commands_enabled(target.repo_path, enabled=True)


@rewrite_group.command("uninstall")
Expand Down
28 changes: 22 additions & 6 deletions packages/cli/src/repowise/cli/commands/init_cmd/_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,28 @@

def offer_distill_rewrite_hook(
console_obj: Any,
repo_path: Path,
repo_paths: list[Path],
flag: bool | None,
) -> None:
"""Opt-in install of the distill command-rewrite hook (Claude Code).

``flag`` is the resolved ``--distill-hook/--no-distill-hook`` value:
True installs without prompting, False skips AND gates this repo off in
True installs without prompting, False skips AND gates the repos off in
config (so a hook installed globally from another repo stays inert
here), None prompts when interactive and does nothing otherwise —
there), None prompts when interactive and does nothing otherwise —
strictly opt-in.

The hook itself is user-level (one install covers every repo), but the
verdict is recorded per repo as ``distill.commands.enabled`` in each
entry of ``repo_paths`` — one repo for the single-repo init flow, every
selected repo for the workspace flow. Recording the verdict everywhere
matters because the hook treats any repo with ``.repowise/`` and no
config as enabled (with the ``ask`` posture).
"""
import os

if not repo_paths:
return
if os.environ.get("REPOWISE_SKIP_EDITOR_SETUP", "").strip().lower() not in (
"",
"0",
Expand All @@ -46,8 +55,9 @@ def offer_distill_rewrite_hook(
"[bold]Distill:[/bold] rewrite noisy agent commands (tests, builds, "
"git, searches) to `repowise distill ...` for compact output?"
)
scope = f"Applies to all {len(repo_paths)} selected repos. " if len(repo_paths) > 1 else ""
console_obj.print(
" [dim]Each rewrite is shown for approval; raw output stays "
f" [dim]{scope}Each rewrite is shown for approval; raw output stays "
"recoverable via `repowise expand`.[/dim]"
)
flag = click.confirm(" Install the Claude Code rewrite hook?", default=False)
Expand All @@ -58,12 +68,18 @@ def offer_distill_rewrite_hook(
console_obj.print(f" [green]✓[/green] Rewrite hook installed ({path})")
else:
console_obj.print(" [yellow]Rewrite hook install failed.[/yellow]")
_save_distill_enabled(repo_path, enabled=True)
else:
console_obj.print(
" [dim]Skipped. Run 'repowise hook rewrite install' later to set up.[/dim]"
)
_save_distill_enabled(repo_path, enabled=False)

for repo_path in repo_paths:
try:
_save_distill_enabled(repo_path, enabled=bool(flag))
except Exception as exc: # init must not crash on a config write
console_obj.print(
f" [yellow]Could not record distill verdict for {repo_path.name}: {exc}[/yellow]"
)


def offer_hook_install(
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/repowise/cli/commands/init_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ def _run_generation_phase(
help=(
"Install the Claude Code command-rewrite hook that routes noisy "
"commands (tests, builds, git, searches) through `repowise distill` "
"for compact output. Default: ask when interactive; skip otherwise."
"for compact output. Default: ask when interactive; skip otherwise. "
"In workspace mode the verdict applies to every selected repo."
),
)
@click.option(
Expand Down Expand Up @@ -418,6 +419,7 @@ def init_command(
no_claude_md=no_claude_md,
agents_md=agents_md,
codex_setup=codex_setup,
distill_hook=distill_hook,
include_submodules=include_submodules,
provider_name=provider_name,
model=model,
Expand Down Expand Up @@ -850,6 +852,6 @@ async def _index_with_resume() -> Any:
# Offer to install post-commit hook (both index-only and full modes)
offer_hook_install(console, [repo_path])

# Opt-in distill command-rewrite hook for Claude Code (single-repo only;
# workspace repos can enable it later via `repowise hook rewrite install`).
offer_distill_rewrite_hook(console, repo_path, distill_hook)
# Opt-in distill command-rewrite hook for Claude Code. The workspace flow
# runs its own offer across all selected repos inside _workspace_init.
offer_distill_rewrite_hook(console, [repo_path], distill_hook)
10 changes: 9 additions & 1 deletion packages/cli/src/repowise/cli/commands/init_cmd/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
print_banner,
)

from ._interactive import offer_hook_install
from ._interactive import offer_distill_rewrite_hook, offer_hook_install
from .generation import (
CostGateDeclined,
cost_gate_declined,
Expand Down Expand Up @@ -391,6 +391,7 @@ def _workspace_init(
no_claude_md: bool,
agents_md: bool | None,
codex_setup: bool | None,
distill_hook: bool | None,
include_submodules: bool,
# Generation params (passed through from init_command)
provider_name: str | None = None,
Expand Down Expand Up @@ -616,4 +617,11 @@ def _workspace_init(
[r.path for r in indexed_repos],
aliases=[r.alias for r in indexed_repos],
)
# Opt-in distill command-rewrite hook for Claude Code: one user-level
# install, with the verdict recorded per repo. Applied to *all* selected
# repos (not just successfully indexed ones, and even when every repo
# failed) because ensure_repowise_dir already created `.repowise/` in
# each, and the hook treats any repo with `.repowise/` and no recorded
# verdict as enabled — a decline must gate every one of them off.
offer_distill_rewrite_hook(console, [r.path for r in selected], distill_hook)
console.print()
17 changes: 17 additions & 0 deletions packages/cli/src/repowise/cli/commands/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ def _on_done(result: RepoUpdateResult) -> None:
)
)

# Backfill the distill rewrite-hook verdict for members that were just
# indexed for the first time (e.g. added with --no-index) — they would
# otherwise default to enabled despite a workspace-wide decline at init.
from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict

for entry in ws_config.repos:
if repo_alias and entry.alias != repo_alias:
continue
inherit_workspace_distill_verdict((ws_root / entry.path).resolve())

# Summary
updated = sum(1 for r in results if r.updated)
errors = sum(1 for r in results if r.error)
Expand Down Expand Up @@ -977,6 +987,13 @@ def update_command(
assert repo_path is not None # single mode always sets repo_path
ensure_repowise_dir(repo_path)

# If this repo is a workspace member updated here for the first time,
# inherit the workspace's distill rewrite-hook verdict (best-effort,
# no-op outside a workspace or once a verdict exists).
from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict

inherit_workspace_distill_verdict(repo_path)

# --- Fast -> full upgrade (--full): a distinct path that reuses the
# persisted graph rather than diffing changed files. Dispatched before any
# incremental change-detection so the normal `repowise update` flow below
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/repowise/cli/commands/workspace_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,66 @@ def _resolve_docs_flag(
return False, "no provider configured"


def _inherit_distill_verdict(repo_path: Path, primary_cfg: dict) -> None:
"""Copy the primary repo's explicit distill rewrite-hook verdict.

``repowise init`` records ``distill.commands.enabled`` in every repo it
asks about; a repo added later would otherwise default to enabled (with
the ``ask`` posture) the moment ``.repowise/`` exists — even after a
workspace-wide decline. No explicit verdict on the primary → leave the
new repo's config untouched.
"""
distill = primary_cfg.get("distill")
commands = distill.get("commands") if isinstance(distill, dict) else None
enabled = commands.get("enabled") if isinstance(commands, dict) else None
if not isinstance(enabled, bool):
return
import contextlib

from repowise.cli.helpers import save_distill_commands_enabled

# Inheritance is best-effort; never fail an add over it.
with contextlib.suppress(Exception):
save_distill_commands_enabled(repo_path, enabled=enabled)


def inherit_workspace_distill_verdict(repo_path: Path) -> None:
"""Best-effort backfill of a workspace member's distill verdict.

Repos that get ``.repowise/`` outside the init flow (``workspace add
--no-index`` followed by an update, or first-time indexing via
``repowise update``) never recorded a ``distill.commands.enabled``
verdict, so a globally installed rewrite hook would treat them as
enabled. Copies the primary repo's explicit verdict when the member has
none of its own. No-op when the repo has no ``.repowise/`` yet, sits
outside a workspace, is itself the primary, already holds a verdict, or
the primary never recorded one.
"""
import contextlib

with contextlib.suppress(Exception):
if not (repo_path / ".repowise").is_dir():
return
from repowise.cli.helpers import load_config
from repowise.core.workspace.config import WorkspaceConfig

cfg = load_config(repo_path)
distill = cfg.get("distill")
commands = distill.get("commands") if isinstance(distill, dict) else None
if isinstance(commands, dict) and isinstance(commands.get("enabled"), bool):
return # repo already has its own verdict
ws_root = find_workspace_root(repo_path)
if ws_root is None:
return
primary = WorkspaceConfig.load(ws_root).get_primary()
if primary is None:
return
primary_path = (ws_root / primary.path).resolve()
if primary_path == repo_path.resolve():
return
_inherit_distill_verdict(repo_path, load_config(primary_path))


def _run_index_for_repo(
repo_path: Path,
alias: str,
Expand Down Expand Up @@ -453,6 +513,7 @@ def _run_index_for_repo(
docs_skip_reason = f"provider failure: {exc}"

ensure_repowise_dir(repo_path)
_inherit_distill_verdict(repo_path, primary_cfg)

async def _do_index() -> tuple[int, int, int]:
result = await run_pipeline(
Expand Down
Loading
Loading