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 +3 -0
- kctl_op/__main__.py +3 -0
- kctl_op/cli.py +196 -0
- kctl_op/commands/__init__.py +0 -0
- kctl_op/commands/backup.py +178 -0
- kctl_op/commands/config_cmd.py +374 -0
- kctl_op/commands/diff_cmd.py +124 -0
- kctl_op/commands/discover.py +69 -0
- kctl_op/commands/doctor_cmd.py +75 -0
- kctl_op/commands/health.py +175 -0
- kctl_op/commands/projects.py +197 -0
- kctl_op/commands/skill_cmd.py +76 -0
- kctl_op/commands/status.py +95 -0
- kctl_op/commands/sync_cmd.py +153 -0
- kctl_op/commands/vault.py +122 -0
- kctl_op/core/__init__.py +0 -0
- kctl_op/core/callbacks.py +44 -0
- kctl_op/core/client.py +390 -0
- kctl_op/core/config.py +147 -0
- kctl_op/core/diff.py +224 -0
- kctl_op/core/discovery.py +171 -0
- kctl_op/core/exceptions.py +47 -0
- kctl_op/core/op_client.py +250 -0
- kctl_op/core/op_config.py +140 -0
- kctl_op/core/output.py +8 -0
- kctl_op/core/parser.py +101 -0
- kctl_op/core/sync.py +258 -0
- kctl_op-0.5.0.dist-info/METADATA +17 -0
- kctl_op-0.5.0.dist-info/RECORD +31 -0
- kctl_op-0.5.0.dist-info/WHEEL +4 -0
- kctl_op-0.5.0.dist-info/entry_points.txt +2 -0
kctl_op/__init__.py
ADDED
kctl_op/__main__.py
ADDED
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).")
|