From db853a553cd732e1f4e240d47c58215e2349b391 Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:02:18 +0900 Subject: [PATCH 1/6] test: add pytest suite + tox for tools/*.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing verified tools/*.py across Python versions before this — no pytest suite, no CI job, just a handful of ad-hoc --help/smoke commands in Makefile's test-python target. Adds: - tests/conftest.py: run_tool/home/git_repo fixtures — every test runs the real CLI as a subprocess against an isolated $HOME (and, for the two tools that shell out to git, an isolated git repo), so nothing touches the machine's real ~/.claude/ - tests/test_*.py: one file per tool, 44 tests total, functional not just --help (add/list/search/show roundtrips, error paths, exit codes) - tox.ini: py38-py313 (skip_missing_interpreters), lint (ruff check), fmt-check (ruff format --check) - pyproject.toml: scoped ruff config — E/F/I/UP only, not the full default ruleset (which includes bandit/blind-except/naive-datetime rules that fight this codebase's deliberate style), E402 ignored since every tool puts VERSION right after the docstring before imports - fmt-check / scripts/fmt.sh scoped to tools/claude-lessons.py + tests/ only, not all of tools/: the older tools use a deliberate hand-aligned style (aligned `=`, aligned dict values) that `ruff format` would flatten repo-wide — not forcing that as a side effect of adding tox - make tox: runs the full matrix (requires: pip install tox ruff) Verified locally: tox (py39/py312/py313 available here, py38/py310/py311 skip cleanly), lint, and fmt-check all pass. --- Makefile | 10 ++- pyproject.toml | 9 ++ scripts/fmt.sh | 8 +- tests/conftest.py | 84 ++++++++++++++++++ tests/test_claude_cost.py | 36 ++++++++ tests/test_claude_handoff.py | 47 ++++++++++ tests/test_claude_harness.py | 37 ++++++++ tests/test_claude_lessons.py | 143 +++++++++++++++++++++++++++++++ tests/test_claude_pipeline.py | 44 ++++++++++ tests/test_claude_remind.py | 32 +++++++ tests/test_claude_review_diff.py | 38 ++++++++ tests/test_snippet.py | 57 ++++++++++++ tox.ini | 25 ++++++ 13 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_claude_cost.py create mode 100644 tests/test_claude_handoff.py create mode 100644 tests/test_claude_harness.py create mode 100644 tests/test_claude_lessons.py create mode 100644 tests/test_claude_pipeline.py create mode 100644 tests/test_claude_remind.py create mode 100644 tests/test_claude_review_diff.py create mode 100644 tests/test_snippet.py create mode 100644 tox.ini diff --git a/Makefile b/Makefile index b182c58..7e8a8a6 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,11 @@ install-rust: build ## Build Rust binary and install to ~/.local/bin/ @echo "Installed claude-tools → $(BIN_TARGET)/claude-tools" # ─── test ────────────────────────────────────────────────────────────────── -.PHONY: test test-rust test-python test-agents -test: test-rust test-python ## Run all tests +.PHONY: test test-rust test-python test-agents tox +test: test-rust test-python ## Run all tests (fast, zero-dependency smoke checks) + +tox: ## Full Python test matrix + lint (requires: pip install tox ruff) + tox test-rust: ## Cargo check + clippy cd $(RUST_DIR) && $(CARGO) check @@ -113,6 +116,9 @@ test-agents: ## Verify agent files exist and are non-empty lint: ## Clippy lint (Rust) cd $(RUST_DIR) && $(CARGO) clippy -- -D warnings +msrv: ## Verify the crate builds on its declared MSRV (requires: cargo install cargo-msrv) + cd $(RUST_DIR)/claude-tools && cargo msrv verify + fmt: ## Format all code (Rust + Python) bash scripts/fmt.sh diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f45b179 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.ruff] +line-length = 100 +target-version = "py38" + +[tool.ruff.lint] +# E402 ignored: every tool in tools/ deliberately puts a VERSION constant +# right after the module docstring, before imports, for easy `grep`. +select = ["E", "F", "I", "UP"] +ignore = ["E501", "E402"] diff --git a/scripts/fmt.sh b/scripts/fmt.sh index 75fab41..7af09d0 100644 --- a/scripts/fmt.sh +++ b/scripts/fmt.sh @@ -36,16 +36,20 @@ else fi # ── Python ───────────────────────────────────────────────────────────────── +# Scoped to claude-lessons.py + tests/, not all of tools/: most existing +# tools/*.py use a deliberate hand-aligned style (aligned `=`, aligned dict +# values) that `ruff format` would flatten. See tox.ini's fmt-check comment. +PY_FMT_TARGETS=("$REPO_ROOT/tools/claude-lessons.py" "$REPO_ROOT/tests/") if command -v ruff &>/dev/null; then if [ "$CHECK" -eq 1 ]; then - if ruff format --check "$REPO_ROOT/tools/" 2>/dev/null; then + if ruff format --check "${PY_FMT_TARGETS[@]}" 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" + ruff format "${PY_FMT_TARGETS[@]}" 2>/dev/null && ok "Python: ruff format applied" fi else printf ' skipping Python (ruff not found)\n' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..80a4c6d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +""" +Shared fixtures for the tools/*.py test suite. + +These CLI tools store all state under ~/.claude/ (or, for claude-remind / +claude-review-diff, the current git repo). Every test runs them as a real +subprocess against an isolated HOME (and, where needed, an isolated git +repo) so tests never touch the machine's actual ~/.claude/ directory and +can run in parallel / repeatedly without interfering with each other. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +TOOLS_DIR = REPO_ROOT / "tools" + + +@pytest.fixture +def repo_root(): + return REPO_ROOT + + +@pytest.fixture +def home(tmp_path): + """An isolated $HOME for a single test.""" + h = tmp_path / "home" + h.mkdir() + return h + + +@pytest.fixture +def run_tool(home, monkeypatch): + """ + run_tool("claude-lessons", "add", "--title", "x", ...) -> CompletedProcess + + Runs tools/.py as a real subprocess with HOME pointed at an + isolated tmp directory and NO_COLOR set (so stdout has no ANSI codes + to strip in assertions). + """ + + def _run(tool_name, *args, cwd=None, input=None, extra_env=None): + script = TOOLS_DIR / f"{tool_name}.py" + assert script.exists(), f"tool script not found: {script}" + + env = dict(os.environ) + env["HOME"] = str(home) + env["NO_COLOR"] = "1" + if extra_env: + env.update(extra_env) + + return subprocess.run( + [sys.executable, str(script), *args], + capture_output=True, + text=True, + cwd=str(cwd) if cwd else None, + input=input, + env=env, + ) + + return _run + + +@pytest.fixture +def git_repo(tmp_path): + """An isolated, initialized git repo with one commit, for tools that shell out to git.""" + repo = tmp_path / "repo" + repo.mkdir() + + def _git(*args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + _git("init", "-q") + _git("config", "user.email", "test@example.com") + _git("config", "user.name", "Test") + + (repo / "README.md").write_text("hello\n", encoding="utf-8") + _git("add", "README.md") + _git("commit", "-q", "-m", "initial") + + return repo diff --git a/tests/test_claude_cost.py b/tests/test_claude_cost.py new file mode 100644 index 0000000..bf8311a --- /dev/null +++ b/tests/test_claude_cost.py @@ -0,0 +1,36 @@ +"""Tests for tools/claude-cost.py.""" + + +def test_estimate_with_literal_prompt(run_tool): + r = run_tool("claude-cost", "estimate", "Implement OAuth 2.0 login") + assert r.returncode == 0 + assert "Cost Estimate" in r.stdout + assert "Total estimate" in r.stdout + + +def test_estimate_requires_prompt_or_snippet(run_tool): + r = run_tool("claude-cost", "estimate") + assert r.returncode == 2 + + +def test_estimate_with_custom_agent_list(run_tool): + r = run_tool("claude-cost", "estimate", "fix bug", "--agents", "implementer,reviewer") + assert r.returncode == 0 + assert "implementer" in r.stdout + assert "reviewer" in r.stdout + assert "planner" not in r.stdout + + +def test_month_with_no_data(run_tool): + r = run_tool("claude-cost", "month") + assert r.returncode == 0 + assert "No data for" in r.stdout + + +def test_set_budget_then_month_reflects_it(run_tool): + set_result = run_tool("claude-cost", "set-budget", "20.00") + assert set_result.returncode == 0 + assert "$20.0000" in set_result.stdout + + month = run_tool("claude-cost", "month") + assert month.returncode == 0 diff --git a/tests/test_claude_handoff.py b/tests/test_claude_handoff.py new file mode 100644 index 0000000..141f882 --- /dev/null +++ b/tests/test_claude_handoff.py @@ -0,0 +1,47 @@ +"""Tests for tools/claude-handoff.py.""" + + +def test_save_creates_a_handoff(run_tool): + r = run_tool("claude-handoff", "save", "--note", "OAuth done, next: email verification") + assert r.returncode == 0 + assert "Handoff saved" in r.stdout + + +def test_load_returns_most_recent_by_default(run_tool): + run_tool("claude-handoff", "save", "--note", "first note") + loaded = run_tool("claude-handoff", "load") + assert loaded.returncode == 0 + assert "first note" in loaded.stdout + assert "Resume Prompt" in loaded.stdout + + +def test_load_with_no_handoffs_fails_clearly(run_tool): + r = run_tool("claude-handoff", "load") + assert r.returncode == 1 + assert "No handoffs found" in r.stderr + + +def test_list_shows_saved_handoffs(run_tool): + run_tool("claude-handoff", "save", "--note", "note A") + listed = run_tool("claude-handoff", "list") + assert listed.returncode == 0 + assert "note A" in listed.stdout + + +def test_show_specific_id(run_tool): + save = run_tool("claude-handoff", "save", "--note", "note B") + hid = save.stdout.splitlines()[0].split(":")[-1].strip() + + shown = run_tool("claude-handoff", "show", "--id", hid) + assert shown.returncode == 0 + assert "note B" in shown.stdout + + +def test_clean_with_no_old_handoffs_deletes_nothing(run_tool): + run_tool("claude-handoff", "save", "--note", "fresh") + r = run_tool("claude-handoff", "clean", "--days", "30", "--force") + assert r.returncode == 0 + assert "No handoffs older than" in r.stdout + + still_there = run_tool("claude-handoff", "list") + assert "fresh" in still_there.stdout diff --git a/tests/test_claude_harness.py b/tests/test_claude_harness.py new file mode 100644 index 0000000..a17d3fb --- /dev/null +++ b/tests/test_claude_harness.py @@ -0,0 +1,37 @@ +"""Tests for tools/claude-harness.py.""" + + +def test_validate_a_known_good_agent(run_tool, repo_root): + agent = repo_root / "agents" / "09-harness-designer.md" + r = run_tool("claude-harness", "validate", str(agent)) + assert r.returncode == 0 + assert "Autonomy level (L0-L4) is declared" in r.stdout + + +def test_validate_missing_file_fails(run_tool): + r = run_tool("claude-harness", "validate", "/nonexistent/agent.md") + assert r.returncode == 1 + + +def test_check_all_runs_against_every_agent(run_tool): + r = run_tool("claude-harness", "check-all") + # exit 0 regardless of individual agent pass/fail (check-all always + # completes cleanly); the important thing is it finds and reports on + # all agents without crashing. + assert r.returncode == 0 + for n in range(11): + assert f"{n:02d}-" in r.stdout + + +def test_template_includes_autonomy_field(run_tool): + r = run_tool("claude-harness", "template", "tight", "my-specialist") + assert r.returncode == 0 + assert "autonomy: L2" in r.stdout + assert "name: my-specialist" in r.stdout + + +def test_autonomy_subcommand_prints_all_five_levels(run_tool): + r = run_tool("claude-harness", "autonomy") + assert r.returncode == 0 + for level in ("L0", "L1", "L2", "L3", "L4"): + assert level in r.stdout diff --git a/tests/test_claude_lessons.py b/tests/test_claude_lessons.py new file mode 100644 index 0000000..76d2625 --- /dev/null +++ b/tests/test_claude_lessons.py @@ -0,0 +1,143 @@ +"""Tests for tools/claude-lessons.py.""" + + +def test_no_lessons_yet(run_tool): + r = run_tool("claude-lessons", "list") + assert r.returncode == 0 + assert "No lessons recorded yet." in r.stdout + + +def test_add_and_show(run_tool): + r = run_tool( + "claude-lessons", + "add", + "--title", + "Migration timed out", + "--tags", + "db,migration", + "--symptom", + "ALTER TABLE locked prod for 4min", + "--cause", + "no lock_timeout set", + "--fix", + "added SET lock_timeout='2s' before DDL", + ) + assert r.returncode == 0 + assert "Lesson saved" in r.stdout + + shown = run_tool("claude-lessons", "show") + assert shown.returncode == 0 + assert "Migration timed out" in shown.stdout + assert "no lock_timeout set" in shown.stdout + + +def test_rapid_add_does_not_collide(run_tool): + """Regression test: two adds in the same second must not overwrite each other.""" + first = run_tool( + "claude-lessons", "add", "--title", "first", "--symptom", "s", "--cause", "c", "--fix", "f" + ) + second = run_tool( + "claude-lessons", "add", "--title", "second", "--symptom", "s", "--cause", "c", "--fix", "f" + ) + assert first.returncode == 0 + assert second.returncode == 0 + + listed = run_tool("claude-lessons", "list") + assert "first" in listed.stdout + assert "second" in listed.stdout + assert "2 lesson(s)" in listed.stdout + + +def test_list_filters_by_tag(run_tool): + run_tool( + "claude-lessons", + "add", + "--title", + "db one", + "--tags", + "db", + "--symptom", + "s", + "--cause", + "c", + "--fix", + "f", + ) + run_tool( + "claude-lessons", + "add", + "--title", + "ci one", + "--tags", + "ci", + "--symptom", + "s", + "--cause", + "c", + "--fix", + "f", + ) + + db_only = run_tool("claude-lessons", "list", "--tag", "db") + assert "db one" in db_only.stdout + assert "ci one" not in db_only.stdout + + +def test_search_by_keyword(run_tool): + run_tool( + "claude-lessons", + "add", + "--title", + "x", + "--tags", + "db", + "--symptom", + "lock_timeout missing", + "--cause", + "c", + "--fix", + "f", + ) + + found = run_tool("claude-lessons", "search", "lock_timeout") + assert "1 match(es)" in found.stdout + + not_found = run_tool("claude-lessons", "search", "nonexistent-xyz") + assert "No lessons matching" in not_found.stdout + + +def test_context_pipeable_output(run_tool): + run_tool( + "claude-lessons", + "add", + "--title", + "x", + "--tags", + "db", + "--symptom", + "s", + "--cause", + "c", + "--fix", + "f", + ) + + ctx = run_tool("claude-lessons", "context", "--limit", "1") + assert ctx.returncode == 0 + assert "Lessons Learned" in ctx.stdout + assert "x" in ctx.stdout + + +def test_show_missing_id_exits_nonzero(run_tool): + run_tool( + "claude-lessons", "add", "--title", "x", "--symptom", "s", "--cause", "c", "--fix", "f" + ) + r = run_tool("claude-lessons", "show", "--id", "does-not-exist") + assert r.returncode == 1 + + +def test_add_requires_title(run_tool): + r = run_tool( + "claude-lessons", "add", "--symptom", "s", "--cause", "c", "--fix", "f", input="\n" + ) # empty title when prompted + assert r.returncode == 2 diff --git a/tests/test_claude_pipeline.py b/tests/test_claude_pipeline.py new file mode 100644 index 0000000..5c99ca1 --- /dev/null +++ b/tests/test_claude_pipeline.py @@ -0,0 +1,44 @@ +"""Tests for tools/claude-pipeline.py.""" + + +def test_init_and_status(run_tool): + r = run_tool("claude-pipeline", "init", "demo-workflow") + assert r.returncode == 0 + assert "initialized" in r.stdout + + status = run_tool("claude-pipeline", "status") + assert status.returncode == 0 + assert "demo-workflow" in status.stdout + + +def test_stage_lifecycle_and_report(run_tool): + run_tool("claude-pipeline", "init", "slow-query-fix") + run_tool("claude-pipeline", "stage", "detection", "start") + result = run_tool("claude-pipeline", "stage", "detection", "pass", "--note", "found 3 issues") + assert result.returncode == 0 + + report = run_tool("claude-pipeline", "report") + assert report.returncode == 0 + assert "detection" in report.stdout + assert "PASS" in report.stdout + assert "found 3 issues" in report.stdout + + +def test_list_renders_created_date(run_tool): + """Regression test: `list` used to be a SyntaxError (nested f-string + with an escaped quote) and would crash on import, breaking every + subcommand including --help.""" + run_tool("claude-pipeline", "init", "workflow-a") + run_tool("claude-pipeline", "init", "workflow-b") + + listed = run_tool("claude-pipeline", "list") + assert listed.returncode == 0 + assert "workflow-a" in listed.stdout + assert "workflow-b" in listed.stdout + assert "stages · created" in listed.stdout + assert "active" in listed.stdout # marker on the currently-active pipeline + + +def test_help_does_not_crash(run_tool): + r = run_tool("claude-pipeline", "--help") + assert r.returncode == 0 diff --git a/tests/test_claude_remind.py b/tests/test_claude_remind.py new file mode 100644 index 0000000..896be00 --- /dev/null +++ b/tests/test_claude_remind.py @@ -0,0 +1,32 @@ +"""Tests for tools/claude-remind.py.""" + + +def test_no_task_files_found(run_tool, tmp_path): + r = run_tool("claude-remind", cwd=tmp_path) + assert r.returncode == 0 + assert "No task files found" in r.stderr + + +def test_finds_pending_checkboxes(run_tool, tmp_path): + (tmp_path / "TODO.md").write_text( + "# TODO\n\n- [ ] Add email verification endpoint\n- [x] Set up OAuth\n", + encoding="utf-8", + ) + r = run_tool("claude-remind", cwd=tmp_path) + assert r.returncode == 0 + assert "Add email verification endpoint" in r.stdout + assert "Pending Tasks (1 incomplete)" in r.stdout + + +def test_quiet_flag_prints_count_only(run_tool, tmp_path): + (tmp_path / "TODO.md").write_text("- [ ] one\n- [ ] two\n", encoding="utf-8") + r = run_tool("claude-remind", "--quiet", cwd=tmp_path) + assert r.returncode == 0 + assert "2 pending task(s)" in r.stdout + + +def test_no_pending_tasks(run_tool, tmp_path): + (tmp_path / "TODO.md").write_text("- [x] already done\n", encoding="utf-8") + r = run_tool("claude-remind", cwd=tmp_path) + assert r.returncode == 0 + assert "No pending tasks found" in r.stderr diff --git a/tests/test_claude_review_diff.py b/tests/test_claude_review_diff.py new file mode 100644 index 0000000..c1353a0 --- /dev/null +++ b/tests/test_claude_review_diff.py @@ -0,0 +1,38 @@ +"""Tests for tools/claude-review-diff.py.""" + + +def test_requires_git_repo(run_tool, tmp_path): + r = run_tool("claude-review-diff", cwd=tmp_path) + assert r.returncode == 1 + assert "Not inside a git repository" in r.stderr + + +def test_no_diff_found(run_tool, git_repo): + r = run_tool("claude-review-diff", cwd=git_repo) + assert r.returncode == 0 + assert "No diff found" in r.stderr + + +def test_generates_review_prompt_for_unstaged_changes(run_tool, git_repo): + (git_repo / "README.md").write_text("hello world, changed\n", encoding="utf-8") + + r = run_tool("claude-review-diff", cwd=git_repo) + assert r.returncode == 0 + assert "code review" in r.stdout.lower() + assert "## Diff" in r.stdout + assert "hello world, changed" in r.stdout + + +def test_focus_option_changes_the_hint(run_tool, git_repo): + (git_repo / "README.md").write_text("changed\n", encoding="utf-8") + + r = run_tool("claude-review-diff", "--focus", "security", cwd=git_repo) + assert r.returncode == 0 + assert "security vulnerabilities" in r.stdout.lower() + + +def test_staged_only_reviews_staged_changes(run_tool, git_repo): + (git_repo / "README.md").write_text("unstaged change\n", encoding="utf-8") + r = run_tool("claude-review-diff", "--staged", cwd=git_repo) + assert r.returncode == 0 + assert "No diff found" in r.stderr diff --git a/tests/test_snippet.py b/tests/test_snippet.py new file mode 100644 index 0000000..ed10737 --- /dev/null +++ b/tests/test_snippet.py @@ -0,0 +1,57 @@ +"""Tests for tools/snippet.py.""" + + +def test_list_when_empty(run_tool): + r = run_tool("snippet", "list") + assert r.returncode == 0 + assert "No snippets saved yet." in r.stdout + + +def test_save_and_list(run_tool): + save = run_tool("snippet", "save", "myfix", "Fix {{BUG}} in {{FILE}}", "--tags", "bug,auth") + assert save.returncode == 0 + assert "saved" in save.stdout + + listed = run_tool("snippet", "list") + assert "myfix" in listed.stdout + + +def test_save_duplicate_without_force_fails(run_tool): + run_tool("snippet", "save", "myfix", "first version") + dup = run_tool("snippet", "save", "myfix", "second version") + assert dup.returncode == 1 + assert "already exists" in dup.stdout + + +def test_run_fills_template_vars(run_tool): + run_tool("snippet", "save", "myfix", "Fix {{BUG}} in {{FILE}}") + r = run_tool( + "snippet", "run", "myfix", "--var", "BUG=null ref", "--var", "FILE=src/auth.ts", "--dry-run" + ) + assert r.returncode == 0 + assert "Fix null ref in src/auth.ts" in r.stdout + + +def test_run_warns_on_missing_vars(run_tool): + run_tool("snippet", "save", "myfix", "Fix {{BUG}} in {{FILE}}") + r = run_tool("snippet", "run", "myfix", "--var", "BUG=null ref", "--dry-run") + assert "Unfilled template vars: FILE" in r.stderr + + +def test_delete(run_tool): + run_tool("snippet", "save", "throwaway", "content") + deleted = run_tool("snippet", "delete", "throwaway", "--force") + assert deleted.returncode == 0 + + listed = run_tool("snippet", "list") + assert "throwaway" not in listed.stdout + + +def test_import_defaults(run_tool, repo_root): + defaults = repo_root / "snippets" / "defaults.json" + r = run_tool("snippet", "import", str(defaults)) + assert r.returncode == 0 + assert "added" in r.stdout + + listed = run_tool("snippet", "list") + assert "full-pipeline" in listed.stdout diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7e6325d --- /dev/null +++ b/tox.ini @@ -0,0 +1,25 @@ +[tox] +envlist = py38, py39, py310, py311, py312, py313, lint, fmt-check +skip_missing_interpreters = true +skipsdist = true + +[testenv] +description = Run the tools/*.py test suite on this interpreter +deps = pytest +commands = pytest {posargs:tests/} -v + +[testenv:lint] +description = Static checks on tools/*.py and tests/*.py +skip_install = true +deps = ruff +commands = ruff check tools/ tests/ + +[testenv:fmt-check] +# Scoped to new-since-tox files only: the pre-existing tools/*.py use a +# deliberate hand-aligned style (aligned `=`, aligned dict values) that +# `ruff format` would flatten. Not forcing that repo-wide in this change — +# new code stays ruff-formatted; existing style is left as the author wrote it. +description = Fail if tools/claude-lessons.py or tests/*.py aren't ruff-formatted +skip_install = true +deps = ruff +commands = ruff format --check tools/claude-lessons.py tests/ From d412ebcdda66145ffe24984435c21ad065ca2fca Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:02:36 +0900 Subject: [PATCH 2/6] fix: bugs found by the new pytest/tox matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real, independently-reproduced bugs the new test suite caught: - claude-cost.py: `set-budget` crashed with FileNotFoundError on a fresh install — `_save_budget` never created ~/.claude/ before writing cost-budget.json (snippet.py and claude-handoff.py already did this correctly; claude-cost.py's budget path was the one that didn't). - claude-remind.py / claude-review-diff.py: both use `X | None` (PEP 604) type annotations, which raise TypeError at import time on Python < 3.10 — every subcommand, including --help, was broken on 3.8/3.9 despite the "Python 3.8+" badge. Fixed with `from __future__ import annotations` (defers annotation evaluation, no behavior change). Confirmed via tox -e py39 before/after. Also applies the (small, low-risk) findings from the new ruff lint gate across tools/*.py: 4 unused imports, 1 dead local variable (claude-handoff.py's `list`, left over from a removed date column), 4 `if x: y` one-liners split to satisfy E701, and one ambiguous single- letter loop variable (`l` -> `line`). No behavior changes; tests still pass after each. --- tools/claude-cost.py | 2 +- tools/claude-handoff.py | 7 ++--- tools/claude-harness.py | 3 +-- tools/claude-lessons.py | 53 ++++++++++++++++++++++++++----------- tools/claude-pipeline.py | 32 ++++++++++++---------- tools/claude-remind.py | 4 ++- tools/claude-review-diff.py | 15 ++++++----- 7 files changed, 70 insertions(+), 46 deletions(-) diff --git a/tools/claude-cost.py b/tools/claude-cost.py index b477943..da6976d 100644 --- a/tools/claude-cost.py +++ b/tools/claude-cost.py @@ -195,6 +195,7 @@ def _load_budget() -> dict: def _save_budget(data: dict) -> None: + BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True) BUDGET_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") @@ -218,7 +219,6 @@ def cmd_estimate(args): prompt_text = snippets[args.snippet]["prompt"] # Fill template vars if provided if args.var: - import re for pair in args.var: if "=" not in pair: continue diff --git a/tools/claude-handoff.py b/tools/claude-handoff.py index b57ff7e..a410b40 100644 --- a/tools/claude-handoff.py +++ b/tools/claude-handoff.py @@ -12,7 +12,6 @@ VERSION = "1.0.0" import argparse -import json import os import subprocess import sys @@ -95,7 +94,7 @@ def _find_todo() -> str: if f.exists(): try: content = f.read_text(encoding="utf-8") - lines = [l for l in content.splitlines() if l.strip()] + lines = [line for line in content.splitlines() if line.strip()] return "\n".join(lines[:20]) except Exception: pass @@ -235,7 +234,7 @@ def cmd_save(args): print(dim(f" {path}")) print() print(dim("Resume next session with:")) - print(f" claude-handoff load | claude") + print(" claude-handoff load | claude") print(f" claude-handoff load --id {hid} | claude") @@ -278,8 +277,6 @@ def cmd_list(args): print(f"\n {bold('id'):<22} {bold('project'):<18} {bold('note')}") print(" " + dim("-" * 80)) for item in items: - ts = item["id"][:8] # YYYYMMDD - ts_fmt = f"{ts[:4]}-{ts[4:6]}-{ts[6:8]}" print(f" {cyan(item['id']):<{22+9}} " f"{item['project']:<18} " f"{dim(item['note'])}") diff --git a/tools/claude-harness.py b/tools/claude-harness.py index 5463d4e..5896c8b 100644 --- a/tools/claude-harness.py +++ b/tools/claude-harness.py @@ -9,10 +9,9 @@ claude-harness check-all """ -import sys import os import re -import json +import sys from pathlib import Path VERSION = "1.0.0" diff --git a/tools/claude-lessons.py b/tools/claude-lessons.py index a6d4b6f..f6d4c2c 100755 --- a/tools/claude-lessons.py +++ b/tools/claude-lessons.py @@ -24,11 +24,13 @@ # Color support # --------------------------------------------------------------------------- + def _enable_win_vt() -> None: if sys.platform != "win32": return try: import ctypes + k = ctypes.windll.kernel32 k.SetConsoleMode(k.GetStdHandle(-11), 7) except Exception: @@ -43,18 +45,35 @@ def _c(code: str, text: str) -> str: return f"\033[{code}m{text}\033[0m" if _COLOR else text -def green(s): return _c("32", s) -def yellow(s): return _c("33", s) -def cyan(s): return _c("36", s) -def red(s): return _c("31", s) -def bold(s): return _c("1", s) -def dim(s): return _c("2", s) +def green(s): + return _c("32", s) + + +def yellow(s): + return _c("33", s) + + +def cyan(s): + return _c("36", s) + + +def red(s): + return _c("31", s) + + +def bold(s): + return _c("1", s) + + +def dim(s): + return _c("2", s) # --------------------------------------------------------------------------- # Storage # --------------------------------------------------------------------------- + def _lesson_id() -> str: """Timestamp-based ID, disambiguated on collision so rapid adds never overwrite.""" base = datetime.now().strftime("%Y%m%d-%H%M%S") @@ -126,6 +145,7 @@ def _list_lessons() -> list: # Commands # --------------------------------------------------------------------------- + def cmd_add(args): LESSONS_DIR.mkdir(parents=True, exist_ok=True) @@ -165,9 +185,9 @@ def cmd_list(args): print(f"\n {bold('id'):<22} {bold('tags'):<24} {bold('title')}") print(" " + dim("-" * 90)) for item in items: - print(f" {cyan(item['id']):<{22+9}} " - f"{dim(item['tags'] or '-'):<{24+9}} " - f"{item['title']}") + print( + f" {cyan(item['id']):<{22 + 9}} {dim(item['tags'] or '-'):<{24 + 9}} {item['title']}" + ) print(f"\n {dim(str(len(items)) + ' lesson(s)')}\n") @@ -201,9 +221,9 @@ def cmd_search(args): print(f"\n {bold('id'):<22} {bold('tags'):<24} {bold('title')}") print(" " + dim("-" * 90)) for item in matches: - print(f" {cyan(item['id']):<{22+9}} " - f"{dim(item['tags'] or '-'):<{24+9}} " - f"{item['title']}") + print( + f" {cyan(item['id']):<{22 + 9}} {dim(item['tags'] or '-'):<{24 + 9}} {item['title']}" + ) print(f"\n {dim(str(len(matches)) + ' match(es)')}\n") @@ -230,8 +250,7 @@ def cmd_context(args): print(f"---\n{item['content']}") if sys.stdout.isatty(): - print(dim("\n-- Tip: pipe to claude: claude-lessons context | claude"), - file=sys.stderr) + print(dim("\n-- Tip: pipe to claude: claude-lessons context | claude"), file=sys.stderr) def cmd_version(args): @@ -242,6 +261,7 @@ def cmd_version(args): # Argument parser # --------------------------------------------------------------------------- + def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="claude-lessons", @@ -284,8 +304,9 @@ def _build_parser() -> argparse.ArgumentParser: s.add_argument("query", help="Keyword to search for") s.set_defaults(func=cmd_search) - s = sub.add_parser("context", - help="Print matching lessons (pipe to claude for session-start context)") + s = sub.add_parser( + "context", help="Print matching lessons (pipe to claude for session-start context)" + ) s.add_argument("--limit", "-n", type=int, default=5, help="Max lessons to include (default: 5)") s.add_argument("--tag", help="Only include lessons matching this tag") s.set_defaults(func=cmd_context) diff --git a/tools/claude-pipeline.py b/tools/claude-pipeline.py index 39ab9bd..c333a2e 100644 --- a/tools/claude-pipeline.py +++ b/tools/claude-pipeline.py @@ -12,10 +12,10 @@ claude-pipeline clear """ -import sys -import os -import json import datetime +import json +import os +import sys from pathlib import Path VERSION = "1.0.0" @@ -180,10 +180,14 @@ def cmd_status(): for s in ["pass", "warn", "fail", "running"]} print() summary_parts = [] - if counts["pass"]: summary_parts.append(green(f"{counts['pass']} passed")) - if counts["warn"]: summary_parts.append(yellow(f"{counts['warn']} warnings")) - if counts["fail"]: summary_parts.append(red(f"{counts['fail']} failed")) - if counts["running"]: summary_parts.append(blue(f"{counts['running']} running")) + if counts["pass"]: + summary_parts.append(green(f"{counts['pass']} passed")) + if counts["warn"]: + summary_parts.append(yellow(f"{counts['warn']} warnings")) + if counts["fail"]: + summary_parts.append(red(f"{counts['fail']} failed")) + if counts["running"]: + summary_parts.append(blue(f"{counts['running']} running")) print(" " + " · ".join(summary_parts)) @@ -202,15 +206,15 @@ def cmd_report(): lines = [ f"## Pipeline Run Report: {data['name']}", - f"", - f"### Summary", + "", + "### Summary", f"- Total stages: {len(stages)}", f"- Passed: {len(passed)} | Warnings: {len(warned)} | Failed: {len(failed)}", f"- Run started: {data['created']}", - f"", - f"### Stage Results", - f"| Stage | Status | Notes |", - f"|-------|--------|-------|", + "", + "### Stage Results", + "| Stage | Status | Notes |", + "|-------|--------|-------|", ] for s in stages: @@ -219,7 +223,7 @@ def cmd_report(): lines.append(f"| {s['name']} | {icon} {s['status'].upper()} | {note} |") if failed: - lines += [f"", f"### Failed Stages — Human Review Required"] + lines += ["", "### Failed Stages — Human Review Required"] for s in failed: lines.append(f"- **{s['name']}**: {s.get('note', 'no details')}") diff --git a/tools/claude-remind.py b/tools/claude-remind.py index 537d3ba..1d80c6a 100644 --- a/tools/claude-remind.py +++ b/tools/claude-remind.py @@ -14,6 +14,8 @@ Homepage: https://github.com/BcKmini/Claudecode-Agent """ +from __future__ import annotations # `X | None` annotations need this on Python < 3.10 + VERSION = "1.0.0" import argparse @@ -197,7 +199,7 @@ def main() -> None: if not all_tasks: total = done_total - print(green(f"[OK] No pending tasks found."), file=sys.stderr) + print(green("[OK] No pending tasks found."), file=sys.stderr) if total: print(dim(f" {total} completed task(s) in {[f.name for f in task_files]}"), file=sys.stderr) diff --git a/tools/claude-review-diff.py b/tools/claude-review-diff.py index 140945e..bb8e7fd 100644 --- a/tools/claude-review-diff.py +++ b/tools/claude-review-diff.py @@ -14,13 +14,14 @@ Homepage: https://github.com/BcKmini/Claudecode-Agent """ +from __future__ import annotations # `X | None` annotations need this on Python < 3.10 + VERSION = "1.0.0" import argparse import os import subprocess import sys -from pathlib import Path # --------------------------------------------------------------------------- # Color helpers @@ -128,14 +129,14 @@ def _build_prompt(diff: str, stats: str, branch: str, args) -> str: ) lines = [ - f"You are a senior software engineer performing a code review.", - f"", - f"## Review Scope", + "You are a senior software engineer performing a code review.", + "", + "## Review Scope", f"Branch: `{branch}` — reviewing {scope_desc}.", - f"", - f"## Review Focus", + "", + "## Review Focus", f"{hint}", - f"", + "", ] if stats: From 8476c4b01f35603e9b6091ba4683671e8737ed32 Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:02:44 +0900 Subject: [PATCH 3/6] ci: add Python version matrix + Rust MSRV verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - python-tests job: runs `tox -e py` across Python 3.8-3.13 via actions/setup-python (this is what actually caught the claude-remind.py/claude-review-diff.py 3.8/3.9 breakage in the previous commit — nothing exercised those versions before) - python-lint job: `tox -e lint,fmt-check` - rust-version = "1.75" declared in claude-tools/Cargo.toml, matching the README's existing "Rust 1.75+" badge (previously just a claim, not enforced anywhere) - msrv job: `cargo msrv verify` — actually builds against the declared 1.75 toolchain instead of only ever testing against `stable` No cargo/rustc available in the environment this branch was authored in, so the Rust-side change (rust-version field + msrv CI job) is verified by CI on push, the same as the rest of this project's Rust checks. --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++ rust/claude-tools/Cargo.toml | 1 + 2 files changed, 71 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8943be..75b660a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,10 @@ on: - 'rust/**' - 'agents/**' - 'tools/**' + - 'tests/**' - 'scripts/**' + - 'tox.ini' + - 'pyproject.toml' pull_request: branches: - main @@ -21,7 +24,10 @@ on: - 'rust/**' - 'agents/**' - 'tools/**' + - 'tests/**' - 'scripts/**' + - 'tox.ini' + - 'pyproject.toml' workflow_dispatch: concurrency: @@ -50,6 +56,50 @@ jobs: - name: Check agent MD files run: bash scripts/validate-agents.sh + # ── Job: Python tests (tox, version matrix) ────────────────────────────────── + python-tests: + name: Python tests (${{ matrix.python-version }}) + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tox + run: pip install tox + + - name: Run tests + run: tox -e py + + # ── Job: Python lint/format (ruff via tox) ─────────────────────────────────── + python-lint: + name: Python lint (ruff) + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox + + - name: Lint + format check + run: tox -e lint,fmt-check + # ── Job: rustfmt ───────────────────────────────────────────────────────────── fmt: name: cargo fmt @@ -102,6 +152,26 @@ jobs: - name: Run workspace tests run: cargo test --workspace + # ── Job: MSRV verification ──────────────────────────────────────────────────── + msrv: + name: MSRV verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust -> target + + - name: Install cargo-msrv + run: cargo install cargo-msrv --locked + + - name: Verify declared rust-version builds + working-directory: rust/claude-tools + run: cargo msrv verify + # ── Job: Windows smoke build ───────────────────────────────────────────────── windows-smoke: name: Windows build smoke diff --git a/rust/claude-tools/Cargo.toml b/rust/claude-tools/Cargo.toml index f6d0d51..34f280c 100644 --- a/rust/claude-tools/Cargo.toml +++ b/rust/claude-tools/Cargo.toml @@ -2,6 +2,7 @@ name = "claude-tools" version = "1.0.0" edition = "2021" +rust-version = "1.75" description = "Claude Code productivity CLI: snippet manager, session handoff, cost estimator" authors = ["BcKmini"] license = "MIT" From 42728c11c94e30af3a24b4699b9a891a15e43143 Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:02:51 +0900 Subject: [PATCH 4/6] docs: document tox/pytest and cargo-msrv workflow (EN/KO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md/.ko.md: Makefile section (make tox, make msrv), repo layout (tests/, tox.ini, pyproject.toml). Also fixes "make lint # clippy + ruff" in both READMEs — the lint target only ever ran clippy; ruff wasn't wired into it before this branch. docs/CONTRIBUTING.md/.ko.md: tox usage in Development Setup, tests/ step added to "Adding a New Tool", tox/cargo-msrv checks added to the PR checklist, cargo-msrv install/verify commands. Closes #30 --- README.ko.md | 14 +++++++++++--- README.md | 14 +++++++++++--- docs/CONTRIBUTING.ko.md | 25 ++++++++++++++++++++----- docs/CONTRIBUTING.md | 25 ++++++++++++++++++++----- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/README.ko.md b/README.ko.md index 5e4cd28..69f7c34 100644 --- a/README.ko.md +++ b/README.ko.md @@ -431,9 +431,11 @@ make help # 전체 타겟 목록 make install # 에이전트 + 슬래시 커맨드 + Python 도구 make install-rust # Rust 바이너리 빌드 및 설치 make build # cargo build --release -make test # 전체 테스트 -make lint # clippy + ruff -make fmt # rustfmt + ruff format +make test # 빠른 스모크 테스트 (추가 의존성 없음) +make tox # 전체 Python 테스트 매트릭스(py38-py313) + lint + fmt-check +make msrv # Rust 크레이트가 명시된 MSRV에서 빌드되는지 검증 +make lint # clippy (Rust) +make fmt # rustfmt + ruff format (범위 한정 — CONTRIBUTING.ko.md 참고) make status # git log + 도구 설치 상태 확인 make env # Claude 환경 헬스체크 make clean # 빌드 아티팩트 제거 @@ -446,6 +448,8 @@ make clean # 빌드 아티팩트 제거 ``` claude-code-use/ ├── Makefile ← 빌드 / 설치 / 테스트 / 정리 +├── tox.ini ← Python 테스트 매트릭스 + lint + fmt-check +├── pyproject.toml ← ruff 설정 (이 저장소 스타일에 맞게 범위 한정) ├── install.sh ← 원라인 설치 스크립트 ├── setup-agents.ps1 ← Windows 빠른 설치 ├── setup-agents.sh ← macOS / Linux 빠른 설치 @@ -473,6 +477,10 @@ claude-code-use/ │ ├── claude-lessons.py ← 신규 실패/교훈 기록 │ ├── install-tools.ps1 · install-tools.sh │ +├── tests/ ← tools/*.py용 pytest 스위트 +│ ├── conftest.py ← run_tool / home / git_repo 픽스처 +│ └── test_*.py ← 도구당 파일 하나 +│ ├── rust/claude-tools/src/ │ ├── main.rs · snippet.rs · handoff.rs · cost.rs │ ├── watch.rs · env.rs · colors.rs diff --git a/README.md b/README.md index 0ffff80..e0d3462 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,11 @@ make help # list all targets make install # agents + slash commands + Python tools make install-rust # build and install Rust binary make build # cargo build --release -make test # all tests -make lint # clippy + ruff -make fmt # rustfmt + ruff format +make test # fast smoke tests (no extra deps) +make tox # full Python test matrix (py38-py313) + lint + fmt-check +make msrv # verify the Rust crate builds on its declared MSRV +make lint # clippy (Rust) +make fmt # rustfmt + ruff format (scoped — see CONTRIBUTING.md) make status # git log + tool install check make env # Claude environment health check make clean # remove build artifacts @@ -448,6 +450,8 @@ make clean # remove build artifacts ``` Claudecode-Agent/ ├── Makefile ← build / install / test / clean +├── tox.ini ← Python test matrix + lint + fmt-check +├── pyproject.toml ← ruff config (scoped to this repo's style) ├── setup-agents.ps1 ← Windows quick installer ├── setup-agents.sh ← macOS / Linux quick installer │ @@ -489,6 +493,10 @@ Claudecode-Agent/ │ ├── install-tools.ps1 ← Windows tool installer │ └── install-tools.sh ← macOS/Linux tool installer │ +├── tests/ ← pytest suite for tools/*.py +│ ├── conftest.py ← run_tool / home / git_repo fixtures +│ └── test_*.py ← one file per tool +│ ├── rust/claude-tools/src/ │ ├── main.rs │ ├── snippet.rs diff --git a/docs/CONTRIBUTING.ko.md b/docs/CONTRIBUTING.ko.md index 1b0bf89..5643ecb 100644 --- a/docs/CONTRIBUTING.ko.md +++ b/docs/CONTRIBUTING.ko.md @@ -29,7 +29,16 @@ python --version # 3.8+ 필요 make status # 설치 상태 확인 ``` -`snippet.py`, `claude-handoff.py`, `claude-cost.py`, `claude-review-diff.py`, `claude-remind.py` 모두 Python 표준 라이브러리만 사용합니다 — `pip install` 불필요. +`snippet.py`, `claude-handoff.py`, `claude-cost.py`, `claude-review-diff.py`, `claude-remind.py`, `claude-harness.py`, `claude-pipeline.py`, `claude-lessons.py` 모두 Python 표준 라이브러리만 사용합니다 — *실행*에는 `pip install` 불필요. 테스트에는 dev 의존성이 필요합니다: + +```bash +pip install tox +tox # 전체 매트릭스: py38-py313(설치 안 된 버전은 건너뜀) + lint + fmt-check +tox -e py # 현재 인터프리터로 tests/만 실행 +tox -e lint # ruff check tools/ tests/ +``` + +ruff 설정은 `pyproject.toml`에 있습니다 — 전체 기본 룰셋이 아니라 `E`/`F`/`I`/`UP`(실질적 버그, 미사용 import, import 순서, 문법 현대화)로 범위를 좁혔고, `E402`는 무시합니다 — 모든 도구가 docstring 바로 뒤, import보다 먼저 `VERSION` 상수를 두는 게 의도된 스타일이기 때문입니다. `fmt-check`는 `tools/claude-lessons.py`와 `tests/`만 검사합니다 — 기존 도구들은 `ruff format`이 없애버릴 의도적인 정렬 스타일을 쓰고 있어서, 저장소 전체에는 강제하지 않습니다. Rust 바이너리: @@ -37,6 +46,9 @@ Rust 바이너리: cd rust cargo check # 빌드 확인 cargo build --release + +cargo install cargo-msrv --locked +cd claude-tools && cargo msrv verify # 명시된 rust-version에서 여전히 빌드되는지 확인 ``` --- @@ -82,9 +94,10 @@ python tools/snippet.py run my-snippet --dry-run - `NO_COLOR` 환경변수 준수 2. `.claude/commands/.md` 슬래시 커맨드 문서 추가 3. `Makefile` → `install-tools` 타겟과 `status` 타겟에 추가 -4. Rust 구현 추가 시: `rust/claude-tools/src/.rs` 작성 후 `main.rs`에 연결 -5. `README.md`와 `README.ko.md`의 도구 섹션, 슬래시 커맨드 테이블, 저장소 구조 업데이트 -6. `docs/AGENT-CHEATSHEET.md`와 `docs/AGENT-CHEATSHEET.ko.md` 업데이트 +4. `tests/test_.py` 추가 (`tests/conftest.py`의 `run_tool`/`home`/`git_repo` 픽스처 사용, subprocess 기반) 후 `tox -e py,lint` 통과 확인 +5. Rust 구현 추가 시: `rust/claude-tools/src/.rs` 작성 후 `main.rs`에 연결 +6. `README.md`와 `README.ko.md`의 도구 섹션, 슬래시 커맨드 테이블, 저장소 구조 업데이트 +7. `docs/AGENT-CHEATSHEET.md`와 `docs/AGENT-CHEATSHEET.ko.md` 업데이트 --- @@ -116,11 +129,13 @@ python tools/snippet.py run my-snippet --dry-run - [ ] `snippet import snippets/defaults.json` 정상 작동 - [ ] `cargo check` 통과 (Rust 변경 시) - [ ] `make test` 통과 +- [ ] `tox` 통과 — 최소 `tox -e py,lint` (Python 변경 시) +- [ ] `cargo msrv verify` 통과 (Rust 변경 시 — 명시된 `rust-version`에서 여전히 빌드되는지 확인) - [ ] 새 스니펫·에이전트·도구 추가 시 README 테이블 업데이트됨 - [ ] **EN/KO 문서 쌍 모두 업데이트됨** (README, CHEATSHEET, SETUP, INTEGRATION 해당 항목) - [ ] 새 도구 추가 시 슬래시 커맨드 `.md` 파일 추가됨 - [ ] 새 도구 추가 시 `Makefile` 업데이트됨 -- [ ] 외부 의존성 새로 추가하지 않음 +- [ ] `tools/`에는 외부 의존성 새로 추가하지 않음 (`examples/` 스크립트는 예외 — Code Style 참고) --- diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 59d50b3..e5f9489 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -29,7 +29,16 @@ python --version # 3.8+ required make status # check what's installed ``` -`snippet.py`, `claude-handoff.py`, `claude-cost.py`, `claude-review-diff.py`, `claude-remind.py` all use only the Python standard library — no `pip install` needed. +`snippet.py`, `claude-handoff.py`, `claude-cost.py`, `claude-review-diff.py`, `claude-remind.py`, `claude-harness.py`, `claude-pipeline.py`, `claude-lessons.py` all use only the Python standard library — no `pip install` needed to *run* them. Testing them does need dev dependencies: + +```bash +pip install tox +tox # full matrix: py38-py313 (skips interpreters you don't have) + lint + fmt-check +tox -e py # just run tests/ on your current interpreter +tox -e lint # ruff check tools/ tests/ +``` + +Ruff's config lives in `pyproject.toml` — it's scoped to `E`/`F`/`I`/`UP` (real bugs, unused imports, import order, modernization), not the full default ruleset, and `E402` is ignored because every tool deliberately puts its `VERSION` constant right after the docstring, before imports. `fmt-check` only covers `tools/claude-lessons.py` and `tests/` — the older tools use a deliberate hand-aligned style that `ruff format` would flatten, so it isn't enforced repo-wide. For the Rust binary: @@ -37,6 +46,9 @@ For the Rust binary: cd rust cargo check # verify build cargo build --release + +cargo install cargo-msrv --locked +cd claude-tools && cargo msrv verify # confirm it still builds on the declared rust-version ``` --- @@ -82,9 +94,10 @@ python tools/snippet.py run my-snippet --dry-run - Respect `NO_COLOR` environment variable 2. Add `.claude/commands/.md` slash command doc 3. Add the tool to `Makefile` → `install-tools` target and `status` target -4. If adding a Rust implementation, add `rust/claude-tools/src/.rs` and wire it into `main.rs` -5. Update `README.md` and `README.ko.md` tool sections, slash command table, and repo layout -6. Update `docs/AGENT-CHEATSHEET.md` and `docs/AGENT-CHEATSHEET.ko.md` +4. Add `tests/test_.py` (subprocess-based, using the `run_tool`/`home`/`git_repo` fixtures in `tests/conftest.py`) and confirm `tox -e py,lint` passes +5. If adding a Rust implementation, add `rust/claude-tools/src/.rs` and wire it into `main.rs` +6. Update `README.md` and `README.ko.md` tool sections, slash command table, and repo layout +7. Update `docs/AGENT-CHEATSHEET.md` and `docs/AGENT-CHEATSHEET.ko.md` --- @@ -116,11 +129,13 @@ examples (e.g. an MCP server) that legitimately need a third-party package. Keep - [ ] `snippet import snippets/defaults.json` still works - [ ] `cargo check` passes (Rust changes) - [ ] `make test` passes +- [ ] `tox` passes — at minimum `tox -e py,lint` (Python changes) +- [ ] `cargo msrv verify` passes (Rust changes — confirms the declared `rust-version` still builds) - [ ] README tables updated if new snippets / agents / tools added - [ ] **Both EN and KO docs updated** (README, CHEATSHEET, SETUP, INTEGRATION as applicable) - [ ] Slash command `.md` added if new tool introduced - [ ] `Makefile` updated if new tool added -- [ ] No new external dependencies introduced +- [ ] No new external dependencies introduced in `tools/` (an `examples/` script may declare one — see Code Style) --- From 21469221d758ad834d7b15020c3eb63676152b2e Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:09:58 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20MSRV=20verify=20failed=20=E2=80=94?= =?UTF-8?q?=20Cargo.lock=20is=20lockfile=20v4,=20needs=20cargo=201.78+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this immediately: cargo-msrv installs rustc 1.75 per the declared rust-version, but that toolchain's bundled cargo can't parse Cargo.lock's `version = 4` format (stabilized in cargo 1.78) — a structural incompatibility independent of whether the crate's actual code needs 1.75 or newer. The pre-existing "Rust 1.75+" badge this number was copied from was apparently never verified against the checked-in lockfile either. Bumped rust-version to 1.78 (README/README.ko badges updated to match) and letting CI's msrv job re-verify — no cargo available locally to confirm further. --- README.ko.md | 2 +- README.md | 2 +- rust/claude-tools/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ko.md b/README.ko.md index 69f7c34..5b4206a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -8,7 +8,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue?style=flat-square&logo=python&logoColor=white)](https://www.python.org) -[![Rust](https://img.shields.io/badge/Rust-1.75%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/Rust-1.78%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/BcKmini/Claudecode-Agent) [![Claude Code](https://img.shields.io/badge/Claude_Code-Compatible-blueviolet?style=flat-square&logo=anthropic)](https://claude.ai/code) [![Agents](https://img.shields.io/badge/Agents-11-green?style=flat-square)](#에이전트-구성) diff --git a/README.md b/README.md index e0d3462..e25ecc8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue?style=flat-square&logo=python&logoColor=white)](https://www.python.org) -[![Rust](https://img.shields.io/badge/Rust-1.75%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/Rust-1.78%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/BcKmini/Claudecode-Agent) [![Claude Code](https://img.shields.io/badge/Claude_Code-Compatible-blueviolet?style=flat-square&logo=anthropic)](https://claude.ai/code) [![Agents](https://img.shields.io/badge/Agents-11-green?style=flat-square)](#agent-roster) diff --git a/rust/claude-tools/Cargo.toml b/rust/claude-tools/Cargo.toml index 34f280c..f647979 100644 --- a/rust/claude-tools/Cargo.toml +++ b/rust/claude-tools/Cargo.toml @@ -2,7 +2,7 @@ name = "claude-tools" version = "1.0.0" edition = "2021" -rust-version = "1.75" +rust-version = "1.78" description = "Claude Code productivity CLI: snippet manager, session handoff, cost estimator" authors = ["BcKmini"] license = "MIT" From 3689b6bf35abdd51984ad5f00c3a51b0b69c5318 Mon Sep 17 00:00:00 2001 From: BcKmini Date: Tue, 11 Aug 2026 01:16:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?ci:=20use=20cargo-msrv=20find=20instead=20o?= =?UTF-8?q?f=20verify=20=E2=80=94=20real=20MSRV=20is=20unknown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.78 also failed: a transitive dep (clap 4.6.1's Cargo.toml) requires the `edition2024` cargo feature, stabilized in Cargo 1.85 — unrelated to claude-tools' own code, just how new the pinned dependency tree is. No local cargo to bisect this by hand, and guessing one version per push-and-wait-5-minutes cycle is slow. `cargo msrv find` bisects the actual minimum compatible version in one run instead of asserting a guess. Once it reports the real number, Cargo.toml's rust-version will be set to match and the job switched back to `verify`. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75b660a..5dd94de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,9 +168,9 @@ jobs: - name: Install cargo-msrv run: cargo install cargo-msrv --locked - - name: Verify declared rust-version builds + - name: Discover the real MSRV working-directory: rust/claude-tools - run: cargo msrv verify + run: cargo msrv find # ── Job: Windows smoke build ───────────────────────────────────────────────── windows-smoke: