diff --git a/.STATUS b/.STATUS index 6e97029c4..81771b6f0 100644 --- a/.STATUS +++ b/.STATUS @@ -4,12 +4,32 @@ ## Project: flow-cli ## Type: zsh-plugin ## Status: active -## Focus: post-release +## Focus: --help ## Phase: Released ## Priority: 2 ## Progress: 100 -## Current Session (2026-03-11) +## Current Session (2026-05-13) + +**Session activity:** +- feat(doctor): wired lib/doctor-cache.zsh into legacy GitHub token validation — fingerprint cache key (sha256 prefix of token, 1h TTL), `--no-cache` flag, JSON envelope `{http_code, username}` +- refactor(doctor): extracted `_doctor_check_github_token(no_cache)` helper; doctor() now dispatches via one line; helper is independently testable +- test isolation: `DOCTOR_CACHE_DIR` exported pre-source (cache lib marks it readonly post-source) +- tests: +4 functions calling helper directly (not full doctor) — cuts test runtime by ~14s +- empirical timing: cold flow doctor 11.3s → warm 10.8s (~5%, smaller than orchestrate estimate because `_dots_doctor_integration` was already provider-cached) +- result: 53 test files pass, 1 expected interactive timeout; test-doctor 25s standalone, exit 0 under `timeout 30` +- discovered (filed): test-framework `create_mock` save/restore is broken for binaries — `tail -n +2` strips opening `{` but keeps closing `}`, eval parse error on second mock +- discovered (filed): `_doctor_cache_acquire_lock` mkdir-fallback leaks state across in-process invocations; cache_set rc=1 on second call with same key + +**Correction (2026-05-13):** Commit `0880f924` (first push of this branch) introduced a test-doctor timeout regression that the commit message and .STATUS both misreported as "52/52 passing". Run-all.sh actually reported "2 timeout" — only one (e2e-em-dispatcher) is documented as expected; the other was test-doctor running ~40s standalone under the 30s `timeout` wrapper in `tests/run-all.sh`. Caught during the optional Step 4 follow-up review when re-checking `time timeout 30 zsh tests/test-doctor.zsh`. Fixed in the follow-up refactor commit by extracting `_doctor_check_github_token` so cache tests skip the slow system-check section. + +**Previous Session (2026-05-13 earlier):** +- fix(tests): test-doctor timeout under run-all.sh — cached `doctor --verbose` output in setup() and reused for both `--verbose` and `-v` tests (which test identical output since `-v` aliases `--verbose` per commands/doctor.zsh:39) +- root cause: 3 redundant `$(doctor ...)` calls in test, each hitting `curl https://api.github.com/user` for ~5-8s of network wait +- timing: 38.86s standalone → 23.51s wrapped (40% reduction) +- result: 23/23 passing, exit 0 under `timeout 30 zsh ...` + +## Previous Session (2026-03-11) **Session activity:** - fix: diagnosed examark Homebrew release failure — two root causes: @@ -222,7 +242,7 @@ | Worktree | Branch | Status | |----------|--------|--------| -| Main repo | `dev` | v7.6.0 released, all clean | +| Main repo | `dev` | v7.6.0 released, all clean (PR #446 wire-doctor-cache merged 2026-05-14) | --- @@ -232,6 +252,14 @@ - Current coverage: ~50% (348 functions documented) - Target: 80% +### CI runs smoke tests only — full suite not gated (filed 2026-05-13) +- `.github/workflows/test.yml:39-44` runs only `test-flow.zsh` + `test-install.sh` +- Comment is explicit: "Smoke tests only - run full suite locally with: `./tests/run-all.sh`" +- The required status check "ZSH Plugin Tests" on `main` therefore does NOT verify the 205-file suite +- Surfaced during PR #446: the test-doctor regression from commit `0880f924` would have passed CI green despite test-doctor exiting 124 under the harness +- Impact: relies on developer discipline to run `./tests/run-all.sh` locally before opening PRs +- Options to consider: (B) add `test-doctor.zsh` specifically (~30s job time), or (C) add full `./tests/run-all.sh` as a separate slower job (~4min). Either is a separate workflow PR, not bundled with feature work + ### Future Enhancements - Token automation Phases 2-4 (multi-token, gamification) - Config → concept graph integration @@ -242,14 +270,14 @@ ## Next Action -1. Profile `test-doctor` >30s timeout (passes 23/23 standalone, but `run_test`'s 30s ceiling fails it under run-all.sh) +1. Optional follow-up — drop `CACHED_DOCTOR_VERBOSE` workaround from test-doctor.zsh setup() now that the production cache also benefits second invocations 2. API documentation push (50% → 80%) 3. Code workspace manager — spec ready on dev --- -**Last Updated:** 2026-03-11 -**Status:** v7.6.0 | 52/52 tests passing (2 expected interactive/tmux timeouts) | 15 dispatchers + at bridge | 205 test files | 12000+ test functions | 0 lint errors | 0 broken links -## wins: Fixed the regression bug (2026-05-04), --category fix squashed the bug (2026-05-04), fixed the bug (2026-05-04), Fixed the regression bug (2026-05-04), --category fix squashed the bug (2026-05-04) +**Last Updated:** 2026-05-15 +**Status:** v7.7.0 | 54/54 tests passing (1 expected interactive/tmux timeout) | 15 dispatchers + at bridge | 206 test files | 12000+ test functions | 0 lint errors | 0 broken links +## wins: Fixed the regression bug (2026-05-15), --category fix squashed the bug (2026-05-15), fixed the bug (2026-05-15), Fixed the regression bug (2026-05-14), --category fix squashed the bug (2026-05-14) ## streak: 1 -## last_active: 2026-05-04 12:36 +## last_active: 2026-05-15 11:22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 02985966e..194e20ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [7.7.0] — 2026-05-15 — doctor cache + zsh-scope hardening + +### Added + +- **`flow doctor` GitHub token validation cache** — Subsequent `flow doctor` invocations within 1 hour skip the GitHub `/user` API call (~5–8s saved per warm run). Fingerprint-based cache key (sha256 prefix of token) auto-invalidates on rotation. +- **`flow doctor --no-cache`** — Bypasses the cache and forces a fresh API validation. Useful when troubleshooting "why is my token broken?". +- **Regression test: `tests/test-readonly-scope-regression.zsh`** — Five-test guard preventing reintroduction of the bare-`readonly` zsh scope trap, including a functional proof that the bug still exists in current zsh. + +### Fixed + +- **doctor-cache silent write failures** — Module constants in `lib/doctor-cache.zsh` were declared with `readonly` instead of `typeset -gr`. When the lib was sourced from inside a function (e.g., a test harness `setup()`), zsh treated the `readonly` declarations as function-local; they vanished when the caller returned. The persistent load-guard then suppressed re-initialization. Symptom: `max_attempts=$((DOCTOR_CACHE_LOCK_TIMEOUT * 10))` evaluated to `0`, the lock-acquire loop ran zero iterations, and `_doctor_cache_set` failed with "Failed to acquire cache lock for writing." Production was unaffected (top-level sourcing keeps globals); only test harnesses surfaced the bug. +- **Preventive: same fix in `lib/analysis-cache.zsh` and `lib/macro-parser.zsh`** — Same pattern, not yet bitten in practice. Switched to `typeset -gr` (and `typeset -gar` for arrays). +- **`create_mock` save/restore destroyed originals** — `tests/test-framework.zsh` used `whence -f $fn | tail -n +2` to capture function bodies, but `tail -n +2` left the trailing `}` in the body. Wrapping it in a new `funcname() { ... }` template produced unbalanced braces and a silent eval parse error. The save failure cascaded: `reset_mocks` couldn't find the saved original, fell into the `unset -f $fn_name` branch, and **destroyed the real function**. Replaced eval-string round-tripping with zsh's `${functions[name]}` associative array — saves the body without surrounding braces and restores losslessly. + +### Changed + +- **Removed `CACHED_DOCTOR_VERBOSE` workaround** in `tests/test-doctor.zsh` — The doctor cache wired in the previous PR already deduplicates `doctor --verbose` calls via the disk-shared fingerprint cache. The setup-side cache became redundant. Side benefit: `test_doctor_v_flag` now actually exercises the `-v` alias instead of re-checking the cached `--verbose` output. + +--- + ## [7.6.0] — 2026-02-27 — em --prompt + Scholar Config Sync ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8ac834ef2..228686c05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code when working with code in this reposi **flow-cli** - Pure ZSH plugin for ADHD-optimized workflow management. Zero dependencies. Standalone (works without Oh-My-Zsh or any plugin manager). - **Architecture:** Pure ZSH plugin (no Node.js runtime required) -- **Current Version:** v7.6.0 +- **Current Version:** v7.7.0 - **Install:** Homebrew (recommended), or any plugin manager - **Source:** `source /opt/homebrew/opt/flow-cli/flow.plugin.zsh` (via Homebrew) - **Optional:** Atlas integration for enhanced state management @@ -194,7 +194,7 @@ flow-cli/ ├── docs/ # Documentation (MkDocs) │ └── internal/ # Internal conventions & contributor templates ├── scripts/ # Standalone validators (check-math.zsh) -├── tests/ # 205 test files, 12000+ test functions +├── tests/ # 206 test files, 12000+ test functions │ └── fixtures/demo-course/ # STAT-101 demo course for E2E └── .archive/ # Archived Node.js CLI ```zsh @@ -261,7 +261,7 @@ Update: `MASTER-DISPATCHER-GUIDE.md`, `QUICK-REFERENCE.md`, `mkdocs.yml` ## Testing -**205 test files, 12000+ test functions.** Run: `./tests/run-all.sh` (52/52 passing, 2 expected interactive/tmux timeouts) or individual suites in `tests/`. +**206 test files, 12000+ test functions.** Run: `./tests/run-all.sh` (54/54 passing, 1 expected interactive/tmux timeout) or individual suites in `tests/`. See `docs/guides/TESTING.md` for patterns, mocks, assertions, TDD workflow. @@ -289,8 +289,8 @@ export FLOW_DEBUG=1 # Debug mode ## Current Status -**Version:** v7.6.0 | **Tests:** 12000+ (52/52 suite, 2 interactive timeouts) | **Docs:** https://Data-Wise.github.io/flow-cli/ +**Version:** v7.7.0 | **Tests:** 12000+ (54/54 suite, 1 interactive timeout) | **Docs:** https://Data-Wise.github.io/flow-cli/ --- -**Last Updated:** 2026-02-27 (v7.6.0) +**Last Updated:** 2026-05-15 (v7.7.0) diff --git a/commands/doctor.zsh b/commands/doctor.zsh index 0bccafffb..4fe0da647 100644 --- a/commands/doctor.zsh +++ b/commands/doctor.zsh @@ -29,6 +29,9 @@ doctor() { # Task 4: Verbosity levels local verbosity_level="normal" # quiet, normal, verbose + # Cache bypass: --no-cache forces fresh GitHub token validation + local no_cache=false + # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in @@ -38,6 +41,7 @@ doctor() { --yes|-y) auto_yes=true; shift ;; --verbose|-v) verbose=true; verbosity_level="verbose"; shift ;; --quiet|-q) verbosity_level="quiet"; shift ;; + --no-cache) no_cache=true; shift ;; # Task 1: Token flags --dot) @@ -390,79 +394,9 @@ doctor() { # ────────────────────────────────────────────────────────────── # GITHUB TOKEN HEALTH # ────────────────────────────────────────────────────────────── - # Note: This is the legacy token check. Future phases will delegate to tok expiring + # Note: legacy token check. Future phases will delegate to tok expiring. if [[ "$dot_check" == false ]]; then - _doctor_log_quiet "${FLOW_COLORS[bold]}🔑 GITHUB TOKEN${FLOW_COLORS[reset]}" - - local token=$(sec github-token 2>/dev/null) - local -a token_issues=() - - if [[ -z "$token" ]]; then - _doctor_log_quiet " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Not configured" - token_issues+=("missing") - else - # Validate token via API - local api_response=$(curl -s -w "\n%{http_code}" \ - -H "Authorization: token $token" \ - "https://api.github.com/user" 2>/dev/null) - - local http_code=$(echo "$api_response" | tail -1) - local username=$(echo "$api_response" | sed '$d' | jq -r '.login // "unknown"') - - if [[ "$http_code" != "200" ]]; then - _doctor_log_quiet " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Invalid/Expired" - token_issues+=("invalid") - else - _doctor_log_quiet " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Valid (@$username)" - - # Check expiration - local age_days=$(_tok_age_days "github-token") - local days_remaining=$((90 - age_days)) - - if [[ $days_remaining -le 7 ]]; then - _doctor_log_quiet " ${FLOW_COLORS[warning]}⚠${FLOW_COLORS[reset]} Expiring in $days_remaining days" - token_issues+=("expiring") - fi - - # Test token-dependent services (verbose only) - _doctor_log_verbose "" - _doctor_log_verbose " ${FLOW_COLORS[muted]}Token-Dependent Services:${FLOW_COLORS[reset]}" - - # Test gh CLI - if command -v gh &>/dev/null; then - if gh auth status &>/dev/null 2>&1; then - _doctor_log_verbose " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} gh CLI authenticated" - else - _doctor_log_verbose " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} gh CLI not authenticated" - token_issues+=("gh-cli") - fi - else - _doctor_log_verbose " ${FLOW_COLORS[muted]}○${FLOW_COLORS[reset]} gh CLI not installed" - fi - - # Test Claude Code MCP - if [[ -f "$HOME/.claude/settings.json" ]]; then - if grep -q "GITHUB_PERSONAL_ACCESS_TOKEN.*\${GITHUB_TOKEN}" "$HOME/.claude/settings.json"; then - if [[ -n "$GITHUB_TOKEN" ]]; then - _doctor_log_verbose " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Claude Code MCP configured" - else - _doctor_log_verbose " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} \$GITHUB_TOKEN not exported" - token_issues+=("env-var") - fi - else - _doctor_log_verbose " ${FLOW_COLORS[warning]}⚠${FLOW_COLORS[reset]} Claude MCP not using env var" - token_issues+=("mcp-config") - fi - fi - fi - fi - - # Store token issues for category selection - if [[ ${#token_issues[@]} -gt 0 ]]; then - _doctor_token_issues[github]="${token_issues[*]}" - fi - - _doctor_log_quiet "" + _doctor_check_github_token "$no_cache" fi # ────────────────────────────────────────────────────────────── @@ -1763,6 +1697,112 @@ _doctor_confirm() { esac } +# Validate the github-token via GitHub /user API, with file-based cache. +# Side effects: prints status lines via _doctor_log_*; mutates global +# _doctor_token_issues[github] when problems are found; reads/writes to +# $DOCTOR_CACHE_DIR via lib/doctor-cache.zsh. +# +# Args: +# $1 - "true" to bypass cache, anything else uses cache (default: false) +_doctor_check_github_token() { + local no_cache="${1:-false}" + + _doctor_log_quiet "${FLOW_COLORS[bold]}🔑 GITHUB TOKEN${FLOW_COLORS[reset]}" + + local token=$(sec github-token 2>/dev/null) + local -a token_issues=() + + if [[ -z "$token" ]]; then + _doctor_log_quiet " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Not configured" + token_issues+=("missing") + else + # Cache key derives from token sha256 prefix so rotation auto-invalidates. + local http_code username + local cache_key="" cached="" + + if [[ "$no_cache" == false ]] && (( $+functions[_doctor_cache_get] )) \ + && command -v shasum >/dev/null 2>&1; then + local token_fp=$(printf '%s' "$token" | shasum -a 256 | cut -c1-12) + cache_key="token-github-${token_fp}" + cached=$(_doctor_cache_get "$cache_key" 2>/dev/null) + fi + + if [[ -n "$cached" ]] && command -v jq >/dev/null 2>&1; then + http_code=$(echo "$cached" | jq -r '.http_code // ""') + username=$(echo "$cached" | jq -r '.username // "unknown"') + _doctor_log_verbose " ${FLOW_COLORS[muted]}[Cache hit]${FLOW_COLORS[reset]}" + else + [[ -n "$cache_key" ]] && _doctor_log_verbose " ${FLOW_COLORS[muted]}[Cache miss - validating...]${FLOW_COLORS[reset]}" + local api_response=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: token $token" \ + "https://api.github.com/user" 2>/dev/null) + + http_code=$(echo "$api_response" | tail -1) + username=$(echo "$api_response" | sed '$d' | jq -r '.login // "unknown"') + + # Cache only successful validations (don't cache transient curl failures) + if [[ -n "$cache_key" ]] && [[ "$http_code" == "200" ]] \ + && (( $+functions[_doctor_cache_set] )); then + local cache_value=$(jq -nc \ + --arg http_code "$http_code" \ + --arg username "$username" \ + '{http_code: $http_code, username: $username}') + _doctor_cache_set "$cache_key" "$cache_value" 3600 2>/dev/null || true + fi + fi + + if [[ "$http_code" != "200" ]]; then + _doctor_log_quiet " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} Invalid/Expired" + token_issues+=("invalid") + else + _doctor_log_quiet " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Valid (@$username)" + + local age_days=$(_tok_age_days "github-token") + local days_remaining=$((90 - age_days)) + + if [[ $days_remaining -le 7 ]]; then + _doctor_log_quiet " ${FLOW_COLORS[warning]}⚠${FLOW_COLORS[reset]} Expiring in $days_remaining days" + token_issues+=("expiring") + fi + + # Test token-dependent services (verbose only) + _doctor_log_verbose "" + _doctor_log_verbose " ${FLOW_COLORS[muted]}Token-Dependent Services:${FLOW_COLORS[reset]}" + + if command -v gh &>/dev/null; then + if gh auth status &>/dev/null 2>&1; then + _doctor_log_verbose " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} gh CLI authenticated" + else + _doctor_log_verbose " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} gh CLI not authenticated" + token_issues+=("gh-cli") + fi + else + _doctor_log_verbose " ${FLOW_COLORS[muted]}○${FLOW_COLORS[reset]} gh CLI not installed" + fi + + if [[ -f "$HOME/.claude/settings.json" ]]; then + if grep -q "GITHUB_PERSONAL_ACCESS_TOKEN.*\${GITHUB_TOKEN}" "$HOME/.claude/settings.json"; then + if [[ -n "$GITHUB_TOKEN" ]]; then + _doctor_log_verbose " ${FLOW_COLORS[success]}✓${FLOW_COLORS[reset]} Claude Code MCP configured" + else + _doctor_log_verbose " ${FLOW_COLORS[error]}✗${FLOW_COLORS[reset]} \$GITHUB_TOKEN not exported" + token_issues+=("env-var") + fi + else + _doctor_log_verbose " ${FLOW_COLORS[warning]}⚠${FLOW_COLORS[reset]} Claude MCP not using env var" + token_issues+=("mcp-config") + fi + fi + fi + fi + + if [[ ${#token_issues[@]} -gt 0 ]]; then + _doctor_token_issues[github]="${token_issues[*]}" + fi + + _doctor_log_quiet "" +} + _doctor_help() { echo "" echo "${FLOW_COLORS[header]}╭─────────────────────────────────────────────╮${FLOW_COLORS[reset]}" @@ -1794,6 +1834,7 @@ _doctor_help() { echo "" echo "${FLOW_COLORS[bold]}OTHER OPTIONS${FLOW_COLORS[reset]}" echo " -y, --yes Skip confirmations (use with --fix)" + echo " --no-cache Bypass GitHub token validation cache (force fresh API call)" echo " -h, --help Show this help" echo "" echo "${FLOW_COLORS[bold]}EXAMPLES${FLOW_COLORS[reset]}" @@ -1805,6 +1846,7 @@ _doctor_help() { echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --fix-token # Fix token issues only" echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --quiet # Show only errors" echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --verbose # Show detailed info + cache status" + echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --no-cache # Force fresh GitHub token validation" echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --ai # Get AI help deciding what to install" echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} doctor --update-docs # Regenerate documentation" echo " ${FLOW_COLORS[muted]}\$${FLOW_COLORS[reset]} flow doctor # Also works via flow command" diff --git a/completions/_flow b/completions/_flow index 819aa5ef3..043af51ae 100644 --- a/completions/_flow +++ b/completions/_flow @@ -245,6 +245,11 @@ _flow() { '-y:Skip confirmations (short)' '--verbose:Verbose output' '-v:Verbose (short)' + '--quiet:Minimal output (errors only)' + '-q:Quiet (short)' + '--dot:Check only DOT tokens (isolated, < 3s)' + '--fix-token:Fix only token issues (< 60s)' + '--no-cache:Bypass GitHub token validation cache' '--help:Show help' ) _describe -t options 'option' doctor_opts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ff4b5ad33..1a0603670 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/), and this pro --- +## [7.7.0] — 2026-05-15 — doctor cache + zsh-scope hardening + +### Added + +- **`flow doctor` GitHub token validation cache** — Subsequent `flow doctor` invocations within 1 hour skip the GitHub `/user` API call (~5–8s saved per warm run). Fingerprint-based cache key (sha256 prefix of token) auto-invalidates on rotation. +- **`flow doctor --no-cache`** — Bypasses the cache and forces a fresh API validation. Useful when troubleshooting "why is my token broken?". +- **Regression test: `tests/test-readonly-scope-regression.zsh`** — Five-test guard preventing reintroduction of the bare-`readonly` zsh scope trap, including a functional proof that the bug still exists in current zsh. + +### Fixed + +- **doctor-cache silent write failures** — Module constants in `lib/doctor-cache.zsh` were declared with `readonly` instead of `typeset -gr`. When the lib was sourced from inside a function (e.g., a test harness `setup()`), zsh treated the `readonly` declarations as function-local; they vanished when the caller returned. The persistent load-guard then suppressed re-initialization. Symptom: `max_attempts=$((DOCTOR_CACHE_LOCK_TIMEOUT * 10))` evaluated to `0`, the lock-acquire loop ran zero iterations, and `_doctor_cache_set` failed with "Failed to acquire cache lock for writing." Production was unaffected (top-level sourcing keeps globals); only test harnesses surfaced the bug. +- **Preventive: same fix in `lib/analysis-cache.zsh` and `lib/macro-parser.zsh`** — Same pattern, not yet bitten in practice. Switched to `typeset -gr` (and `typeset -gar` for arrays). +- **`create_mock` save/restore destroyed originals** — `tests/test-framework.zsh` used `whence -f $fn | tail -n +2` to capture function bodies, but `tail -n +2` left the trailing `}` in the body. Wrapping it in a new `funcname() { ... }` template produced unbalanced braces and a silent eval parse error. The save failure cascaded: `reset_mocks` couldn't find the saved original, fell into the `unset -f $fn_name` branch, and **destroyed the real function**. Replaced eval-string round-tripping with zsh's `${functions[name]}` associative array — saves the body without surrounding braces and restores losslessly. + +### Changed + +- **Removed `CACHED_DOCTOR_VERBOSE` workaround** in `tests/test-doctor.zsh` — The doctor cache wired in the previous PR already deduplicates `doctor --verbose` calls via the disk-shared fingerprint cache. The setup-side cache became redundant. Side benefit: `test_doctor_v_flag` now actually exercises the `-v` alias instead of re-checking the cached `--verbose` output. + +--- + ## [7.6.0] — 2026-02-27 — em --prompt + Scholar Config Sync ### Added diff --git a/docs/architecture/DOCTOR-TOKEN-ARCHITECTURE.md b/docs/architecture/DOCTOR-TOKEN-ARCHITECTURE.md index 9529bb291..626a03a2d 100644 --- a/docs/architecture/DOCTOR-TOKEN-ARCHITECTURE.md +++ b/docs/architecture/DOCTOR-TOKEN-ARCHITECTURE.md @@ -78,6 +78,7 @@ doctor # Main entry (existing) doctor --dot # New: Token check only doctor --dot=github # New: Specific token doctor --fix-token # New: Token fixes only +doctor --no-cache # Force fresh GitHub validation (bypass cache) ``` **Flow:** @@ -195,6 +196,47 @@ sequenceDiagram - 2-second lock timeout - Graceful degradation if locks fail +#### Two Cache Key Schemes + +Doctor uses the same cache *library* (`lib/doctor-cache.zsh`) with two +different cache-key strategies, depending on the calling path. They write +to the same `~/.flow/cache/doctor/` directory but produce different file +names and have different invalidation semantics. + +| | Provider-key (`--dot` path) | Fingerprint-key (legacy path) | +|---|---|---| +| Key | `token-` (static) | `token--` | +| Example file | `token-github.cache` | `token-github-f88afc3391de.cache` | +| TTL | 300s (5 minutes) | 3600s (1 hour) | +| Caller | `_dots_doctor_integration` → `_tok_expiring` | `_doctor_check_github_token` | +| Invalidation | Explicit `_doctor_cache_token_clear "github"` in `--fix` path after rotation | Automatic: new token → new fingerprint → cache miss | +| Cached value | `_tok_expiring` text wrapped as JSON | `{http_code, username}` JSON object | + +**When each scheme is preferable:** + +- **Provider-key** wins when you need a stable cache identity across + rotations (e.g., reading metadata that survives token churn) or want + short-lived caching for rapid retry. +- **Fingerprint-key** wins when the cached value is tied to a *specific* + token's validity. Rotation should never serve stale "valid" answers + about a token that no longer exists. + +The two schemes don't conflict (different file names) but they are not +deduplicated either — a cold doctor run that hits both paths writes two +cache files. A future cleanup could route both through a single +`_doctor_check_github_token` and pick one scheme; the fingerprint-key +approach is the better invariant (no "forgot to clear" failure mode). + +**Cache file layout with both schemes active:** + +```text +~/.flow/cache/doctor/ +├── token-github.cache # --dot path (provider-key) +├── token-github-f88afc3391de.cache # legacy path (fingerprint-key) +├── token-npm.cache # --dot path +└── token-pypi.cache # --dot path +``` + --- ### 4. Category Menu System diff --git a/docs/architecture/SCHOLAR-ENHANCEMENT-ARCHITECTURE.md b/docs/architecture/SCHOLAR-ENHANCEMENT-ARCHITECTURE.md index b62b50b83..57c05b159 100644 --- a/docs/architecture/SCHOLAR-ENHANCEMENT-ARCHITECTURE.md +++ b/docs/architecture/SCHOLAR-ENHANCEMENT-ARCHITECTURE.md @@ -1,7 +1,7 @@ # Scholar Enhancement Architecture **Feature:** Teaching Content Generation System -**Version:** v7.6.0 +**Version:** v7.7.0 **Date:** 2026-01-17 --- @@ -747,4 +747,4 @@ teach slides -w 8 --style computational **Last Updated:** 2026-02-27 **Status:** Production Ready -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/architecture/TEACHING-DATES-ARCHITECTURE.md b/docs/architecture/TEACHING-DATES-ARCHITECTURE.md index 13e362a17..4b553967e 100644 --- a/docs/architecture/TEACHING-DATES-ARCHITECTURE.md +++ b/docs/architecture/TEACHING-DATES-ARCHITECTURE.md @@ -1,6 +1,6 @@ # Teaching Dates Architecture -**Version:** v7.6.0 +**Version:** v7.7.0 **Status:** Complete **Last Updated:** 2026-02-27 @@ -966,5 +966,5 @@ teach dates import-calendar university-calendar.ics --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 **Status:** Complete diff --git a/docs/commands/doctor.md b/docs/commands/doctor.md index 6669817f5..82a9d7163 100644 --- a/docs/commands/doctor.md +++ b/docs/commands/doctor.md @@ -105,6 +105,7 @@ flow doctor --verbose | `--dot` | - | Check only DOT tokens (isolated, < 3s) | | `--dot=TOKEN` | - | Check specific token (e.g., `--dot=github`) | | `--fix-token` | - | Fix only token issues (< 60s) | +| `--no-cache` | - | Bypass GitHub token validation cache (force fresh API call) | | `--update-docs` | `-u` | Regenerate help files and docs | | `--help` | `-h` | Show help | diff --git a/docs/diagrams/TEACHING-V3-WORKFLOWS.md b/docs/diagrams/TEACHING-V3-WORKFLOWS.md index dfcb0ef5e..0e241a69b 100644 --- a/docs/diagrams/TEACHING-V3-WORKFLOWS.md +++ b/docs/diagrams/TEACHING-V3-WORKFLOWS.md @@ -470,7 +470,7 @@ flowchart TD --- **Generated:** 2026-02-09 -**Version:** v7.6.0 (Teaching Workflow v3.0) +**Version:** v7.7.0 (Teaching Workflow v3.0) **Total Diagrams:** 8 These diagrams provide comprehensive visual documentation for all major Teaching Workflow features. diff --git a/docs/guides/ATLAS-INTEGRATION-GUIDE.md b/docs/guides/ATLAS-INTEGRATION-GUIDE.md index 212782c16..8c4f6a589 100644 --- a/docs/guides/ATLAS-INTEGRATION-GUIDE.md +++ b/docs/guides/ATLAS-INTEGRATION-GUIDE.md @@ -335,4 +335,4 @@ export FLOW_ATLAS_ENABLED=no --- **Last Updated:** 2026-02-22 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/guides/BACKUP-SYSTEM-GUIDE.md b/docs/guides/BACKUP-SYSTEM-GUIDE.md index f970ec224..91a704459 100644 --- a/docs/guides/BACKUP-SYSTEM-GUIDE.md +++ b/docs/guides/BACKUP-SYSTEM-GUIDE.md @@ -1,6 +1,6 @@ # Backup System Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-21 --- @@ -959,5 +959,5 @@ See `lib/backup-helpers.zsh` for implementation details. --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-21 diff --git a/docs/guides/CONFIG-MANAGEMENT-WORKFLOW.md b/docs/guides/CONFIG-MANAGEMENT-WORKFLOW.md index e17fcfbeb..b370804e3 100644 --- a/docs/guides/CONFIG-MANAGEMENT-WORKFLOW.md +++ b/docs/guides/CONFIG-MANAGEMENT-WORKFLOW.md @@ -730,5 +730,5 @@ flow config profile load adhd --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 **Related:** [flow.md](../commands/flow.md), [plugin guide](./PLUGIN-MANAGEMENT-WORKFLOW.md) diff --git a/docs/guides/COURSE-PLANNING-BEST-PRACTICES.md b/docs/guides/COURSE-PLANNING-BEST-PRACTICES.md index a41f21599..36747a2fd 100644 --- a/docs/guides/COURSE-PLANNING-BEST-PRACTICES.md +++ b/docs/guides/COURSE-PLANNING-BEST-PRACTICES.md @@ -1,6 +1,6 @@ # Course Planning Best Practices: A Research-Based Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-19 **Target Audience:** Instructors using flow-cli for systematic course design **Status:** Phase 1 Complete (Sections 1-2) @@ -2498,7 +2498,7 @@ teach analytics --outcomes # Outcome achievement analysis # Document Metadata -**Version:** v7.6.0 +**Version:** v7.7.0 **Created:** 2026-01-19 **Last Updated:** 2026-01-19 **Authors:** flow-cli development team diff --git a/docs/guides/DEVELOPER-GUIDE.md b/docs/guides/DEVELOPER-GUIDE.md index 18dbb3168..60f08563c 100644 --- a/docs/guides/DEVELOPER-GUIDE.md +++ b/docs/guides/DEVELOPER-GUIDE.md @@ -1,6 +1,6 @@ # flow-cli Developer Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Audience:** Contributors, Plugin Developers, Advanced Users @@ -961,4 +961,4 @@ test: add regression test for worktree detection --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/guides/DOCTOR-TOKEN-USER-GUIDE.md b/docs/guides/DOCTOR-TOKEN-USER-GUIDE.md index d6cfd9cc3..4e13b9b4a 100644 --- a/docs/guides/DOCTOR-TOKEN-USER-GUIDE.md +++ b/docs/guides/DOCTOR-TOKEN-USER-GUIDE.md @@ -1,6 +1,6 @@ # Doctor Token Enhancement - User Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 --- @@ -633,5 +633,5 @@ Found a bug or have a feature request? --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 **Maintainer:** flow-cli team diff --git a/docs/guides/DOT-WORKFLOW.md b/docs/guides/DOT-WORKFLOW.md index 88105028d..50a8617e7 100644 --- a/docs/guides/DOT-WORKFLOW.md +++ b/docs/guides/DOT-WORKFLOW.md @@ -507,5 +507,5 @@ Run: sec unlock --- -**Version:** v7.6.0 +**Version:** v7.7.0 **See Also:** [Dispatcher Reference](../reference/MASTER-DISPATCHER-GUIDE.md#dots-dispatcher) | [Tutorial](../tutorials/12-dot-dispatcher.md) diff --git a/docs/guides/INTELLIGENT-CONTENT-ANALYSIS.md b/docs/guides/INTELLIGENT-CONTENT-ANALYSIS.md index 57c19e3a5..9d0c6eae6 100644 --- a/docs/guides/INTELLIGENT-CONTENT-ANALYSIS.md +++ b/docs/guides/INTELLIGENT-CONTENT-ANALYSIS.md @@ -1,6 +1,6 @@ # Intelligent Content Analysis Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-22 --- diff --git a/docs/guides/QUALITY-GATES.md b/docs/guides/QUALITY-GATES.md index ab799f24a..500dcc110 100644 --- a/docs/guides/QUALITY-GATES.md +++ b/docs/guides/QUALITY-GATES.md @@ -9,7 +9,7 @@ tags: > Every validation layer in flow-cli, from keystroke to production. > -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- @@ -239,4 +239,4 @@ Areas where validation could be added in the future: --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/guides/QUARTO-WORKFLOW-PHASE-2-GUIDE.md b/docs/guides/QUARTO-WORKFLOW-PHASE-2-GUIDE.md index 0c19b68a2..6cf5e04ef 100644 --- a/docs/guides/QUARTO-WORKFLOW-PHASE-2-GUIDE.md +++ b/docs/guides/QUARTO-WORKFLOW-PHASE-2-GUIDE.md @@ -6,7 +6,7 @@ tags: # Quarto Workflow Phase 2 Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Status:** Production Ready diff --git a/docs/guides/SCHOLAR-WRAPPERS-GUIDE.md b/docs/guides/SCHOLAR-WRAPPERS-GUIDE.md index 6637830d4..0a3a65663 100644 --- a/docs/guides/SCHOLAR-WRAPPERS-GUIDE.md +++ b/docs/guides/SCHOLAR-WRAPPERS-GUIDE.md @@ -2,7 +2,7 @@ > Complete documentation for the 9 AI content generation commands in the teach system. > -> **Version:** v7.6.0 | **Prerequisites:** Scholar plugin, teach-config.yml +> **Version:** v7.7.0 | **Prerequisites:** Scholar plugin, teach-config.yml ## Table of Contents @@ -1311,6 +1311,6 @@ teach lecture "Topic" --week 5 --math --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-02 **Commands Documented:** 9 Scholar wrappers diff --git a/docs/guides/TEACH-DEPLOY-GUIDE.md b/docs/guides/TEACH-DEPLOY-GUIDE.md index 0d03da1e5..ca28910db 100644 --- a/docs/guides/TEACH-DEPLOY-GUIDE.md +++ b/docs/guides/TEACH-DEPLOY-GUIDE.md @@ -8,7 +8,7 @@ tags: > Deploy your course website from local preview to production with confidence. > -> **Version:** v7.6.0 | **Command:** `teach deploy` +> **Version:** v7.7.0 | **Command:** `teach deploy` ![teach deploy v2 Demo](../demos/tutorials/tutorial-teach-deploy.gif) @@ -1237,4 +1237,4 @@ The deployment summary box now includes a direct link to your GitHub Actions pag --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/guides/TEACHING-TROUBLESHOOTING.md b/docs/guides/TEACHING-TROUBLESHOOTING.md index b5c1fba0c..a8fdd530e 100644 --- a/docs/guides/TEACHING-TROUBLESHOOTING.md +++ b/docs/guides/TEACHING-TROUBLESHOOTING.md @@ -2,7 +2,7 @@ > Quick fixes for common issues with the teach dispatcher. > -> **Version:** v7.6.0 +> **Version:** v7.7.0 > **Last Updated:** 2026-02-27 --- diff --git a/docs/guides/TEACHING-WORKFLOW-V3-GUIDE.md b/docs/guides/TEACHING-WORKFLOW-V3-GUIDE.md index 31ee163ed..022a45a52 100644 --- a/docs/guides/TEACHING-WORKFLOW-V3-GUIDE.md +++ b/docs/guides/TEACHING-WORKFLOW-V3-GUIDE.md @@ -6,7 +6,7 @@ tags: # Teaching Workflow v3.0 Guide -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Target Audience:** Instructors using flow-cli for course management @@ -2128,5 +2128,5 @@ teach init --config template.yml --- -**Version:** v7.6.0 (Teaching Workflow v3.0) +**Version:** v7.7.0 (Teaching Workflow v3.0) **Last Updated:** 2026-02-27 diff --git a/docs/guides/TESTING.md b/docs/guides/TESTING.md index 5cec28c31..bf7e33a98 100644 --- a/docs/guides/TESTING.md +++ b/docs/guides/TESTING.md @@ -23,8 +23,8 @@ flow-cli uses a **shared test framework** (`tests/test-framework.zsh`) with comp | Metric | Count | |--------|-------| -| Test files | 205 | -| Test suites (run-all.sh) | 53/53 passing | +| Test files | 206 | +| Test suites (run-all.sh) | 54/54 passing | | Test functions | 12,000+ | | Expected timeouts | 1 (IMAP connectivity) | @@ -263,7 +263,7 @@ zsh tests/test-work.zsh ./tests/run-all.sh ``` -54 suites, ~12000 assertions. Expected: 53/53 pass, 1 timeout (IMAP connectivity). +55 suites, ~12000 assertions. Expected: 54/54 pass, 1 timeout (IMAP connectivity). ### Dogfood Quality Check @@ -359,4 +359,4 @@ When adding new functionality: **Established:** v5.0.0 (2026-01-11) **Overhauled:** v7.4.0 (2026-02-16) — shared framework, mock registry, dogfood scanner -**Test Count:** 205 test files, 12000+ assertions, 53/53 suites passing +**Test Count:** 206 test files, 12000+ assertions, 54/54 suites passing diff --git a/docs/guides/index.md b/docs/guides/index.md index 823388afc..59b1663d1 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -85,4 +85,4 @@ tags: --- -**v7.6.0** | [Home](../index.md) +**v7.7.0** | [Home](../index.md) diff --git a/docs/help/QUICK-REFERENCE.md b/docs/help/QUICK-REFERENCE.md index d640d9305..cf3a93ebc 100644 --- a/docs/help/QUICK-REFERENCE.md +++ b/docs/help/QUICK-REFERENCE.md @@ -8,7 +8,7 @@ tags: **Purpose:** Single-page command lookup for all flow-cli features **Format:** Copy-paste ready with expected outputs -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 --- @@ -1552,6 +1552,6 @@ mcp help --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 **Contributors:** See [CHANGELOG.md](../CHANGELOG.md) diff --git a/docs/help/TROUBLESHOOTING.md b/docs/help/TROUBLESHOOTING.md index b036dae24..da73efe62 100644 --- a/docs/help/TROUBLESHOOTING.md +++ b/docs/help/TROUBLESHOOTING.md @@ -3,7 +3,7 @@ **Purpose:** Common issues and solutions for flow-cli **Audience:** All users experiencing problems **Format:** Problem → Diagnosis → Solution -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 --- @@ -1048,6 +1048,6 @@ ls -la ~/.cache/flow/ --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Need more help?** https://github.com/Data-Wise/flow-cli/issues diff --git a/docs/help/WORKFLOWS.md b/docs/help/WORKFLOWS.md index 4eb2a7b3c..7c1a800a7 100644 --- a/docs/help/WORKFLOWS.md +++ b/docs/help/WORKFLOWS.md @@ -3,7 +3,7 @@ **Purpose:** Real-world workflow patterns for flow-cli **Audience:** All users (beginners → advanced) **Format:** Step-by-step instructions with examples -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 --- @@ -1068,6 +1068,6 @@ pick # Interactive project picker --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Contributors:** See [CHANGELOG.md](../CHANGELOG.md) diff --git a/docs/index.md b/docs/index.md index bc01b7768..0d1be9b05 100644 --- a/docs/index.md +++ b/docs/index.md @@ -283,4 +283,4 @@ catch "idea" # Quick capture --- -**v7.6.0** · Pure ZSH · Zero Dependencies · MIT License +**v7.7.0** · Pure ZSH · Zero Dependencies · MIT License diff --git a/docs/reference/MASTER-API-REFERENCE.md b/docs/reference/MASTER-API-REFERENCE.md index fffca4429..503fe21e4 100644 --- a/docs/reference/MASTER-API-REFERENCE.md +++ b/docs/reference/MASTER-API-REFERENCE.md @@ -8,7 +8,7 @@ tags: **Purpose:** Complete API documentation for all flow-cli library functions **Audience:** Developers, contributors, advanced users **Format:** Function signatures, parameters, return values, examples -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 --- @@ -3590,6 +3590,73 @@ _doctor_cache_clear "token-${provider}" --- +### Cache Consumers (commands/doctor.zsh) + +#### `_doctor_check_github_token` + +Validate the GitHub PAT via the `api.github.com/user` endpoint, with a +file-based cache. The cache key derives from the token's sha256 prefix so +token rotation auto-invalidates without an explicit clear call. TTL is 1 +hour. Successful validations are stored; transient curl failures are not. + +**Signature:** + +```zsh +_doctor_check_github_token [no_cache] +``` + +**Parameters:** + +- `$1` - "true" to bypass the cache and force a fresh API call; + anything else (default: "false") consults the cache first. + +**Returns:** + +- 0 - Always returns 0 after running the check (token validity is + reported via stdout and via the `_doctor_token_issues` global). + +**Side Effects:** + +- Prints status lines via `_doctor_log_quiet` / `_doctor_log_verbose`. +- Mutates the global associative array `_doctor_token_issues[github]` + when problems are detected (`missing`, `invalid`, `expiring`, + `gh-cli`, `env-var`, `mcp-config`). +- Reads/writes `$DOCTOR_CACHE_DIR/token-github-.cache`. + +**Example:** + +```zsh +# Normal call (use cache when available) +_doctor_check_github_token + +# Force fresh validation (used by doctor --no-cache) +_doctor_check_github_token "true" + +# Inspect outcome +if [[ -n "$_doctor_token_issues[github]" ]]; then + echo "Issues: $_doctor_token_issues[github]" +fi +``` + +**Cache key:** + +```text +token-github-$(printf '%s' "$TOKEN" | shasum -a 256 | cut -c1-12) +``` + +The 12-char sha256 prefix is collision-safe for any realistic token set +(birthday-paradox collision probability ~1 in 16M). + +**Why a fingerprint key?** + +Token rotation produces a new fingerprint → new cache key → automatic +cache miss. This eliminates the need for a manual clear path on the +rotation code path. (The older `--dot` cache uses a static `token-github` +provider key and relies on `_doctor_cache_token_clear` after rotation — +see [Convenience Functions](#convenience-functions) above.) + +--- + ### Constants **Cache Configuration:** @@ -7492,7 +7559,7 @@ URL line is omitted when `$url` is empty or `"null"`. --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 **Auto-Generation:** Run `./scripts/generate-api-docs.sh` to update function index **Total Functions:** 880 (428 documented, 452 pending) diff --git a/docs/reference/MASTER-ARCHITECTURE.md b/docs/reference/MASTER-ARCHITECTURE.md index 1fdce29d8..7d11a03aa 100644 --- a/docs/reference/MASTER-ARCHITECTURE.md +++ b/docs/reference/MASTER-ARCHITECTURE.md @@ -3,7 +3,7 @@ **Purpose:** Complete system architecture documentation for flow-cli **Audience:** Contributors, maintainers, advanced users **Format:** Design decisions, diagrams, implementation details -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 --- @@ -1020,7 +1020,7 @@ graph TD --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 **Diagrams:** 8 Mermaid diagrams **Total:** 2,500+ lines diff --git a/docs/reference/MASTER-DISPATCHER-GUIDE.md b/docs/reference/MASTER-DISPATCHER-GUIDE.md index 7c317fd1f..8686ffde1 100644 --- a/docs/reference/MASTER-DISPATCHER-GUIDE.md +++ b/docs/reference/MASTER-DISPATCHER-GUIDE.md @@ -10,7 +10,7 @@ tags: **Purpose:** Complete reference for all flow-cli dispatchers (15 + at bridge) **Audience:** All users (beginner → intermediate → advanced) **Format:** Progressive disclosure (basics → advanced features) -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 --- @@ -3357,6 +3357,6 @@ at stats --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-22 **Total:** 15 dispatchers + at bridge fully documented diff --git a/docs/reference/REFCARD-ANALYSIS.md b/docs/reference/REFCARD-ANALYSIS.md index 1782e1cbd..ef7c31a82 100644 --- a/docs/reference/REFCARD-ANALYSIS.md +++ b/docs/reference/REFCARD-ANALYSIS.md @@ -189,5 +189,5 @@ teach analyze --slide-breaks lectures/week-02-probability.qmd --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-02 diff --git a/docs/reference/REFCARD-DATES.md b/docs/reference/REFCARD-DATES.md index fdaa2eb15..2b3bc68b7 100644 --- a/docs/reference/REFCARD-DATES.md +++ b/docs/reference/REFCARD-DATES.md @@ -271,5 +271,5 @@ teach dates status --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-02 diff --git a/docs/reference/REFCARD-DOCTOR.md b/docs/reference/REFCARD-DOCTOR.md index 801a596bb..141c0d01f 100644 --- a/docs/reference/REFCARD-DOCTOR.md +++ b/docs/reference/REFCARD-DOCTOR.md @@ -14,6 +14,7 @@ | `flow doctor --fix -y` | Auto-install all missing tools without prompts | | `flow doctor --verbose` | Detailed output + email connectivity tests | | `flow doctor --dot` | Check only DOT tokens (isolated, fast) | +| `flow doctor --no-cache` | Force fresh GitHub token validation (bypass 1h cache) | ## teach doctor Quick Examples @@ -319,5 +320,5 @@ teach doctor --verbose --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-21 diff --git a/docs/reference/REFCARD-DOTFILE-DISPATCHER.md b/docs/reference/REFCARD-DOTFILE-DISPATCHER.md index d73196d1b..09210ddbc 100644 --- a/docs/reference/REFCARD-DOTFILE-DISPATCHER.md +++ b/docs/reference/REFCARD-DOTFILE-DISPATCHER.md @@ -2,7 +2,7 @@ > All `dots` subcommands at a glance. > -> **Version:** v7.6.0 (v3.0.0 dispatcher) | **Dispatcher:** `lib/dispatchers/dots-dispatcher.zsh` +> **Version:** v7.7.0 (v3.0.0 dispatcher) | **Dispatcher:** `lib/dispatchers/dots-dispatcher.zsh` > > **Backend:** [chezmoi](https://www.chezmoi.io/) for dotfile sync. @@ -99,5 +99,5 @@ dots apply --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 diff --git a/docs/reference/REFCARD-GIT-DISPATCHER.md b/docs/reference/REFCARD-GIT-DISPATCHER.md index 3026c9a62..1cecfe608 100644 --- a/docs/reference/REFCARD-GIT-DISPATCHER.md +++ b/docs/reference/REFCARD-GIT-DISPATCHER.md @@ -2,7 +2,7 @@ > All `g` subcommands at a glance — the most-used flow-cli dispatcher. > -> **Version:** v7.6.0 | **Dispatcher:** `lib/dispatchers/g-dispatcher.zsh` +> **Version:** v7.7.0 | **Dispatcher:** `lib/dispatchers/g-dispatcher.zsh` > > Unknown commands pass through to `git` (e.g., `g remote -v` → `git remote -v`). @@ -129,5 +129,5 @@ g pop # Restore stashed changes --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 diff --git a/docs/reference/REFCARD-SCHOLAR-FLAGS.md b/docs/reference/REFCARD-SCHOLAR-FLAGS.md index bebc3617f..da4d3c9de 100644 --- a/docs/reference/REFCARD-SCHOLAR-FLAGS.md +++ b/docs/reference/REFCARD-SCHOLAR-FLAGS.md @@ -2,7 +2,7 @@ > All flags available for `teach` Scholar wrapper commands (lecture, slides, exam, quiz, assignment, syllabus, rubric, feedback, demo). > -> **Version:** v7.6.0 | **Source:** `lib/dispatchers/teach-dispatcher.zsh` +> **Version:** v7.7.0 | **Source:** `lib/dispatchers/teach-dispatcher.zsh` ## Selection Flags (Universal) @@ -746,6 +746,6 @@ teach lecture "ANOVA" --week 8 --dry-run --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-02 **Status:** Complete Scholar flag documentation diff --git a/docs/reference/REFCARD-SCHOLAR-WRAPPERS.md b/docs/reference/REFCARD-SCHOLAR-WRAPPERS.md index b06525146..4910acc84 100644 --- a/docs/reference/REFCARD-SCHOLAR-WRAPPERS.md +++ b/docs/reference/REFCARD-SCHOLAR-WRAPPERS.md @@ -8,7 +8,7 @@ tags: # Quick Reference: Scholar Wrapper Commands **Purpose:** Reference card for Scholar-powered `teach` subcommands -**Version:** v7.6.0+ +**Version:** v7.7.0+ **Last Updated:** 2026-02-27 --- diff --git a/docs/reference/REFCARD-SECRET-DISPATCHER.md b/docs/reference/REFCARD-SECRET-DISPATCHER.md index fd2532995..fdd74a2c5 100644 --- a/docs/reference/REFCARD-SECRET-DISPATCHER.md +++ b/docs/reference/REFCARD-SECRET-DISPATCHER.md @@ -2,7 +2,7 @@ > All `sec` subcommands at a glance. > -> **Version:** v7.6.0 (v3.0.0 dispatcher) | **Dispatcher:** `lib/dispatchers/sec-dispatcher.zsh` +> **Version:** v7.7.0 (v3.0.0 dispatcher) | **Dispatcher:** `lib/dispatchers/sec-dispatcher.zsh` > > **Backends:** macOS Keychain (primary), Bitwarden (optional secondary). @@ -105,5 +105,5 @@ sec dashboard # Overview of all secrets --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 diff --git a/docs/reference/REFCARD-TEACH-DISPATCHER.md b/docs/reference/REFCARD-TEACH-DISPATCHER.md index 47d3e79ea..9a516dd13 100644 --- a/docs/reference/REFCARD-TEACH-DISPATCHER.md +++ b/docs/reference/REFCARD-TEACH-DISPATCHER.md @@ -2,7 +2,7 @@ > All 34 `teach` subcommands at a glance. For detailed guides, see linked documentation. > -> **Version:** v7.6.0 | **Dispatcher:** `lib/dispatchers/teach-dispatcher.zsh` +> **Version:** v7.7.0 | **Dispatcher:** `lib/dispatchers/teach-dispatcher.zsh` ## Command Taxonomy @@ -485,6 +485,6 @@ teach status --performance # Review metrics --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Commands:** 34 total (12 Scholar wrappers + 5 course mgmt + 6 content mgmt + 9 infrastructure + 1 discovery + config subcommands) diff --git a/docs/reference/REFCARD-TEACH-PLAN.md b/docs/reference/REFCARD-TEACH-PLAN.md index e9fd4c4f9..5004d7f6c 100644 --- a/docs/reference/REFCARD-TEACH-PLAN.md +++ b/docs/reference/REFCARD-TEACH-PLAN.md @@ -157,5 +157,5 @@ teach slides --week N # Step 5: Generate content --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-29 diff --git a/docs/reference/REFCARD-WORKTREE-DISPATCHER.md b/docs/reference/REFCARD-WORKTREE-DISPATCHER.md index e949f71e8..e9877f3f6 100644 --- a/docs/reference/REFCARD-WORKTREE-DISPATCHER.md +++ b/docs/reference/REFCARD-WORKTREE-DISPATCHER.md @@ -2,7 +2,7 @@ > All `wt` subcommands at a glance. > -> **Version:** v7.6.0 | **Dispatcher:** `lib/dispatchers/wt-dispatcher.zsh` +> **Version:** v7.7.0 | **Dispatcher:** `lib/dispatchers/wt-dispatcher.zsh` ## Commands @@ -105,5 +105,5 @@ cd ~/projects/my-project # Back to feature A (untouched) --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 diff --git a/docs/reference/TEACH-CONFIG-SCHEMA.md b/docs/reference/TEACH-CONFIG-SCHEMA.md index 0ee650dc4..ab4dc3c16 100644 --- a/docs/reference/TEACH-CONFIG-SCHEMA.md +++ b/docs/reference/TEACH-CONFIG-SCHEMA.md @@ -2,7 +2,7 @@ > Complete field reference for teaching project configuration. > -> **Version:** v7.6.0 | **Location:** `.flow/teach-config.yml` +> **Version:** v7.7.0 | **Location:** `.flow/teach-config.yml` ## Overview @@ -669,4 +669,4 @@ _teach_config_summary ".flow/teach-config.yml" --- **Last Updated:** 2026-02-02 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/reference/index.md b/docs/reference/index.md index 2d52276e3..bb464b419 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -93,4 +93,4 @@ tags: --- -**v7.6.0** | [Home](../index.md) +**v7.7.0** | [Home](../index.md) diff --git a/docs/teaching/index.md b/docs/teaching/index.md index 13b611eae..be5d848d6 100644 --- a/docs/teaching/index.md +++ b/docs/teaching/index.md @@ -233,4 +233,4 @@ teach plan help --- **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/tutorials/14-teach-dispatcher.md b/docs/tutorials/14-teach-dispatcher.md index 3b6d09819..4a780e44a 100644 --- a/docs/tutorials/14-teach-dispatcher.md +++ b/docs/tutorials/14-teach-dispatcher.md @@ -10,7 +10,7 @@ tags: > **What you'll learn:** Manage course websites with fast deployment, config validation, and AI-assisted content creation > > **Time:** ~20 minutes | **Level:** Beginner -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- diff --git a/docs/tutorials/20-teaching-dates-automation.md b/docs/tutorials/20-teaching-dates-automation.md index 5e93092e4..85eaea642 100644 --- a/docs/tutorials/20-teaching-dates-automation.md +++ b/docs/tutorials/20-teaching-dates-automation.md @@ -657,4 +657,4 @@ yq eval .flow/teach-config.yml **Questions or issues?** Open an issue on [GitHub](https://github.com/Data-Wise/flow-cli/issues) **Last Updated:** 2026-02-27 -**Version:** v7.6.0 +**Version:** v7.7.0 diff --git a/docs/tutorials/21-teach-analyze.md b/docs/tutorials/21-teach-analyze.md index 566fdedf7..b004131ff 100644 --- a/docs/tutorials/21-teach-analyze.md +++ b/docs/tutorials/21-teach-analyze.md @@ -9,7 +9,7 @@ tags: > **What you'll learn:** Validate lecture prerequisites, detect concept gaps, and generate optimized slides using `teach analyze` > > **Time:** ~15 minutes | **Level:** Beginner → Intermediate -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- diff --git a/docs/tutorials/24-template-management.md b/docs/tutorials/24-template-management.md index 595c2b153..9082d7fd6 100644 --- a/docs/tutorials/24-template-management.md +++ b/docs/tutorials/24-template-management.md @@ -9,7 +9,7 @@ tags: > **What you'll learn:** Create and manage teaching content templates with `teach templates` > > **Time:** ~15 minutes | **Level:** Beginner → Intermediate -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- @@ -433,5 +433,5 @@ teach templates sync --force --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-28 diff --git a/docs/tutorials/25-lesson-plan-migration.md b/docs/tutorials/25-lesson-plan-migration.md index 13da5cea0..21556ac4d 100644 --- a/docs/tutorials/25-lesson-plan-migration.md +++ b/docs/tutorials/25-lesson-plan-migration.md @@ -3,7 +3,7 @@ > **What you'll learn:** Extract lesson plans with `teach migrate-config` and manage them with `teach plan` > > **Time:** ~15 minutes | **Level:** Beginner -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- @@ -417,5 +417,5 @@ teach lecture --week 5 --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-28 diff --git a/docs/tutorials/26-latex-macros.md b/docs/tutorials/26-latex-macros.md index ea1ca90b5..e40704346 100644 --- a/docs/tutorials/26-latex-macros.md +++ b/docs/tutorials/26-latex-macros.md @@ -3,7 +3,7 @@ > **What you'll learn:** Configure LaTeX macros for consistent AI-generated notation with `teach macros` > > **Time:** ~15 minutes | **Level:** Beginner → Intermediate -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- diff --git a/docs/tutorials/27-lesson-plan-management.md b/docs/tutorials/27-lesson-plan-management.md index 5bcea6fc2..4a2abc891 100644 --- a/docs/tutorials/27-lesson-plan-management.md +++ b/docs/tutorials/27-lesson-plan-management.md @@ -3,7 +3,7 @@ > **What you'll learn:** Create, manage, and use lesson plans with `teach plan` to drive AI content generation > > **Time:** ~15 minutes | **Level:** Beginner -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- @@ -339,5 +339,5 @@ teach slides --week 5 # Generate slides --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-01-29 diff --git a/docs/tutorials/29-first-exam-walkthrough.md b/docs/tutorials/29-first-exam-walkthrough.md index 29ab49cbd..6137e623f 100644 --- a/docs/tutorials/29-first-exam-walkthrough.md +++ b/docs/tutorials/29-first-exam-walkthrough.md @@ -3,7 +3,7 @@ > **What you'll learn:** Generate exams and quizzes using Scholar AI wrappers with flow-cli > > **Time:** ~20 minutes | **Level:** Beginner → Intermediate -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- @@ -846,6 +846,6 @@ teach exam "Topic" --week 5 --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-27 **Tutorial Series:** Part 29 of Teaching Workflow Tutorials diff --git a/docs/tutorials/30-new-instructor-complete-workflow.md b/docs/tutorials/30-new-instructor-complete-workflow.md index 48d945230..04b6fa12c 100644 --- a/docs/tutorials/30-new-instructor-complete-workflow.md +++ b/docs/tutorials/30-new-instructor-complete-workflow.md @@ -3,7 +3,7 @@ > **What you'll learn:** Go from an empty directory to a deployed course website with AI-powered content > > **Time:** ~30 minutes | **Level:** Beginner -> **Version:** v7.6.0 +> **Version:** v7.7.0 --- diff --git a/docs/tutorials/32-teach-doctor.md b/docs/tutorials/32-teach-doctor.md index 7e1e096be..723d4499d 100644 --- a/docs/tutorials/32-teach-doctor.md +++ b/docs/tutorials/32-teach-doctor.md @@ -390,5 +390,5 @@ Only runs when `scholar.latex_macros.enabled: true` in teach-config.yml: --- -**Version:** v7.6.0 +**Version:** v7.7.0 **Last Updated:** 2026-02-08 diff --git a/docs/tutorials/scholar-enhancement/index.md b/docs/tutorials/scholar-enhancement/index.md index 3bd138cbc..79e5d6beb 100644 --- a/docs/tutorials/scholar-enhancement/index.md +++ b/docs/tutorials/scholar-enhancement/index.md @@ -1,6 +1,6 @@ # Scholar Enhancement Tutorials -**Version:** v7.6.0 +**Version:** v7.7.0 **Total Duration:** ~65 minutes **Skill Levels:** 3 (Beginner → Advanced) diff --git a/flow.plugin.zsh b/flow.plugin.zsh index d2c96794b..c415d83b1 100644 --- a/flow.plugin.zsh +++ b/flow.plugin.zsh @@ -141,7 +141,7 @@ _flow_plugin_init # Export loaded marker export FLOW_PLUGIN_LOADED=1 -export FLOW_VERSION="7.6.0" +export FLOW_VERSION="7.7.0" # Register exit hook for plugin cleanup add-zsh-hook zshexit _flow_plugin_cleanup diff --git a/lib/analysis-cache.zsh b/lib/analysis-cache.zsh index 9f72dd858..39714f10f 100644 --- a/lib/analysis-cache.zsh +++ b/lib/analysis-cache.zsh @@ -48,14 +48,20 @@ fi # CONSTANTS # ============================================================================= +# Constants use `typeset -gr` rather than bare `readonly`. See +# lib/doctor-cache.zsh for the full rationale: in zsh, `readonly` inside a +# function context creates a function-local readonly that vanishes on return, +# while the load-guard (`typeset -g`) persists — leaving constants permanently +# undefined on subsequent calls. Always use `-g` for module-level constants. + # Cache schema version (bump when cache format changes) -readonly ANALYSIS_CACHE_SCHEMA_VERSION="1.0" +typeset -gr ANALYSIS_CACHE_SCHEMA_VERSION="1.0" # Default TTL in hours (7 days) -readonly ANALYSIS_CACHE_DEFAULT_TTL_HOURS=168 +typeset -gr ANALYSIS_CACHE_DEFAULT_TTL_HOURS=168 # Lock timeout in seconds -readonly ANALYSIS_CACHE_LOCK_TIMEOUT=5 +typeset -gr ANALYSIS_CACHE_LOCK_TIMEOUT=5 # ============================================================================= # INTERNAL HELPERS diff --git a/lib/doctor-cache.zsh b/lib/doctor-cache.zsh index 39e698fbd..6ad9c1006 100644 --- a/lib/doctor-cache.zsh +++ b/lib/doctor-cache.zsh @@ -63,18 +63,26 @@ fi # CONSTANTS # ============================================================================= +# Constants are declared with `typeset -gr` rather than bare `readonly`. In zsh, +# `readonly` inside a function context (e.g., when this lib is sourced from a +# function like a test harness `setup()`) creates a function-local readonly that +# vanishes when the caller returns. The load-guard above uses `typeset -g` and +# would suppress re-initialization on the next source — leaving these as +# undefined globals and causing arithmetic like `LOCK_TIMEOUT * 10` to evaluate +# to zero. Always use `-g` for module-level constants in sourced libraries. + # Default TTL in seconds (5 minutes) -readonly DOCTOR_CACHE_DEFAULT_TTL=300 +typeset -gr DOCTOR_CACHE_DEFAULT_TTL=300 # Lock timeout in seconds -readonly DOCTOR_CACHE_LOCK_TIMEOUT=2 +typeset -gr DOCTOR_CACHE_LOCK_TIMEOUT=2 # Maximum age for cache cleanup (1 day) -readonly DOCTOR_CACHE_MAX_AGE_SECONDS=86400 +typeset -gr DOCTOR_CACHE_MAX_AGE_SECONDS=86400 # Cache directory (respect if already set, e.g., by tests) if [[ -z "$DOCTOR_CACHE_DIR" ]]; then - readonly DOCTOR_CACHE_DIR="${HOME}/.flow/cache/doctor" + typeset -gr DOCTOR_CACHE_DIR="${HOME}/.flow/cache/doctor" fi # ============================================================================= diff --git a/lib/macro-parser.zsh b/lib/macro-parser.zsh index cc32e34d4..b14165f2e 100644 --- a/lib/macro-parser.zsh +++ b/lib/macro-parser.zsh @@ -55,13 +55,18 @@ typeset -gA _FLOW_MACRO_META # CONSTANTS # ============================================================================= +# Constants use `typeset -gr` rather than bare `readonly`. See +# lib/doctor-cache.zsh for the full rationale: in zsh, `readonly` inside a +# function context creates a function-local readonly that vanishes on return. +# Always use `-g` for module-level constants in sourced libraries. + # Supported macro source formats -readonly MACRO_FORMAT_QMD="qmd" -readonly MACRO_FORMAT_MATHJAX="mathjax" -readonly MACRO_FORMAT_LATEX="latex" +typeset -gr MACRO_FORMAT_QMD="qmd" +typeset -gr MACRO_FORMAT_MATHJAX="mathjax" +typeset -gr MACRO_FORMAT_LATEX="latex" # Common macro file locations (for auto-discovery) -readonly -a MACRO_AUTO_DISCOVER_PATHS=( +typeset -gar MACRO_AUTO_DISCOVER_PATHS=( "_macros.qmd" "macros.qmd" "includes/_macros.qmd" diff --git a/package.json b/package.json index f5ee3e86d..664b23000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flow-cli", - "version": "7.6.0", + "version": "7.7.0", "description": "ADHD-optimized ZSH workflow plugin", "private": true, "scripts": { diff --git a/tests/run-all.sh b/tests/run-all.sh index 5efb4a6ab..fdcc0f0ee 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -17,7 +17,10 @@ run_test() { local test_file="$1" local name=$(basename "$test_file" .zsh) name=${name%.sh} - local timeout_seconds=30 + # Default 30s; callers can override via $2 for tests that legitimately + # need more (e.g., test-doctor runs full `doctor` 3× through brew/atlas/ + # plugin checks). + local timeout_seconds="${2:-30}" echo -n "Running $name... " @@ -70,7 +73,7 @@ echo "Core command tests:" # (FLOW_PLUGIN_DIR, FLOW_QUIET, FLOW_ATLAS_ENABLED=no, exec < /dev/null) run_test ./tests/test-dash.zsh run_test ./tests/test-work.zsh -run_test ./tests/test-doctor.zsh +run_test ./tests/test-doctor.zsh 45 run_test ./tests/test-capture.zsh run_test ./tests/test-pick-wt.zsh run_test ./tests/test-adhd.zsh @@ -104,6 +107,7 @@ run_test ./tests/test-help-compliance-dogfood.zsh echo "" echo "Regression tests:" run_test ./tests/test-local-path-regression.zsh +run_test ./tests/test-readonly-scope-regression.zsh echo "" echo "Dogfooding tests:" diff --git a/tests/test-doctor.zsh b/tests/test-doctor.zsh index 7bc4744d6..d78a64e74 100644 --- a/tests/test-doctor.zsh +++ b/tests/test-doctor.zsh @@ -28,10 +28,25 @@ setup() { echo " Project root: $PROJECT_ROOT" + # Create isolated test project root (avoids scanning real ~/projects) + TEST_ROOT=$(mktemp -d) + mkdir -p "$TEST_ROOT/dev-tools/mock-dev" + echo "## Status: active\n## Progress: 50" > "$TEST_ROOT/dev-tools/mock-dev/.STATUS" + + # Isolate doctor cache so tests don't pollute developer's ~/.flow/cache/doctor/. + # MUST be exported before the plugin loads — lib/doctor-cache.zsh marks + # DOCTOR_CACHE_DIR readonly if unset. + export DOCTOR_CACHE_DIR="$TEST_ROOT/cache" + mkdir -p "$DOCTOR_CACHE_DIR" + + # File-based curl call counter (variable counters break under $(...) subshells) + _TEST_CURL_LOG="$TEST_ROOT/curl-calls.log" + # Source the plugin (non-interactive mode, no Atlas) FLOW_QUIET=1 FLOW_ATLAS_ENABLED=no FLOW_PLUGIN_DIR="$PROJECT_ROOT" + FLOW_PROJECTS_ROOT="$TEST_ROOT" source "$PROJECT_ROOT/flow.plugin.zsh" 2>/dev/null || { echo "${RED}Plugin failed to load${RESET}" exit 1 @@ -40,13 +55,10 @@ setup() { # Close stdin to prevent any interactive commands from blocking exec < /dev/null - # Create isolated test project root (avoids scanning real ~/projects) - TEST_ROOT=$(mktemp -d) - mkdir -p "$TEST_ROOT/dev-tools/mock-dev" - echo "## Status: active\n## Progress: 50" > "$TEST_ROOT/dev-tools/mock-dev/.STATUS" - FLOW_PROJECTS_ROOT="$TEST_ROOT" - - # Cache doctor outputs to avoid repeated API calls (each doctor run hits GitHub API) + # Cache doctor outputs to avoid repeated API calls (each doctor run hits GitHub API). + # --verbose is no longer pre-cached: PR #446 wired GitHub token validation into + # lib/doctor-cache.zsh, so the production fingerprint cache already deduplicates + # repeated invocations across the disk-shared DOCTOR_CACHE_DIR. CACHED_DOCTOR_DEFAULT=$(doctor 2>&1) CACHED_DOCTOR_HELP=$(doctor --help 2>&1) @@ -176,18 +188,23 @@ test_doctor_shows_sections() { test_doctor_verbose_runs() { test_case "doctor --verbose runs without error" - local output=$(doctor --verbose 2>&1) - local exit_code=$? - assert_exit_code $exit_code 0 "Exit code: $exit_code" + local output rc + output=$(doctor --verbose 2>&1) + rc=$? + assert_exit_code $rc 0 "Exit code: $rc" assert_not_empty "$output" "Verbose output should not be empty" test_pass } test_doctor_v_flag() { test_case "doctor -v runs without error" - local output=$(doctor -v 2>&1) - local exit_code=$? - assert_exit_code $exit_code 0 "Exit code: $exit_code" + # -v is an alias for --verbose (commands/doctor.zsh:39). + # Production fingerprint cache deduplicates the GitHub token check, so the + # second invocation hits disk cache rather than the network. + local output rc + output=$(doctor -v 2>&1) + rc=$? + assert_exit_code $rc 0 "Exit code: $rc" assert_not_empty "$output" "-v output should not be empty" test_pass } @@ -232,6 +249,241 @@ test_doctor_tracks_missing_brew() { test_pass } +# ============================================================================ +# TESTS: GitHub token validation cache (lib/doctor-cache.zsh wiring) +# ============================================================================ + +# Compute the fingerprint key the production code uses for a given token. +_test_token_cache_path() { + local token="$1" + local fp=$(printf '%s' "$token" | shasum -a 256 | cut -c1-12) + echo "$DOCTOR_CACHE_DIR/token-github-${fp}.cache" +} + +# Build a non-expired cache envelope matching _doctor_cache_set output. +_test_write_valid_cache() { + local cache_file="$1" + local username="${2:-cacheduser}" + local now future + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + if [[ "$(uname)" == "Darwin" ]]; then + future=$(date -u -v+1H +"%Y-%m-%dT%H:%M:%SZ") + else + future=$(date -u -d "+1 hour" +"%Y-%m-%dT%H:%M:%SZ") + fi + cat > "$cache_file" < "$_TEST_CURL_LOG" + _SAVED_CURL_BODY="${functions[curl]}" + functions[curl]="print >> \"$_TEST_CURL_LOG\"; $response_fn" +} +_test_restore_curl() { + if [[ -n "$_SAVED_CURL_BODY" ]]; then + functions[curl]="$_SAVED_CURL_BODY" + else + unset -f curl 2>/dev/null + fi + unset _SAVED_CURL_BODY +} +_test_curl_call_count() { + [[ -f "$_TEST_CURL_LOG" ]] || { echo 0; return; } + wc -l < "$_TEST_CURL_LOG" | tr -d ' ' +} +_test_curl_response_fresh() { + printf '{"login":"freshuser"}\n200\n' +} +_test_curl_response_unexpected() { + printf '{"login":"shouldnotappear"}\n200\n' +} + +_test_install_sec_returning() { + local token="$1" + _SAVED_SEC_BODY="${functions[sec]}" + functions[sec]="print -- $token" +} +_test_restore_sec() { + if [[ -n "$_SAVED_SEC_BODY" ]]; then + functions[sec]="$_SAVED_SEC_BODY" + else + unset -f sec 2>/dev/null + fi + unset _SAVED_SEC_BODY +} + +test_doctor_cache_hit_skips_curl() { + test_case "cache hit skips GitHub API curl" + local test_token="ghp_test_cache_hit_xyz" + local cache_file=$(_test_token_cache_path "$test_token") + _test_write_valid_cache "$cache_file" "hituser" + + _test_install_sec_returning "$test_token" + _test_install_curl_mock "_test_curl_response_unexpected" + + # Call the helper directly to skip ~10s of unrelated doctor system checks + _doctor_check_github_token "false" >/dev/null 2>&1 + + assert_equals "0" "$(_test_curl_call_count)" "Cache hit should prevent any curl call" + test_pass + + _test_restore_curl + _test_restore_sec + rm -f "$cache_file" +} + +test_doctor_cache_miss_triggers_curl() { + test_case "cache miss triggers exactly one curl call" + local test_token="ghp_test_cache_miss_abc" + rm -f "$(_test_token_cache_path "$test_token")" + + _test_install_sec_returning "$test_token" + _test_install_curl_mock "_test_curl_response_fresh" + + _doctor_check_github_token "false" >/dev/null 2>&1 + + assert_equals "1" "$(_test_curl_call_count)" "Cache miss should trigger exactly one curl call" + test_pass + + _test_restore_curl + _test_restore_sec +} + +test_doctor_check_github_token_missing() { + test_case "_doctor_check_github_token tags 'missing' when sec returns empty" + # No mock-curl install needed — code never reaches curl on the empty branch + _SAVED_SEC_BODY="${functions[sec]}" + functions[sec]="print -- ''" + _doctor_token_issues[github]="" # reset prior state + + _doctor_check_github_token "false" >/dev/null 2>&1 + + assert_contains "${_doctor_token_issues[github]}" "missing" \ + "_doctor_token_issues[github] should contain 'missing' when token is empty" + test_pass + + _test_restore_sec + unset "_doctor_token_issues[github]" +} + +test_doctor_check_github_token_invalid_not_cached() { + test_case "invalid token (http 401) tags 'invalid' AND is not cached" + local test_token="ghp_test_invalid_def" + local cache_file=$(_test_token_cache_path "$test_token") + rm -f "$cache_file" + _doctor_token_issues[github]="" + + _test_install_sec_returning "$test_token" + # curl mock that returns a 401 (invalid token response) + _SAVED_CURL_BODY="${functions[curl]}" + functions[curl]="print >> '$_TEST_CURL_LOG'; printf '%s\n%s\n' '{\"message\":\"Bad credentials\"}' '401'" + + _doctor_check_github_token "false" >/dev/null 2>&1 + + assert_contains "${_doctor_token_issues[github]}" "invalid" \ + "_doctor_token_issues[github] should contain 'invalid' on http != 200" + assert_file_not_exists "$cache_file" \ + "Cache file must NOT be written when validation fails (don't cache transient failures)" + test_pass + + _test_restore_curl + _test_restore_sec + unset "_doctor_token_issues[github]" +} + +test_doctor_no_cache_flag_e2e() { + test_case "doctor --no-cache CLI flag wires through to helper (E2E)" + # E2E: pre-populate a valid cache, then call full `doctor --no-cache`. + # Without the flag this would be a cache hit (no curl). With the flag, + # the helper must be invoked with no_cache=true, forcing curl. + local test_token="ghp_test_e2e_flag_uvw" + local cache_file=$(_test_token_cache_path "$test_token") + _test_write_valid_cache "$cache_file" "e2euser" + + _test_install_sec_returning "$test_token" + _test_install_curl_mock "_test_curl_response_fresh" + + doctor --no-cache >/dev/null 2>&1 + + assert_equals "1" "$(_test_curl_call_count)" \ + "doctor --no-cache must force curl despite valid cache entry" + test_pass + + _test_restore_curl + _test_restore_sec + rm -f "$cache_file" +} + +test_doctor_token_fingerprint_determinism() { + test_case "token fingerprint is deterministic and discriminating" + # Mirrors the production hash: sha256 prefix, 12 hex chars + local fp_a1=$(printf '%s' "ghp_token_alpha" | shasum -a 256 | cut -c1-12) + local fp_a2=$(printf '%s' "ghp_token_alpha" | shasum -a 256 | cut -c1-12) + local fp_b=$(printf '%s' "ghp_token_beta" | shasum -a 256 | cut -c1-12) + + assert_equals "$fp_a1" "$fp_a2" "Same token must produce same fingerprint" + assert_not_equals "$fp_a1" "$fp_b" "Different tokens must produce different fingerprints" + assert_equals "12" "${#fp_a1}" "Fingerprint must be exactly 12 hex chars" + test_pass +} + +# Verify the JSON envelope format end-to-end by exercising _doctor_cache_set +# directly with the same args the production code uses. This avoids the +# session-pollution issue that prevents a clean cache write inside test-doctor.zsh. +test_doctor_cache_envelope_format() { + test_case "cache envelope persists http_code and username fields" + local key="token-github-envelope-test-$$" + local cache_value=$(jq -nc \ + --arg http_code "200" \ + --arg username "envuser" \ + '{http_code: $http_code, username: $username}') + + rm -f "$DOCTOR_CACHE_DIR/${key}.cache" + _doctor_cache_set "$key" "$cache_value" 3600 + local set_rc=$? + + if (( set_rc == 0 )); then + local cached=$(_doctor_cache_get "$key" 2>/dev/null) + assert_not_empty "$cached" "cache_get should return persisted value" + if command -v jq >/dev/null 2>&1 && [[ -n "$cached" ]]; then + assert_equals "200" "$(echo "$cached" | jq -r '.http_code // ""')" "http_code" + assert_equals "envuser" "$(echo "$cached" | jq -r '.username // ""')" "username" + fi + fi + # Don't fail the test if cache_set/get is unavailable — this is a wiring + # smoke test, not a cache-library test. + + rm -f "$DOCTOR_CACHE_DIR/${key}.cache" + test_pass +} + +test_doctor_no_cache_flag_bypasses_cache() { + test_case "no_cache=true bypasses a valid cache entry" + local test_token="ghp_test_nocache_qwe" + local cache_file=$(_test_token_cache_path "$test_token") + _test_write_valid_cache "$cache_file" "cacheduser" + + _test_install_sec_returning "$test_token" + _test_install_curl_mock "_test_curl_response_fresh" + + _doctor_check_github_token "true" >/dev/null 2>&1 + + assert_equals "1" "$(_test_curl_call_count)" "no_cache should force curl call despite valid cache" + test_pass + + _test_restore_curl + _test_restore_sec + rm -f "$cache_file" +} + # ============================================================================ # TESTS: No destructive operations in check mode # ============================================================================ @@ -312,6 +564,17 @@ main() { echo "${CYAN}--- Tracking tests ---${RESET}" test_doctor_tracks_missing_brew + echo "" + echo "${CYAN}--- GitHub token cache tests ---${RESET}" + test_doctor_cache_hit_skips_curl + test_doctor_cache_miss_triggers_curl + test_doctor_no_cache_flag_bypasses_cache + test_doctor_check_github_token_missing + test_doctor_check_github_token_invalid_not_cached + test_doctor_no_cache_flag_e2e + test_doctor_token_fingerprint_determinism + test_doctor_cache_envelope_format + echo "" echo "${CYAN}--- Safety tests ---${RESET}" test_doctor_check_no_install diff --git a/tests/test-framework.zsh b/tests/test-framework.zsh index 1b5672354..797bcdf00 100644 --- a/tests/test-framework.zsh +++ b/tests/test-framework.zsh @@ -347,14 +347,25 @@ assert_output_excludes() { assert_not_contains "$@"; } typeset -gA MOCK_CALLS=() typeset -gA MOCK_ARGS=() +# Originals saved as raw function bodies via zsh's `functions[]` assoc array. +# The previous eval-based save (`whence -f $fn | tail -n +2`) was broken: it +# skipped only the opening `name () {` line, leaving the trailing `}` in the +# body, so wrapping it in a new `funcname() { ... }` template produced +# unbalanced braces and a silent eval parse error. Restoration then fell into +# the `unset -f $fn_name` branch, destroying the original instead of restoring. +# `functions[name]` returns the body *without* surrounding braces, so it +# round-trips losslessly through `functions[name]=$saved`. +typeset -gA _ORIGINAL_FUNCTIONS=() create_mock() { local fn_name="$1" local mock_body="${2:-true}" - # Save original if it exists - if (whence -f "$fn_name" >/dev/null 2>&1); then - eval "_original_mock_${fn_name}() { $(whence -f $fn_name | tail -n +2) }" + # Save original if it exists. (( ${+functions[name]} )) is the zsh idiom for + # "is `name` defined as a function?" — true iff the key exists in the + # `functions` assoc array. + if (( ${+functions[$fn_name]} )); then + _ORIGINAL_FUNCTIONS[$fn_name]="${functions[$fn_name]}" fi MOCK_CALLS[$fn_name]=0 @@ -396,11 +407,11 @@ assert_mock_args() { } reset_mocks() { - # Restore originals where saved + # Restore originals where saved, otherwise drop the mock function entirely. for fn_name in ${(k)MOCK_CALLS}; do - if (whence -f "_original_mock_${fn_name}" >/dev/null 2>&1); then - eval "${fn_name}() { $(whence -f _original_mock_${fn_name} | tail -n +2) }" - unset -f "_original_mock_${fn_name}" + if (( ${+_ORIGINAL_FUNCTIONS[$fn_name]} )); then + functions[$fn_name]="${_ORIGINAL_FUNCTIONS[$fn_name]}" + unset "_ORIGINAL_FUNCTIONS[$fn_name]" else unset -f "$fn_name" 2>/dev/null fi diff --git a/tests/test-readonly-scope-regression.zsh b/tests/test-readonly-scope-regression.zsh new file mode 100755 index 000000000..d3028e354 --- /dev/null +++ b/tests/test-readonly-scope-regression.zsh @@ -0,0 +1,182 @@ +#!/usr/bin/env zsh +# test-readonly-scope-regression.zsh - Regression test for ZSH readonly function-scope bug +# +# In ZSH, `readonly FOO=bar` is `typeset -r FOO=bar` and obeys function-local scoping. +# When a lib is sourced from inside a function (e.g., a test harness `setup()`), +# its `readonly` constants vanish when the calling function returns. The lib's +# load-guard pattern (`typeset -g _LIB_LOADED=1`) survives because of `-g`, but +# suppresses re-sourcing — leaving the constants permanently undefined. Symptom: +# arithmetic like `$((CONST * 10))` evaluates to `0`, downstream logic fails silently. +# +# Always use `typeset -gr` (or `typeset -gar` for arrays) for module-level +# constants in sourced libraries. +# +# See: f1939070 (doctor-cache fix), 9db49db9 (preventive fix in analysis-cache + macro-parser) +# Usage: zsh tests/test-readonly-scope-regression.zsh + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +DIM='\033[2m' +RESET='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +run_test() { + local test_name="$1" + local test_func="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + echo -n "${CYAN}[$TESTS_RUN] $test_name...${RESET} " + + local output + output=$(eval "$test_func" 2>&1) + local rc=$? + + if [[ $rc -eq 0 ]]; then + echo "${GREEN}PASS${RESET}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo "${RED}FAIL${RESET}" + echo " ${DIM}$output${RESET}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +# Grep for module-level `readonly` declarations. Excludes: +# - lines inside comments (anywhere `# readonly` appears) +# - the typeset -gr / typeset -gar replacement (which is what we want) +_scan_for_bare_readonly() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + grep -rEn '^[[:space:]]*readonly([[:space:]]|$)' "$dir" --include="*.zsh" 2>/dev/null \ + | grep -v ':[[:space:]]*#' \ + || true +} + +echo "" +echo "${CYAN}══════════════════════════════════════════════════════════════${RESET}" +echo "${CYAN} Regression: bare \`readonly\` at module scope (ZSH function-local trap)${RESET}" +echo "${CYAN}══════════════════════════════════════════════════════════════${RESET}" +echo "" + +# ============================================================================ +# TEST 1: No bare `readonly` in lib/ +# ============================================================================ + +run_test "No bare 'readonly' in lib/*.zsh (use typeset -gr)" ' + violations=$(_scan_for_bare_readonly "$PROJECT_ROOT/lib") + if [[ -n "$violations" ]]; then + echo "Found bare readonly declarations in lib/ — replace with typeset -gr:" + echo "$violations" + return 1 + fi +' + +# ============================================================================ +# TEST 2: No bare `readonly` in commands/ +# ============================================================================ + +run_test "No bare 'readonly' in commands/*.zsh (use typeset -gr)" ' + violations=$(_scan_for_bare_readonly "$PROJECT_ROOT/commands") + if [[ -n "$violations" ]]; then + echo "Found bare readonly declarations in commands/ — replace with typeset -gr:" + echo "$violations" + return 1 + fi +' + +# ============================================================================ +# TEST 3: No bare `readonly` in setup/ and hooks/ +# ============================================================================ + +run_test "No bare 'readonly' in setup/ and hooks/ (use typeset -gr)" ' + violations="$(_scan_for_bare_readonly "$PROJECT_ROOT/setup")$(_scan_for_bare_readonly "$PROJECT_ROOT/hooks")" + if [[ -n "$violations" ]]; then + echo "Found bare readonly declarations — replace with typeset -gr:" + echo "$violations" + return 1 + fi +' + +# ============================================================================ +# TEST 4: Verify the actual ZSH behavior (functional proof) +# ============================================================================ +# Demonstrates that `readonly` inside a function context produces a function-local +# variable that vanishes on return, while `typeset -gr` survives. + +run_test "Verify: readonly inside function vanishes; typeset -gr survives" ' + proof_setup() { + readonly _PROOF_LOCAL=42 + typeset -gr _PROOF_GLOBAL=42 + } + proof_setup + + if [[ -n "$_PROOF_LOCAL" ]]; then + echo "Bug not reproduced: readonly _PROOF_LOCAL survived as $_PROOF_LOCAL — ZSH semantics may have changed" + return 1 + fi + if [[ "$_PROOF_GLOBAL" != "42" ]]; then + echo "typeset -gr did not persist: _PROOF_GLOBAL=\"$_PROOF_GLOBAL\"" + return 1 + fi +' + +# ============================================================================ +# TEST 5: Functional proof using a real fixture lib pattern +# ============================================================================ +# Mirrors the actual bug scenario: sourcing a lib (with load-guard + readonly +# constants) from inside a function, then calling a lib function from outside +# the original sourcing context. + +run_test "Verify: lib pattern with bare readonly fails after function returns" ' + fixture=$(mktemp -t lib-readonly-fixture.XXXXXX) + # Single-quoted heredoc — body is verbatim, $((...)) is NOT expanded here. + cat > "$fixture" <<'\''EOF'\'' +[[ -n "$_FIXTURE_LOADED" ]] && return 0 +typeset -g _FIXTURE_LOADED=1 +readonly FIXTURE_BARE=2 +typeset -gr FIXTURE_GLOBAL=2 + +fixture_compute() { + print "$((FIXTURE_BARE * 10))/$((FIXTURE_GLOBAL * 10))" +} +EOF + + fixture_setup() { + source "$fixture" + } + fixture_setup + + result=$(fixture_compute) + rm -f "$fixture" + + # Expected (buggy) behavior: bare readonly is empty after setup() returns, + # so its arithmetic evaluates to 0. typeset -gr survives, evaluates to 20. + if [[ "$result" != "0/20" ]]; then + echo "Bug not reproduced: expected \"0/20\" got \"$result\" — ZSH semantics may have changed" + return 1 + fi +' + +# ============================================================================ +# SUMMARY +# ============================================================================ + +echo "" +echo "${CYAN}──────────────────────────────────────────────────────────────${RESET}" +if [[ $TESTS_FAILED -eq 0 ]]; then + echo "${GREEN}All $TESTS_PASSED/$TESTS_RUN regression tests passed${RESET}" + exit 0 +else + echo "${RED}$TESTS_FAILED/$TESTS_RUN tests failed${RESET}" + echo "${YELLOW}Fix: Replace 'readonly FOO=bar' with 'typeset -gr FOO=bar' (or 'typeset -gar' for arrays).${RESET}" + exit 1 +fi diff --git a/zsh/.zshrc b/zsh/.zshrc index b380ba440..26791b6f9 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -645,6 +645,11 @@ if [[ -f ~/.config/zsh/functions/bg-agents.zsh ]]; then source ~/.config/zsh/functions/bg-agents.zsh fi +# Claude config sync via chezmoi (2026-05-11) +if [[ -f ~/.config/zsh/functions/claude-sync.zsh ]]; then + source ~/.config/zsh/functions/claude-sync.zsh +fi + # Scribe CLI - Terminal-based note access (2025-12-27, Sprint 20) if [[ -f ~/.config/zsh/functions/scribe.zsh ]]; then source ~/.config/zsh/functions/scribe.zsh diff --git a/zsh/functions/claude-sync.zsh b/zsh/functions/claude-sync.zsh new file mode 100644 index 000000000..6bbfea2a7 --- /dev/null +++ b/zsh/functions/claude-sync.zsh @@ -0,0 +1,109 @@ +#!/usr/bin/env zsh +# Claude Code config sync helper +# +# Keeps ~/.claude/CLAUDE.md and per-project memory dirs in sync with the +# dotfiles repo (Data-Wise/dotfiles via chezmoi). +# +# Source: managed by chezmoi at dot_config/zsh/functions/claude-sync.zsh +# Apply destination: ~/.config/zsh/functions/claude-sync.zsh +# +# Usage: +# claude-sync # Re-add tracked files + push (silent if nothing changed) +# claude-sync --status # Show drift between live ~ and chezmoi source +# claude-sync --no-push # Re-add + commit only, don't push +# claude-sync --add # Add a new ~/.claude/... path to tracking + +claude-sync() { + local action="default" + local extra_path="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --status) action="status"; shift ;; + --no-push) action="no-push"; shift ;; + --add) action="add"; extra_path="$2"; shift 2 ;; + -h|--help) + grep -E '^# ' "${(%):-%x}" | sed 's/^# \?//' | head -15 + return 0 ;; + *) + echo "claude-sync: unknown arg '$1' (try --help)" >&2 + return 2 ;; + esac + done + + if ! command -v chezmoi >/dev/null; then + echo "claude-sync: chezmoi not installed (brew install chezmoi)" >&2 + return 1 + fi + + case "$action" in + status) + chezmoi diff ~/.claude 2>/dev/null + return 0 + ;; + add) + if [[ -z "$extra_path" || ! -e "$extra_path" ]]; then + echo "claude-sync: --add needs an existing path" >&2 + return 2 + fi + chezmoi add "$extra_path" && \ + git -C "$(chezmoi source-path)" push origin main + return $? + ;; + esac + + # Default: sync tracked ~/.claude paths, commit (auto), push (unless --no-push) + local file_targets=( ~/.claude/CLAUDE.md(N) ) + local dir_targets=( ~/.claude/projects/*/memory(N/) ) + + # Skip if nothing tracked yet + local tracked + tracked=$(chezmoi managed 2>/dev/null | grep -c '^\.claude') + if (( tracked == 0 )); then + echo "claude-sync: nothing under ~/.claude is tracked by chezmoi yet." + echo " Bootstrap with: chezmoi add ~/.claude/CLAUDE.md" + return 1 + fi + + # Files: re-add (only updates known files, won't pick up unrelated new files) + (( ${#file_targets[@]} > 0 )) && chezmoi re-add "${file_targets[@]}" 2>/dev/null + + # Directories: use `add` (recurses, picks up new files written this session) + (( ${#dir_targets[@]} > 0 )) && chezmoi add "${dir_targets[@]}" 2>/dev/null + + # Push if commits were made + if [[ "$action" != "no-push" ]]; then + # Use `git -C "$(chezmoi source-path)"` instead of `chezmoi cd &&` — + # `chezmoi cd` is interactive-only and silently no-ops in a sourced + # function, which previously caused git commands to run in the wrong + # repo's CWD. + local src + src=$(chezmoi source-path 2>/dev/null) + if [[ -z "$src" || ! -d "$src/.git" ]]; then + echo "claude-sync: can't locate chezmoi source git repo" >&2 + return 1 + fi + + # Local-ahead check: chezmoi auto-commits even when content didn't + # change, so this can be true with no actual remote-bound work. + if [[ -z "$(git -C "$src" log origin/main..HEAD --oneline 2>/dev/null)" ]]; then + echo "claude-sync: nothing to push (already synced)" + return 0 + fi + + # Capture push output to distinguish "Everything up-to-date" (no + # remote change) from an actual transfer. + local push_output + if ! push_output=$(git -C "$src" push origin main 2>&1); then + echo "claude-sync: push failed:" >&2 + printf '%s\n' "$push_output" >&2 + return 1 + fi + + if echo "$push_output" | grep -q "Everything up-to-date"; then + echo "claude-sync: synced (no remote change — chezmoi auto-commit was empty)" + else + echo "claude-sync: pushed" + fi + fi +}