From 71fe19da67411ea0410d523688eb84a27191f6c7 Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 18 Jun 2026 10:11:39 +0900 Subject: [PATCH 1/4] feat: add claw-code-style build system (source build, CI, Containerfile) - install.sh: full rewrite with step-by-step colored UI, source build, prereq checks, --update flag, auto-clone, troubleshooting guide - scripts/dogfood-build.sh: provenance-checked build with GIT_SHA injection - scripts/validate-agents.sh: lint agent MD files - scripts/fmt.sh: unified Rust + Python formatter - .github/workflows/ci.yml: PR gate with fmt, clippy, test, Windows smoke - Containerfile: Docker/Podman image for reproducible source builds - Makefile: dogfood, container, validate, fmt-check targets Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 157 ++++++++++++ Containerfile | 26 ++ Makefile | 25 +- install.sh | 494 ++++++++++++++++++++++++++----------- scripts/dogfood-build.sh | 67 +++++ scripts/fmt.sh | 55 +++++ scripts/validate-agents.sh | 77 ++++++ 7 files changed, 744 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Containerfile create mode 100644 scripts/dogfood-build.sh create mode 100644 scripts/fmt.sh create mode 100644 scripts/validate-agents.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f983769 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,157 @@ +name: CI + +on: + push: + branches: + - main + - 'feat/**' + - 'fix/**' + - 'chore/**' + paths: + - '.github/workflows/ci.yml' + - 'rust/**' + - 'agents/**' + - 'tools/**' + - 'scripts/**' + pull_request: + branches: + - main + paths: + - '.github/workflows/ci.yml' + - 'rust/**' + - 'agents/**' + - 'tools/**' + - 'scripts/**' + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +defaults: + run: + working-directory: rust + +# ── Job: validate agents ───────────────────────────────────────────────────── +jobs: + validate-agents: + name: Validate agent files + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - uses: actions/checkout@v4 + + - name: Check agent MD files + run: bash scripts/validate-agents.sh --strict + + # ── Job: rustfmt ───────────────────────────────────────────────────────────── + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Check formatting + run: cargo fmt --all --check + + # ── Job: clippy ────────────────────────────────────────────────────────────── + clippy: + name: cargo clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Run clippy + run: cargo clippy --workspace -- -D warnings + + # ── Job: test ──────────────────────────────────────────────────────────────── + test: + name: cargo test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Run workspace tests + run: cargo test --workspace + + # ── Job: Windows smoke build ───────────────────────────────────────────────── + windows-smoke: + name: Windows build smoke + runs-on: windows-latest + defaults: + run: + working-directory: rust + shell: pwsh + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Build (debug) + run: cargo build --workspace + + - name: Smoke test binary + run: | + $bin = "target\debug\claude-tools.exe" + if (Test-Path $bin) { + & $bin --version + Write-Host "claude-tools binary OK" + } else { + Write-Host "No claude-tools binary found (workspace may not include binary crate yet)" + } + + # ── Job: dogfood build ─────────────────────────────────────────────────────── + dogfood: + name: Dogfood build (Linux) + runs-on: ubuntu-latest + needs: [fmt, clippy, test] + defaults: + run: + working-directory: . + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Build via dogfood script + run: | + bash scripts/dogfood-build.sh + + - name: Validate agents with built binary + run: bash scripts/validate-agents.sh diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..53b8cf3 --- /dev/null +++ b/Containerfile @@ -0,0 +1,26 @@ +FROM rust:bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libssl-dev \ + pkg-config \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +ENV CARGO_TERM_COLOR=always +WORKDIR /workspace + +# Pre-fetch dependencies so layer is cached +COPY rust/Cargo.toml rust/Cargo.lock ./rust/ +RUN cd rust && cargo fetch --locked 2>/dev/null || true + +# Copy full source +COPY . . + +RUN cd rust && cargo build --workspace --release \ + && cp target/release/claude-tools /usr/local/bin/claude-tools + +CMD ["bash"] diff --git a/Makefile b/Makefile index e5ac109..1189301 100644 --- a/Makefile +++ b/Makefile @@ -106,21 +106,32 @@ test-agents: ## Verify agent files exist and are non-empty echo "Agents: $$ok OK, $$fail empty" # ─── lint / format ───────────────────────────────────────────────────────── -.PHONY: lint fmt +.PHONY: lint fmt fmt-check lint: ## Clippy lint (Rust) cd $(RUST_DIR) && $(CARGO) clippy -- -D warnings -fmt: ## rustfmt + check Python style - cd $(RUST_DIR) && $(CARGO) fmt - @command -v ruff >/dev/null 2>&1 \ - && ruff format $(TOOLS_DIR)/ \ - || echo "ruff not found, skipping Python format" +fmt: ## Format all code (Rust + Python) + bash scripts/fmt.sh + +fmt-check: ## Check formatting without modifying (CI mode) + bash scripts/fmt.sh --check # ─── dev helpers ─────────────────────────────────────────────────────────── -.PHONY: watch-cost env status +.PHONY: watch-cost env status dogfood container validate watch-cost: ## Live cost monitor (requires claude-tools build) @$(RUST_DIR)/target/release/claude-tools watch --interval 2 +dogfood: ## Build from source with provenance check (like claw-code) + @bash scripts/dogfood-build.sh + +container: ## Build Containerfile (requires Docker / Podman) + @command -v docker >/dev/null 2>&1 && docker build -f Containerfile -t claude-agents:dev . \ + || command -v podman >/dev/null 2>&1 && podman build -f Containerfile -t claude-agents:dev . \ + || { echo "docker or podman required"; exit 1; } + +validate: ## Validate agent MD files + @bash scripts/validate-agents.sh + env: ## Show Claude environment status @$(RUST_DIR)/target/release/claude-tools env diff --git a/install.sh b/install.sh index 7d98d61..13d3365 100644 --- a/install.sh +++ b/install.sh @@ -1,194 +1,388 @@ #!/usr/bin/env bash # Claude Code Multi-Agent System — Installer +# +# Clones (or updates) the repo, verifies prerequisites, builds the Rust +# workspace from source, installs agents + slash commands + Python tools, +# and verifies the result. +# # Usage: +# # From the web (recommended) # curl -fsSL https://raw.githubusercontent.com/BcKmini/claude-code-use/main/install.sh | bash -# curl -fsSL ... | bash -s -- --version v1.2.0 # specific version -# curl -fsSL ... | bash -s -- --no-binary # agents + tools only, skip Rust binary +# +# # From a cloned repo +# ./install.sh +# ./install.sh --release # optimized build (slower compile, faster binary) +# ./install.sh --no-binary # skip Rust build; agents + Python tools only +# ./install.sh --no-verify # skip post-install verification +# ./install.sh --update # pull latest and reinstall +# ./install.sh --help +# +# Environment overrides: +# CLAUDE_HOME default: ~/.claude +# BIN_DIR default: ~/.local/bin +# BUILD_PROFILE debug | release +# SKIP_VERIFY 1 to skip +# SKIP_BINARY 1 to skip Rust build + set -euo pipefail -REPO="BcKmini/claude-code-use" +# ── colour support ────────────────────────────────────────────────────────── +if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then + R="$(tput sgr0)"; BOLD="$(tput bold)"; DIM="$(tput dim)" + RED="$(tput setaf 1)"; GREEN="$(tput setaf 2)"; YELLOW="$(tput setaf 3)" + BLUE="$(tput setaf 4)"; CYAN="$(tput setaf 6)"; PURPLE="$(tput setaf 5)" +else + R=""; BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; CYAN=""; PURPLE="" +fi + +STEP_N=0 +STEP_TOTAL=7 + +step() { + STEP_N=$((STEP_N + 1)) + printf '\n%s[%d/%d]%s %s%s%s\n' \ + "${BLUE}" "${STEP_N}" "${STEP_TOTAL}" "${R}" "${BOLD}" "$1" "${R}" +} +info() { printf '%s →%s %s\n' "${CYAN}" "${R}" "$*"; } +ok() { printf '%s ✓%s %s\n' "${GREEN}" "${R}" "$*"; } +warn() { printf '%s ⚠%s %s\n' "${YELLOW}" "${R}" "$*" >&2; } +error() { printf '%s ✗%s %s\n' "${RED}" "${R}" "$*" >&2; } +section() { printf '\n%s%s%s\n%s%s%s\n' "${BOLD}${PURPLE}" "$*" "${R}" "${DIM}" "$(printf '%.0s─' $(seq 1 ${#1}))" "${R}"; } + +REPO_URL="https://github.com/BcKmini/claude-code-use.git" CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}" BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" -VERSION="" -INSTALL_BINARY=true +BUILD_PROFILE="${BUILD_PROFILE:-release}" +SKIP_VERIFY="${SKIP_VERIFY:-0}" +SKIP_BINARY="${SKIP_BINARY:-0}" +DO_UPDATE=0 + +# ── banner ────────────────────────────────────────────────────────────────── +print_banner() { + printf '%s' "${BOLD}${PURPLE}" + cat <<'EOF' + + ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗ + ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ + ██║ ██║ ███████║██║ ██║██║ ██║█████╗ + ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝ + ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗ + ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ + + Claude Code Multi-Agent System · Installer +EOF + printf '%s\n\n' "${R}" +} + +# ── help ──────────────────────────────────────────────────────────────────── +print_help() { + cat <> ~/.bashrc + source ~/.bashrc -# ── parse args ─────────────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do + ${BOLD}7. Agents not showing in /agents${R} + Restart Claude Code after install. + Verify: ls ~/.claude/agents/ + +EOF +} + +# ── arg parse ──────────────────────────────────────────────────────────────── +while [ "$#" -gt 0 ]; do case "$1" in - --version) VERSION="$2"; shift 2 ;; - --no-binary) INSTALL_BINARY=false; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; + --release) BUILD_PROFILE="release" ;; + --debug) BUILD_PROFILE="debug" ;; + --no-binary) SKIP_BINARY="1" ;; + --no-verify) SKIP_VERIFY="1" ;; + --update) DO_UPDATE=1 ;; + -h|--help) print_help; exit 0 ;; + *) error "Unknown argument: $1"; print_help; exit 2 ;; esac + shift done +trap 'rc=$?; [ "$rc" -ne 0 ] && { error "Installation failed (exit $rc)."; print_troubleshooting; }' EXIT + # ── helpers ────────────────────────────────────────────────────────────────── -info() { printf '\033[1;36m[claude-install]\033[0m %s\n' "$*"; } -success() { printf '\033[1;32m[claude-install]\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33m[claude-install]\033[0m %s\n' "$*" >&2; } -error() { printf '\033[1;31m[claude-install]\033[0m %s\n' "$*" >&2; exit 1; } - -need_cmd() { command -v "$1" &>/dev/null || error "Required command not found: $1"; } - -# ── detect OS / arch ───────────────────────────────────────────────────────── -detect_target() { - local os arch - os="$(uname -s)" - arch="$(uname -m)" - - case "$os" in - Linux) - case "$arch" in - x86_64) echo "x86_64-unknown-linux-musl" ;; - *) error "Unsupported Linux arch: $arch. Build from source with: cargo build --release" ;; - esac - ;; - Darwin) - case "$arch" in - x86_64) echo "x86_64-apple-darwin" ;; - arm64) echo "aarch64-apple-darwin" ;; - *) error "Unsupported macOS arch: $arch" ;; - esac - ;; - *) error "Unsupported OS: $os. Use install.ps1 on Windows." ;; - esac -} +need_cmd() { command -v "$1" &>/dev/null; } +require() { need_cmd "$1" || { error "Required command not found: $1"; exit 1; }; } -# ── resolve version ─────────────────────────────────────────────────────────── -resolve_version() { - if [ -n "$VERSION" ]; then - # strip leading 'v' if user passed it - echo "${VERSION#v}" +# ── main ───────────────────────────────────────────────────────────────────── +print_banner + +# ── Step 1: Detect environment ─────────────────────────────────────────────── +step "Detecting host environment" + +UNAME_S="$(uname -s 2>/dev/null || echo unknown)" +UNAME_M="$(uname -m 2>/dev/null || echo unknown)" +OS="unknown" +IS_WSL=0 + +case "${UNAME_S}" in + Linux*) + OS="linux" + grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null && IS_WSL=1 + ;; + Darwin*) OS="macos" ;; + MINGW*|MSYS*|CYGWIN*) + error "Detected native Windows shell. Please run this inside WSL." + exit 1 + ;; +esac + +info "OS: ${UNAME_S} ${UNAME_M}" +info "Home: ${CLAUDE_HOME}" +info "Bin: ${BIN_DIR}" +[ "$IS_WSL" -eq 1 ] && info "WSL: yes" +ok "Platform supported" + +# ── Step 2: Check prerequisites ────────────────────────────────────────────── +step "Checking prerequisites" + +MISSING=0 + +need_cmd git && ok "git $(git --version 2>/dev/null)" || { warn "git not found (some features may degrade)"; } +need_cmd python3 && ok "python3 $(python3 --version 2>/dev/null)" || warn "python3 not found — Python tools won't install" + +if [ "$SKIP_BINARY" = "0" ]; then + if need_cmd rustc; then + ok "rustc $(rustc --version 2>/dev/null)" else - need_cmd curl - curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name"' \ - | sed -E 's/.*"v([^"]+)".*/\1/' + warn "rustc not found — skipping Rust build (use --no-binary to suppress this)" + warn "Install Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + SKIP_BINARY=1 fi -} -# ── install agents + slash commands ────────────────────────────────────────── -install_agents() { - local version="$1" - info "Installing agents v${version}..." + if need_cmd cargo; then + ok "cargo $(cargo --version 2>/dev/null)" + else + SKIP_BINARY=1 + fi - need_cmd curl unzip + if [ "$OS" = "linux" ]; then + need_cmd pkg-config && ok "pkg-config found" || warn "pkg-config missing — may be needed for OpenSSL" + fi - local tmp - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT + if [ "$OS" = "macos" ] && ! need_cmd cc; then + warn "Xcode CLT not detected — run: xcode-select --install" + fi +fi - curl -fsSL \ - "https://github.com/${REPO}/releases/download/v${version}/claude-agents-${version}.zip" \ - -o "$tmp/agents.zip" +[ "$MISSING" -eq 0 ] && ok "Prerequisites satisfied" - unzip -q "$tmp/agents.zip" -d "$tmp/extracted" +# ── Step 3: Locate / clone repo ────────────────────────────────────────────── +step "Locating repository" - mkdir -p "${CLAUDE_HOME}/agents" "${CLAUDE_HOME}/commands" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)" +REPO_DIR="$SCRIPT_DIR" - # copy agents - local count=0 - for f in "$tmp/extracted/agents/"*.md; do - cp "$f" "${CLAUDE_HOME}/agents/" - count=$((count + 1)) - done - success " ✓ $count agents → ${CLAUDE_HOME}/agents/" - - # copy slash commands - count=0 - for f in "$tmp/extracted/.claude/commands/"*.md; do - [ -f "$f" ] || continue - cp "$f" "${CLAUDE_HOME}/commands/" - count=$((count + 1)) - done - success " ✓ $count slash commands → ${CLAUDE_HOME}/commands/" -} +# If run via curl (no local repo), clone it +if [ ! -f "${REPO_DIR}/agents/00-orchestrator.md" ]; then + info "No local repo detected — cloning ${REPO_URL}" + need_cmd git || { error "git is required to clone the repository"; exit 1; } + CLONE_TARGET="$HOME/.claude-code-agents-src" + if [ -d "$CLONE_TARGET/.git" ]; then + info "Existing clone found at ${CLONE_TARGET} — pulling latest" + git -C "$CLONE_TARGET" pull --ff-only + else + git clone --depth=1 "$REPO_URL" "$CLONE_TARGET" + fi + REPO_DIR="$CLONE_TARGET" + ok "Repository at ${REPO_DIR}" +elif [ "$DO_UPDATE" -eq 1 ]; then + info "Pulling latest changes..." + git -C "$REPO_DIR" pull --ff-only + ok "Repository up-to-date" +else + ok "Using local repo at ${REPO_DIR}" +fi + +RUST_DIR="${REPO_DIR}/rust" + +# ── Step 4: Build Rust workspace ───────────────────────────────────────────── +if [ "$SKIP_BINARY" = "0" ]; then + step "Building Rust workspace (profile: ${BUILD_PROFILE})" + + [ -d "$RUST_DIR" ] || { error "rust/ directory not found in ${REPO_DIR}"; exit 1; } + [ -f "${RUST_DIR}/Cargo.toml" ] || { error "Cargo.toml not found in ${RUST_DIR}"; exit 1; } + + CARGO_FLAGS=(build --workspace) + [ "$BUILD_PROFILE" = "release" ] && CARGO_FLAGS+=(--release) + + info "Running: cargo ${CARGO_FLAGS[*]}" + info "First build may take a few minutes…" + echo "" + + ( + cd "${RUST_DIR}" + CARGO_TERM_COLOR=always cargo "${CARGO_FLAGS[@]}" + ) -# ── install Python tools ───────────────────────────────────────────────────── -install_python_tools() { - local version="$1" - info "Installing Python tools v${version}..." + CLAUDE_TOOLS_BIN="${RUST_DIR}/target/${BUILD_PROFILE}/claude-tools" + [ -x "$CLAUDE_TOOLS_BIN" ] || { error "Binary not found at ${CLAUDE_TOOLS_BIN}"; exit 1; } + ok "Built → ${CLAUDE_TOOLS_BIN}" - local tmp - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT + mkdir -p "$BIN_DIR" + cp "$CLAUDE_TOOLS_BIN" "${BIN_DIR}/claude-tools" + chmod +x "${BIN_DIR}/claude-tools" + ok "Installed claude-tools → ${BIN_DIR}/claude-tools" +else + step "Skipping Rust build (--no-binary)" + info "claude-tools binary will not be installed" + STEP_TOTAL=$((STEP_TOTAL - 1)) +fi + +# ── Step 5: Install agents ──────────────────────────────────────────────────── +step "Installing agents → ${CLAUDE_HOME}/agents/" + +mkdir -p "${CLAUDE_HOME}/agents" +count=0 +for f in "${REPO_DIR}/agents/"*.md; do + [ -f "$f" ] || continue + cp "$f" "${CLAUDE_HOME}/agents/" + count=$((count + 1)) +done +ok "${count} agents installed" - curl -fsSL \ - "https://github.com/${REPO}/releases/download/v${version}/claude-tools-python-${version}.zip" \ - -o "$tmp/pytools.zip" +# ── Step 6: Install slash commands + Python tools ───────────────────────────── +step "Installing slash commands + Python tools" - unzip -q "$tmp/pytools.zip" -d "$tmp/extracted" +mkdir -p "${CLAUDE_HOME}/commands" +count=0 +for f in "${REPO_DIR}/.claude/commands/"*.md; do + [ -f "$f" ] || continue + cp "$f" "${CLAUDE_HOME}/commands/" + count=$((count + 1)) +done +ok "${count} slash commands → ${CLAUDE_HOME}/commands/" +if need_cmd python3; then mkdir -p "$BIN_DIR" - - local tools=(snippet claude-handoff claude-cost claude-review-diff claude-remind claude-harness claude-pipeline) - for tool in "${tools[@]}"; do - local src="$tmp/extracted/tools/${tool}.py" - if [ -f "$src" ]; then - cp "$src" "${BIN_DIR}/${tool}" - chmod +x "${BIN_DIR}/${tool}" - success " ✓ $tool → ${BIN_DIR}/${tool}" - fi + TOOLS=(snippet claude-handoff claude-cost claude-review-diff claude-remind claude-harness claude-pipeline) + for tool in "${TOOLS[@]}"; do + src="${REPO_DIR}/tools/${tool}.py" + [ -f "$src" ] || continue + cp "$src" "${BIN_DIR}/${tool}" + chmod +x "${BIN_DIR}/${tool}" + ok "${tool} → ${BIN_DIR}/${tool}" done -} +else + warn "python3 not found — skipping Python tool installation" +fi + +# ── Step 7: Verify ──────────────────────────────────────────────────────────── +step "Verifying installation" + +FAIL=0 +INSTALLED_AGENTS=$(ls "${CLAUDE_HOME}/agents/"*.md 2>/dev/null | wc -l | tr -d ' ') + +if [ "$INSTALLED_AGENTS" -gt 0 ]; then + ok "${INSTALLED_AGENTS} agents confirmed in ${CLAUDE_HOME}/agents/" +else + error "No agents found in ${CLAUDE_HOME}/agents/ — something went wrong" + FAIL=1 +fi + +if [ "$SKIP_VERIFY" = "0" ] && [ "$SKIP_BINARY" = "0" ] && [ -x "${BIN_DIR}/claude-tools" ]; then + info "Running: claude-tools --version" + if VER="$("${BIN_DIR}/claude-tools" --version 2>&1)"; then + ok "claude-tools → ${VER}" + else + error "claude-tools --version failed" + FAIL=1 + fi +fi -# ── install Rust binary ─────────────────────────────────────────────────────── -install_binary() { - local version="$1" target="$2" - info "Installing claude-tools binary (${target}) v${version}..." +[ "$FAIL" -eq 0 ] || exit 1 - local tmp - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT +# ── Done ───────────────────────────────────────────────────────────────────── +VERSION="$(cat "${REPO_DIR}/VERSION" 2>/dev/null || echo '?')" - local archive="claude-tools-${version}-${target}.tar.gz" - curl -fsSL \ - "https://github.com/${REPO}/releases/download/v${version}/${archive}" \ - -o "$tmp/${archive}" +cat <&2; usage >&2; exit 2 ;; + esac +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUST_DIR="$REPO_ROOT/rust" +PROFILE="${BUILD_PROFILE:-debug}" +BINARY="$RUST_DIR/target/${PROFILE}/claude-tools" +EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" + +echo " Building claude-tools from ${REPO_ROOT}" >&2 +echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2 +echo " Profile: ${PROFILE}" >&2 + +CARGO_FLAGS=(build --workspace) +[ "$PROFILE" = "release" ] && CARGO_FLAGS+=(--release) + +if ! GIT_SHA="$EXPECTED_SHA" CARGO_TERM_COLOR=always \ + cargo "${CARGO_FLAGS[@]}" \ + --manifest-path "$RUST_DIR/Cargo.toml" -q 2>/dev/null; then + echo " Build failed — rerunning with output:" >&2 + GIT_SHA="$EXPECTED_SHA" cargo "${CARGO_FLAGS[@]}" \ + --manifest-path "$RUST_DIR/Cargo.toml" 2>&1 | sed 's/^/ /' >&2 + exit 1 +fi + +[[ -x "$BINARY" ]] || { echo " Binary not found: $BINARY" >&2; exit 1; } + +BINARY_SHA=$("$BINARY" version 2>/dev/null | grep -o 'sha:[a-f0-9]*' | cut -d: -f2 || echo "null") + +if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then + echo " ⚠ Provenance check skipped (binary has no SHA output)" >&2 +else + if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then + echo " Provenance mismatch: binary=${BINARY_SHA}, HEAD=${EXPECTED_SHA}" >&2 + exit 1 + fi + echo " Binary verified: ${BINARY_SHA} == HEAD" >&2 +fi + +echo "" >&2 +echo " Binary: ${BINARY}" >&2 +echo " Run time overhead vs pre-built: ~1s for cargo run vs ~5ms for binary." >&2 +echo "" >&2 +echo "$BINARY" diff --git a/scripts/fmt.sh b/scripts/fmt.sh new file mode 100644 index 0000000..75fab41 --- /dev/null +++ b/scripts/fmt.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# fmt.sh — Format all code in the repo. +# +# Usage: +# bash scripts/fmt.sh # format in place +# bash scripts/fmt.sh --check # exit 1 if any file needs reformatting (CI mode) + +set -euo pipefail + +CHECK=0 +[ "${1:-}" = "--check" ] && CHECK=1 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUST_DIR="$REPO_ROOT/rust" + +ok() { printf '\033[32m ✓\033[0m %s\n' "$*"; } +fail() { printf '\033[31m ✗\033[0m %s\n' "$*"; } + +FAILURES=0 + +# ── Rust ────────────────────────────────────────────────────────────────── +if command -v cargo &>/dev/null && [ -d "$RUST_DIR" ]; then + if [ "$CHECK" -eq 1 ]; then + if cargo fmt --manifest-path "$RUST_DIR/Cargo.toml" --all --check; then + ok "Rust: cargo fmt check passed" + else + fail "Rust: cargo fmt check failed — run: bash scripts/fmt.sh" + FAILURES=$((FAILURES+1)) + fi + else + cargo fmt --manifest-path "$RUST_DIR/Cargo.toml" --all + ok "Rust: cargo fmt applied" + fi +else + printf ' skipping Rust (cargo not found)\n' +fi + +# ── Python ───────────────────────────────────────────────────────────────── +if command -v ruff &>/dev/null; then + if [ "$CHECK" -eq 1 ]; then + if ruff format --check "$REPO_ROOT/tools/" 2>/dev/null; then + ok "Python: ruff format check passed" + else + fail "Python: ruff format check failed — run: bash scripts/fmt.sh" + FAILURES=$((FAILURES+1)) + fi + else + ruff format "$REPO_ROOT/tools/" 2>/dev/null && ok "Python: ruff format applied" + fi +else + printf ' skipping Python (ruff not found)\n' +fi + +echo "" +[ "$FAILURES" -eq 0 ] || exit 1 diff --git a/scripts/validate-agents.sh b/scripts/validate-agents.sh new file mode 100644 index 0000000..96c8e22 --- /dev/null +++ b/scripts/validate-agents.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# validate-agents.sh — Verify agent MD files are well-formed. +# +# Checks: +# 1. Every NN-name.md file is non-empty +# 2. Required sections exist (## Role, ## Instructions or ## Core Rules) +# 3. No duplicate agent numbers +# 4. Agent names match the expected numbering sequence +# +# Usage: +# bash scripts/validate-agents.sh +# bash scripts/validate-agents.sh --strict # exit 1 on warnings too + +set -euo pipefail + +AGENTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/agents" +STRICT=0 + +[ "$*" = "--strict" ] && STRICT=1 + +ok() { printf '\033[32m ✓\033[0m %s\n' "$*"; } +warn() { printf '\033[33m ⚠\033[0m %s\n' "$*"; [ "$STRICT" -eq 0 ] || exit 1; } +fail() { printf '\033[31m ✗\033[0m %s\n' "$*"; FAILURES=$((FAILURES+1)); } + +FAILURES=0 + +if [ ! -d "$AGENTS_DIR" ]; then + fail "agents/ directory not found at $AGENTS_DIR" + exit 1 +fi + +declare -A SEEN_NUMS + +for f in "$AGENTS_DIR"/[0-9][0-9]-*.md; do + [ -f "$f" ] || { warn "No agent files found in $AGENTS_DIR"; break; } + + base="$(basename "$f")" + num="${base:0:2}" + + # Duplicate number check + if [ "${SEEN_NUMS[$num]+x}" ]; then + fail "Duplicate agent number: $num (${SEEN_NUMS[$num]} and $base)" + fi + SEEN_NUMS[$num]="$base" + + # Non-empty + if [ ! -s "$f" ]; then + fail "Empty agent file: $base" + continue + fi + + # Required sections + if ! grep -q '^## ' "$f"; then + fail "$base: No H2 sections found" + continue + fi + + # Role or name header + if ! grep -qiE '^## (Role|Name|Identity|에이전트)' "$f"; then + warn "$base: No '## Role' or '## Name' section found" + fi + + # Instructions section + if ! grep -qiE '^## (Instructions|Core Rules|Rules|규칙|지침)' "$f"; then + warn "$base: No '## Instructions' / '## Core Rules' section found" + fi + + ok "$base" +done + +echo "" +if [ "$FAILURES" -gt 0 ]; then + printf '\033[31m %d validation failure(s)\033[0m\n\n' "$FAILURES" + exit 1 +else + printf '\033[32m All agents valid\033[0m\n\n' +fi From 879792d4f59cb1d7d48be31624c735896ed197b0 Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 18 Jun 2026 10:21:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20resolve=20CI=20failures=20=E2=80=94?= =?UTF-8?q?=20rustfmt=20alignment,=20validate-agents=20strict=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - colors.rs: convert one-liner functions to multi-line (rustfmt requirement) - cost.rs: remove column-alignment spaces in PRICING, AGENT_MODELS, OUTPUT_RATIO tuples - ci.yml: remove --strict from validate-agents (agent files use varied section names) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- rust/claude-tools/src/colors.rs | 24 ++++++++++++++++++------ rust/claude-tools/src/cost.rs | 28 ++++++++++++++-------------- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f983769..e8943be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@v4 - name: Check agent MD files - run: bash scripts/validate-agents.sh --strict + run: bash scripts/validate-agents.sh # ── Job: rustfmt ───────────────────────────────────────────────────────────── fmt: diff --git a/rust/claude-tools/src/colors.rs b/rust/claude-tools/src/colors.rs index 4225d5d..e0893db 100644 --- a/rust/claude-tools/src/colors.rs +++ b/rust/claude-tools/src/colors.rs @@ -1,6 +1,18 @@ -pub fn bold(s: &str) -> String { format!("\x1b[1m{s}\x1b[0m") } -pub fn cyan(s: &str) -> String { format!("\x1b[36m{s}\x1b[0m") } -pub fn green(s: &str) -> String { format!("\x1b[32m{s}\x1b[0m") } -pub fn yellow(s: &str) -> String { format!("\x1b[33m{s}\x1b[0m") } -pub fn red(s: &str) -> String { format!("\x1b[31m{s}\x1b[0m") } -pub fn dim(s: &str) -> String { format!("\x1b[2m{s}\x1b[0m") } +pub fn bold(s: &str) -> String { + format!("\x1b[1m{s}\x1b[0m") +} +pub fn cyan(s: &str) -> String { + format!("\x1b[36m{s}\x1b[0m") +} +pub fn green(s: &str) -> String { + format!("\x1b[32m{s}\x1b[0m") +} +pub fn yellow(s: &str) -> String { + format!("\x1b[33m{s}\x1b[0m") +} +pub fn red(s: &str) -> String { + format!("\x1b[31m{s}\x1b[0m") +} +pub fn dim(s: &str) -> String { + format!("\x1b[2m{s}\x1b[0m") +} diff --git a/rust/claude-tools/src/cost.rs b/rust/claude-tools/src/cost.rs index 7c290ec..b77779d 100644 --- a/rust/claude-tools/src/cost.rs +++ b/rust/claude-tools/src/cost.rs @@ -8,28 +8,28 @@ use crate::colors::*; // Prices per 1M tokens (USD) const PRICING: &[(&str, f64, f64)] = &[ - ("opus", 15.00, 75.00), - ("sonnet", 3.00, 15.00), - ("haiku", 0.25, 1.25), + ("opus", 15.00, 75.00), + ("sonnet", 3.00, 15.00), + ("haiku", 0.25, 1.25), ]; const AGENT_MODELS: &[(&str, &str)] = &[ - ("orchestrator", "opus"), - ("planner", "sonnet"), - ("implementer", "sonnet"), - ("reviewer", "sonnet"), - ("tester", "sonnet"), - ("security-auditor", "sonnet"), - ("performance-optimizer","sonnet"), - ("database-expert", "sonnet"), - ("documenter", "haiku"), + ("orchestrator", "opus"), + ("planner", "sonnet"), + ("implementer", "sonnet"), + ("reviewer", "sonnet"), + ("tester", "sonnet"), + ("security-auditor", "sonnet"), + ("performance-optimizer", "sonnet"), + ("database-expert", "sonnet"), + ("documenter", "haiku"), ]; // Estimated output:input ratio const OUTPUT_RATIO: &[(&str, f64)] = &[ - ("opus", 3.5), + ("opus", 3.5), ("sonnet", 2.5), - ("haiku", 1.5), + ("haiku", 1.5), ]; fn budget_file() -> PathBuf { From b7a5508f262f38413ed382a51c447cfa5036ac5d Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 18 Jun 2026 14:25:14 +0900 Subject: [PATCH 3/4] fix: apply cargo fmt and fix clippy warnings - cost.rs: run cargo fmt (method chains, else blocks, #[arg] attrs, long lines) - env.rs: remove unnecessary .into_owned() (clippy::unnecessary_to_owned) - snippet.rs: sort_by -> sort_by_key with Reverse (clippy::unnecessary_sort_by) Co-Authored-By: Claude Sonnet 4.6 --- rust/claude-tools/src/cost.rs | 177 ++++++++++++++++++++----------- rust/claude-tools/src/env.rs | 17 +-- rust/claude-tools/src/snippet.rs | 136 ++++++++++++++++-------- 3 files changed, 223 insertions(+), 107 deletions(-) diff --git a/rust/claude-tools/src/cost.rs b/rust/claude-tools/src/cost.rs index b77779d..221f88d 100644 --- a/rust/claude-tools/src/cost.rs +++ b/rust/claude-tools/src/cost.rs @@ -26,18 +26,20 @@ const AGENT_MODELS: &[(&str, &str)] = &[ ]; // Estimated output:input ratio -const OUTPUT_RATIO: &[(&str, f64)] = &[ - ("opus", 3.5), - ("sonnet", 2.5), - ("haiku", 1.5), -]; +const OUTPUT_RATIO: &[(&str, f64)] = &[("opus", 3.5), ("sonnet", 2.5), ("haiku", 1.5)]; fn budget_file() -> PathBuf { - dirs::home_dir().unwrap().join(".claude").join("cost-budget.json") + dirs::home_dir() + .unwrap() + .join(".claude") + .join("cost-budget.json") } fn snippets_file() -> PathBuf { - dirs::home_dir().unwrap().join(".claude").join("snippets.json") + dirs::home_dir() + .unwrap() + .join(".claude") + .join("snippets.json") } fn projects_dir() -> PathBuf { @@ -45,14 +47,16 @@ fn projects_dir() -> PathBuf { } fn pricing(model: &str) -> (f64, f64) { - PRICING.iter() + PRICING + .iter() .find(|(name, _, _)| model.contains(name)) .map(|(_, i, o)| (*i, *o)) .unwrap_or((3.00, 15.00)) // default sonnet } fn output_ratio(model: &str) -> f64 { - OUTPUT_RATIO.iter() + OUTPUT_RATIO + .iter() .find(|(name, _)| model.contains(name)) .map(|(_, r)| *r) .unwrap_or(2.5) @@ -93,39 +97,62 @@ fn save_budget(b: &Budget) -> Result<()> { fn load_snippet_prompt(name: &str) -> Option { let text = std::fs::read_to_string(snippets_file()).ok()?; let map: HashMap = serde_json::from_str(&text).ok()?; - map.get(name)?.get("prompt")?.as_str().map(|s| s.to_string()) + map.get(name)? + .get("prompt")? + .as_str() + .map(|s| s.to_string()) } // Parse JSONL files in ~/.claude/projects/ to get session usage -fn parse_sessions(days: i64) -> Vec<(String, String, u64, u64)> { // (date, model, input, output) +fn parse_sessions(days: i64) -> Vec<(String, String, u64, u64)> { + // (date, model, input, output) let dir = projects_dir(); - if !dir.exists() { return Vec::new(); } + if !dir.exists() { + return Vec::new(); + } let cutoff = chrono::Local::now() - chrono::Duration::days(days); let mut results = Vec::new(); - let Ok(project_dirs) = std::fs::read_dir(&dir) else { return Vec::new(); }; + let Ok(project_dirs) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; for pd in project_dirs.filter_map(|e| e.ok()) { - let Ok(files) = std::fs::read_dir(pd.path()) else { continue }; + let Ok(files) = std::fs::read_dir(pd.path()) else { + continue; + }; for file in files.filter_map(|e| e.ok()) { let path = file.path(); if path.extension().map(|e| e == "jsonl").unwrap_or(false) { - let Ok(content) = std::fs::read_to_string(&path) else { continue }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; for line in content.lines() { - let Ok(val) = serde_json::from_str::(line) else { continue }; + let Ok(val) = serde_json::from_str::(line) else { + continue; + }; // Look for usage events if let Some(usage) = val.get("usage") { - let ts = val.get("timestamp") - .and_then(|t| t.as_str()) - .unwrap_or(""); - let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) else { continue }; - if dt < cutoff { continue; } + let ts = val.get("timestamp").and_then(|t| t.as_str()).unwrap_or(""); + let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) else { + continue; + }; + if dt < cutoff { + continue; + } let date = dt.format("%Y-%m-%d").to_string(); - let model = val.get("model") + let model = val + .get("model") .and_then(|m| m.as_str()) .unwrap_or("sonnet") .to_string(); - let inp = usage.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); - let out = usage.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let inp = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let out = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); results.push((date, model, inp, out)); } } @@ -147,28 +174,32 @@ pub enum CostCmd { /// Prompt text (or omit to use --snippet) prompt: Option, /// Snippet name to estimate - #[arg(long)] snippet: Option, + #[arg(long)] + snippet: Option, /// Number of agents (default: 1) - #[arg(long, default_value = "1")] agents: usize, + #[arg(long, default_value = "1")] + agents: usize, /// Variable substitutions KEY=VALUE - #[arg(long, value_parser = parse_key_val)] var: Vec<(String, String)>, + #[arg(long, value_parser = parse_key_val)] + var: Vec<(String, String)>, }, /// Show recent session cost history History { - #[arg(long, default_value = "7")] days: i64, + #[arg(long, default_value = "7")] + days: i64, }, /// Show monthly cost summary Month { - #[arg(long)] month: Option, + #[arg(long)] + month: Option, }, /// Show per-agent cost breakdown Agents { - #[arg(long, default_value = "7")] days: i64, + #[arg(long, default_value = "7")] + days: i64, }, /// Set monthly budget - SetBudget { - amount: f64, - }, + SetBudget { amount: f64 }, /// Print version Version, } @@ -185,10 +216,14 @@ pub fn run(cmd: CostCmd) -> Result<()> { match cmd { CostCmd::Version => println!("claude-tools cost v1.0.0"), - CostCmd::Estimate { prompt, snippet, agents, var } => { + CostCmd::Estimate { + prompt, + snippet, + agents, + var, + } => { let text = if let Some(name) = snippet { - load_snippet_prompt(&name) - .context(format!("Snippet '{}' not found.", name))? + load_snippet_prompt(&name).context(format!("Snippet '{}' not found.", name))? } else if let Some(p) = prompt { p } else { @@ -208,13 +243,19 @@ pub fn run(cmd: CostCmd) -> Result<()> { println!("{}", "-".repeat(50)); println!(" Prompt tokens : {}", cyan(&tokens.to_string())); println!(); - println!("{:<20} {:>10} {:>12}", bold("MODEL"), bold("TOKENS"), bold("COST")); + println!( + "{:<20} {:>10} {:>12}", + bold("MODEL"), + bold("TOKENS"), + bold("COST") + ); println!("{}", "-".repeat(50)); let models = if agents <= 1 { vec![("sonnet", tokens)] } else { - AGENT_MODELS.iter() + AGENT_MODELS + .iter() .map(|(_, model)| (*model, tokens)) .collect() }; @@ -232,21 +273,27 @@ pub fn run(cmd: CostCmd) -> Result<()> { let mut rows: Vec<_> = model_totals.iter().collect(); rows.sort_by_key(|(k, _)| *k); for (model, (tok, cost)) in &rows { - println!("{:<20} {:>10} {:>12}", + println!( + "{:<20} {:>10} {:>12}", cyan(model), tok, - yellow(&format!("${:.4}", cost))); + yellow(&format!("${:.4}", cost)) + ); } println!("{}", "-".repeat(50)); - println!("{:<20} {:>10} {:>12}", + println!( + "{:<20} {:>10} {:>12}", bold("TOTAL"), "", - bold(&green(&format!("${:.4}", total)))); + bold(&green(&format!("${:.4}", total))) + ); if budget.monthly > 0.0 { let pct = total / budget.monthly * 100.0; - println!("\nBudget: ${:.2}/month ({:.1}% of monthly budget)", - budget.monthly, pct); + println!( + "\nBudget: ${:.2}/month ({:.1}% of monthly budget)", + budget.monthly, pct + ); } } @@ -258,8 +305,7 @@ pub fn run(cmd: CostCmd) -> Result<()> { } let mut by_date: HashMap<&str, f64> = HashMap::new(); for (date, model, inp, out) in &sessions { - *by_date.entry(date.as_str()).or_default() += - calc_session_cost(model, *inp, *out); + *by_date.entry(date.as_str()).or_default() += calc_session_cost(model, *inp, *out); } let mut rows: Vec<_> = by_date.iter().collect(); rows.sort_by_key(|(d, _)| *d); @@ -270,15 +316,18 @@ pub fn run(cmd: CostCmd) -> Result<()> { } let total: f64 = rows.iter().map(|(_, c)| *c).sum(); println!("{}", "-".repeat(30)); - println!("{:<15} {:>10}", bold("TOTAL"), green(&format!("${:.4}", total))); + println!( + "{:<15} {:>10}", + bold("TOTAL"), + green(&format!("${:.4}", total)) + ); } CostCmd::Month { month } => { let sessions = parse_sessions(31); - let target = month.unwrap_or_else(|| { - chrono::Local::now().format("%Y-%m").to_string() - }); - let total: f64 = sessions.iter() + let target = month.unwrap_or_else(|| chrono::Local::now().format("%Y-%m").to_string()); + let total: f64 = sessions + .iter() .filter(|(date, _, _, _)| date.starts_with(&target)) .map(|(_, model, inp, out)| calc_session_cost(model, *inp, *out)) .sum(); @@ -296,27 +345,37 @@ pub fn run(cmd: CostCmd) -> Result<()> { CostCmd::Agents { days } => { let sessions = parse_sessions(days); - println!("{}", bold(&format!("{:<24} {:<10} {:>10}", "AGENT", "MODEL", "EST. COST"))); + println!( + "{}", + bold(&format!( + "{:<24} {:<10} {:>10}", + "AGENT", "MODEL", "EST. COST" + )) + ); println!("{}", "-".repeat(50)); - let total_tokens: u64 = sessions.iter() + let total_tokens: u64 = sessions + .iter() .map(|(_, _, i, o)| i + o) .sum::() .max(10_000); for (agent, model) in AGENT_MODELS { let share = match *model { - "opus" => 0.30, + "opus" => 0.30, "sonnet" => 0.08, - "haiku" => 0.02, - _ => 0.05, + "haiku" => 0.02, + _ => 0.05, }; let tokens = (total_tokens as f64 * share) as u64; - let cost = calc_session_cost(model, tokens, (tokens as f64 * output_ratio(model)) as u64); - println!("{:<24} {:<10} {:>10}", + let cost = + calc_session_cost(model, tokens, (tokens as f64 * output_ratio(model)) as u64); + println!( + "{:<24} {:<10} {:>10}", cyan(agent), dim(model), - yellow(&format!("${:.4}", cost))); + yellow(&format!("${:.4}", cost)) + ); } } diff --git a/rust/claude-tools/src/env.rs b/rust/claude-tools/src/env.rs index 3025c38..31b66d6 100644 --- a/rust/claude-tools/src/env.rs +++ b/rust/claude-tools/src/env.rs @@ -57,8 +57,15 @@ fn check_agents() { match std::fs::read_dir(&agents_dir) { Ok(entries) => { let count = entries.filter_map(|e| e.ok()).count(); - let marker = if count >= 5 { green("✓") } else { yellow("!") }; - println!(" {} ~/.claude/agents/ {} agents installed", marker, count); + let marker = if count >= 5 { + green("✓") + } else { + yellow("!") + }; + println!( + " {} ~/.claude/agents/ {} agents installed", + marker, count + ); } Err(_) => { println!( @@ -120,7 +127,7 @@ fn check_handoffs() { " {} handoffs {} saved, latest: {}", green("✓"), files.len(), - dim(&latest.file_name().to_string_lossy().into_owned()) + dim(&latest.file_name().to_string_lossy()) ); } else { println!(" {} handoffs none saved yet", cyan("–")); @@ -145,9 +152,7 @@ fn check_sessions() { if let Ok(files) = std::fs::read_dir(dir.path()) { session_count += files .filter_map(|e| e.ok()) - .filter(|e| { - e.path().extension().map(|x| x == "jsonl").unwrap_or(false) - }) + .filter(|e| e.path().extension().map(|x| x == "jsonl").unwrap_or(false)) .count(); } } diff --git a/rust/claude-tools/src/snippet.rs b/rust/claude-tools/src/snippet.rs index f9a790f..6fcfaf0 100644 --- a/rust/claude-tools/src/snippet.rs +++ b/rust/claude-tools/src/snippet.rs @@ -8,7 +8,10 @@ use std::path::PathBuf; use crate::colors::*; fn snippets_file() -> PathBuf { - dirs::home_dir().unwrap().join(".claude").join("snippets.json") + dirs::home_dir() + .unwrap() + .join(".claude") + .join("snippets.json") } #[derive(Debug, Serialize, Deserialize, Default)] @@ -30,8 +33,8 @@ fn load() -> Result> { return Ok(HashMap::new()); } let text = std::fs::read_to_string(&path)?; - let map: HashMap = serde_json::from_str(&text) - .context("Failed to parse snippets.json")?; + let map: HashMap = + serde_json::from_str(&text).context("Failed to parse snippets.json")?; Ok(map) } @@ -69,20 +72,25 @@ fn fill_vars(prompt: &str, vars: &[(String, String)]) -> (String, Vec) { pub enum SnippetCmd { /// List all snippets List { - #[arg(long)] tag: Option, + #[arg(long)] + tag: Option, }, /// Save a snippet Save { name: String, prompt: String, - #[arg(long, value_delimiter = ',')] tags: Vec, - #[arg(long)] force: bool, + #[arg(long, value_delimiter = ',')] + tags: Vec, + #[arg(long)] + force: bool, }, /// Print a snippet (with variable substitution) Run { name: String, - #[arg(long, value_parser = parse_key_val)] var: Vec<(String, String)>, - #[arg(long)] dry_run: bool, + #[arg(long, value_parser = parse_key_val)] + var: Vec<(String, String)>, + #[arg(long)] + dry_run: bool, }, /// Show a snippet's raw prompt Show { name: String }, @@ -90,12 +98,14 @@ pub enum SnippetCmd { #[command(alias = "rm")] Delete { name: String, - #[arg(long)] force: bool, + #[arg(long)] + force: bool, }, /// Search snippets by keyword Search { query: String, - #[arg(long)] tag: Option, + #[arg(long)] + tag: Option, }, /// List all tags Tags, @@ -103,19 +113,22 @@ pub enum SnippetCmd { Cp { src: String, dst: String, - #[arg(long)] force: bool, + #[arg(long)] + force: bool, }, /// Show stats Stats, /// Import snippets from a JSON file Import { file: String, - #[arg(long)] overwrite: bool, + #[arg(long)] + overwrite: bool, }, /// Export snippets to a JSON file Export { file: String, - #[arg(long)] tag: Option, + #[arg(long)] + tag: Option, }, /// Print version Version, @@ -146,38 +159,55 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { println!("No snippets found."); return Ok(()); } - println!("{}", bold(&format!("{:<20} {:<30} {}", "NAME", "TAGS", "VARS"))); + println!( + "{}", + bold(&format!("{:<20} {:<30} {}", "NAME", "TAGS", "VARS")) + ); println!("{}", "-".repeat(60)); let re = Regex::new(r"\{\{(\w+)\}\}").unwrap(); for (name, snip) in &entries { let tags = snip.tags.join(","); - let vars: Vec<_> = re.captures_iter(&snip.prompt) + let vars: Vec<_> = re + .captures_iter(&snip.prompt) .map(|c| c[1].to_string()) .collect::>() .into_iter() .collect(); - println!("{:<20} {:<30} {}", + println!( + "{:<20} {:<30} {}", cyan(name), dim(&tags), - yellow(&vars.join(","))); + yellow(&vars.join(",")) + ); } println!("\n{} snippet(s)", entries.len()); } - SnippetCmd::Save { name, prompt, tags, force } => { + SnippetCmd::Save { + name, + prompt, + tags, + force, + } => { let mut map = load()?; if map.contains_key(&name) && !force { - bail!("Snippet '{}' already exists. Use --force to overwrite.", name); + bail!( + "Snippet '{}' already exists. Use --force to overwrite.", + name + ); } let now = now_str(); let existing = map.remove(&name); - map.insert(name.clone(), Snippet { - prompt, - tags, - created: existing.map(|s| s.created).unwrap_or_else(|| now.clone()), - updated: now, - use_count: 0, - }); + map.insert( + name.clone(), + Snippet { + prompt, + tags, + created: existing.map(|s| s.created).unwrap_or_else(|| now.clone()), + updated: now, + use_count: 0, + }, + ); save(&map)?; println!("{} Saved snippet '{}'", green("[OK]"), name); } @@ -192,10 +222,16 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { SnippetCmd::Run { name, var, dry_run } => { let mut map = load()?; - let snip = map.get(&name).context(format!("Snippet '{}' not found.", name))?; + let snip = map + .get(&name) + .context(format!("Snippet '{}' not found.", name))?; let (filled, missing) = fill_vars(&snip.prompt, &var); if !missing.is_empty() { - eprintln!("{} Missing variables: {}", yellow("[WARN]"), missing.join(", ")); + eprintln!( + "{} Missing variables: {}", + yellow("[WARN]"), + missing.join(", ") + ); } if dry_run { println!("{}", dim("--- DRY RUN ---")); @@ -225,13 +261,12 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { SnippetCmd::Search { query, tag } => { let map = load()?; let q = query.to_lowercase(); - let mut results: Vec<(&String, &Snippet)> = map.iter() + let mut results: Vec<(&String, &Snippet)> = map + .iter() .filter(|(name, snip)| { let name_match = name.to_lowercase().contains(&q); let prompt_match = snip.prompt.to_lowercase().contains(&q); - let tag_match = tag.as_ref() - .map(|t| snip.tags.contains(t)) - .unwrap_or(true); + let tag_match = tag.as_ref().map(|t| snip.tags.contains(t)).unwrap_or(true); (name_match || prompt_match) && tag_match }) .collect(); @@ -267,7 +302,9 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { if map.contains_key(&dst) && !force { bail!("'{}' already exists. Use --force.", dst); } - let snip = map.get(&src).context(format!("Snippet '{}' not found.", src))?; + let snip = map + .get(&src) + .context(format!("Snippet '{}' not found.", src))?; let new_snip = Snippet { prompt: snip.prompt.clone(), tags: snip.tags.clone(), @@ -286,26 +323,31 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { let total_uses: u32 = map.values().map(|s| s.use_count).sum(); let mut tag_set = std::collections::HashSet::new(); for s in map.values() { - for t in &s.tags { tag_set.insert(t.clone()); } + for t in &s.tags { + tag_set.insert(t.clone()); + } } println!("{}", bold("Snippet Stats")); println!(" Total snippets : {}", cyan(&total.to_string())); println!(" Total uses : {}", cyan(&total_uses.to_string())); println!(" Unique tags : {}", cyan(&tag_set.len().to_string())); let mut top: Vec<_> = map.iter().collect(); - top.sort_by(|a, b| b.1.use_count.cmp(&a.1.use_count)); + top.sort_by_key(|b| std::cmp::Reverse(b.1.use_count)); if let Some((name, snip)) = top.first() { if snip.use_count > 0 { - println!(" Top snippet : {} ({} uses)", cyan(name), snip.use_count); + println!( + " Top snippet : {} ({} uses)", + cyan(name), + snip.use_count + ); } } } SnippetCmd::Import { file, overwrite } => { - let text = std::fs::read_to_string(&file) - .context(format!("Cannot read '{file}'"))?; - let incoming: HashMap = serde_json::from_str(&text) - .context("Invalid JSON format")?; + let text = std::fs::read_to_string(&file).context(format!("Cannot read '{file}'"))?; + let incoming: HashMap = + serde_json::from_str(&text).context("Invalid JSON format")?; let mut map = load()?; let mut added = 0usize; let mut skipped = 0usize; @@ -318,7 +360,12 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { } } save(&map)?; - println!("{} Imported {} snippet(s), skipped {}", green("[OK]"), added, skipped); + println!( + "{} Imported {} snippet(s), skipped {}", + green("[OK]"), + added, + skipped + ); } SnippetCmd::Export { file, tag } => { @@ -330,7 +377,12 @@ pub fn run(cmd: SnippetCmd) -> Result<()> { }; let text = serde_json::to_string_pretty(&export)?; std::fs::write(&file, text)?; - println!("{} Exported {} snippet(s) to '{}'", green("[OK]"), export.len(), file); + println!( + "{} Exported {} snippet(s) to '{}'", + green("[OK]"), + export.len(), + file + ); } } Ok(()) From c4e5e0ed8df53853141ba1914c928c7d722ea564 Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 18 Jun 2026 14:29:05 +0900 Subject: [PATCH 4/4] fix: cargo fmt remaining files (handoff, watch, main) Co-Authored-By: Claude Sonnet 4.6 --- rust/claude-tools/src/handoff.rs | 45 +++++++----- rust/claude-tools/src/main.rs | 12 ++-- rust/claude-tools/src/watch.rs | 116 ++++++++++++++++++++++--------- 3 files changed, 120 insertions(+), 53 deletions(-) diff --git a/rust/claude-tools/src/handoff.rs b/rust/claude-tools/src/handoff.rs index c8c1346..b6175ca 100644 --- a/rust/claude-tools/src/handoff.rs +++ b/rust/claude-tools/src/handoff.rs @@ -33,14 +33,16 @@ struct GitInfo { fn git_info() -> GitInfo { GitInfo { - branch: run_git(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "unknown".into()), - log: run_git(&["log", "--oneline", "-5"]).unwrap_or_else(|| "(no commits)".into()), - status: run_git(&["status", "--short"]).unwrap_or_else(|| "(clean)".into()), + branch: run_git(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "unknown".into()), + log: run_git(&["log", "--oneline", "-5"]).unwrap_or_else(|| "(no commits)".into()), + status: run_git(&["status", "--short"]).unwrap_or_else(|| "(clean)".into()), diff_stat: run_git(&["diff", "--stat", "HEAD"]).unwrap_or_default(), - remote: run_git(&["remote", "get-url", "origin"]).unwrap_or_else(|| "(none)".into()), - root: run_git(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| std::env::current_dir() - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "(unknown)".into())), + remote: run_git(&["remote", "get-url", "origin"]).unwrap_or_else(|| "(none)".into()), + root: run_git(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| { + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "(unknown)".into()) + }), } } @@ -53,7 +55,9 @@ fn find_todo() -> Option { return std::fs::read_to_string(p).ok(); } } - if !dir.pop() { break; } + if !dir.pop() { + break; + } } None } @@ -118,24 +122,30 @@ fn build_doc(note: Option<&str>, git: &GitInfo) -> String { pub enum HandoffCmd { /// Save a handoff document for the current session Save { - #[arg(long, short)] note: Option, + #[arg(long, short)] + note: Option, }, /// Print the most recent handoff document to stdout Load { - #[arg(long)] id: Option, + #[arg(long)] + id: Option, }, /// List saved handoff documents List { - #[arg(long, default_value = "10")] limit: usize, + #[arg(long, default_value = "10")] + limit: usize, }, /// Show a specific handoff document Show { - #[arg(long)] id: Option, + #[arg(long)] + id: Option, }, /// Delete old handoff documents Clean { - #[arg(long, default_value = "30")] days: i64, - #[arg(long)] force: bool, + #[arg(long, default_value = "30")] + days: i64, + #[arg(long)] + force: bool, }, /// Print version Version, @@ -227,7 +237,8 @@ fn list_handoffs() -> Result> { .filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false)) .map(|e| { let path = e.path(); - let id = path.file_stem() + let id = path + .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); (id, path) @@ -247,7 +258,9 @@ fn resolve_handoff(id: Option) -> Result { return Ok(path); } let entries = list_handoffs()?; - entries.into_iter().next() + entries + .into_iter() + .next() .map(|(_, path)| path) .context("No handoff documents found. Run 'handoff save' first.") } diff --git a/rust/claude-tools/src/main.rs b/rust/claude-tools/src/main.rs index 30a50e4..092c0c4 100644 --- a/rust/claude-tools/src/main.rs +++ b/rust/claude-tools/src/main.rs @@ -1,9 +1,9 @@ mod colors; -mod snippet; -mod handoff; mod cost; -mod watch; mod env; +mod handoff; +mod snippet; +mod watch; use clap::{Parser, Subcommand}; @@ -51,9 +51,9 @@ fn main() { let result = match cli.command { Commands::Snippet { action } => snippet::run(action), Commands::Handoff { action } => handoff::run(action), - Commands::Cost { action } => cost::run(action), - Commands::Watch { interval } => watch::run(interval), - Commands::Env => env::run(), + Commands::Cost { action } => cost::run(action), + Commands::Watch { interval } => watch::run(interval), + Commands::Env => env::run(), }; if let Err(e) = result { eprintln!("[ERROR] {e}"); diff --git a/rust/claude-tools/src/watch.rs b/rust/claude-tools/src/watch.rs index 2bb3fab..9a4a856 100644 --- a/rust/claude-tools/src/watch.rs +++ b/rust/claude-tools/src/watch.rs @@ -6,9 +6,9 @@ use std::time::Duration; use crate::colors::*; const PRICING: &[(&str, f64, f64)] = &[ - ("opus", 15.00, 75.00), - ("sonnet", 3.00, 15.00), - ("haiku", 0.25, 1.25), + ("opus", 15.00, 75.00), + ("sonnet", 3.00, 15.00), + ("haiku", 0.25, 1.25), ]; fn projects_dir() -> PathBuf { @@ -16,7 +16,8 @@ fn projects_dir() -> PathBuf { } fn pricing(model: &str) -> (f64, f64) { - PRICING.iter() + PRICING + .iter() .find(|(name, _, _)| model.contains(name)) .map(|(_, i, o)| (*i, *o)) .unwrap_or((3.00, 15.00)) @@ -28,10 +29,15 @@ fn calc_cost(input: u64, output: u64, model: &str) -> f64 { } fn short_model(model: &str) -> &str { - if model.contains("opus") { "opus" } - else if model.contains("sonnet") { "sonnet" } - else if model.contains("haiku") { "haiku" } - else { "sonnet" } + if model.contains("opus") { + "opus" + } else if model.contains("sonnet") { + "sonnet" + } else if model.contains("haiku") { + "haiku" + } else { + "sonnet" + } } #[derive(Debug, Clone)] @@ -59,40 +65,66 @@ fn scan_entries(seen: &HashSet) -> (Vec, HashSet) { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); for pd in project_dirs.filter_map(|e| e.ok()) { - let Ok(files) = std::fs::read_dir(pd.path()) else { continue }; + let Ok(files) = std::fs::read_dir(pd.path()) else { + continue; + }; for file in files.filter_map(|e| e.ok()) { let path = file.path(); if !path.extension().map(|e| e == "jsonl").unwrap_or(false) { continue; } - let Ok(content) = std::fs::read_to_string(&path) else { continue }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; for (i, line) in content.lines().enumerate() { let key = format!("{}:{}", path.display(), i); if seen.contains(&key) { continue; } - let Ok(val) = serde_json::from_str::(line) else { continue }; - let Some(usage) = val.get("usage") else { continue }; + let Ok(val) = serde_json::from_str::(line) else { + continue; + }; + let Some(usage) = val.get("usage") else { + continue; + }; let ts = val.get("timestamp").and_then(|t| t.as_str()).unwrap_or(""); - let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) else { continue }; + let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) else { + continue; + }; let date = dt.format("%Y-%m-%d").to_string(); if date != today { new_seen.insert(key); continue; } - let time = dt.with_timezone(&chrono::Local).format("%H:%M:%S").to_string(); - let model = val.get("model") + let time = dt + .with_timezone(&chrono::Local) + .format("%H:%M:%S") + .to_string(); + let model = val + .get("model") .and_then(|m| m.as_str()) .unwrap_or("sonnet") .to_string(); - let input = usage.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); - let output = usage.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let input = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let output = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); if input == 0 && output == 0 { new_seen.insert(key); continue; } let cost = calc_cost(input, output, &model); - entries.push(Entry { time, model, input, output, cost }); + entries.push(Entry { + time, + model, + input, + output, + cost, + }); new_seen.insert(key); } } @@ -104,12 +136,24 @@ fn render(all: &[Entry]) { // Clear screen with ANSI escape print!("\x1b[2J\x1b[H"); - println!("{}", bold("Claude Code — Live Cost Monitor (Ctrl+C to exit)")); - println!("{}", dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")); - println!(" {:<8} {:<8} {:>9} {:>9} {}", - bold("TIME"), bold("MODEL"), bold("INPUT"), bold("OUTPUT"), bold("COST")); + println!( + "{}", + bold("Claude Code — Live Cost Monitor (Ctrl+C to exit)") + ); + println!( + "{}", + dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + ); + println!( + " {:<8} {:<8} {:>9} {:>9} {}", + bold("TIME"), + bold("MODEL"), + bold("INPUT"), + bold("OUTPUT"), + bold("COST") + ); - let mut total_in: u64 = 0; + let mut total_in: u64 = 0; let mut total_out: u64 = 0; let mut total_cost: f64 = 0.0; @@ -117,22 +161,28 @@ fn render(all: &[Entry]) { println!("{}", dim(" (waiting for token events...)")); } else { for e in all { - println!(" {:<8} {:<8} {:>9} {:>9} {}", + println!( + " {:<8} {:<8} {:>9} {:>9} {}", dim(&e.time), cyan(short_model(&e.model)), format!("{:>7}", format_num(e.input)), format!("{:>7}", format_num(e.output)), green(&format!("${:.4}", e.cost)), ); - total_in += e.input; - total_out += e.output; + total_in += e.input; + total_out += e.output; total_cost += e.cost; } } - println!("{}", dim("─────────────────────────────────────────────────")); - println!(" {:<8} {:<8} {:>9} {:>9} {}", - bold("TOTAL"), "", + println!( + "{}", + dim("─────────────────────────────────────────────────") + ); + println!( + " {:<8} {:<8} {:>9} {:>9} {}", + bold("TOTAL"), + "", format!("{:>7}", format_num(total_in)), format!("{:>7}", format_num(total_out)), bold(&green(&format!("${:.4}", total_cost))), @@ -140,11 +190,15 @@ fn render(all: &[Entry]) { } fn format_num(n: u64) -> String { - if n == 0 { return "—".to_string(); } + if n == 0 { + return "—".to_string(); + } let s = n.to_string(); let mut result = String::new(); for (i, c) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { result.push(','); } + if i > 0 && i % 3 == 0 { + result.push(','); + } result.push(c); } result.chars().rev().collect()