crackerjack 0.28.0__py3-none-any.whl → 0.29.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

@@ -274,7 +274,7 @@ python -m crackerjack --resume-from {self.progress_file.name}
274
274
 
275
275
  """
276
276
 
277
- all_files = set()
277
+ all_files: set[str] = set()
278
278
  for task in self.tasks.values():
279
279
  if task.files_changed:
280
280
  all_files.update(task.files_changed)
@@ -351,7 +351,7 @@ python -m crackerjack --resume-from {self.progress_file.name}
351
351
 
352
352
  @classmethod
353
353
  def find_recent_progress_files(cls, directory: Path = Path.cwd()) -> list[Path]:
354
- progress_files = []
354
+ progress_files: list[Path] = []
355
355
  for file_path in directory.glob("SESSION-PROGRESS-*.md"):
356
356
  try:
357
357
  if file_path.is_file():
@@ -525,6 +525,7 @@ class OptionsProtocol(t.Protocol):
525
525
  update_precommit: bool
526
526
  update_docs: bool
527
527
  force_update_docs: bool
528
+ compress_docs: bool
528
529
  clean: bool
529
530
  test: bool
530
531
  benchmark: bool
@@ -1683,38 +1684,321 @@ class ConfigManager(BaseModel, arbitrary_types_allowed=True):
1683
1684
  if configs_to_add:
1684
1685
  self.execute_command(["git", "add"] + configs_to_add)
1685
1686
 
1686
- def copy_documentation_templates(self, force_update: bool = False) -> None:
1687
+ def copy_documentation_templates(
1688
+ self, force_update: bool = False, compress_docs: bool = False
1689
+ ) -> None:
1687
1690
  docs_to_add: list[str] = []
1688
1691
  for doc_file in documentation_files:
1689
- doc_path = self.our_path / doc_file
1690
- pkg_doc_path = self.pkg_path / doc_file
1691
- if not doc_path.exists():
1692
- continue
1693
- if self.pkg_path.stem == "crackerjack":
1694
- continue
1695
- should_update = force_update or not pkg_doc_path.exists()
1696
- if should_update:
1697
- pkg_doc_path.touch()
1698
- content = doc_path.read_text(encoding="utf-8")
1699
- updated_content = self._customize_documentation_content(
1700
- content, doc_file
1701
- )
1702
- pkg_doc_path.write_text(updated_content, encoding="utf-8")
1703
- docs_to_add.append(doc_file)
1704
- self.console.print(
1705
- f"[green]📋[/green] Updated {doc_file} with latest Crackerjack quality standards"
1692
+ if self._should_process_doc_file(doc_file):
1693
+ self._process_single_doc_file(
1694
+ doc_file, force_update, compress_docs, docs_to_add
1706
1695
  )
1696
+
1707
1697
  if docs_to_add:
1708
1698
  self.execute_command(["git", "add"] + docs_to_add)
1709
1699
 
1710
- def _customize_documentation_content(self, content: str, filename: str) -> str:
1700
+ def _should_process_doc_file(self, doc_file: str) -> bool:
1701
+ doc_path = self.our_path / doc_file
1702
+ if not doc_path.exists():
1703
+ return False
1704
+ if self.pkg_path.stem == "crackerjack":
1705
+ return False
1706
+ return True
1707
+
1708
+ def _process_single_doc_file(
1709
+ self,
1710
+ doc_file: str,
1711
+ force_update: bool,
1712
+ compress_docs: bool,
1713
+ docs_to_add: list[str],
1714
+ ) -> None:
1715
+ doc_path = self.our_path / doc_file
1716
+ pkg_doc_path = self.pkg_path / doc_file
1717
+ should_update = force_update or not pkg_doc_path.exists()
1718
+
1719
+ if should_update:
1720
+ pkg_doc_path.touch()
1721
+ content = doc_path.read_text(encoding="utf-8")
1722
+
1723
+ auto_compress = self._should_compress_doc(doc_file, compress_docs)
1724
+ updated_content = self._customize_documentation_content(
1725
+ content, doc_file, auto_compress
1726
+ )
1727
+ pkg_doc_path.write_text(updated_content, encoding="utf-8")
1728
+ docs_to_add.append(doc_file)
1729
+
1730
+ self._print_doc_update_message(doc_file, auto_compress)
1731
+
1732
+ def _should_compress_doc(self, doc_file: str, compress_docs: bool) -> bool:
1733
+ return compress_docs or (
1734
+ self.pkg_path.stem != "crackerjack" and doc_file == "CLAUDE.md"
1735
+ )
1736
+
1737
+ def _print_doc_update_message(self, doc_file: str, auto_compress: bool) -> None:
1738
+ compression_note = (
1739
+ " (compressed for Claude Code)"
1740
+ if auto_compress and doc_file == "CLAUDE.md"
1741
+ else ""
1742
+ )
1743
+ self.console.print(
1744
+ f"[green]📋[/green] Updated {doc_file} with latest Crackerjack quality standards{compression_note}"
1745
+ )
1746
+
1747
+ def _customize_documentation_content(
1748
+ self, content: str, filename: str, compress: bool = False
1749
+ ) -> str:
1711
1750
  if filename == "CLAUDE.md":
1712
- return self._customize_claude_md(content)
1751
+ return self._customize_claude_md(content, compress)
1713
1752
  elif filename == "RULES.md":
1714
1753
  return self._customize_rules_md(content)
1715
1754
  return content
1716
1755
 
1717
- def _customize_claude_md(self, content: str) -> str:
1756
+ def _compress_claude_md(self, content: str, target_size: int = 30000) -> str:
1757
+ content.split("\n")
1758
+ current_size = len(content)
1759
+ if current_size <= target_size:
1760
+ return content
1761
+ essential_sections = [
1762
+ "# ",
1763
+ "## Project Overview",
1764
+ "## Key Commands",
1765
+ "## Development Guidelines",
1766
+ "## Code Quality Compliance",
1767
+ "### Refurb Standards",
1768
+ "### Bandit Security Standards",
1769
+ "### Pyright Type Safety Standards",
1770
+ "## AI Code Generation Best Practices",
1771
+ "## Task Completion Requirements",
1772
+ ]
1773
+ compression_strategies = [
1774
+ self._remove_redundant_examples,
1775
+ self._compress_command_examples,
1776
+ self._remove_verbose_sections,
1777
+ self._compress_repeated_patterns,
1778
+ self._summarize_long_sections,
1779
+ ]
1780
+ compressed_content = content
1781
+ for strategy in compression_strategies:
1782
+ compressed_content = strategy(compressed_content)
1783
+ if len(compressed_content) <= target_size:
1784
+ break
1785
+ if len(compressed_content) > target_size:
1786
+ compressed_content = self._extract_essential_sections(
1787
+ compressed_content, essential_sections, target_size
1788
+ )
1789
+
1790
+ return self._add_compression_notice(compressed_content)
1791
+
1792
+ def _remove_redundant_examples(self, content: str) -> str:
1793
+ lines = content.split("\n")
1794
+ result = []
1795
+ in_example_block = False
1796
+ example_count = 0
1797
+ max_examples_per_section = 2
1798
+ for line in lines:
1799
+ if line.strip().startswith("```"):
1800
+ if not in_example_block:
1801
+ example_count += 1
1802
+ if example_count <= max_examples_per_section:
1803
+ result.append(line)
1804
+ in_example_block = True
1805
+ else:
1806
+ in_example_block = "skip"
1807
+ else:
1808
+ if in_example_block != "skip":
1809
+ result.append(line)
1810
+ in_example_block = False
1811
+ elif in_example_block == "skip":
1812
+ continue
1813
+ elif line.startswith(("## ", "### ")):
1814
+ example_count = 0
1815
+ result.append(line)
1816
+ else:
1817
+ result.append(line)
1818
+
1819
+ return "\n".join(result)
1820
+
1821
+ def _compress_command_examples(self, content: str) -> str:
1822
+ import re
1823
+
1824
+ content = re.sub(
1825
+ r"```bash\n((?:[^`]+\n){3,})```",
1826
+ lambda m: "```bash\n"
1827
+ + "\n".join(m.group(1).split("\n")[:3])
1828
+ + "\n# ... (additional commands available)\n```",
1829
+ content,
1830
+ flags=re.MULTILINE,
1831
+ )
1832
+
1833
+ return content
1834
+
1835
+ def _remove_verbose_sections(self, content: str) -> str:
1836
+ sections_to_compress = [
1837
+ "## Recent Bug Fixes and Improvements",
1838
+ "## Development Memories",
1839
+ "## Self-Maintenance Protocol for AI Assistants",
1840
+ "## Pre-commit Hook Maintenance",
1841
+ ]
1842
+ lines = content.split("\n")
1843
+ result = []
1844
+ skip_section = False
1845
+ for line in lines:
1846
+ if any(line.startswith(section) for section in sections_to_compress):
1847
+ skip_section = True
1848
+ result.extend(
1849
+ (line, "*[Detailed information available in full CLAUDE.md]*")
1850
+ )
1851
+ result.append("")
1852
+ elif line.startswith("## ") and skip_section:
1853
+ skip_section = False
1854
+ result.append(line)
1855
+ elif not skip_section:
1856
+ result.append(line)
1857
+
1858
+ return "\n".join(result)
1859
+
1860
+ def _compress_repeated_patterns(self, content: str) -> str:
1861
+ import re
1862
+
1863
+ content = re.sub(r"\n{3,}", "\n\n", content)
1864
+ content = re.sub(
1865
+ r"(\*\*[A-Z][^*]+:\*\*[^\n]+\n){3,}",
1866
+ lambda m: m.group(0)[:200]
1867
+ + "...\n*[Additional patterns available in full documentation]*\n",
1868
+ content,
1869
+ )
1870
+
1871
+ return content
1872
+
1873
+ def _summarize_long_sections(self, content: str) -> str:
1874
+ lines = content.split("\n")
1875
+ result = []
1876
+ current_section = []
1877
+ section_header = ""
1878
+ for line in lines:
1879
+ if line.startswith(("### ", "## ")):
1880
+ if current_section and len("\n".join(current_section)) > 1000:
1881
+ summary = self._create_section_summary(
1882
+ section_header, current_section
1883
+ )
1884
+ result.extend(summary)
1885
+ else:
1886
+ result.extend(current_section)
1887
+ current_section = [line]
1888
+ section_header = line
1889
+ else:
1890
+ current_section.append(line)
1891
+ if current_section:
1892
+ if len("\n".join(current_section)) > 1000:
1893
+ summary = self._create_section_summary(section_header, current_section)
1894
+ result.extend(summary)
1895
+ else:
1896
+ result.extend(current_section)
1897
+
1898
+ return "\n".join(result)
1899
+
1900
+ def _create_section_summary(
1901
+ self, header: str, section_lines: list[str]
1902
+ ) -> list[str]:
1903
+ summary = [header, ""]
1904
+
1905
+ key_points = []
1906
+ for line in section_lines[2:]:
1907
+ if line.strip().startswith(("- ", "* ", "1. ", "2. ")):
1908
+ key_points.append(line)
1909
+ elif line.strip().startswith("**") and ":" in line:
1910
+ key_points.append(line)
1911
+
1912
+ if len(key_points) >= 5:
1913
+ break
1914
+
1915
+ if key_points:
1916
+ summary.extend(key_points[:5])
1917
+ summary.append("*[Complete details available in full CLAUDE.md]*")
1918
+ else:
1919
+ content_preview = " ".join(
1920
+ line.strip()
1921
+ for line in section_lines[2:10]
1922
+ if line.strip() and not line.startswith("#")
1923
+ )[:200]
1924
+ summary.extend(
1925
+ (
1926
+ f"{content_preview}...",
1927
+ "*[Full section available in complete documentation]*",
1928
+ )
1929
+ )
1930
+
1931
+ summary.append("")
1932
+ return summary
1933
+
1934
+ def _extract_essential_sections(
1935
+ self, content: str, essential_sections: list[str], target_size: int
1936
+ ) -> str:
1937
+ lines = content.split("\n")
1938
+ result = []
1939
+ current_section = []
1940
+ keep_section = False
1941
+
1942
+ for line in lines:
1943
+ new_section_started = self._process_line_for_section(
1944
+ line, essential_sections, current_section, keep_section, result
1945
+ )
1946
+ if new_section_started is not None:
1947
+ current_section, keep_section = new_section_started
1948
+ else:
1949
+ current_section.append(line)
1950
+
1951
+ if self._should_stop_extraction(result, target_size):
1952
+ break
1953
+
1954
+ self._finalize_extraction(current_section, keep_section, result, target_size)
1955
+ return "\n".join(result)
1956
+
1957
+ def _process_line_for_section(
1958
+ self,
1959
+ line: str,
1960
+ essential_sections: list[str],
1961
+ current_section: list[str],
1962
+ keep_section: bool,
1963
+ result: list[str],
1964
+ ) -> tuple[list[str], bool] | None:
1965
+ if any(line.startswith(section) for section in essential_sections):
1966
+ if current_section and keep_section:
1967
+ result.extend(current_section)
1968
+ return ([line], True)
1969
+ elif line.startswith(("## ", "### ")):
1970
+ if current_section and keep_section:
1971
+ result.extend(current_section)
1972
+ return ([line], False)
1973
+ return None
1974
+
1975
+ def _should_stop_extraction(self, result: list[str], target_size: int) -> bool:
1976
+ return len("\n".join(result)) > target_size
1977
+
1978
+ def _finalize_extraction(
1979
+ self,
1980
+ current_section: list[str],
1981
+ keep_section: bool,
1982
+ result: list[str],
1983
+ target_size: int,
1984
+ ) -> None:
1985
+ if current_section and keep_section and len("\n".join(result)) < target_size:
1986
+ result.extend(current_section)
1987
+
1988
+ def _add_compression_notice(self, content: str) -> str:
1989
+ notice = """
1990
+ *Note: This CLAUDE.md has been automatically compressed by Crackerjack to optimize for Claude Code usage.
1991
+ Complete documentation is available in the source repository.*
1992
+
1993
+ """
1994
+
1995
+ lines = content.split("\n")
1996
+ if len(lines) > 5:
1997
+ lines.insert(5, notice)
1998
+
1999
+ return "\n".join(lines)
2000
+
2001
+ def _customize_claude_md(self, content: str, compress: bool = False) -> str:
1718
2002
  project_name = self.pkg_name
1719
2003
  content = content.replace("crackerjack", project_name).replace(
1720
2004
  "Crackerjack", project_name.title()
@@ -1737,9 +2021,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1737
2021
 
1738
2022
  if start_idx > 0:
1739
2023
  relevant_content = "\n".join(lines[start_idx:])
1740
- return header + relevant_content
2024
+ full_content = header + relevant_content
2025
+ else:
2026
+ full_content = header + content
1741
2027
 
1742
- return header + content
2028
+ if compress:
2029
+ return self._compress_claude_md(full_content)
2030
+ return full_content
1743
2031
 
1744
2032
  def _customize_rules_md(self, content: str) -> str:
1745
2033
  project_name = self.pkg_name
@@ -1844,13 +2132,22 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
1844
2132
  "[bold bright_blue]⚡ INIT[/bold bright_blue] [bold bright_white]First-time project setup[/bold bright_white]"
1845
2133
  )
1846
2134
  self.console.print("─" * 80 + "\n")
1847
- self.execute_command(["uv", "tool", "install", "keyring"])
2135
+ if self.options and getattr(self.options, "ai_agent", False):
2136
+ import subprocess
2137
+
2138
+ self.execute_command(
2139
+ ["uv", "tool", "install", "keyring"],
2140
+ capture_output=True,
2141
+ stderr=subprocess.DEVNULL,
2142
+ )
2143
+ else:
2144
+ self.execute_command(["uv", "tool", "install", "keyring"])
1848
2145
  self.execute_command(["git", "init"])
1849
2146
  self.execute_command(["git", "branch", "-m", "main"])
1850
2147
  self.execute_command(["git", "add", "pyproject.toml", "uv.lock"])
1851
2148
  self.execute_command(["git", "config", "advice.addIgnoredFile", "false"])
1852
2149
  install_cmd = ["uv", "run", "pre-commit", "install"]
1853
- if hasattr(self, "options") and getattr(self.options, "ai_agent", False):
2150
+ if self.options and getattr(self.options, "ai_agent", False):
1854
2151
  install_cmd.extend(["-c", ".pre-commit-config-ai.yaml"])
1855
2152
  else:
1856
2153
  install_cmd.extend(["-c", ".pre-commit-config-fast.yaml"])
@@ -1927,7 +2224,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
1927
2224
  result = self.execute_command(cmd, capture_output=True, text=True)
1928
2225
  total_duration = time.time() - start_time
1929
2226
  hook_results = self._parse_hook_output(result.stdout, result.stderr)
1930
- if hasattr(self, "options") and getattr(self.options, "ai_agent", False):
2227
+ if self.options and getattr(self.options, "ai_agent", False):
1931
2228
  self._generate_hooks_analysis(hook_results, total_duration)
1932
2229
  self._generate_quality_metrics()
1933
2230
  self._generate_project_structure_analysis()
@@ -2049,7 +2346,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2049
2346
  return suggestions
2050
2347
 
2051
2348
  def _generate_quality_metrics(self) -> None:
2052
- if not (hasattr(self, "options") and getattr(self.options, "ai_agent", False)):
2349
+ if not (self.options and getattr(self.options, "ai_agent", False)):
2053
2350
  return
2054
2351
  metrics = {
2055
2352
  "project_info": {
@@ -2224,7 +2521,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2224
2521
  return recommendations
2225
2522
 
2226
2523
  def _generate_project_structure_analysis(self) -> None:
2227
- if not (hasattr(self, "options") and getattr(self.options, "ai_agent", False)):
2524
+ if not (self.options and getattr(self.options, "ai_agent", False)):
2228
2525
  return
2229
2526
  structure = {
2230
2527
  "project_overview": {
@@ -2248,7 +2545,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2248
2545
  )
2249
2546
 
2250
2547
  def _generate_error_context_analysis(self) -> None:
2251
- if not (hasattr(self, "options") and getattr(self.options, "ai_agent", False)):
2548
+ if not (self.options and getattr(self.options, "ai_agent", False)):
2252
2549
  return
2253
2550
  context = {
2254
2551
  "analysis_info": {
@@ -2269,7 +2566,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2269
2566
  )
2270
2567
 
2271
2568
  def _generate_ai_agent_summary(self) -> None:
2272
- if not (hasattr(self, "options") and getattr(self.options, "ai_agent", False)):
2569
+ if not (self.options and getattr(self.options, "ai_agent", False)):
2273
2570
  return
2274
2571
  summary = {
2275
2572
  "analysis_summary": {
@@ -2549,7 +2846,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2549
2846
  raise SystemExit(1)
2550
2847
  else:
2551
2848
  self.console.print(
2552
- "\n[bold bright_green] Pre-commit passed all checks![/bold bright_green]"
2849
+ "\n[bold bright_green]🏆 Pre-commit passed all checks![/bold bright_green]"
2553
2850
  )
2554
2851
 
2555
2852
  async def run_pre_commit_with_analysis_async(self) -> list[HookResult]:
@@ -2595,7 +2892,7 @@ class ProjectManager(BaseModel, arbitrary_types_allowed=True):
2595
2892
  raise SystemExit(1)
2596
2893
  else:
2597
2894
  self.console.print(
2598
- "\n[bold bright_green] Pre-commit passed all checks![/bold bright_green]"
2895
+ "\n[bold bright_green]🏆 Pre-commit passed all checks![/bold bright_green]"
2599
2896
  )
2600
2897
  self._generate_analysis_files(hook_results)
2601
2898
 
@@ -2686,6 +2983,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
2686
2983
  config_manager: ConfigManager | None = None
2687
2984
  project_manager: ProjectManager | None = None
2688
2985
  session_tracker: SessionTracker | None = None
2986
+ options: t.Any = None
2689
2987
  _file_cache: dict[str, list[Path]] = {}
2690
2988
  _file_cache_with_mtime: dict[str, tuple[float, list[Path]]] = {}
2691
2989
  _state_file: Path = Path(".crackerjack-state")
@@ -3080,7 +3378,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3080
3378
 
3081
3379
  def _handle_test_success(self, options: t.Any) -> None:
3082
3380
  self.console.print(
3083
- "\n\n[bold bright_green] Tests passed successfully![/bold bright_green]\n"
3381
+ "\n\n[bold bright_green]🏆 Tests passed successfully![/bold bright_green]\n"
3084
3382
  )
3085
3383
  self._print_ai_agent_files(options)
3086
3384
 
@@ -3149,6 +3447,121 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3149
3447
  self._mark_version_bumped(version_type)
3150
3448
  break
3151
3449
 
3450
+ def _validate_authentication_setup(self) -> None:
3451
+ import os
3452
+ import shutil
3453
+
3454
+ keyring_provider = self._get_keyring_provider()
3455
+ has_publish_token = bool(os.environ.get("UV_PUBLISH_TOKEN"))
3456
+ has_keyring = shutil.which("keyring") is not None
3457
+ self.console.print("[dim]🔐 Validating authentication setup...[/dim]")
3458
+ if has_publish_token:
3459
+ self._handle_publish_token_found()
3460
+ return
3461
+ if keyring_provider == "subprocess" and has_keyring:
3462
+ self._handle_keyring_validation()
3463
+ return
3464
+ if keyring_provider == "subprocess" and not has_keyring:
3465
+ self._handle_missing_keyring()
3466
+ if not keyring_provider:
3467
+ self._handle_no_keyring_provider()
3468
+
3469
+ def _handle_publish_token_found(self) -> None:
3470
+ self.console.print(
3471
+ "[dim] ✅ UV_PUBLISH_TOKEN environment variable found[/dim]"
3472
+ )
3473
+
3474
+ def _handle_keyring_validation(self) -> None:
3475
+ self.console.print(
3476
+ "[dim] ✅ Keyring provider configured and keyring executable found[/dim]"
3477
+ )
3478
+ try:
3479
+ result = self.execute_command(
3480
+ ["keyring", "get", "https://upload.pypi.org/legacy/", "__token__"],
3481
+ capture_output=True,
3482
+ text=True,
3483
+ )
3484
+ if result.returncode == 0:
3485
+ self.console.print("[dim] ✅ PyPI token found in keyring[/dim]")
3486
+ else:
3487
+ self.console.print(
3488
+ "[yellow] ⚠️ No PyPI token found in keyring - will prompt during publish[/yellow]"
3489
+ )
3490
+ except Exception:
3491
+ self.console.print(
3492
+ "[yellow] ⚠️ Could not check keyring - will attempt publish anyway[/yellow]"
3493
+ )
3494
+
3495
+ def _handle_missing_keyring(self) -> None:
3496
+ if not (self.options and getattr(self.options, "ai_agent", False)):
3497
+ self.console.print(
3498
+ "[yellow] ⚠️ Keyring provider set to 'subprocess' but keyring executable not found[/yellow]"
3499
+ )
3500
+ self.console.print(
3501
+ "[yellow] Install keyring: uv tool install keyring[/yellow]"
3502
+ )
3503
+
3504
+ def _handle_no_keyring_provider(self) -> None:
3505
+ if not (self.options and getattr(self.options, "ai_agent", False)):
3506
+ self.console.print(
3507
+ "[yellow] ⚠️ No keyring provider configured and no UV_PUBLISH_TOKEN set[/yellow]"
3508
+ )
3509
+
3510
+ def _get_keyring_provider(self) -> str | None:
3511
+ import os
3512
+ import tomllib
3513
+ from pathlib import Path
3514
+
3515
+ env_provider = os.environ.get("UV_KEYRING_PROVIDER")
3516
+ if env_provider:
3517
+ return env_provider
3518
+ for config_file in ("pyproject.toml", "uv.toml"):
3519
+ config_path = Path(config_file)
3520
+ if config_path.exists():
3521
+ try:
3522
+ with config_path.open("rb") as f:
3523
+ config = tomllib.load(f)
3524
+ return config.get("tool", {}).get("uv", {}).get("keyring-provider")
3525
+ except Exception:
3526
+ continue
3527
+
3528
+ return None
3529
+
3530
+ def _build_publish_command(self) -> list[str]:
3531
+ import os
3532
+
3533
+ cmd = ["uv", "publish"]
3534
+ publish_token = os.environ.get("UV_PUBLISH_TOKEN")
3535
+ if publish_token:
3536
+ cmd.extend(["--token", publish_token])
3537
+ keyring_provider = self._get_keyring_provider()
3538
+ if keyring_provider:
3539
+ cmd.extend(["--keyring-provider", keyring_provider])
3540
+
3541
+ return cmd
3542
+
3543
+ def _display_authentication_help(self) -> None:
3544
+ self.console.print(
3545
+ "\n[bold bright_red]❌ Publish failed. Run crackerjack again to retry publishing without re-bumping version.[/bold bright_red]"
3546
+ )
3547
+ if not (self.options and getattr(self.options, "ai_agent", False)):
3548
+ self.console.print("\n[bold yellow]🔐 Authentication Help:[/bold yellow]")
3549
+ self.console.print(" [dim]To fix authentication issues, you can:[/dim]")
3550
+ self.console.print(
3551
+ " [dim]1. Set PyPI token: export UV_PUBLISH_TOKEN=pypi-your-token-here[/dim]"
3552
+ )
3553
+ self.console.print(
3554
+ " [dim]2. Install keyring: uv tool install keyring[/dim]"
3555
+ )
3556
+ self.console.print(
3557
+ " [dim]3. Store token in keyring: keyring set https://upload.pypi.org/legacy/ __token__[/dim]"
3558
+ )
3559
+ self.console.print(
3560
+ " [dim]4. Ensure keyring-provider is set in pyproject.toml:[/dim]"
3561
+ )
3562
+ self.console.print(" [dim] [tool.uv][/dim]")
3563
+ self.console.print(' [dim] keyring-provider = "subprocess"[/dim]')
3564
+
3152
3565
  def _publish_project(self, options: OptionsProtocol) -> None:
3153
3566
  if options.publish:
3154
3567
  self.console.print("\n" + "-" * 80)
@@ -3167,18 +3580,169 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3167
3580
  )
3168
3581
  raise SystemExit(1)
3169
3582
  try:
3170
- self.execute_command(["uv", "publish"])
3583
+ self._validate_authentication_setup()
3584
+ publish_cmd = self._build_publish_command()
3585
+ self.execute_command(publish_cmd)
3171
3586
  self._mark_publish_completed()
3172
3587
  self._clear_state()
3173
3588
  self.console.print(
3174
- "\n[bold bright_green] Package published successfully![/bold bright_green]"
3589
+ "\n[bold bright_green]🏆 Package published successfully![/bold bright_green]"
3175
3590
  )
3176
3591
  except SystemExit:
3177
- self.console.print(
3178
- "\n[bold bright_red]❌ Publish failed. Run crackerjack again to retry publishing without re-bumping version.[/bold bright_red]"
3179
- )
3592
+ self._display_authentication_help()
3180
3593
  raise
3181
3594
 
3595
+ def _analyze_git_changes(self) -> dict[str, t.Any]:
3596
+ diff_result = self._get_git_diff_output()
3597
+ changes = self._parse_git_diff_output(diff_result)
3598
+ changes["stats"] = self._get_git_stats()
3599
+ return changes
3600
+
3601
+ def _get_git_diff_output(self) -> t.Any:
3602
+ diff_cmd = ["git", "diff", "--cached", "--name-status"]
3603
+ diff_result = self.execute_command(diff_cmd, capture_output=True, text=True)
3604
+ if not diff_result.stdout and diff_result.returncode == 0:
3605
+ diff_cmd = ["git", "diff", "--name-status"]
3606
+ diff_result = self.execute_command(diff_cmd, capture_output=True, text=True)
3607
+ return diff_result
3608
+
3609
+ def _parse_git_diff_output(self, diff_result: t.Any) -> dict[str, t.Any]:
3610
+ changes = {
3611
+ "added": [],
3612
+ "modified": [],
3613
+ "deleted": [],
3614
+ "renamed": [],
3615
+ "total_changes": 0,
3616
+ }
3617
+ if diff_result.returncode == 0 and diff_result.stdout:
3618
+ self._process_diff_lines(diff_result.stdout, changes)
3619
+ return changes
3620
+
3621
+ def _process_diff_lines(self, stdout: str, changes: dict[str, t.Any]) -> None:
3622
+ for line in stdout.strip().split("\n"):
3623
+ if not line:
3624
+ continue
3625
+ self._process_single_diff_line(line, changes)
3626
+
3627
+ def _process_single_diff_line(self, line: str, changes: dict[str, t.Any]) -> None:
3628
+ parts = line.split("\t")
3629
+ if len(parts) >= 2:
3630
+ status, filename = parts[0], parts[1]
3631
+ self._categorize_file_change(status, filename, parts, changes)
3632
+ changes["total_changes"] += 1
3633
+
3634
+ def _categorize_file_change(
3635
+ self, status: str, filename: str, parts: list[str], changes: dict[str, t.Any]
3636
+ ) -> None:
3637
+ if status == "A":
3638
+ changes["added"].append(filename)
3639
+ elif status == "M":
3640
+ changes["modified"].append(filename)
3641
+ elif status == "D":
3642
+ changes["deleted"].append(filename)
3643
+ elif status.startswith("R"):
3644
+ if len(parts) >= 3:
3645
+ changes["renamed"].append((parts[1], parts[2]))
3646
+ else:
3647
+ changes["renamed"].append((filename, "unknown"))
3648
+
3649
+ def _get_git_stats(self) -> str:
3650
+ stat_cmd = ["git", "diff", "--cached", "--stat"]
3651
+ stat_result = self.execute_command(stat_cmd, capture_output=True, text=True)
3652
+ if not stat_result.stdout and stat_result.returncode == 0:
3653
+ stat_cmd = ["git", "diff", "--stat"]
3654
+ stat_result = self.execute_command(stat_cmd, capture_output=True, text=True)
3655
+ return stat_result.stdout if stat_result.returncode == 0 else ""
3656
+
3657
+ def _categorize_changes(self, changes: dict[str, t.Any]) -> dict[str, list[str]]:
3658
+ categories = {
3659
+ "docs": [],
3660
+ "tests": [],
3661
+ "config": [],
3662
+ "core": [],
3663
+ "ci": [],
3664
+ "deps": [],
3665
+ }
3666
+ file_patterns = {
3667
+ "docs": ["README.md", "CLAUDE.md", "RULES.md", "docs/", ".md"],
3668
+ "tests": ["test_", "_test.py", "tests/", "conftest.py"],
3669
+ "config": ["pyproject.toml", ".yaml", ".yml", ".json", ".gitignore"],
3670
+ "ci": [".github/", "ci/", ".pre-commit"],
3671
+ "deps": ["requirements", "pyproject.toml", "uv.lock"],
3672
+ }
3673
+ for file_list in ("added", "modified", "deleted"):
3674
+ for filename in changes.get(file_list, []):
3675
+ categorized = False
3676
+ for category, patterns in file_patterns.items():
3677
+ if any(pattern in filename for pattern in patterns):
3678
+ categories[category].append(filename)
3679
+ categorized = True
3680
+ break
3681
+ if not categorized:
3682
+ categories["core"].append(filename)
3683
+
3684
+ return categories
3685
+
3686
+ def _get_primary_changes(self, categories: dict[str, list[str]]) -> list[str]:
3687
+ primary_changes = []
3688
+ category_mapping = [
3689
+ ("core", "core functionality"),
3690
+ ("tests", "tests"),
3691
+ ("docs", "documentation"),
3692
+ ("config", "configuration"),
3693
+ ("deps", "dependencies"),
3694
+ ]
3695
+ for key, label in category_mapping:
3696
+ if categories[key]:
3697
+ primary_changes.append(label)
3698
+
3699
+ return primary_changes or ["project files"]
3700
+
3701
+ def _determine_primary_action(self, changes: dict[str, t.Any]) -> str:
3702
+ added_count = len(changes["added"])
3703
+ modified_count = len(changes["modified"])
3704
+ deleted_count = len(changes["deleted"])
3705
+ if added_count > modified_count + deleted_count:
3706
+ return "Add"
3707
+ elif deleted_count > modified_count + added_count:
3708
+ return "Remove"
3709
+ elif changes["renamed"]:
3710
+ return "Refactor"
3711
+ return "Update"
3712
+
3713
+ def _generate_body_lines(self, changes: dict[str, t.Any]) -> list[str]:
3714
+ body_lines = []
3715
+ change_types = [
3716
+ ("added", "Added"),
3717
+ ("modified", "Modified"),
3718
+ ("deleted", "Deleted"),
3719
+ ("renamed", "Renamed"),
3720
+ ]
3721
+ for change_type, label in change_types:
3722
+ items = changes.get(change_type, [])
3723
+ if items:
3724
+ count = len(items)
3725
+ body_lines.append(f"- {label} {count} file(s)")
3726
+ if change_type not in ("deleted", "renamed"):
3727
+ for file in items[:3]:
3728
+ body_lines.append(f" * {file}")
3729
+ if count > 3:
3730
+ body_lines.append(f" * ... and {count - 3} more")
3731
+
3732
+ return body_lines
3733
+
3734
+ def _generate_commit_message(self, changes: dict[str, t.Any]) -> str:
3735
+ if changes["total_changes"] == 0:
3736
+ return "Update project files"
3737
+ categories = self._categorize_changes(changes)
3738
+ primary_changes = self._get_primary_changes(categories)
3739
+ primary_action = self._determine_primary_action(changes)
3740
+ commit_subject = f"{primary_action} {' and '.join(primary_changes[:2])}"
3741
+ body_lines = self._generate_body_lines(changes)
3742
+ if body_lines:
3743
+ return f"{commit_subject}\n\n" + "\n".join(body_lines)
3744
+ return commit_subject
3745
+
3182
3746
  def _commit_and_push(self, options: OptionsProtocol) -> None:
3183
3747
  if options.commit:
3184
3748
  self.console.print("\n" + "-" * 80)
@@ -3186,7 +3750,39 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3186
3750
  "[bold bright_white]📝 COMMIT[/bold bright_white] [bold bright_white]Saving changes to git[/bold bright_white]"
3187
3751
  )
3188
3752
  self.console.print("-" * 80 + "\n")
3189
- commit_msg = input("\nCommit message: ")
3753
+ changes = self._analyze_git_changes()
3754
+ if changes["total_changes"] > 0:
3755
+ self.console.print("[dim]🔍 Analyzing changes...[/dim]\n")
3756
+ if changes["stats"]:
3757
+ self.console.print(changes["stats"])
3758
+ suggested_msg = self._generate_commit_message(changes)
3759
+ self.console.print(
3760
+ "\n[bold cyan]📋 Suggested commit message:[/bold cyan]"
3761
+ )
3762
+ self.console.print(f"[cyan]{suggested_msg}[/cyan]\n")
3763
+ user_choice = (
3764
+ input("Use suggested message? [Y/n/e to edit]: ").strip().lower()
3765
+ )
3766
+ if user_choice in ("", "y"):
3767
+ commit_msg = suggested_msg
3768
+ elif user_choice == "e":
3769
+ import os
3770
+ import tempfile
3771
+
3772
+ with tempfile.NamedTemporaryFile(
3773
+ mode="w", suffix=".txt", delete=False
3774
+ ) as f:
3775
+ f.write(suggested_msg)
3776
+ temp_path = f.name
3777
+ editor = os.environ.get("EDITOR", "vi")
3778
+ self.execute_command([editor, temp_path])
3779
+ with open(temp_path) as f:
3780
+ commit_msg = f.read().strip()
3781
+ Path(temp_path).unlink()
3782
+ else:
3783
+ commit_msg = input("\nEnter custom commit message: ")
3784
+ else:
3785
+ commit_msg = input("\nCommit message: ")
3190
3786
  self.execute_command(
3191
3787
  ["git", "commit", "-m", commit_msg, "--no-verify", "--", "."]
3192
3788
  )
@@ -3215,7 +3811,8 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3215
3811
  )
3216
3812
  self.console.print("-" * 80 + "\n")
3217
3813
  self.config_manager.copy_documentation_templates(
3218
- force_update=options.force_update_docs
3814
+ force_update=options.force_update_docs,
3815
+ compress_docs=options.compress_docs,
3219
3816
  )
3220
3817
 
3221
3818
  def execute_command(
@@ -3289,7 +3886,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3289
3886
  raise SystemExit(1)
3290
3887
  else:
3291
3888
  self.console.print(
3292
- "\n[bold bright_green] All comprehensive quality checks passed![/bold bright_green]"
3889
+ "\n[bold bright_green]🏆 All comprehensive quality checks passed![/bold bright_green]"
3293
3890
  )
3294
3891
 
3295
3892
  async def _run_comprehensive_quality_checks_async(
@@ -3339,7 +3936,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3339
3936
  raise SystemExit(1)
3340
3937
  else:
3341
3938
  self.console.print(
3342
- "[bold bright_green] All comprehensive quality checks passed![/bold bright_green]"
3939
+ "[bold bright_green]🏆 All comprehensive quality checks passed![/bold bright_green]"
3343
3940
  )
3344
3941
 
3345
3942
  def _run_tracked_task(
@@ -3450,7 +4047,8 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3450
4047
  self._run_tracked_task(
3451
4048
  "clean_project", "Clean project code", lambda: self._clean_project(options)
3452
4049
  )
3453
- self.project_manager.options = options
4050
+ if self.project_manager is not None:
4051
+ self.project_manager.options = options
3454
4052
  if not options.skip_hooks:
3455
4053
  self._run_tracked_task(
3456
4054
  "pre_commit",
@@ -3480,7 +4078,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3480
4078
  )
3481
4079
  self.console.print("\n" + "-" * 80)
3482
4080
  self.console.print(
3483
- "[bold bright_green] CRACKERJACK COMPLETE[/bold bright_green] [bold bright_white]Workflow completed successfully![/bold bright_white]"
4081
+ "[bold bright_green]🏆 CRACKERJACK COMPLETE[/bold bright_green] [bold bright_white]Workflow completed successfully![/bold bright_white]"
3484
4082
  )
3485
4083
  self.console.print("-" * 80 + "\n")
3486
4084
 
@@ -3500,7 +4098,8 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3500
4098
  self._update_project(options)
3501
4099
  self._update_precommit(options)
3502
4100
  await self._clean_project_async(options)
3503
- self.project_manager.options = options
4101
+ if self.project_manager is not None:
4102
+ self.project_manager.options = options
3504
4103
  if not options.skip_hooks:
3505
4104
  if getattr(options, "ai_agent", False):
3506
4105
  await self.project_manager.run_pre_commit_with_analysis_async()
@@ -3517,7 +4116,7 @@ class Crackerjack(BaseModel, arbitrary_types_allowed=True):
3517
4116
  self._publish_project(options)
3518
4117
  self.console.print("\n" + "-" * 80)
3519
4118
  self.console.print(
3520
- "[bold bright_green] CRACKERJACK COMPLETE[/bold bright_green] [bold bright_white]Workflow completed successfully![/bold bright_white]"
4119
+ "[bold bright_green]🏆 CRACKERJACK COMPLETE[/bold bright_green] [bold bright_white]Workflow completed successfully![/bold bright_white]"
3521
4120
  )
3522
4121
  self.console.print("-" * 80 + "\n")
3523
4122