kctl-op 0.5.0__tar.gz

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.
Files changed (41) hide show
  1. kctl_op-0.5.0/.gitignore +33 -0
  2. kctl_op-0.5.0/PKG-INFO +17 -0
  3. kctl_op-0.5.0/README.md +74 -0
  4. kctl_op-0.5.0/pyproject.toml +45 -0
  5. kctl_op-0.5.0/skills/1password-admin/SKILL.md +141 -0
  6. kctl_op-0.5.0/src/kctl_op/__init__.py +3 -0
  7. kctl_op-0.5.0/src/kctl_op/__main__.py +3 -0
  8. kctl_op-0.5.0/src/kctl_op/cli.py +196 -0
  9. kctl_op-0.5.0/src/kctl_op/commands/__init__.py +0 -0
  10. kctl_op-0.5.0/src/kctl_op/commands/backup.py +178 -0
  11. kctl_op-0.5.0/src/kctl_op/commands/config_cmd.py +374 -0
  12. kctl_op-0.5.0/src/kctl_op/commands/diff_cmd.py +124 -0
  13. kctl_op-0.5.0/src/kctl_op/commands/discover.py +69 -0
  14. kctl_op-0.5.0/src/kctl_op/commands/doctor_cmd.py +75 -0
  15. kctl_op-0.5.0/src/kctl_op/commands/health.py +175 -0
  16. kctl_op-0.5.0/src/kctl_op/commands/projects.py +197 -0
  17. kctl_op-0.5.0/src/kctl_op/commands/skill_cmd.py +76 -0
  18. kctl_op-0.5.0/src/kctl_op/commands/status.py +95 -0
  19. kctl_op-0.5.0/src/kctl_op/commands/sync_cmd.py +153 -0
  20. kctl_op-0.5.0/src/kctl_op/commands/vault.py +122 -0
  21. kctl_op-0.5.0/src/kctl_op/core/__init__.py +0 -0
  22. kctl_op-0.5.0/src/kctl_op/core/callbacks.py +44 -0
  23. kctl_op-0.5.0/src/kctl_op/core/client.py +390 -0
  24. kctl_op-0.5.0/src/kctl_op/core/config.py +147 -0
  25. kctl_op-0.5.0/src/kctl_op/core/diff.py +224 -0
  26. kctl_op-0.5.0/src/kctl_op/core/discovery.py +171 -0
  27. kctl_op-0.5.0/src/kctl_op/core/exceptions.py +47 -0
  28. kctl_op-0.5.0/src/kctl_op/core/op_client.py +250 -0
  29. kctl_op-0.5.0/src/kctl_op/core/op_config.py +140 -0
  30. kctl_op-0.5.0/src/kctl_op/core/output.py +8 -0
  31. kctl_op-0.5.0/src/kctl_op/core/parser.py +101 -0
  32. kctl_op-0.5.0/src/kctl_op/core/sync.py +258 -0
  33. kctl_op-0.5.0/tests/__init__.py +0 -0
  34. kctl_op-0.5.0/tests/conftest.py +95 -0
  35. kctl_op-0.5.0/tests/test_backup.py +152 -0
  36. kctl_op-0.5.0/tests/test_config.py +133 -0
  37. kctl_op-0.5.0/tests/test_diff.py +336 -0
  38. kctl_op-0.5.0/tests/test_discovery.py +272 -0
  39. kctl_op-0.5.0/tests/test_parser.py +198 -0
  40. kctl_op-0.5.0/tests/test_smoke.py +21 -0
  41. kctl_op-0.5.0/tests/test_vault.py +134 -0
