kctl-op 0.5.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.
kctl_op/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """kctl-op: 1Password secret management CLI."""
2
+
3
+ __version__ = "0.5.0"
kctl_op/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from kctl_op.cli import _run
2
+
3
+ _run()
kctl_op/cli.py ADDED
@@ -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).")