Skip to content

feat(cli): add --target flag to install and uninstall#55

Open
dale-stewart wants to merge 1 commit into
nWave-ai:mainfrom
dale-stewart:feat/target-flag-on-install
Open

feat(cli): add --target flag to install and uninstall#55
dale-stewart wants to merge 1 commit into
nWave-ai:mainfrom
dale-stewart:feat/target-flag-on-install

Conversation

@dale-stewart
Copy link
Copy Markdown

Closes #40.

Summary

  • Adds --target <path> to nwave-ai install and nwave-ai uninstall, enabling installation to a non-default Claude config directory.
  • Supports both real use cases from the issue thread: alternate global (nwave-ai install --target ~/.claude-nwave → run Claude with CLAUDE_CONFIG_DIR=~/.claude-nwave claude for a side-by-side config) and per-project (nwave-ai install --target ./.claude for dogfooding a fork without disturbing ~/.claude/).
  • Pure Python — no shell scripts added, respects the existing zero-shell-scripts policy.
  • Backward compatible: omitting --target is byte-identical to the previous release.

How it works

The flag is parsed in _handle_install / _handle_uninstall (manual parsing, consistent with how --yes and --density-only are handled), normalized via Path.expanduser().resolve(), and threaded into the install subprocess by setting CLAUDE_CONFIG_DIR=<absolute-target>. The existing PathUtils.get_claude_config_dir() seam already honors that env var, so no per-callsite plumbing was needed.

--target $HOME (or any path whose realpath equals realpath $HOME) is rejected at parse time with exit 2 and zero filesystem mutation — guards against the catastrophic global-overwrite case.

Load-bearing fix: hook-command path resolution

When nwave-ai install --target <non-default> runs, the existing installer correctly lands files under <target>/lib/python/des/, but des_plugin._generate_hook_command hardcoded lib_path = "$HOME/.claude/lib/python". At runtime, Claude Code passes hook commands to a shell where $HOME is the user's real home (not the chosen target), so the portable form pointed at the wrong directory — hooks failed or silently exercised the global install.

The fix is a 3-line conditional: when context.claude_dir == Path.home() / ".claude" keep the existing portable $HOME/.claude/lib/python form (preserves cross-machine ~/.claude sync semantics). Otherwise, emit the absolute <target>/lib/python. Non-default targets are per-machine by user choice, so the loss of cross-machine portability is the intended trade.

env.PATH already honored context.claude_dir (it computes str(context.claude_dir / "bin") at line 862), so no change was needed there — that was a happy accident from prior refactoring.

Files changed

File Change
nwave_ai/cli.py New _extract_target_flag helper; --target parsing in _handle_install; new _handle_uninstall wrapper; dispatch rewired (+79 lines)
scripts/install/plugins/des_plugin.py 3-line conditional + comment in _generate_hook_command (+13 lines)
tests/installer/unit/cli/test_target_flag.py New: 10 tests covering arg consumption, env-var setting, ~ expansion, relative-path resolution, $HOME refusal (direct and via . from $HOME), uninstall symmetry
tests/plugins/plugin-architecture/unit/test_des_plugin_target_flag.py New: 4 tests for hook-command target-aware path selection
tests/des/acceptance/test_hook_path_portability.py Fixture monkeypatches Path.home() to tmp_path so the default-home assertions still hold under the test fixture

Net production: 2 files, +92 LOC. Tests: +282 LOC.

Test plan

  • pytest tests/installer/unit/cli/test_target_flag.py tests/plugins/plugin-architecture/unit/test_des_plugin_target_flag.py — 14/14 green
  • pytest tests/plugins tests/installer/unit/cli tests/des/acceptance/test_hook_path_portability.py tests/des/integration/test_hook_configuration.py tests/build/unit/shared — 275/275 green (2 skips are pre-existing container-level skips owned by tests/e2e/test_fresh_install.py)
  • ruff check + ruff format --check on all modified files — clean
  • mypy --ignore-missing-imports on modified files — clean
  • Manual dogfood: nwave-ai install --target ~/.claude-la-nwave
    • 149 skills, 32 agents (under agents/nw/), 5 DES bin shims, settings.json, templates landed in ~/.claude-la-nwave/
    • All 9 hook commands have PYTHONPATH=/home/<user>/.claude-la-nwave/lib/python (absolute, points at target)
    • env.PATH prepends /home/<user>/.claude-la-nwave/bin
    • ~/.claude/ mtime unchanged (zero global pollution)
    • nwave-ai uninstall --target ~/.claude-la-nwave --force removes all DES hooks from settings.json and the agents/ tree (residual files under bin/skills/scripts/templates/ are the known upstream issue nwave-ai uninstall leaves residual artifacts (skills, lib, DES hooks) #39, not a regression of this PR)
  • nwave-ai install --target $HOME refused with exit 2 and no filesystem mutation
  • nwave-ai install (no --target) byte-identical to today's behavior (verified by the existing test suite passing unchanged)

Open follow-ups (not in this PR)

Notes for review

The DESIGN considered (a) bash wrapper + curl distribution and (b) HOME=<target> override + post-install sed — both were rejected after maintainer feedback in the issue thread. CLAUDE_CONFIG_DIR is strictly better than HOME=<target> because it doesn't pollute the subprocess tree's view of $HOME for tools like git, gh, ssh, and pipx.

The hook-path conditional preserves the $HOME-portable form only for the default-home case, which is the one for which it was designed. Existing users who sync ~/.claude/ across machines are unaffected.

Adds `--target <path>` to `nwave-ai install` and `nwave-ai uninstall`,
allowing installation to a non-default Claude config directory. Supports
two real use cases:

  - alternate global: `nwave-ai install --target ~/.claude-nwave` then
    `CLAUDE_CONFIG_DIR=~/.claude-nwave claude` for side-by-side configs
  - per-project: `nwave-ai install --target ./.claude` for dogfooding a
    fork without polluting global ~/.claude/

The flag is threaded into the install subprocess via the existing
CLAUDE_CONFIG_DIR seam in PathUtils.get_claude_config_dir (no per-callsite
plumbing). Paths are resolved with Path.expanduser().resolve(); --target
resolving to $HOME is refused at parse time with exit 2 and zero
filesystem mutation.

Also fixes the load-bearing hook-path bug: when the install target is
non-default, des_plugin._generate_hook_command now emits an absolute
<target>/lib/python rather than the portable $HOME/.claude/lib/python
form. Claude Code passes hook commands to a shell where $HOME resolves
to the user's real home (not the chosen target), so the portable form
would point at the wrong directory at runtime. The default-target case
keeps the existing $HOME-portable form for cross-machine ~/.claude
sync.

Backward compatibility: omitting --target preserves byte-identical
behavior. env.PATH writer already honored context.claude_dir; no change
needed there.

Refs: nWave-ai#40

Co-Authored-By: nWave <nwave@nwave.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: per-project installation support

1 participant