Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 265 additions & 4 deletions dotfile_sync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from . import __version__
from .core import DotfileSync, DotfileSyncError, NotInitializedError
from .templates import TemplateEngine

console = Console()
err_console = Console(stderr=True)
Expand Down Expand Up @@ -51,11 +52,14 @@ def init(remote: str | None) -> None:

@main.command()
@click.argument("path", type=click.Path())
@click.option(
"--template", "-t", is_flag=True, help="Track as a template for variable substitution."
)
@_handle_error
def track(path: str) -> None:
def track(path: str, template: bool) -> None:
"""Add a file or directory to the tracking manifest."""
sync = DotfileSync()
result = sync.track(path)
result = sync.track(path, as_template=template)
console.print(f"[green]✓[/green] {result}")


Expand Down Expand Up @@ -90,11 +94,12 @@ def backup(message: str | None) -> None:

@main.command()
@click.option("--only", "-o", default=None, help="Restore only a specific file path.")
@click.option("--no-render", is_flag=True, help="Skip template rendering, write raw content.")
@_handle_error
def restore(only: str | None) -> None:
def restore(only: str | None, no_render: bool) -> None:
"""Copy files from the repo back to their original locations."""
sync = DotfileSync()
result = sync.restore(only=only)
result = sync.restore(only=only, render=not no_render)
console.print(Panel(result, title="📂 Restore", border_style="green"))


Expand Down Expand Up @@ -134,5 +139,261 @@ def pull() -> None:
console.print(f"[green]✓[/green] {result}")


# --- Template & Context Commands ---


@main.group()
def template() -> None:
"""Manage template files and contexts."""
pass


@template.command("scan")
@click.argument("path", type=click.Path())
@_handle_error
def template_scan(path: str) -> None:
"""Scan a file for template variables.

Shows all Jinja2 variables used in the file without modifying anything.
"""
from pathlib import Path

source = Path(path).expanduser().resolve()
if not source.exists():
raise DotfileSyncError(f"Path does not exist: {source}")

try:
content = source.read_text(encoding="utf-8")
except UnicodeDecodeError:
raise DotfileSyncError(f"Cannot read {source} as text")

engine = TemplateEngine()
if not engine.is_template(content):
console.print(f"[yellow]No template syntax found in {source}[/yellow]")
return

variables = engine.extract_variables(content)
if variables:
console.print(f"[bold]Template variables in {source}:[/bold]")
for var in sorted(variables):
console.print(f" • [cyan]{var}[/cyan]")
else:
console.print(f"Template syntax found but no variables extracted from {source}")


@template.command("render")
@click.argument("path", type=click.Path())
@click.option(
"--context",
"-c",
multiple=True,
help="Context key=value pairs (e.g., -c name=john).",
)
@click.option("--dry-run", is_flag=True, help="Print rendered output without writing to disk.")
@_handle_error
def template_render(path: str, context: tuple[str, ...], dry_run: bool) -> None:
"""Render a template file with the active context.

By default renders using the stored context. Override with --context flags.
"""
from pathlib import Path

sync = DotfileSync()
sync._ensure_initialized()
source = Path(path).expanduser().resolve()

if not source.exists():
raise DotfileSyncError(f"Path does not exist: {source}")

try:
content = source.read_text(encoding="utf-8")
except UnicodeDecodeError:
raise DotfileSyncError(f"Cannot read {source} as text")

engine = TemplateEngine()

# Build context
if context:
ctx: dict = {}
for pair in context:
if "=" not in pair:
raise DotfileSyncError(f"Invalid context format: {pair}. Use key=value")
key, value = pair.split("=", 1)
ctx[key] = value
else:
ctx = sync._build_render_context()

rendered = engine.render(content, ctx)

if dry_run:
console.print(Panel(rendered, title=f"Rendered: {source.name}", border_style="blue"))
else:
source.write_text(rendered, encoding="utf-8")
console.print(f"[green]✓[/green] Rendered template to {source}")


# --- Context Management Commands ---


@main.group()
def context() -> None:
"""Manage template contexts (variables per machine/profile)."""
pass


@context.command("list")
@_handle_error
def context_list() -> None:
"""List all available contexts."""
sync = DotfileSync()
sync._ensure_initialized()
ctx_mgr = sync._get_context_manager()
contexts = ctx_mgr.list_contexts()

