Skip to content

Commit 8f7b99d

Browse files
rysweetUbuntuCopilotgithub-actions[bot]
authored
fix: stage rust binaries in .claude/bin (#3027)
* fix: stage rust binaries in claude bin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * [skip ci] chore: Auto-bump patch version --------- Co-authored-by: Ubuntu <azureuser@devy.yb0a3bvkdghunmsjr4s3fnfhra.phxx.internal.cloudapp.net> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 22279b5 commit 8f7b99d

8 files changed

Lines changed: 173 additions & 36 deletions

File tree

.claude/bin/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

build_hooks.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
This module provides custom build hooks that copy directories from the repository
44
root into src/amplihack/ before building the wheel. This ensures the framework
5-
files, plugin manifest, and bundle files are included in the wheel distribution
6-
for UVX deployment.
5+
files, plugin manifest, bundle files, and staged Rust binaries are included in
6+
the wheel distribution for UVX deployment.
77
88
Why this is needed:
99
- MANIFEST.in only controls sdist, not wheels
@@ -21,7 +21,9 @@
2121
so missing setuptools import at runtime is expected and not an error.
2222
"""
2323

24+
import os
2425
import shutil
26+
import stat
2527
from pathlib import Path
2628

2729
from setuptools import build_meta as _orig
@@ -53,6 +55,7 @@ def __init__(self):
5355
self.skills_dest = pkg_root / "skills"
5456
self.agents_src = self.claude_src / "agents"
5557
self.agents_dest = pkg_root / "agents"
58+
self.rust_binaries = ("amplihack", "amplihack-hooks")
5659

5760
def _get_ignore_patterns(self):
5861
"""Return common ignore patterns for directory copying."""
@@ -113,6 +116,52 @@ def _copy_claude_directory(self):
113116
"""Copy .claude/ from repo root to src/amplihack/ if needed."""
114117
self._copy_plugin_directory(self.claude_src, self.claude_dest, ".claude/")
115118

119+
def _find_rust_binary(self, binary_name):
120+
"""Locate a Rust binary to stage into the wheel, if one is available."""
121+
env_dir = os.environ.get("AMPLIHACK_RS_BIN_DIR")
122+
candidates = []
123+
if env_dir:
124+
candidates.append(Path(env_dir) / binary_name)
125+
126+
sibling_target = self.repo_root.parent / "amplihack-rs" / "target"
127+
candidates.extend([
128+
sibling_target / "release" / binary_name,
129+
sibling_target / "debug" / binary_name,
130+
Path.home() / ".cargo" / "bin" / binary_name,
131+
])
132+
133+
on_path = shutil.which(binary_name)
134+
if on_path:
135+
candidates.append(Path(on_path))
136+
137+
for candidate in candidates:
138+
expanded = candidate.expanduser()
139+
if expanded.is_file() and os.access(expanded, os.X_OK):
140+
return expanded
141+
return None
142+
143+
def _copy_rust_binaries(self):
144+
"""Stage Rust CLI binaries into amplihack/.claude/bin when available."""
145+
bin_dest = self.claude_dest / "bin"
146+
bin_dest.mkdir(parents=True, exist_ok=True)
147+
148+
staged = 0
149+
for binary_name in self.rust_binaries:
150+
source = self._find_rust_binary(binary_name)
151+
if source is None:
152+
print(f"Info: {binary_name} not found for wheel staging")
153+
continue
154+
155+
target = bin_dest / binary_name
156+
print(f"Copying Rust binary {source} -> {target}")
157+
shutil.copy2(source, target)
158+
current_mode = target.stat().st_mode
159+
target.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
160+
staged += 1
161+
162+
if staged == 0:
163+
print("Info: No Rust binaries staged; wheel will use Python-only packaging for binaries")
164+
116165
def _copy_plugin_manifest(self):
117166
"""Copy .claude-plugin/ from repo root to src/amplihack/ for wheel inclusion."""
118167
self._copy_plugin_directory(self.plugin_src, self.plugin_dest, ".claude-plugin/")
@@ -256,6 +305,7 @@ def build_wheel(self, wheel_directory, config_settings=None, metadata_directory=
256305
"""Build wheel with .claude/, .claude-plugin/, .github/, amplifier-bundle/, AMPLIHACK.md, and CLAUDE.md included."""
257306
try:
258307
self._copy_claude_directory()
308+
self._copy_rust_binaries()
259309
self._copy_plugin_manifest()
260310
self._copy_github_directory()
261311
self._copy_bundle_directory()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ backend-path = ["."]
55

66
[project]
77
name = "amplihack"
8-
version = "0.6.20"
8+
version = "0.6.21"
99
description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows"
1010
requires-python = ">=3.11"
1111
dependencies = [

src/amplihack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565

6666
# Essential directories that must be copied during installation
6767
ESSENTIAL_DIRS = [
68+
"bin", # Staged Rust binaries (amplihack, amplihack-hooks)
6869
"agents/amplihack", # Specialized agents
6970
"commands/amplihack", # Slash commands
7071
"tools/amplihack", # Hooks and utilities
@@ -214,4 +215,3 @@ def main():
214215
# Main
215216
"main",
216217
]
217-

src/amplihack/settings.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -142,26 +142,22 @@ def find_rust_hook_binary():
142142
143143
Search order:
144144
1. PATH (via shutil.which)
145-
2. ~/.amplihack/bin/amplihack-hooks
146-
3. ~/.cargo/bin/amplihack-hooks
145+
2. ~/.amplihack/.claude/bin/amplihack-hooks
146+
3. ~/.amplihack/bin/amplihack-hooks (legacy)
147+
4. ~/.cargo/bin/amplihack-hooks
147148
148149
Returns:
149150
Absolute path to the binary, or None if not found.
150151
"""
151-
# 1. Check PATH
152-
on_path = shutil.which("amplihack-hooks")
153-
if on_path:
154-
return os.path.abspath(on_path)
155-
156-
# 2. Check ~/.amplihack/bin/
157-
amplihack_bin = os.path.expanduser("~/.amplihack/bin/amplihack-hooks")
158-
if os.path.isfile(amplihack_bin) and os.access(amplihack_bin, os.X_OK):
159-
return os.path.abspath(amplihack_bin)
160-
161-
# 3. Check ~/.cargo/bin/
162-
cargo_bin = os.path.expanduser("~/.cargo/bin/amplihack-hooks")
163-
if os.path.isfile(cargo_bin) and os.access(cargo_bin, os.X_OK):
164-
return os.path.abspath(cargo_bin)
152+
candidates = [
153+
shutil.which("amplihack-hooks"),
154+
os.path.expanduser("~/.amplihack/.claude/bin/amplihack-hooks"),
155+
os.path.expanduser("~/.amplihack/bin/amplihack-hooks"),
156+
os.path.expanduser("~/.cargo/bin/amplihack-hooks"),
157+
]
158+
for candidate in candidates:
159+
if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
160+
return os.path.abspath(candidate)
165161

166162
return None
167163

tests/integration/test_cli_unified_staging.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,25 @@ def test_copytree_manifest_overwrites_existing_files(self, tmp_path):
466466
result = (dst_context / "PHILOSOPHY.md").read_text()
467467
assert result == "# Updated Philosophy", f"File not overwritten. Got: {result}"
468468

469+
def test_copytree_manifest_copies_bin_directory(self, tmp_path):
470+
"""Rust binaries under .claude/bin should be staged into ~/.amplihack/.claude/bin."""
471+
from src.amplihack.install import copytree_manifest
472+
473+
src_dir = tmp_path / "src"
474+
bin_dir = src_dir / ".claude" / "bin"
475+
bin_dir.mkdir(parents=True)
476+
binary = bin_dir / "amplihack-hooks"
477+
binary.write_text("#!/bin/sh\necho hooks\n")
478+
binary.chmod(0o755)
479+
480+
dst_dir = tmp_path / "dst"
481+
copied = copytree_manifest(str(src_dir), str(dst_dir), ".claude")
482+
483+
staged_binary = dst_dir / "bin" / "amplihack-hooks"
484+
assert "bin" in copied
485+
assert staged_binary.exists()
486+
assert os.access(staged_binary, os.X_OK)
487+
469488

470489
class TestStagingE2EBehavior:
471490
"""End-to-end tests for staging behavior with real subprocess calls (optional)."""
@@ -485,4 +504,6 @@ def test_real_staging_from_uvx_creates_files(self):
485504
assert (staging_dir / "agents").exists(), "agents/ directory missing after staging"
486505
assert (staging_dir / "skills").exists(), "skills/ directory missing after staging"
487506
assert (staging_dir / "tools").exists(), "tools/ directory missing after staging"
488-
assert (staging_dir / "hooks").exists(), "hooks/ directory missing after staging"
507+
assert (staging_dir / "tools" / "amplihack" / "hooks").exists(), (
508+
"tools/amplihack/hooks directory missing after staging"
509+
)

tests/test_rust_hook_engine.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for Rust hook engine registration (AMPLIHACK_HOOK_ENGINE=rust).
22
33
Covers:
4-
- find_rust_hook_binary(): PATH lookup, ~/.amplihack/bin, ~/.cargo/bin
4+
- find_rust_hook_binary(): PATH lookup, ~/.amplihack/.claude/bin, legacy ~/.amplihack/bin, ~/.cargo/bin
55
- get_hook_engine(): env var parsing
66
- update_hook_paths() with hook_engine="rust": uses Rust binary, errors when missing
77
- stage_hooks() with AMPLIHACK_HOOK_ENGINE=rust: generates Rust wrapper scripts
@@ -81,17 +81,31 @@ def test_finds_on_path(self, tmp_path):
8181
assert result is not None
8282
assert result.endswith("amplihack-hooks")
8383

84-
def test_finds_in_amplihack_bin(self, tmp_path):
84+
def test_finds_in_staged_claude_bin(self, tmp_path):
85+
bin_dir = tmp_path / ".amplihack" / ".claude" / "bin"
86+
bin_dir.mkdir(parents=True)
87+
binary = bin_dir / "amplihack-hooks"
88+
binary.write_text("#!/bin/sh\necho test")
89+
binary.chmod(0o755)
90+
91+
with patch("shutil.which", return_value=None):
92+
with patch("os.path.expanduser", side_effect=lambda p: str(tmp_path / p.lstrip("~/"))):
93+
result = find_rust_hook_binary()
94+
assert result is not None
95+
assert ".claude/bin" in result
96+
97+
def test_finds_in_legacy_amplihack_bin(self, tmp_path):
8598
bin_dir = tmp_path / ".amplihack" / "bin"
8699
bin_dir.mkdir(parents=True)
87100
binary = bin_dir / "amplihack-hooks"
88101
binary.write_text("#!/bin/sh\necho test")
89102
binary.chmod(0o755)
90103

91104
with patch("shutil.which", return_value=None):
92-
with patch("os.path.expanduser", side_effect=lambda p: str(tmp_path / p.lstrip("~/"))) :
105+
with patch("os.path.expanduser", side_effect=lambda p: str(tmp_path / p.lstrip("~/"))):
93106
result = find_rust_hook_binary()
94107
assert result is not None
108+
assert "/.amplihack/bin/" in result
95109

96110
def test_finds_in_cargo_bin(self, tmp_path):
97111
cargo_dir = tmp_path / ".cargo" / "bin"
@@ -105,13 +119,19 @@ def test_finds_in_cargo_bin(self, tmp_path):
105119
result = find_rust_hook_binary()
106120
assert result is not None
107121

108-
def test_amplihack_bin_takes_priority_over_cargo_bin(self, tmp_path):
109-
"""~/.amplihack/bin should win over ~/.cargo/bin."""
110-
amplihack_dir = tmp_path / ".amplihack" / "bin"
111-
amplihack_dir.mkdir(parents=True)
112-
amplihack_binary = amplihack_dir / "amplihack-hooks"
113-
amplihack_binary.write_text("#!/bin/sh\necho amplihack")
114-
amplihack_binary.chmod(0o755)
122+
def test_staged_bin_takes_priority_over_legacy_and_cargo_bin(self, tmp_path):
123+
"""~/.amplihack/.claude/bin should win over legacy ~/.amplihack/bin and ~/.cargo/bin."""
124+
staged_dir = tmp_path / ".amplihack" / ".claude" / "bin"
125+
staged_dir.mkdir(parents=True)
126+
staged_binary = staged_dir / "amplihack-hooks"
127+
staged_binary.write_text("#!/bin/sh\necho staged")
128+
staged_binary.chmod(0o755)
129+
130+
legacy_dir = tmp_path / ".amplihack" / "bin"
131+
legacy_dir.mkdir(parents=True)
132+
legacy_binary = legacy_dir / "amplihack-hooks"
133+
legacy_binary.write_text("#!/bin/sh\necho legacy")
134+
legacy_binary.chmod(0o755)
115135

116136
cargo_dir = tmp_path / ".cargo" / "bin"
117137
cargo_dir.mkdir(parents=True)
@@ -123,12 +143,12 @@ def test_amplihack_bin_takes_priority_over_cargo_bin(self, tmp_path):
123143
with patch("os.path.expanduser", side_effect=lambda p: str(tmp_path / p.lstrip("~/"))):
124144
result = find_rust_hook_binary()
125145
assert result is not None
126-
assert ".amplihack" in result
146+
assert ".amplihack/.claude/bin" in result
127147
assert ".cargo" not in result
128148

129149
def test_non_executable_file_skipped(self, tmp_path):
130-
"""A non-executable file at ~/.amplihack/bin/amplihack-hooks should be skipped."""
131-
bin_dir = tmp_path / ".amplihack" / "bin"
150+
"""A non-executable file at ~/.amplihack/.claude/bin/amplihack-hooks should be skipped."""
151+
bin_dir = tmp_path / ".amplihack" / ".claude" / "bin"
132152
bin_dir.mkdir(parents=True)
133153
binary = bin_dir / "amplihack-hooks"
134154
binary.write_text("#!/bin/sh\necho test")

tests/test_wheel_packaging.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
ensuring it's available in UVX deployments.
66
"""
77

8+
import os
89
import subprocess
10+
import sys
911
import tempfile
1012
import zipfile
1113
from pathlib import Path
@@ -18,13 +20,15 @@ def test_wheel_includes_claude_directory():
1820
# Build wheel in temporary directory
1921
with tempfile.TemporaryDirectory() as tmpdir:
2022
wheel_dir = Path(tmpdir)
23+
repo_root = Path(__file__).parent.parent
2124

2225
# Build wheel using pyproject-build
2326
result = subprocess.run(
24-
["python", "-m", "build", "--wheel", "--outdir", str(wheel_dir)],
27+
[sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", str(wheel_dir)],
2528
capture_output=True,
2629
text=True,
2730
timeout=120,
31+
cwd=repo_root,
2832
)
2933

3034
# Check build succeeded
@@ -49,6 +53,7 @@ def test_wheel_includes_claude_directory():
4953
# Verify key files are present
5054
required_files = [
5155
"amplihack/.claude/.version",
56+
"amplihack/.claude/bin/.gitkeep",
5257
"amplihack/.claude/settings.json",
5358
"amplihack/.claude/__init__.py",
5459
]
@@ -64,7 +69,7 @@ def test_wheel_includes_claude_directory():
6469
"amplihack/.claude/commands/",
6570
"amplihack/.claude/context/",
6671
"amplihack/.claude/skills/",
67-
"amplihack/.claude/workflows/",
72+
"amplihack/.claude/workflow/",
6873
]
6974

7075
for required_dir in required_dirs:
@@ -80,6 +85,43 @@ def test_wheel_includes_claude_directory():
8085
)
8186

8287

88+
def test_wheel_includes_rust_binaries_when_staged(tmp_path):
89+
"""Wheel build should include staged Rust binaries when AMPLIHACK_RS_BIN_DIR is set."""
90+
rust_bin_dir = tmp_path / "rust-bin"
91+
rust_bin_dir.mkdir()
92+
for binary_name in ("amplihack", "amplihack-hooks"):
93+
binary = rust_bin_dir / binary_name
94+
binary.write_text("#!/bin/sh\necho staged\n")
95+
binary.chmod(0o755)
96+
97+
wheel_dir = tmp_path / "wheel"
98+
wheel_dir.mkdir()
99+
repo_root = Path(__file__).parent.parent
100+
101+
env = os.environ.copy()
102+
env["AMPLIHACK_RS_BIN_DIR"] = str(rust_bin_dir)
103+
104+
result = subprocess.run(
105+
[sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", str(wheel_dir)],
106+
capture_output=True,
107+
text=True,
108+
timeout=120,
109+
env=env,
110+
cwd=repo_root,
111+
)
112+
113+
if result.returncode != 0:
114+
pytest.fail(f"Wheel build failed:\nstdout: {result.stdout}\nstderr: {result.stderr}")
115+
116+
wheels = list(wheel_dir.glob("*.whl"))
117+
assert len(wheels) == 1, f"Expected 1 wheel, found {len(wheels)}"
118+
119+
with zipfile.ZipFile(wheels[0], "r") as zf:
120+
file_list = zf.namelist()
121+
assert "amplihack/.claude/bin/amplihack" in file_list
122+
assert "amplihack/.claude/bin/amplihack-hooks" in file_list
123+
124+
83125
def test_build_hooks_cleanup():
84126
"""Test that build_hooks.py cleans up .claude/ from src/amplihack/ after build."""
85127
# Path to .claude/ inside package (should not exist after build)
@@ -106,5 +148,12 @@ def test_claude_directory_exists_at_repo_root():
106148
assert subdir_path.exists(), f"Required subdirectory {subdir} not found"
107149

108150

151+
def test_essential_dirs_include_bin():
152+
"""UVX staging must include .claude/bin for Rust binary deployment."""
153+
from amplihack import ESSENTIAL_DIRS
154+
155+
assert "bin" in ESSENTIAL_DIRS
156+
157+
109158
if __name__ == "__main__":
110159
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)