From 54e255b014c50bb8db8e96710a27a24b869f0681 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 00:26:18 +0000 Subject: [PATCH 1/6] Fixes for v3.0.2 patch release. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix uv-dynamic-versioning: bump must be a boolean, not string (was breaking `uv sync` and editable installs on current versions). - Remove broken `strif:main` script entry point (no such function). - Add Python 3.10 to classifiers (was missing despite `requires-python >=3.10`). - Fix file descriptor leak in `temp_output_file`: cleanup now closes the fd returned by `mkstemp`, not just removes the file. - Reorder `is_truthy` so the bool branch runs (was dead code since bool is a subclass of int). Behavior was correct by coincidence; now correct by construction. - Fix `move_file` to check `dest_path.exists()` for the backup decision (was checking `src_path.exists()` — confusing read, accidentally worked). - `copy_to_backup` is now silent on missing source, matching `move_to_backup`. Previously raised `FileNotFoundError`. - Remove `_RANDOM.seed()` — no-op on `random.SystemRandom`. - Add `DEV_NULL` to `__all__` so it's available as a convenience handle via `from strif import DEV_NULL`. Tests: add `tests/test_files.py` with coverage for `atomic_output_file` (happy path, exception leaves partial, backup, make_parents, target-is-dir raises, force replaces dir), `temp_output_file` (no fd leak, double-close tolerated), `temp_output_dir`, `is_truthy` (bool, string, numeric, sized, strict), `copy_to_backup`, and `move_file`. 26 tests total now pass. --- pyproject.toml | 7 +- src/strif/__init__.py | 1 + src/strif/strif.py | 16 +++-- tests/test_files.py | 149 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 tests/test_files.py diff --git a/pyproject.toml b/pyproject.toml index 873d7a1..b7e910a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -54,10 +55,6 @@ dev = [ "funlog>=0.2.0", ] -[project.scripts] -# Add script entry points here: -strif = "strif:main" - # ---- Build system ---- # Dynamic versioning from: @@ -76,7 +73,7 @@ source = "uv-dynamic-versioning" [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" -bump = "true" +bump = true [tool.hatch.build.targets.wheel] # The source location for the package. diff --git a/src/strif/__init__.py b/src/strif/__init__.py index 3465e1e..90e0a41 100644 --- a/src/strif/__init__.py +++ b/src/strif/__init__.py @@ -2,6 +2,7 @@ # atomic_var.py "AtomicVar", # strif.py + "DEV_NULL", "iso_timestamp", "format_iso_timestamp", "new_uid", diff --git a/src/strif/strif.py b/src/strif/strif.py index eca900c..c374298 100644 --- a/src/strif/strif.py +++ b/src/strif/strif.py @@ -21,6 +21,7 @@ from typing import Any __all__ = ( + "DEV_NULL", "iso_timestamp", "format_iso_timestamp", "new_uid", @@ -60,7 +61,6 @@ DEFAULT_BACKUP_SUFFIX = f"{TIMESTAMP_VAR}{BACKUP_SUFFIX}" _RANDOM = random.SystemRandom() -_RANDOM.seed() # # ---- Timestamps ---- @@ -383,10 +383,10 @@ def is_truthy(value: Any, strict: bool = True) -> bool: return True elif value in falsy_values: return False - elif isinstance(value, (int, float)): - return value != 0 elif isinstance(value, bool): return value + elif isinstance(value, (int, float)): + return value != 0 elif isinstance(value, Sized): return len(value) > 0 @@ -452,9 +452,11 @@ def move_to_backup(path: str | Path, backup_suffix: str = DEFAULT_BACKUP_SUFFIX) def copy_to_backup(path: str | Path, backup_suffix: str = DEFAULT_BACKUP_SUFFIX): """ - Same as `move_to_backup()` but only copies. + Same as `move_to_backup()` but only copies. If the path doesn't exist, do nothing. """ path = Path(path) + if not path.exists(): + return backup_path = _prepare_for_backup(path, backup_suffix) if path.is_dir(): copytree_atomic(path, backup_path) @@ -474,7 +476,7 @@ def move_file( """ if not keep_backup and dest_path.exists(): raise FileExistsError(f"Destination file already exists: {quote_if_needed(str(dest_path))}") - if keep_backup and src_path.exists(): + if keep_backup and dest_path.exists(): move_to_backup(str(dest_path), backup_suffix=backup_suffix) dest_path.parent.mkdir(parents=True, exist_ok=True) @@ -590,6 +592,10 @@ def temp_output_file( result = (fd, Path(path)) def clean(): + try: + os.close(fd) + except OSError: + pass try: rmtree_or_file(result[1], ignore_errors=True) except OSError: diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..2120fcd --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,149 @@ +import os +from pathlib import Path + +import pytest + +from strif import ( + atomic_output_file, + copy_to_backup, + is_truthy, + move_file, + temp_output_dir, + temp_output_file, +) + + +def test_atomic_output_file_happy_path(tmp_path: Path): + out = tmp_path / "out.txt" + with atomic_output_file(out) as tmp: + tmp.write_text("hello") + assert out.read_text() == "hello" + assert not list(tmp_path.glob("*.partial")) + + +def test_atomic_output_file_leaves_partial_on_exception(tmp_path: Path): + out = tmp_path / "out.txt" + with pytest.raises(RuntimeError): + with atomic_output_file(out) as tmp: + tmp.write_text("partial") + raise RuntimeError("boom") + assert not out.exists() + assert list(tmp_path.glob("*.partial")) + + +def test_atomic_output_file_with_backup_suffix(tmp_path: Path): + out = tmp_path / "out.txt" + out.write_text("old") + with atomic_output_file(out, backup_suffix=".bak") as tmp: + tmp.write_text("new") + assert out.read_text() == "new" + assert (tmp_path / "out.txt.bak").read_text() == "old" + + +def test_atomic_output_file_make_parents(tmp_path: Path): + target = tmp_path / "sub" / "nested" / "out.txt" + with atomic_output_file(target, make_parents=True) as tmp: + tmp.write_text("deep") + assert target.read_text() == "deep" + + +def test_atomic_output_file_raises_if_target_is_dir(tmp_path: Path): + target = tmp_path / "target" + target.mkdir() + with pytest.raises(FileExistsError): + with atomic_output_file(target) as tmp: + tmp.write_text("x") + + +def test_atomic_output_file_force_replaces_dir(tmp_path: Path): + target = tmp_path / "target" + target.mkdir() + (target / "child.txt").write_text("child") + with atomic_output_file(target, force=True) as tmp: + tmp.write_text("now a file") + assert target.is_file() + assert target.read_text() == "now a file" + + +@pytest.mark.skipif(not os.path.exists("/proc/self/fd"), reason="Linux-only fd accounting") +def test_temp_output_file_no_fd_leak(): + before = len(os.listdir("/proc/self/fd")) + for _ in range(5): + with temp_output_file() as (_fd, _path): + pass + after = len(os.listdir("/proc/self/fd")) + assert after == before + + +def test_temp_output_file_user_close_still_works(): + with temp_output_file() as (fd, _path): + os.close(fd) # Double-close in cleanup must be swallowed. + + +def test_temp_output_dir_cleanup(): + with temp_output_dir() as d: + (d / "a.txt").write_text("x") + assert not d.exists() + + +def test_is_truthy_bool_inputs(): + assert is_truthy(True) is True + assert is_truthy(False) is False + + +def test_is_truthy_string_inputs(): + for val in ("true", "yes", "1", "on", "y", "True", " YES "): + assert is_truthy(val) is True, f"Expected True for {val!r}" + for val in ("false", "no", "0", "off", "n", ""): + assert is_truthy(val) is False, f"Expected False for {val!r}" + + +def test_is_truthy_numeric(): + assert is_truthy(0) is False + assert is_truthy(1) is True + assert is_truthy(0.0) is False + assert is_truthy(3.14) is True + + +def test_is_truthy_sized(): + assert is_truthy([]) is False + assert is_truthy([1]) is True + assert is_truthy({}) is False + assert is_truthy({"a": 1}) is True + + +def test_is_truthy_strict(): + with pytest.raises(ValueError): + is_truthy(object(), strict=True) + assert is_truthy(object(), strict=False) is True + + +def test_copy_to_backup_silent_if_missing(tmp_path: Path): + copy_to_backup(tmp_path / "nonexistent.txt") + + +def test_copy_to_backup_copies(tmp_path: Path): + src = tmp_path / "file.txt" + src.write_text("data") + copy_to_backup(src, backup_suffix=".bak") + assert src.read_text() == "data" + assert (tmp_path / "file.txt.bak").read_text() == "data" + + +def test_move_file_with_backup(tmp_path: Path): + src = tmp_path / "src.txt" + dest = tmp_path / "dest.txt" + dest.write_text("old") + src.write_text("new") + move_file(src, dest, keep_backup=True, backup_suffix=".bak") + assert dest.read_text() == "new" + assert not src.exists() + assert (tmp_path / "dest.txt.bak").read_text() == "old" + + +def test_move_file_creates_parents(tmp_path: Path): + src = tmp_path / "src.txt" + src.write_text("content") + dest = tmp_path / "sub" / "dest.txt" + move_file(src, dest, keep_backup=False) + assert dest.read_text() == "content" From 58eea680c3259ad480e599bc4789c82bbe5124c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 00:29:13 +0000 Subject: [PATCH 2/6] Add tbd v0.1.28 project setup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialize tbd issue tracker with prefix `strif` (rendered as `strif-N`). Adds .tbd/ config and .claude/ integration files. Sync branch: tbd-sync. Note: this commit is orthogonal to the v3.0.2 patch fixes also on this branch — feel free to split out before merging if you prefer to land them separately. --- .claude/.gitignore | 2 + .claude/hooks/tbd-closing-reminder.sh | 15 ++ .claude/scripts/ensure-gh-cli.sh | 88 +++++++++ .claude/scripts/tbd-session.sh | 77 ++++++++ .claude/settings.json | 47 +++++ .claude/skills/tbd/SKILL.md | 259 ++++++++++++++++++++++++++ .tbd/.gitattributes | 2 + .tbd/.gitignore | 21 +++ .tbd/config.yml | 93 +++++++++ 9 files changed, 604 insertions(+) create mode 100644 .claude/.gitignore create mode 100755 .claude/hooks/tbd-closing-reminder.sh create mode 100755 .claude/scripts/ensure-gh-cli.sh create mode 100755 .claude/scripts/tbd-session.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/tbd/SKILL.md create mode 100644 .tbd/.gitattributes create mode 100644 .tbd/.gitignore create mode 100644 .tbd/config.yml diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 0000000..d884526 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,2 @@ +# Backup files +*.bak diff --git a/.claude/hooks/tbd-closing-reminder.sh b/.claude/hooks/tbd-closing-reminder.sh new file mode 100755 index 0000000..a6056b0 --- /dev/null +++ b/.claude/hooks/tbd-closing-reminder.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Remind about close protocol after git push +# Installed by: tbd setup claude + +input=$(cat) +command=$(echo "$input" | jq -r '.tool_input.command // empty') + +# Check if this is a git push command and .tbd exists +if [[ "$command" == git\ push* ]] || [[ "$command" == *"&& git push"* ]] || [[ "$command" == *"; git push"* ]]; then + if [ -d ".tbd" ]; then + tbd closing + fi +fi + +exit 0 diff --git a/.claude/scripts/ensure-gh-cli.sh b/.claude/scripts/ensure-gh-cli.sh new file mode 100755 index 0000000..aac98e4 --- /dev/null +++ b/.claude/scripts/ensure-gh-cli.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Automated GitHub CLI setup for Claude Code sessions +# This script runs on SessionStart to ensure gh CLI is available and authenticated + +set -e + +# Add common binary locations to PATH +export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH" + +# Check if gh is already installed +if command -v gh &> /dev/null; then + echo "[gh] CLI found at $(which gh)" +else + echo "[gh] CLI not found, installing..." + + # Detect platform + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + [ "$ARCH" = "x86_64" ] && ARCH="amd64" + [ "$ARCH" = "aarch64" ] && ARCH="arm64" + + echo "[gh] Detected platform: ${OS}_${ARCH}" + + # Get latest version from GitHub API (with fallback) + GH_VERSION=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest 2>/dev/null \ + | grep -o '"tag_name": *"v[^"]*"' | head -1 | sed 's/.*"v\([^"]*\)".*/\1/') + + # Fallback version if API fails + GH_VERSION=${GH_VERSION:-2.83.1} + + echo "[gh] Version: ${GH_VERSION}" + + # Build download URL based on platform + if [ "$OS" = "darwin" ]; then + DOWNLOAD_URL="https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_macOS_${ARCH}.zip" + ARCHIVE_EXT="zip" + else + DOWNLOAD_URL="https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_${OS}_${ARCH}.tar.gz" + ARCHIVE_EXT="tar.gz" + fi + + echo "[gh] Downloading from ${DOWNLOAD_URL}..." + + # Download + curl -fsSL -o "/tmp/gh.${ARCHIVE_EXT}" "$DOWNLOAD_URL" + + # Extract based on archive type + if [ "$ARCHIVE_EXT" = "zip" ]; then + unzip -q "/tmp/gh.zip" -d /tmp + EXTRACT_DIR="/tmp/gh_${GH_VERSION}_macOS_${ARCH}" + else + tar -xzf "/tmp/gh.tar.gz" -C /tmp + EXTRACT_DIR="/tmp/gh_${GH_VERSION}_${OS}_${ARCH}" + fi + + # Install to ~/.local/bin (works in cloud and local) + mkdir -p ~/.local/bin + cp "${EXTRACT_DIR}/bin/gh" ~/.local/bin/gh + chmod +x ~/.local/bin/gh + + # Clean up + rm -rf "${EXTRACT_DIR}" "/tmp/gh.${ARCHIVE_EXT}" + + echo "[gh] Installed to ~/.local/bin/gh" +fi + +# Verify gh is now in PATH +if ! command -v gh &> /dev/null; then + echo "[gh] ERROR: gh CLI still not found in PATH after installation" + echo "[gh] Ensure ~/.local/bin is in your PATH" + exit 1 +fi + +# Check authentication status +if [ -n "$GH_TOKEN" ]; then + # GH_TOKEN is set, verify it works + if gh auth status &> /dev/null; then + echo "[gh] Authenticated successfully" + else + echo "[gh] WARNING: GH_TOKEN is set but authentication check failed" + echo "[gh] Token may be invalid or expired" + fi +else + echo "[gh] NOTE: GH_TOKEN not set - some operations may require authentication" + echo "[gh] See: docs/general/agent-setup/github-cli-setup.md" +fi + +exit 0 diff --git a/.claude/scripts/tbd-session.sh b/.claude/scripts/tbd-session.sh new file mode 100755 index 0000000..ab249be --- /dev/null +++ b/.claude/scripts/tbd-session.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Ensure tbd CLI is installed and run tbd prime for Claude Code sessions +# Installed by: tbd setup --auto +# This script runs on SessionStart and PreCompact + +# Get npm global bin directory (if npm is available) +NPM_GLOBAL_BIN="" +if command -v npm &> /dev/null; then + NPM_PREFIX=$(npm config get prefix 2>/dev/null) + if [ -n "$NPM_PREFIX" ] && [ -d "$NPM_PREFIX/bin" ]; then + NPM_GLOBAL_BIN="$NPM_PREFIX/bin" + fi +fi + +# Add common binary locations to PATH (persists for entire script) +# Include npm global bin if found +export PATH="$NPM_GLOBAL_BIN:$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH" + +# Function to ensure tbd is available +ensure_tbd() { + # Check if tbd is already installed + if command -v tbd &> /dev/null; then + return 0 + fi + + echo "[tbd] CLI not found, installing..." + + # Try npm first (most common for Node.js tools) + if command -v npm &> /dev/null; then + echo "[tbd] Installing via npm..." + npm install -g get-tbd 2>/dev/null || { + # If global install fails (permissions), try local install + echo "[tbd] Global npm install failed, trying user install..." + mkdir -p ~/.local/bin + npm install --prefix ~/.local get-tbd + # Create symlink if needed + if [ -f ~/.local/node_modules/.bin/tbd ]; then + ln -sf ~/.local/node_modules/.bin/tbd ~/.local/bin/tbd + fi + } + elif command -v pnpm &> /dev/null; then + echo "[tbd] Installing via pnpm..." + pnpm add -g get-tbd + elif command -v yarn &> /dev/null; then + echo "[tbd] Installing via yarn..." + yarn global add get-tbd + else + echo "[tbd] ERROR: No package manager found (npm, pnpm, or yarn required)" + echo "[tbd] Please install Node.js and npm, then run: npm install -g get-tbd" + return 1 + fi + + # Verify installation + if command -v tbd &> /dev/null; then + echo "[tbd] Successfully installed to $(which tbd)" + return 0 + else + echo "[tbd] WARNING: tbd installed but not found in PATH" + echo "[tbd] Checking common locations..." + # Try to find and add to path (include npm global bin) + for dir in "$NPM_GLOBAL_BIN" ~/.local/bin ~/.local/node_modules/.bin /usr/local/bin; do + if [ -n "$dir" ] && [ -x "$dir/tbd" ]; then + export PATH="$dir:$PATH" + echo "[tbd] Found at $dir/tbd" + return 0 + fi + done + echo "[tbd] Could not locate tbd after installation" + return 1 + fi +} + +# Main +ensure_tbd || exit 1 + +# Run tbd prime with any passed arguments (e.g., --brief for PreCompact) +tbd prime "$@" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..31d9eef --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,47 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/tbd-session.sh" + } + ] + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/ensure-gh-cli.sh", + "timeout": 120 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/tbd-session.sh --brief" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tbd-closing-reminder.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/tbd/SKILL.md b/.claude/skills/tbd/SKILL.md new file mode 100644 index 0000000..f7ea19d --- /dev/null +++ b/.claude/skills/tbd/SKILL.md @@ -0,0 +1,259 @@ +--- +allowed-tools: Bash(tbd:*), Read, Write +description: |- + Git-native issue tracking (beads), coding guidelines, knowledge injection, and spec-driven planning for AI agents. Drop-in replacement for bd/Beads with simpler architecture. + Use for: tracking issues/beads with dependencies, creating bugs/features/tasks, planning specs, implementing features from specs, code reviews, committing code, creating PRs, loading coding guidelines (TypeScript, Python, TDD, golden testing, Convex, monorepo patterns), code cleanup, research briefs, architecture docs, agent handoffs, and checking out third-party library source code. + Invoke when user mentions: tbd, beads, bd, shortcuts, issues, bugs, tasks, features, epics, todo, tracking, specs, planning, implementation, validation, guidelines, templates, commit, PR, pull request, code review, testing, TDD, test-driven, golden testing, snapshot testing, TypeScript, Python, Convex, monorepo, cleanup, dead code, refactor, handoff, research, architecture, labels, search, checkout library, source code review, or any workflow shortcut. +name: tbd +--- + + +--- +title: tbd Workflow +description: Full tbd workflow guide for agents +--- +**`tbd` helps humans and agents ship code with greater speed, quality, and discipline.** + +1. **Beads**: Git-native issue tracking (tasks, bugs, features). + Never lose work across sessions. + Drop-in replacement for `bd`. +2. **Spec-Driven Workflows**: Plan features → break into beads → implement + systematically. +3. **Knowledge Injection**: 17+ engineering guidelines (TypeScript, Python, TDD, + testing, Convex, monorepos) available on demand. +4. **Shortcuts**: Reusable instruction templates for common workflows (code review, + commits, PRs, cleanup, handoffs). + +## Installation + +```bash +npm install -g get-tbd@latest +tbd setup --auto --prefix= # Fresh project (--prefix is REQUIRED: 2-8 alphabetic chars recommended. ALWAYS ASK THE USER FOR THE PREFIX; do not guess it) +tbd setup --auto # Existing tbd project (prefix already set) +tbd setup --from-beads # Migration from .beads/ if `bd` has been used +``` + +## Routine Commands + +```bash +tbd --help # Command reference +tbd status # Status +tbd doctor # If there are problems + +tbd setup --auto # Run any time to refresh setup +tbd prime # Restore full context on tbd after compaction +``` + +## CRITICAL: You Operate tbd — The User Doesn’t + +**You are the tbd operator:** Users talk naturally; you translate their requests to tbd +actions. DO NOT tell users to run tbd commands. +That’s your job. + +- **WRONG**: “Run `tbd create` to track this bug” + +- **RIGHT**: *(you run `tbd create` yourself and tell the user it’s tracked)* + +**Welcoming a user:** When users ask “what is tbd?” +or want help → run `tbd shortcut welcome-user` + +## User Request → Agent Action + +| User Says | You (the Agent) Run | +| --- | --- | +| **Issues/Beads** | | +| “There’s a bug where …” | `tbd create "..." --type=bug` | +| “Create a task/feature for …” | `tbd create "..." --type=task` or `--type=feature` | +| “Let’s work on issues/beads” | `tbd ready` | +| “Show me issue X” | `tbd show ` | +| “Close this issue” | `tbd close ` | +| “Search issues for X” | `tbd search "X"` | +| “Add label X to issue” | `tbd label add