if not contexts:
console.print("[yellow]No contexts defined[/yellow]")
return

console.print("[bold]Available contexts:[/bold]")
for name in sorted(contexts):
ctx = ctx_mgr.get_context(name)
console.print(f" • [cyan]{name}[/cyan] ({len(ctx)} variables)")


@context.command("show")
@click.argument("name")
@_handle_error
def context_show(name: str) -> None:
"""Show variables in a specific context."""
sync = DotfileSync()
sync._ensure_initialized()
ctx_mgr = sync._get_context_manager()
ctx = ctx_mgr.get_context(name)

if not ctx:
console.print(f"[yellow]Context '{name}' is empty or does not exist[/yellow]")
return

console.print(f"[bold]Context: {name}[/bold]")
for key, value in sorted(ctx.items()):
console.print(f" {key} = [cyan]{value}[/cyan]")


@context.command("set")
@click.argument("name")
@click.argument("key_value", nargs=-1, required=True)
@_handle_error
def context_set(name: str, key_value: tuple[str, ...]) -> None:
"""Set variables in a context. Usage: context set <name> key=value ...

Example: dotfile-sync context set default hostname=myserver email=me@example.com
"""
sync = DotfileSync()
sync._ensure_initialized()
ctx_mgr = sync._get_context_manager()

# Merge with existing context
existing = ctx_mgr.get_context(name)
for pair in key_value:
if "=" not in pair:
raise DotfileSyncError(f"Invalid format: {pair}. Use key=value")
key, value = pair.split("=", 1)
existing[key] = value

ctx_mgr.set_context(name, existing)
console.print(f"[green]✓[/green] Updated context '{name}' with {len(key_value)} variable(s)")


@context.command("delete")
@click.argument("name")
@click.argument("key", nargs=-1, required=True)
@_handle_error
def context_delete(name: str, key: tuple[str, ...]) -> None:
"""Delete specific keys from a context."""
sync = DotfileSync()
sync._ensure_initialized()
ctx_mgr = sync._get_context_manager()

existing = ctx_mgr.get_context(name)
removed = 0
for k in key:
if k in existing:
del existing[k]
removed += 1

if removed:
ctx_mgr.set_context(name, existing)
console.print(f"[yellow]✗[/yellow] Removed {removed} key(s) from context '{name}'")
else:
console.print(f"[yellow]No matching keys found in context '{name}'[/yellow]")


# --- Profile Commands ---


@main.group()
def profile() -> None:
"""Manage profiles for different machines/environments."""
pass


@profile.command("activate")
@click.argument("name")
@_handle_error
def profile_activate(name: str) -> None:
"""Set the active profile for template rendering."""
from .core import Manifest

sync = DotfileSync()
sync._ensure_initialized()
manifest = Manifest(sync.manifest_path)
manifest.active_profile = name
console.print(f"[green]✓[/green] Active profile set to '{name}'")


@profile.command("deactivate")
@_handle_error
def profile_deactivate() -> None:
"""Clear the active profile."""
from .core import Manifest

sync = DotfileSync()
sync._ensure_initialized()
manifest = Manifest(sync.manifest_path)
manifest.active_profile = None
console.print("[green]✓[/green] Active profile cleared")


@profile.command("machine")
@click.argument("name")
@_handle_error
def profile_machine(name: str) -> None:
"""Set the machine name for template rendering."""
from .core import Manifest

sync = DotfileSync()
sync._ensure_initialized()
manifest = Manifest(sync.manifest_path)
manifest.machine_name = name
console.print(f"[green]✓[/green] Machine name set to '{name}'")


@profile.command("show")
@_handle_error
def profile_show() -> None:
"""Show the current active profile and machine configuration."""
from .core import Manifest

sync = DotfileSync()
sync._ensure_initialized()
manifest = Manifest(sync.manifest_path)

console.print("[bold]Current Configuration:[/bold]")
profile_name = manifest.active_profile or "(none)"
machine = manifest.machine_name or "(none)"
console.print(f" Active profile: [cyan]{profile_name}[/cyan]")
console.print(f" Machine name: [cyan]{machine}[/cyan]")


if __name__ == "__main__":
main()
Loading
Loading