@@ -0,0 +1,33 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
kctl_op-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kctl-op
3
+ Version: 0.5.0
4
+ Summary: Kodemeio 1Password CLI — secret management and .env sync
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: kctl-lib>=0.5.0
7
+ Requires-Dist: pydantic>=2.10.0
8
+ Requires-Dist: python-dotenv>=1.0.0
9
+ Requires-Dist: pyyaml>=6.0.2
10
+ Requires-Dist: rich>=13.9.0
11
+ Requires-Dist: typer>=0.15.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
14
+ Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
17
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
@@ -0,0 +1,74 @@
1
+ # kctl-op
2
+
3
+ Kodemeio 1Password CLI — secret management and .env sync across all projects.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ uv tool install .
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ kctl-op config init
15
+ kctl-op health
16
+ kctl-op vault list
17
+ kctl-op sync pull
18
+ ```
19
+
20
+ ## Command Groups
21
+
22
+ | Group | Description |
23
+ |-------|-------------|
24
+ | `config` | Manage CLI configuration and profiles |
25
+ | `health` | Health checks and diagnostics |
26
+ | `vault` | Vault management operations |
27
+ | `projects` | Project discovery and status |
28
+ | `push` | Push .env files to 1Password |
29
+ | `pull` | Pull secrets from 1Password to .env |
30
+ | `diff` | Show differences between local and 1Password |
31
+ | `discover` | Discover .env files in project directories |
32
+ | `backup` | Backup management for .env files |
33
+ | `status` | Check sync status |
34
+
35
+ Top-level commands: `list` (list all vault items)
36
+
37
+ ## Configuration
38
+
39
+ Config lives in `~/.config/kodemeio/config.yaml` under the `op` service key.
40
+
41
+ ```bash
42
+ # Initialize a profile
43
+ kctl-op config init
44
+
45
+ # Add a named profile
46
+ kctl-op config add prod --vault MyVault --token $OP_SERVICE_ACCOUNT_TOKEN
47
+
48
+ # Switch active profile
49
+ kctl-op config use prod
50
+
51
+ # Show current profile (token masked)
52
+ kctl-op config show
53
+ ```
54
+
55
+ ## Global Options
56
+
57
+ | Option | Short | Description |
58
+ |--------|-------|-------------|
59
+ | `--json` | | Output as JSON |
60
+ | `--quiet` | `-q` | Suppress non-essential output |
61
+ | `--format` | `-f` | Output format: pretty, json, csv, yaml |
62
+ | `--no-header` | | Omit header row in CSV output |
63
+ | `--profile` | `-p` | Config profile to use |
64
+ | `--vault` | | Override vault name |
65
+ | `--token` | | Override service account token |
66
+ | `--version` | `-V` | Show version and exit |
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ uv run pytest tests/ -v
72
+ uv run ruff check src/
73
+ uv run mypy src/
74
+ ```
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-op"
7
+ version = "0.5.0"
8
+ description = "Kodemeio 1Password CLI — secret management and .env sync"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "kctl-lib>=0.5.0",
12
+ "typer>=0.15.0",
13
+ "rich>=13.9.0",
14
+ "pydantic>=2.10.0",
15
+ "pyyaml>=6.0.2",
16
+ "python-dotenv>=1.0.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.3.0",
22
+ "pytest-httpx>=0.35.0",
23
+ "ruff>=0.9.0",
24
+ "mypy>=1.14.0",
25
+ "types-PyYAML>=6.0.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ kctl-op = "kctl_op.cli:_run"
30
+
31
+ [tool.uv.sources]
32
+ kctl-lib = { workspace = true }
33
+
34
+ [project.entry-points."kctl_op.plugins"]
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/kctl_op"]
38
+
39
+ [tool.ruff]
40
+ target-version = "py312"
41
+ line-length = 120
42
+
43
+ [tool.mypy]
44
+ python_version = "3.12"
45
+ strict = true
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: 1password-admin
3
+ description: >
4
+ 1Password secret management via kctl-op CLI (12 groups, ~27 commands).
5
+ MUST use for ANY kctl-op operation.
6
+ Triggers on: "backup", "check", "clean", "config", "current", "dashboard", "diff", "discover", "envs", "generate", "health", "info", "init", "items", "kctl-op", "list", "migrate", "profile", "profiles", "projects", "pull", "push", "remove", "restore", "skill", "status", "test", "vault".
7
+ Auto-generated: 2026-04-05
8
+ registry_hash: d1005426c491
9
+ ---
10
+
11
+ # 1password-admin — kctl-op CLI Reference
12
+
13
+ > Auto-generated from `kctl-op` command registry. Do not edit manually.
14
+ > To regenerate: `kctl-op skill generate`
15
+ > To add custom content: edit `SKILL.extra.md` in the same directory.
16
+
17
+ ## Overview
18
+
19
+ **CLI:** `kctl-op`
20
+ **Command groups:** 12
21
+ **Total commands:** ~27
22
+ **Install:** `cd cli && uv tool install --editable .`
23
+
24
+ ## Global Options
25
+
26
+ | Flag | Description |
27
+ |------|-------------|
28
+ | `--json` | JSON output |
29
+ | `--quiet`, `-q` | Suppress info messages |
30
+ | `--format`, `-f` | Output format: pretty/json/csv/yaml |
31
+ | `--no-header` | Omit CSV header row |
32
+ | `--profile`, `-p` | Config profile name |
33
+ | `--version`, `-V` | Show version |
34
+
35
+ ## Command Reference
36
+
37
+ ### `kctl-op backup`
38
+
39
+ Backup management for .env files.
40
+
41
+ | Command | Description |
42
+ |---------|-------------|
43
+ | `backup clean [--keep] [--force]` | Clean old backups, keeping the N most recent per file. |
44
+ | `backup list [--project]` | List available backups. |
45
+ | `backup restore <project> <environment> [--timestamp] [--force]` | Restore a .env file from backup. |
46
+
47
+ ### `kctl-op config`
48
+
49
+ Manage CLI configuration and profiles.
50
+
51
+ | Command | Description |
52
+ |---------|-------------|
53
+ | `config add <name> [--vault] [--token] [--scan_root]` | Add a new profile. |
54
+ | `config current` | Show current active profile. |
55
+ | `config init [--vault] [--token] [--name] [--scan_root]` | Initialize CLI configuration (interactive if no flags given). |
56
+ | `config migrate` | Migrate legacy configuration to service-scoped format. |
57
+ | `config profiles` | List all profiles. |
58
+ | `config remove <name> [--force]` | Remove a profile's 1Password configuration. |
59
+ | `config set <key> <value>` | Set a configuration value in the current profile. |
60
+ | `config show` | Show full configuration (tokens masked). |
61
+ | `config test` | Test current profile's 1Password connection. |
62
+ | `config use <name>` | Set the default profile. |
63
+
64
+ ### `kctl-op diff`
65
+
66
+ Show differences between local and 1Password.
67
+
68
+ ### `kctl-op discover`
69
+
70
+ Discover .env files.
71
+
72
+ ### `kctl-op health`
73
+
74
+ Health checks and diagnostics.
75
+
76
+ | Command | Description |
77
+ |---------|-------------|
78
+ | `health dashboard` | Overview: projects, files, sync status, last sync times. |
79
+
80
+ ### `kctl-op list`
81
+
82
+ List all items in the 1Password vault.
83
+
84
+ ### `kctl-op projects`
85
+
86
+ Project discovery and status.
87
+
88
+ | Command | Description |
89
+ |---------|-------------|
90
+ | `projects envs <project>` | List all .env files for a project. |
91
+ | `projects list` | List all projects found across scan roots. |
92
+ | `projects status <project>` | Show sync status for all environments in a project. |
93
+
94
+ ### `kctl-op pull`
95
+
96
+ Pull .env files from 1Password.
97
+
98
+ ### `kctl-op push`
99
+
100
+ Push .env files to 1Password.
101
+
102
+ ### `kctl-op skill`
103
+
104
+ Claude Code skill management.
105
+
106
+ | Command | Description |
107
+ |---------|-------------|
108
+ | `skill generate [--output] [--install] [--check]` | Auto-generate SKILL.md from CLI command registry. |
109
+
110
+ **Examples:**
111
+ ```bash
112
+ kctl-op skill generate
113
+ kctl-op skill generate --install
114
+ kctl-op skill generate --check
115
+ ```
116
+
117
+ ### `kctl-op status`
118
+
119
+ Check sync status.
120
+
121
+ ### `kctl-op vault`
122
+
123
+ Vault management operations.
124
+
125
+ | Command | Description |
126
+ |---------|-------------|
127
+ | `vault create [--name] [--description]` | Create a new 1Password vault. |
128
+ | `vault info` | Show vault details. |
129
+ | `vault items` | List all items in the vault with metadata. |
130
+
131
+ ## Configuration
132
+
133
+ Shared config: `~/.config/kodemeio/config.yaml`
134
+
135
+ ```bash
136
+ kctl-op config init # Interactive setup
137
+ kctl-op config show # Show current config
138
+ kctl-op config profiles # List profiles
139
+ kctl-op config current # Show active profile
140
+ kctl-op config validate # Verify config
141
+ ```
@@ -0,0 +1,3 @@
1
+ """kctl-op: 1Password secret management CLI."""
2
+
3
+ __version__ = "0.5.0"
@@ -0,0 +1,3 @@
1
+ from kctl_op.cli import _run
2
+
3
+ _run()
@@ -0,0 +1,196 @@
1
+ """Main Typer application for kctl-op."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_op import __version__
12
+ from kctl_op.commands.backup import app as backup_app
13
+
14
+ # Command group imports
15
+ from kctl_op.commands.config_cmd import app as config_app
16
+ from kctl_op.commands.diff_cmd import app as diff_app
17
+ from kctl_op.commands.discover import app as discover_app
18
+ from kctl_op.commands.health import app as health_app
19
+ from kctl_op.commands.projects import app as projects_app
20
+ from kctl_op.commands.status import app as status_app
21
+ from kctl_op.commands.sync_cmd import pull_app, push_app
22
+ from kctl_op.commands.vault import app as vault_app
23
+ from kctl_op.core.callbacks import AppContext
24
+ from kctl_lib import handle_cli_error
25
+ from kctl_op.commands.skill_cmd import app as skill_app
26
+ from kctl_op.commands.doctor_cmd import app as doctor_app
27
+ from kctl_op.core.exceptions import (
28
+ KctlError,
29
+ )
30
+ from kctl_lib.self_update import notify_if_outdated
31
+ from kctl_lib.tui import add_tui_command
32
+
33
+
34
+ def _version_callback(value: bool) -> None:
35
+ if value:
36
+ print(f"kctl-op {__version__}")
37
+ raise typer.Exit()
38
+
39
+
40
+ app = typer.Typer(
41
+ name="kctl-op",
42
+ help="Kodemeio 1Password CLI - manage secrets across all projects via 1Password.",
43
+ no_args_is_help=True,
44
+ rich_markup_mode="rich",
45
+ pretty_exceptions_enable=False,
46
+ )
47
+
48
+
49
+ @app.callback()
50
+ def main(
51
+ ctx: typer.Context,
52
+ json_output: Annotated[bool, typer.Option("--json", help="Output in JSON format.")] = False,
53
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress non-essential output.")] = False,
54
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile to use.")] = None,
55
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml.")] = "pretty",
56
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output.")] = False,
57
+ vault: Annotated[str | None, typer.Option("--vault", help="Override vault name.")] = None,
58
+ token: Annotated[str | None, typer.Option("--token", help="Override service account token.")] = None,
59
+ version: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ "--version",
63
+ "-V",
64
+ callback=_version_callback,
65
+ is_eager=True,
66
+ help="Show version and exit.",
67
+ ),
68
+ ] = False,
69
+ ) -> None:
70
+ """Global options applied to all commands."""
71
+ ctx.ensure_object(dict)
72
+ ctx.obj = AppContext(
73
+ json_mode=json_output,
74
+ quiet=quiet,
75
+ profile=profile,
76
+ format=format,
77
+ no_header=no_header,
78
+ vault_override=vault,
79
+ token_override=token,
80
+ )
81
+ notify_if_outdated(ctx.obj.output, "kctl-op", __version__)
82
+
83
+
84
+ # Register command groups
85
+ app.add_typer(config_app, name="config")
86
+ app.add_typer(health_app, name="health")
87
+ app.add_typer(discover_app, name="discover")
88
+ app.add_typer(status_app, name="status")
89
+ app.add_typer(push_app, name="push")
90
+ app.add_typer(pull_app, name="pull")
91
+ app.add_typer(diff_app, name="diff")
92
+ app.add_typer(vault_app, name="vault")
93
+ app.add_typer(projects_app, name="projects")
94
+ app.add_typer(backup_app, name="backup")
95
+ app.add_typer(skill_app, name="skill", hidden=True)
96
+ app.add_typer(doctor_app, name="doctor")
97
+ add_tui_command(app, service_key="op", version=__version__)
98
+
99
+
100
+ # Top-level convenience commands
101
+ @app.command(name="list")
102
+ def list_items(ctx: typer.Context) -> None:
103
+ """List all items in the 1Password vault."""
104
+ actx: AppContext = ctx.obj
105
+ out = actx.output
106
+ client = actx.client
107
+
108
+ try:
109
+ items = client.list_items()
110
+ except Exception as e:
111
+ out.error(f"Cannot list items: {e}")
112
+ raise typer.Exit(code=1) from e
113
+
114
+ if not items:
115
+ out.info("No items in vault.")
116
+ return
117
+
118
+ rows = []
119
+ json_data = []
120
+ for item in items:
121
+ title = item.get("title", "untitled")
122
+ tags = ", ".join(item.get("tags", []))
123
+ updated = item.get("updated_at", "unknown")
124
+ rows.append([title, tags, updated])
125
+ json_data.append(
126
+ {
127
+ "title": title,
128
+ "id": item.get("id", ""),
129
+ "tags": item.get("tags", []),
130
+ "updated_at": updated,
131
+ }
132
+ )
133
+
134
+ out.table(
135
+ title=f"Vault Items ({len(items)})",
136
+ columns=[
137
+ ("Title", "green"),
138
+ ("Tags", "cyan"),
139
+ ("Updated", "dim"),
140
+ ],
141
+ rows=rows,
142
+ data_for_json=json_data,
143
+ )
144
+
145
+
146
+ @app.command("self-update")
147
+ def self_update_cmd(ctx: typer.Context) -> None:
148
+ """Check for updates and upgrade kctl-op."""
149
+ actx = ctx.obj
150
+ out = actx.output
151
+
152
+ from kctl_lib.self_update import check_update
153
+ from kctl_lib.self_update import update as do_update
154
+
155
+ latest = check_update("kctl-op", __version__)
156
+ if latest:
157
+ out.info(f"Updating to {latest}...")
158
+ do_update("kctl-op")
159
+ out.success(f"Updated to {latest}")
160
+ else:
161
+ out.success("Already up to date")
162
+
163
+
164
+ @app.command()
165
+ def completions(
166
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
167
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
168
+ ) -> None:
169
+ """Generate or install shell completions."""
170
+ from kctl_lib.completions import get_completion_script, install_completions
171
+
172
+ if install:
173
+ path = install_completions("kctl-op", shell)
174
+ if path:
175
+ typer.echo(f"Completions installed to {path}")
176
+ else:
177
+ typer.echo(f"Could not install completions for {shell}", err=True)
178
+ raise typer.Exit(code=1)
179
+ else:
180
+ script = get_completion_script("kctl-op", shell)
181
+ typer.echo(script)
182
+
183
+
184
+ def _run() -> None:
185
+ """Entry point with error handling."""
186
+ try:
187
+ app()
188
+ except KctlError as e:
189
+ handle_cli_error(e)
190
+ except KeyboardInterrupt:
191
+ print("\nAborted.", file=sys.stderr)
192
+ sys.exit(130)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ _run()
File without changes
@@ -0,0 +1,178 @@
1
+ """Backup management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_op.core.callbacks import AppContext
11
+
12
+ BACKUP_DIR = Path.home() / ".kodemeio-op" / "backups"
13
+
14
+ app = typer.Typer(help="Backup management for .env files.")
15
+
16
+
17
+ @app.command(name="list")
18
+ def list_backups(
19
+ ctx: typer.Context,
20
+ project: Annotated[str | None, typer.Argument(help="Filter by project name.")] = None,
21
+ ) -> None:
22
+ """List available backups."""
23
+ actx: AppContext = ctx.obj
24
+ out = actx.output
25
+
26
+ if not BACKUP_DIR.exists():
27
+ out.info("No backups found.")
28
+ return
29
+
30
+ rows = []
31
+ json_data = []
32
+
33
+ for project_dir in sorted(BACKUP_DIR.iterdir()):
34
+ if not project_dir.is_dir():
35
+ continue
36
+ if project and project_dir.name != project:
37
+ continue
38
+
39
+ for env_dir in sorted(project_dir.iterdir()):
40
+ if not env_dir.is_dir():
41
+ continue
42
+
43
+ backups = sorted(env_dir.glob("*.bak"), reverse=True)
44
+ for bak in backups:
45
+ size = bak.stat().st_size
46
+ rows.append(
47
+ [
48
+ project_dir.name,
49
+ env_dir.name,
50
+ bak.name,
51
+ f"{size:,} bytes",
52
+ ]
53
+ )
54
+ json_data.append(
55
+ {
56
+ "project": project_dir.name,
57
+ "environment": env_dir.name,
58
+ "filename": bak.name,
59
+ "size": size,
60
+ "path": str(bak),
61
+ }
62
+ )
63
+
64
+ if not rows:
65
+ out.info("No backups found." + (f" (project: {project})" if project else ""))
66
+ return
67
+
68
+ out.table(
69
+ title=f"Backups ({len(rows)})",
70
+ columns=[
71
+ ("Project", "green"),
72
+ ("Environment", "cyan"),
73
+ ("Filename", ""),
74
+ ("Size", "dim"),
75
+ ],
76
+ rows=rows,
77
+ data_for_json=json_data,
78
+ )
79
+
80
+
81
+ @app.command()
82
+ def restore(
83
+ ctx: typer.Context,
84
+ project: Annotated[str, typer.Argument(help="Project name.")],
85
+ environment: Annotated[str, typer.Argument(help="Environment name.")],
86
+ timestamp: Annotated[
87
+ str | None, typer.Argument(help="Backup timestamp (from filename). Latest if omitted.")
88
+ ] = None,
89
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
90
+ ) -> None:
91
+ """Restore a .env file from backup."""
92
+ actx: AppContext = ctx.obj
93
+ out = actx.output
94
+
95
+ env_backup_dir = BACKUP_DIR / project / environment
96
+ if not env_backup_dir.exists():
97
+ out.error(f"No backups found for {project}/{environment}")
98
+ raise typer.Exit(code=1)
99
+
100
+ backups = sorted(env_backup_dir.glob("*.bak"), reverse=True)
101
+ if not backups:
102
+ out.error(f"No backup files in {env_backup_dir}")
103
+ raise typer.Exit(code=1)
104
+
105
+ if timestamp:
106
+ selected = [b for b in backups if timestamp in b.name]
107
+ if not selected:
108
+ out.error(f"No backup matching timestamp '{timestamp}'")
109
+ raise typer.Exit(code=1)
110
+ backup_file = selected[0]
111
+ else:
112
+ backup_file = backups[0]
113
+
114
+ out.info(f"Restoring from: {backup_file.name}")
115
+
116
+ # Find the target .env file
117
+ cfg = actx.service_config
118
+ client = actx.client
119
+ files = client.discover_files(
120
+ cfg.scan_roots,
121
+ cfg.exclude_patterns,
122
+ cfg.env_mappings,
123
+ cfg.custom_env_pattern,
124
+ )
125
+ matches = [f for f in files if f.project == project and f.environment == environment]
126
+
127
+ if not matches:
128
+ out.error(f"Cannot find target .env file for {project}/{environment}")
129
+ raise typer.Exit(code=1)
130
+
131
+ target = matches[0].path
132
+
133
+ if not force and not typer.confirm(f"Restore {backup_file.name} to {target}?"):
134
+ raise typer.Exit()
135
+
136
+ # Read backup and write to target
137
+ content = backup_file.read_text()
138
+ target.write_text(content)
139
+ out.success(f"Restored {backup_file.name} -> {target}")
140
+
141
+
142
+ @app.command()
143
+ def clean(
144
+ ctx: typer.Context,
145
+ keep: Annotated[int, typer.Option("--keep", "-k", help="Number of backups to keep per file.")] = 10,
146
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation.")] = False,
147
+ ) -> None:
148
+ """Clean old backups, keeping the N most recent per file."""
149
+ actx: AppContext = ctx.obj
150
+ out = actx.output
151
+
152
+ if not BACKUP_DIR.exists():
153
+ out.info("No backups to clean.")
154
+ return
155
+
156
+ to_delete: list[Path] = []
157
+ for project_dir in BACKUP_DIR.iterdir():
158
+ if not project_dir.is_dir():
159
+ continue
160
+ for env_dir in project_dir.iterdir():
161
+ if not env_dir.is_dir():
162
+ continue
163
+ backups = sorted(env_dir.glob("*.bak"), reverse=True)
164
+ if len(backups) > keep:
165
+ to_delete.extend(backups[keep:])
166
+
167
+ if not to_delete:
168
+ out.info(f"Nothing to clean (keeping {keep} per file).")
169
+ return
170
+
171
+ out.info(f"Will delete {len(to_delete)} old backup(s).")
172
+
173
+ if not force and not typer.confirm("Proceed?"):
174
+ raise typer.Exit()
175
+
176
+ for f in to_delete:
177
+ f.unlink()
178
+ out.success(f"Deleted {len(to_delete)} old backup(s).")