From 0607eae5785bf3236429afc343ac502144245c5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:36:10 +0000 Subject: [PATCH 1/5] fix: correct Python 2 except syntax in all auto-commit blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven occurrences of `except ExcA, ExcB:` (Python 2 syntax) were scattered across cli.py, commands/alias.py, and commands/tool.py. In Python 3 this is a SyntaxError that only surfaces when the code path runs, not at import time — meaning every command that attempts an auto-commit after a mutation would crash silently on its happy path. Changed to `except (ExcA, ExcB):` throughout. https://claude.ai/code/session_01HPtPpBAwQqSjXFafTPQYu9 --- pauldot/cli.py | 6 +++--- pauldot/commands/alias.py | 4 ++-- pauldot/commands/tool.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pauldot/cli.py b/pauldot/cli.py index 164c48d..b20b4c6 100644 --- a/pauldot/cli.py +++ b/pauldot/cli.py @@ -242,7 +242,7 @@ def track( if cfg.git.auto_commit: git.commit(repo_path, f"pauldot: track {home_rel}") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort @@ -510,7 +510,7 @@ def absorb( if cfg.git.auto_commit: git.commit(repo_path, "pauldot: absorb zshrc modifications") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort @@ -548,7 +548,7 @@ def migrate( if cfg.git.auto_commit: git.commit(repo_path, "pauldot: migrate existing zshrc") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort console.print("\nReview the changes, then run `pauldot apply` when ready.") diff --git a/pauldot/commands/alias.py b/pauldot/commands/alias.py index 6f479dd..e6c41c4 100644 --- a/pauldot/commands/alias.py +++ b/pauldot/commands/alias.py @@ -103,7 +103,7 @@ def alias_add( if cfg.git.auto_commit: git.commit(repo_path, f"pauldot: add alias {key}") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort try: @@ -164,7 +164,7 @@ def alias_remove( if cfg.git.auto_commit: git.commit(repo_path, f"pauldot: remove alias {key}") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort try: diff --git a/pauldot/commands/tool.py b/pauldot/commands/tool.py index 6fb87b5..286beca 100644 --- a/pauldot/commands/tool.py +++ b/pauldot/commands/tool.py @@ -193,7 +193,7 @@ def tool_add( if cfg.git.auto_commit: git.commit(repo_path, f"pauldot: add tool {name}") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort @@ -216,5 +216,5 @@ def tool_remove(name: str) -> None: if cfg.git.auto_commit: git.commit(repo_path, f"pauldot: remove tool {name}") console.print("✓ Committed to dotfiles repo.") - except FileNotFoundError, RuntimeError: + except (FileNotFoundError, RuntimeError): pass # auto-commit is best-effort From 8b656d559d2209025036d76ecff67e22aabd61e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:37:34 +0000 Subject: [PATCH 2/5] refactor: extract repeated auto-commit block into _maybe_commit helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same 6-line try/except pattern — load config, check auto_commit, call git.commit, print confirmation — appeared seven times across cli.py, commands/alias.py, and commands/tool.py. Any change to the auto-commit contract (e.g. a new exception type, a dry-run guard, or a log line) had to be applied in seven places; one would inevitably be missed. Each file now has a private _maybe_commit(repo_path, message) helper that owns this logic once. The three files are kept separate rather than sharing a single helper because they are independent command modules that don't otherwise import each other. https://claude.ai/code/session_01HPtPpBAwQqSjXFafTPQYu9 --- pauldot/cli.py | 36 ++++++++++++++---------------------- pauldot/commands/alias.py | 27 +++++++++++++-------------- pauldot/commands/tool.py | 28 ++++++++++++++-------------- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/pauldot/cli.py b/pauldot/cli.py index b20b4c6..0d80500 100644 --- a/pauldot/cli.py +++ b/pauldot/cli.py @@ -49,6 +49,17 @@ console = rich_console.Console() +def _maybe_commit(repo_path: pathlib.Path, message: str) -> None: + """Commit repo changes when auto_commit is enabled. Silently no-ops on any error.""" + try: + cfg = config.load_pauldot_config(repo_path) + if cfg.git.auto_commit: + git.commit(repo_path, message) + console.print("✓ Committed to dotfiles repo.") + except (FileNotFoundError, RuntimeError): + pass + + def _print_apply_result(result: pauldot_apply.ApplyResult, dry_run: bool) -> None: display.print_zshrc_result(result.zshrc, dry_run) display.print_dotfile_apply_results(result.dotfiles, dry_run=dry_run) @@ -237,13 +248,7 @@ def track( console.print(f"✓ Tracking ~/{home_rel} in profile '{s.active_profile}'.") console.print(f" Repo copy: {repo_file.relative_to(repo_path)}") - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, f"pauldot: track {home_rel}") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, f"pauldot: track {home_rel}") @app.command() @@ -505,13 +510,7 @@ def absorb( if not result.lines or dry_run: return - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, "pauldot: absorb zshrc modifications") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, "pauldot: absorb zshrc modifications") @app.command() @@ -543,12 +542,5 @@ def migrate( if dry_run: return - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, "pauldot: migrate existing zshrc") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort - + _maybe_commit(repo_path, "pauldot: migrate existing zshrc") console.print("\nReview the changes, then run `pauldot apply` when ready.") diff --git a/pauldot/commands/alias.py b/pauldot/commands/alias.py index e6c41c4..849cbca 100644 --- a/pauldot/commands/alias.py +++ b/pauldot/commands/alias.py @@ -17,6 +17,17 @@ _ALIAS_PREFIX = "alias " +def _maybe_commit(repo_path: pathlib.Path, message: str) -> None: + """Commit repo changes when auto_commit is enabled. Silently no-ops on any error.""" + try: + cfg = config.load_pauldot_config(repo_path) + if cfg.git.auto_commit: + git.commit(repo_path, message) + console.print("✓ Committed to dotfiles repo.") + except (FileNotFoundError, RuntimeError): + pass + + def _aliases_file(repo_path: pathlib.Path) -> pathlib.Path: return repo_path / "files" / "aliases.zsh" @@ -98,13 +109,7 @@ def alias_add( console.print(f'✓ Added alias {key}="{value}"') - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, f"pauldot: add alias {key}") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, f"pauldot: add alias {key}") try: result = pauldot_apply.run(pathlib.Path.home()) @@ -159,13 +164,7 @@ def alias_remove( console.print(f"✓ Removed alias '{key}' from {', '.join(sources)}") - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, f"pauldot: remove alias {key}") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, f"pauldot: remove alias {key}") try: result = pauldot_apply.run(pathlib.Path.home()) diff --git a/pauldot/commands/tool.py b/pauldot/commands/tool.py index 286beca..e7963de 100644 --- a/pauldot/commands/tool.py +++ b/pauldot/commands/tool.py @@ -14,6 +14,18 @@ console = rich_console.Console() + +def _maybe_commit(repo_path: pathlib.Path, message: str) -> None: + """Commit repo changes when auto_commit is enabled. Silently no-ops on any error.""" + try: + cfg = config.load_pauldot_config(repo_path) + if cfg.git.auto_commit: + git.commit(repo_path, message) + console.print("✓ Committed to dotfiles repo.") + except (FileNotFoundError, RuntimeError): + pass + + _TOOL_ACTION_LABELS: dict[str, tuple[str, str]] = { "installed": ("✓ installed", "green"), "already_installed": ("✓ already installed", "dim"), @@ -188,13 +200,7 @@ def tool_add( except FileNotFoundError: console.print(f"[yellow]⚠[/yellow] Profile '{target_profile}' not found — tool saved to tools.toml only.") - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, f"pauldot: add tool {name}") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, f"pauldot: add tool {name}") @tool_app.command("remove") @@ -211,10 +217,4 @@ def tool_remove(name: str) -> None: config.save_tools(repo_path, updated) console.print(f"✓ Removed '{name}' from tools/tools.toml.") - try: - cfg = config.load_pauldot_config(repo_path) - if cfg.git.auto_commit: - git.commit(repo_path, f"pauldot: remove tool {name}") - console.print("✓ Committed to dotfiles repo.") - except (FileNotFoundError, RuntimeError): - pass # auto-commit is best-effort + _maybe_commit(repo_path, f"pauldot: remove tool {name}") From f80ff35480d6311cecc4a7cef3c0ee2351087136 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:38:47 +0000 Subject: [PATCH 3/5] refactor: move print_tool_results to display.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit display.py is the established home for all rich display helpers, but print_tool_results (and its _TOOL_ACTION_LABELS dict) lived in commands/tool.py — a command module. This forced cli.py to reach into a command module just to render the apply summary, and left display.py as an incomplete reference for display logic. Moved both to display.py alongside the other _*_LABELS dicts and print_* functions. commands/tool.py now imports display and calls display.print_tool_results for tool_install and tool_update output. cli.py._print_apply_result does the same. https://claude.ai/code/session_01HPtPpBAwQqSjXFafTPQYu9 --- pauldot/cli.py | 2 +- pauldot/commands/tool.py | 30 +++--------------------------- pauldot/display.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/pauldot/cli.py b/pauldot/cli.py index 0d80500..47570d1 100644 --- a/pauldot/cli.py +++ b/pauldot/cli.py @@ -64,7 +64,7 @@ def _print_apply_result(result: pauldot_apply.ApplyResult, dry_run: bool) -> Non display.print_zshrc_result(result.zshrc, dry_run) display.print_dotfile_apply_results(result.dotfiles, dry_run=dry_run) if result.tools: - cmd_tool.print_tool_results(result.tools) + display.print_tool_results(result.tools) def _gather_repo_url() -> str | None: diff --git a/pauldot/commands/tool.py b/pauldot/commands/tool.py index e7963de..3c91798 100644 --- a/pauldot/commands/tool.py +++ b/pauldot/commands/tool.py @@ -8,7 +8,7 @@ from rich import table as rich_table from rich import text as rich_text -from pauldot import config, git, profiles, shell, state, tools +from pauldot import config, display, git, profiles, shell, state, tools tool_app = typer.Typer() @@ -26,30 +26,6 @@ def _maybe_commit(repo_path: pathlib.Path, message: str) -> None: pass -_TOOL_ACTION_LABELS: dict[str, tuple[str, str]] = { - "installed": ("✓ installed", "green"), - "already_installed": ("✓ already installed", "dim"), - "updated": ("✓ updated", "green"), - "skipped": ("– skipped", "dim"), - "not_installed": ("✗ not installed", "red"), - "failed": ("⚠ failed", "yellow"), -} - - -def print_tool_results(tool_results: list[tools.ToolResult]) -> None: - t = rich_table.Table(show_header=False, box=None, padding=(0, 2)) - t.add_column(no_wrap=True) - t.add_column(no_wrap=True) - t.add_column(no_wrap=True) - - for result in tool_results: - label, style = _TOOL_ACTION_LABELS[result.action] - error_text = rich_text.Text(result.error or "", style="dim") - t.add_row(rich_text.Text(result.name), rich_text.Text(label, style=style), error_text) - - console.print(t) - - @tool_app.command("list") def tool_list() -> None: """List all tools defined in tools/tools.toml.""" @@ -115,7 +91,7 @@ def tool_install( raise typer.Exit(1) from None results = tools.reconcile(tool_names, all_tools, os_name, console=console) - print_tool_results(results) + display.print_tool_results(results) @tool_app.command("update") @@ -142,7 +118,7 @@ def tool_update( raise typer.Exit(1) from None results = [tools.update(all_tools[n], os_name, console=console) for n in tool_names if n in all_tools] - print_tool_results(results) + display.print_tool_results(results) @tool_app.command("add") diff --git a/pauldot/display.py b/pauldot/display.py index 0f698c0..c3a5fdc 100644 --- a/pauldot/display.py +++ b/pauldot/display.py @@ -9,7 +9,7 @@ from rich import text as rich_text from pauldot import absorb as pauldot_absorb -from pauldot import dotfiles, state, zshrc +from pauldot import dotfiles, state, tools, zshrc from pauldot import migrate as pauldot_migrate console = rich_console.Console() @@ -116,6 +116,34 @@ def print_dotfile_status_results(results: list[dotfiles.DotfileStatus]) -> None: console.print(t) +# --------------------------------------------------------------------------- +# tools +# --------------------------------------------------------------------------- + +_TOOL_ACTION_LABELS: dict[str, tuple[str, str]] = { + "installed": ("✓ installed", "green"), + "already_installed": ("✓ already installed", "dim"), + "updated": ("✓ updated", "green"), + "skipped": ("– skipped", "dim"), + "not_installed": ("✗ not installed", "red"), + "failed": ("⚠ failed", "yellow"), +} + + +def print_tool_results(tool_results: list[tools.ToolResult]) -> None: + t = rich_table.Table(show_header=False, box=None, padding=(0, 2)) + t.add_column(no_wrap=True) + t.add_column(no_wrap=True) + t.add_column(no_wrap=True) + + for result in tool_results: + label, style = _TOOL_ACTION_LABELS[result.action] + error_text = rich_text.Text(result.error or "", style="dim") + t.add_row(rich_text.Text(result.name), rich_text.Text(label, style=style), error_text) + + console.print(t) + + # --------------------------------------------------------------------------- # doctor # --------------------------------------------------------------------------- From c02eddbc7dfa3668bf3ae1da9c39b4a83d5663e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:39:07 +0000 Subject: [PATCH 4/5] fix: remove duplicate state.load_state() call in status command The status command called state.load_state() twice in two separate try/except blocks, assigning to 's' and 'ss' respectively. Both reads hit the same file on disk and held the same value. The second call and the separate try/except existed only to give print_status_attention a variable to work with. Moved print_status_attention(s) inside the existing try block so the file is read once and both uses share the same result. If state.toml is missing, both the dotfiles status check and the attention banner are skipped together, which is the correct behaviour. https://claude.ai/code/session_01HPtPpBAwQqSjXFafTPQYu9 --- pauldot/cli.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pauldot/cli.py b/pauldot/cli.py index 47570d1..68c8b9e 100644 --- a/pauldot/cli.py +++ b/pauldot/cli.py @@ -195,12 +195,7 @@ def status() -> None: if profile.dotfiles: drift = dotfiles.status(profile.dotfiles, home, repo_path) display.print_dotfile_status_results(drift) - except FileNotFoundError: - pass - - try: - ss = state.load_state() - display.print_status_attention(ss) + display.print_status_attention(s) except FileNotFoundError: pass From ae28cb32df0cf0780b69637854b147afabed952c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:39:34 +0000 Subject: [PATCH 5/5] refactor: make raw TOML field names in config.py explicit constants add_tool_to_profile and add_dotfile_to_profile mutate profile TOML files via raw dicts rather than round-tripping through ProfileConfig. This is intentional: loading and re-dumping through the model would silently drop any keys added by a newer pauldot version (forward compatibility). However, the raw string literals "tools" and "dotfiles" had no visible connection to ProfileConfig.tools and ProfileConfig.dotfiles, so a field rename would not be caught. Introduced _PROFILE_FIELD_TOOLS and _PROFILE_FIELD_DOTFILES constants with inline comments naming the model fields they correspond to. The constants act as a single point of update and make the coupling reviewable, without changing the raw-dict approach that preserves unknown keys. https://claude.ai/code/session_01HPtPpBAwQqSjXFafTPQYu9 --- pauldot/config.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pauldot/config.py b/pauldot/config.py index 10dd967..2191d08 100644 --- a/pauldot/config.py +++ b/pauldot/config.py @@ -103,6 +103,15 @@ def save_tools(repo_path: pathlib.Path, tool_list: list[ToolDefinition]) -> None path.write_bytes(tomli_w.dumps(data).encode()) +# TOML field names that correspond to ProfileConfig fields. +# These functions mutate profile TOML via raw dicts (rather than round-tripping +# through ProfileConfig) so that unknown keys added by future pauldot versions +# are preserved. These constants make the coupling to ProfileConfig explicit so +# a field rename is caught at review time rather than silently going wrong. +_PROFILE_FIELD_TOOLS = "tools" # ProfileConfig.tools +_PROFILE_FIELD_DOTFILES = "dotfiles" # ProfileConfig.dotfiles + + def add_tool_to_profile(repo_path: pathlib.Path, profile_name: str, tool_name: str) -> None: """Append tool_name to the tools list in profiles/.toml.""" path = repo_path / "profiles" / f"{profile_name}.toml" @@ -110,10 +119,10 @@ def add_tool_to_profile(repo_path: pathlib.Path, profile_name: str, tool_name: s raise FileNotFoundError(f"Profile '{profile_name}' not found at {path}.") with path.open("rb") as f: data = tomllib.load(f) - tools_list: list[str] = data.get("tools", []) + tools_list: list[str] = data.get(_PROFILE_FIELD_TOOLS, []) if tool_name not in tools_list: tools_list.append(tool_name) - data["tools"] = tools_list + data[_PROFILE_FIELD_TOOLS] = tools_list path.write_bytes(tomli_w.dumps(data).encode()) @@ -124,8 +133,8 @@ def add_dotfile_to_profile(repo_path: pathlib.Path, profile_name: str, home_rel: raise FileNotFoundError(f"Profile '{profile_name}' not found at {path}.") with path.open("rb") as f: data = tomllib.load(f) - dotfiles_list: list[str] = data.get("dotfiles", []) + dotfiles_list: list[str] = data.get(_PROFILE_FIELD_DOTFILES, []) if home_rel not in dotfiles_list: dotfiles_list.append(home_rel) - data["dotfiles"] = dotfiles_list + data[_PROFILE_FIELD_DOTFILES] = dotfiles_list path.write_bytes(tomli_w.dumps(data).encode())