claude-code-tools 0.1.8__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.
Potentially problematic release.
This version of claude-code-tools might be problematic. Click here for more details.
- claude_code_tools/__init__.py +3 -0
- claude_code_tools/dotenv_vault.py +268 -0
- claude_code_tools/find_claude_session.py +523 -0
- claude_code_tools/tmux_cli_controller.py +705 -0
- claude_code_tools-0.1.8.dist-info/METADATA +313 -0
- claude_code_tools-0.1.8.dist-info/RECORD +8 -0
- claude_code_tools-0.1.8.dist-info/WHEEL +4 -0
- claude_code_tools-0.1.8.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Centralized encrypted backup for .env files using SOPS."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import subprocess
|
|
7
|
+
import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DotenvVault:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.vault_dir = Path.home() / "Git" / "dotenvs"
|
|
16
|
+
self.vault_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
self._ensure_gpg_key()
|
|
18
|
+
|
|
19
|
+
def _ensure_gpg_key(self):
|
|
20
|
+
"""Detect GPG key or fail early."""
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["gpg", "--list-secret-keys", "--keyid-format", "LONG"],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=True
|
|
27
|
+
)
|
|
28
|
+
lines = result.stdout.strip().split('\n')
|
|
29
|
+
for line in lines:
|
|
30
|
+
if 'sec' in line:
|
|
31
|
+
key = line.split()[1].split('/')[1]
|
|
32
|
+
self.gpg_key = key
|
|
33
|
+
return
|
|
34
|
+
click.echo("❌ No GPG key found. Run: gpg --gen-key", err=True)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
except subprocess.CalledProcessError:
|
|
37
|
+
click.echo("❌ GPG not found or error listing keys", err=True)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
def _project_name(self):
|
|
41
|
+
"""Get current project name from directory."""
|
|
42
|
+
return Path.cwd().name
|
|
43
|
+
|
|
44
|
+
def _backup_path(self, project=None):
|
|
45
|
+
"""Get encrypted file path for project."""
|
|
46
|
+
project = project or self._project_name()
|
|
47
|
+
return self.vault_dir / f"{project}.env.encrypt"
|
|
48
|
+
|
|
49
|
+
def encrypt(self, force=False):
|
|
50
|
+
"""Encrypt current .env to centralized vault with protection."""
|
|
51
|
+
env_file = Path.cwd() / ".env"
|
|
52
|
+
if not env_file.exists():
|
|
53
|
+
click.echo("❌ .env not found in current directory")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
backup_path = self._backup_path()
|
|
57
|
+
|
|
58
|
+
# Protect encrypted backup
|
|
59
|
+
if backup_path.exists():
|
|
60
|
+
if not force:
|
|
61
|
+
click.echo("⚠️ Encrypted backup already exists!")
|
|
62
|
+
if not click.confirm("Overwrite encrypted backup?", default=False):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
66
|
+
backup_save = f"{backup_path}.backup-{timestamp}"
|
|
67
|
+
backup_path.rename(backup_save)
|
|
68
|
+
click.echo(f"💾 Backed up encrypted file → {backup_save}")
|
|
69
|
+
|
|
70
|
+
cmd = [
|
|
71
|
+
"sops", "--encrypt",
|
|
72
|
+
"--pgp", self.gpg_key,
|
|
73
|
+
"--input-type", "dotenv",
|
|
74
|
+
"--output-type", "dotenv",
|
|
75
|
+
str(env_file)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with open(backup_path, 'w') as f:
|
|
80
|
+
subprocess.run(cmd, stdout=f, check=True)
|
|
81
|
+
click.echo(f"✅ Encrypted .env → {backup_path}")
|
|
82
|
+
return True
|
|
83
|
+
except subprocess.CalledProcessError as e:
|
|
84
|
+
click.echo(f"❌ Encryption failed: {e}")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def decrypt(self, force=False):
|
|
88
|
+
"""Decrypt from centralized vault to .env with safety checks."""
|
|
89
|
+
backup_path = self._backup_path()
|
|
90
|
+
if not backup_path.exists():
|
|
91
|
+
click.echo(f"❌ {backup_path} not found")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
env_file = Path.cwd() / ".env"
|
|
95
|
+
|
|
96
|
+
# Check if current .env is newer than backup
|
|
97
|
+
if env_file.exists():
|
|
98
|
+
env_mtime = env_file.stat().st_mtime
|
|
99
|
+
backup_mtime = backup_path.stat().st_mtime
|
|
100
|
+
|
|
101
|
+
if env_mtime > backup_mtime:
|
|
102
|
+
click.echo("⚠️ Current .env is newer than backup!")
|
|
103
|
+
if not click.confirm("Proceed with decryption?", default=False):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Always backup current .env if it exists
|
|
107
|
+
if env_file.exists():
|
|
108
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
109
|
+
backup_name = f".env.backup-{timestamp}"
|
|
110
|
+
env_file.rename(backup_name)
|
|
111
|
+
click.echo(f"💾 Backed up current .env → {backup_name}")
|
|
112
|
+
|
|
113
|
+
cmd = [
|
|
114
|
+
"sops", "--decrypt",
|
|
115
|
+
"--input-type", "dotenv",
|
|
116
|
+
"--output-type", "dotenv",
|
|
117
|
+
str(backup_path)
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with open(env_file, 'w') as f:
|
|
122
|
+
subprocess.run(cmd, stdout=f, check=True)
|
|
123
|
+
click.echo(f"✅ Decrypted {backup_path} → {env_file}")
|
|
124
|
+
return True
|
|
125
|
+
except subprocess.CalledProcessError as e:
|
|
126
|
+
click.echo(f"❌ Decryption failed: {e}")
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def list_backups(self):
|
|
130
|
+
"""List all encrypted backups."""
|
|
131
|
+
backups = list(self.vault_dir.glob("*.env.encrypt"))
|
|
132
|
+
if not backups:
|
|
133
|
+
click.echo("❌ No encrypted files found")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
click.echo(f"📋 Encrypted files in {self.vault_dir}:")
|
|
137
|
+
for backup in sorted(backups):
|
|
138
|
+
size = backup.stat().st_size
|
|
139
|
+
click.echo(f" {backup.name} ({size} bytes)")
|
|
140
|
+
|
|
141
|
+
def status(self):
|
|
142
|
+
"""Show status for current project."""
|
|
143
|
+
project = self._project_name()
|
|
144
|
+
backup_path = self._backup_path()
|
|
145
|
+
|
|
146
|
+
env_exists = Path(".env").exists()
|
|
147
|
+
backup_exists = backup_path.exists()
|
|
148
|
+
|
|
149
|
+
if env_exists and backup_exists:
|
|
150
|
+
env_mtime = Path(".env").stat().st_mtime
|
|
151
|
+
backup_mtime = backup_path.stat().st_mtime
|
|
152
|
+
|
|
153
|
+
if env_mtime > backup_mtime:
|
|
154
|
+
click.echo("📊 Local .env is NEWER than backup")
|
|
155
|
+
click.echo(f" .env: {datetime.datetime.fromtimestamp(env_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
156
|
+
click.echo(f" backup: {datetime.datetime.fromtimestamp(backup_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
157
|
+
return "local_newer"
|
|
158
|
+
elif backup_mtime > env_mtime:
|
|
159
|
+
click.echo("📊 Backup is NEWER than local .env")
|
|
160
|
+
click.echo(f" .env: {datetime.datetime.fromtimestamp(env_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
161
|
+
click.echo(f" backup: {datetime.datetime.fromtimestamp(backup_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
162
|
+
return "backup_newer"
|
|
163
|
+
else:
|
|
164
|
+
click.echo("✅ .env and backup are identical")
|
|
165
|
+
return "identical"
|
|
166
|
+
elif env_exists:
|
|
167
|
+
click.echo("📊 .env exists, no backup")
|
|
168
|
+
return "local_only"
|
|
169
|
+
elif backup_exists:
|
|
170
|
+
click.echo("📊 Backup exists, no local .env")
|
|
171
|
+
return "backup_only"
|
|
172
|
+
else:
|
|
173
|
+
click.echo("❌ Neither .env nor backup exists")
|
|
174
|
+
return "neither"
|
|
175
|
+
|
|
176
|
+
def sync(self, direction=None):
|
|
177
|
+
"""Smart sync between local .env and centralized vault."""
|
|
178
|
+
project = self._project_name()
|
|
179
|
+
backup_path = self._backup_path()
|
|
180
|
+
env_file = Path.cwd() / ".env"
|
|
181
|
+
|
|
182
|
+
status = self.status()
|
|
183
|
+
|
|
184
|
+
if status == "identical":
|
|
185
|
+
click.echo("✅ Already in sync")
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
elif status == "local_only":
|
|
189
|
+
click.echo("🔄 Encrypting local .env to centralized vault")
|
|
190
|
+
return self.encrypt()
|
|
191
|
+
|
|
192
|
+
elif status == "backup_only":
|
|
193
|
+
click.echo("🔄 Decrypting centralized vault to local .env")
|
|
194
|
+
return self.decrypt()
|
|
195
|
+
|
|
196
|
+
elif status == "local_newer":
|
|
197
|
+
if direction == "pull":
|
|
198
|
+
click.echo("⚠️ Local .env is newer but --pull requested")
|
|
199
|
+
if click.confirm("Overwrite local .env with backup?", default=False):
|
|
200
|
+
return self.decrypt()
|
|
201
|
+
else:
|
|
202
|
+
click.echo("🔄 Encrypting local .env to centralized vault")
|
|
203
|
+
return self.encrypt()
|
|
204
|
+
|
|
205
|
+
elif status == "backup_newer":
|
|
206
|
+
if direction == "push":
|
|
207
|
+
click.echo("⚠️ Backup is newer but --push requested")
|
|
208
|
+
if click.confirm("Overwrite backup with local .env?", default=False):
|
|
209
|
+
return self.encrypt()
|
|
210
|
+
else:
|
|
211
|
+
click.echo("🔄 Decrypting centralized vault to local .env")
|
|
212
|
+
return self.decrypt()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main():
|
|
216
|
+
"""Centralized encrypted backup for .env files."""
|
|
217
|
+
import sys
|
|
218
|
+
|
|
219
|
+
if len(sys.argv) == 1:
|
|
220
|
+
# Default to sync if no command provided
|
|
221
|
+
vault = DotenvVault()
|
|
222
|
+
vault.sync()
|
|
223
|
+
else:
|
|
224
|
+
# Normal CLI handling
|
|
225
|
+
import click
|
|
226
|
+
|
|
227
|
+
@click.group()
|
|
228
|
+
def cli():
|
|
229
|
+
"""Centralized encrypted backup for .env files."""
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
@cli.command()
|
|
233
|
+
def encrypt():
|
|
234
|
+
"""Encrypt current .env to centralized vault."""
|
|
235
|
+
vault = DotenvVault()
|
|
236
|
+
vault.encrypt()
|
|
237
|
+
|
|
238
|
+
@cli.command()
|
|
239
|
+
def decrypt():
|
|
240
|
+
"""Decrypt from centralized vault to .env."""
|
|
241
|
+
vault = DotenvVault()
|
|
242
|
+
vault.decrypt()
|
|
243
|
+
|
|
244
|
+
@cli.command()
|
|
245
|
+
def list():
|
|
246
|
+
"""List all encrypted backups."""
|
|
247
|
+
vault = DotenvVault()
|
|
248
|
+
vault.list_backups()
|
|
249
|
+
|
|
250
|
+
@cli.command()
|
|
251
|
+
def status():
|
|
252
|
+
"""Show detailed status for current project."""
|
|
253
|
+
vault = DotenvVault()
|
|
254
|
+
vault.status()
|
|
255
|
+
|
|
256
|
+
@cli.command()
|
|
257
|
+
@click.option('--push', 'direction', flag_value='push', help='Force push (encrypt)')
|
|
258
|
+
@click.option('--pull', 'direction', flag_value='pull', help='Force pull (decrypt)')
|
|
259
|
+
def sync(direction):
|
|
260
|
+
"""Smart sync between local .env and centralized vault."""
|
|
261
|
+
vault = DotenvVault()
|
|
262
|
+
vault.sync(direction)
|
|
263
|
+
|
|
264
|
+
cli()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|