vaultuner 0.1.6__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.
vaultuner/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # ABOUTME: Vaultuner package for Bitwarden Secrets Manager.
2
+ # ABOUTME: Provides CLI with PROJECT/[ENV/]SECRET naming convention.
vaultuner/cli.py ADDED
@@ -0,0 +1,434 @@
1
+ # ABOUTME: Typer CLI for Bitwarden Secrets Manager.
2
+ # ABOUTME: Commands for listing, getting, setting, and deleting secrets.
3
+
4
+ from importlib.metadata import version
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from vaultuner.client import find_secret_by_key, get_client, get_or_create_project
13
+ from vaultuner.config import (
14
+ DEFAULT_PROJECT_NAME,
15
+ delete_keyring_value,
16
+ get_keyring_value,
17
+ get_settings,
18
+ set_keyring_value,
19
+ )
20
+ from vaultuner.models import (
21
+ SecretPath,
22
+ is_deleted,
23
+ mark_deleted,
24
+ unmark_deleted,
25
+ )
26
+
27
+ __version__ = version("vaultuner")
28
+
29
+
30
+ def version_callback(value: bool) -> None:
31
+ if value:
32
+ print(f"vaultuner {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ app = typer.Typer(
37
+ help=f"Bitwarden Secrets Manager CLI with PROJECT/[ENV/]SECRET naming. (v{__version__})",
38
+ no_args_is_help=True,
39
+ )
40
+ config_app = typer.Typer(help="Manage vaultuner credentials stored in system keychain.")
41
+ app.add_typer(config_app, name="config")
42
+
43
+
44
+ @app.callback()
45
+ def main(
46
+ version: Annotated[
47
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True)
48
+ ] = False,
49
+ ) -> None:
50
+ """Bitwarden Secrets Manager CLI."""
51
+
52
+
53
+ console = Console()
54
+ err_console = Console(stderr=True)
55
+
56
+ ConfigKey = Literal["access-token", "organization-id"]
57
+ KEYRING_MAP = {
58
+ "access-token": "bws_access_token",
59
+ "organization-id": "bws_organization_id",
60
+ }
61
+
62
+
63
+ @config_app.command("set")
64
+ def config_set(
65
+ key: ConfigKey = typer.Argument(..., help="Config key to set"),
66
+ value: str = typer.Argument(..., help="Value to store"),
67
+ ):
68
+ """Store a credential in the system keychain."""
69
+ keyring_key = KEYRING_MAP[key]
70
+ set_keyring_value(keyring_key, value)
71
+ console.print(f"[green]Stored:[/green] {key}")
72
+
73
+
74
+ @config_app.command("show")
75
+ def config_show():
76
+ """Show current configuration status."""
77
+ table = Table(show_header=True, header_style="bold")
78
+ table.add_column("Setting", style="cyan")
79
+ table.add_column("Status")
80
+
81
+ access_token = get_keyring_value(KEYRING_MAP["access-token"])
82
+ org_id = get_keyring_value(KEYRING_MAP["organization-id"])
83
+
84
+ table.add_row(
85
+ "access-token",
86
+ "[green]configured[/green]" if access_token else "[red]not set[/red]",
87
+ )
88
+ table.add_row(
89
+ "organization-id",
90
+ "[green]configured[/green]" if org_id else "[red]not set[/red]",
91
+ )
92
+ console.print(table)
93
+
94
+
95
+ @config_app.command("delete")
96
+ def config_delete(
97
+ key: ConfigKey = typer.Argument(..., help="Config key to delete"),
98
+ ):
99
+ """Remove a credential from the system keychain."""
100
+ keyring_key = KEYRING_MAP[key]
101
+ delete_keyring_value(keyring_key)
102
+ console.print(f"[red]Deleted:[/red] {key}")
103
+
104
+
105
+ @app.command("list")
106
+ def list_secrets(
107
+ project: str | None = typer.Option(
108
+ None, "--project", "-p", help="Filter by project"
109
+ ),
110
+ env: str | None = typer.Option(None, "--env", "-e", help="Filter by environment"),
111
+ deleted: bool = typer.Option(False, "--deleted", "-d", help="Show deleted secrets"),
112
+ ):
113
+ """List secrets. Optionally filter by project and/or environment."""
114
+ settings = get_settings()
115
+ client = get_client()
116
+ response = client.secrets().list(settings.organization_id)
117
+ if not response.data or not response.data.data:
118
+ console.print("[dim]No secrets found.[/dim]")
119
+ return
120
+
121
+ table = Table(show_header=True, header_style="bold")
122
+ table.add_column("Project", style="cyan")
123
+ table.add_column("Env", style="yellow")
124
+ table.add_column("Name", style="green")
125
+ if deleted:
126
+ table.add_column("Status", style="red")
127
+
128
+ count = 0
129
+ for secret in response.data.data:
130
+ key = secret.key
131
+ secret_deleted = is_deleted(key)
132
+
133
+ if secret_deleted and not deleted:
134
+ continue
135
+
136
+ display_key = unmark_deleted(key) if secret_deleted else key
137
+
138
+ try:
139
+ path = SecretPath.parse(display_key)
140
+ except ValueError:
141
+ continue
142
+
143
+ if project and path.project != project:
144
+ continue
145
+ if env and path.env != env:
146
+ continue
147
+
148
+ if deleted:
149
+ status = "[red]deleted[/red]" if secret_deleted else "[green]active[/green]"
150
+ table.add_row(path.project, path.env or "-", path.name, status)
151
+ else:
152
+ table.add_row(path.project, path.env or "-", path.name)
153
+ count += 1
154
+
155
+ if count == 0:
156
+ console.print("[dim]No secrets found.[/dim]")
157
+ else:
158
+ console.print(table)
159
+
160
+
161
+ @app.command()
162
+ def get(
163
+ path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
164
+ value_only: bool = typer.Option(
165
+ False, "--value", "-v", help="Print only the value"
166
+ ),
167
+ ):
168
+ """Get a secret by path."""
169
+ client = get_client()
170
+ secret_info = find_secret_by_key(client, path)
171
+ if not secret_info:
172
+ err_console.print(f"[red]Secret not found:[/red] {path}")
173
+ raise typer.Exit(1)
174
+
175
+ response = client.secrets().get(secret_info["id"])
176
+ if not response.data:
177
+ err_console.print(f"[red]Failed to retrieve secret:[/red] {path}")
178
+ raise typer.Exit(1)
179
+
180
+ if value_only:
181
+ console.print(response.data.value)
182
+ else:
183
+ table = Table(show_header=False, box=None)
184
+ table.add_column("Label", style="dim")
185
+ table.add_column("Value")
186
+ table.add_row("Path", f"[cyan]{response.data.key}[/cyan]")
187
+ table.add_row("Value", f"[green]{response.data.value}[/green]")
188
+ if response.data.note:
189
+ table.add_row("Note", f"[dim]{response.data.note}[/dim]")
190
+ console.print(table)
191
+
192
+
193
+ @app.command()
194
+ def set(
195
+ path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
196
+ value: str = typer.Argument(..., help="Secret value"),
197
+ note: str | None = typer.Option(None, "--note", "-n", help="Optional note"),
198
+ ):
199
+ """Create or update a secret."""
200
+ settings = get_settings()
201
+ client = get_client()
202
+
203
+ existing = find_secret_by_key(client, path)
204
+ if existing:
205
+ response = client.secrets().update(
206
+ organization_id=settings.organization_id,
207
+ id=existing["id"],
208
+ key=path,
209
+ value=value,
210
+ note=note,
211
+ project_ids=None,
212
+ )
213
+ if not response.data:
214
+ err_console.print("[red]Failed to update secret.[/red]")
215
+ raise typer.Exit(1)
216
+ console.print(f"[yellow]Updated:[/yellow] {path}")
217
+ else:
218
+ project_id = get_or_create_project(client, DEFAULT_PROJECT_NAME)
219
+ response = client.secrets().create(
220
+ organization_id=settings.organization_id,
221
+ key=path,
222
+ value=value,
223
+ note=note,
224
+ project_ids=[project_id],
225
+ )
226
+ if not response.data:
227
+ err_console.print("[red]Failed to create secret.[/red]")
228
+ raise typer.Exit(1)
229
+ console.print(f"[green]Created:[/green] {path}")
230
+
231
+
232
+ @app.command()
233
+ def delete(
234
+ path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
235
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
236
+ permanent: bool = typer.Option(False, "--permanent", help="Permanently delete"),
237
+ ):
238
+ """Delete a secret (soft-delete by default)."""
239
+ settings = get_settings()
240
+ client = get_client()
241
+ secret_info = find_secret_by_key(client, path)
242
+ if not secret_info:
243
+ err_console.print(f"[red]Secret not found:[/red] {path}")
244
+ raise typer.Exit(1)
245
+
246
+ if not force:
247
+ action = "permanently delete" if permanent else "delete"
248
+ confirm = typer.confirm(f"{action.capitalize()} secret '{path}'?")
249
+ if not confirm:
250
+ raise typer.Abort()
251
+
252
+ if permanent:
253
+ client.secrets().delete([secret_info["id"]])
254
+ console.print(f"[red]Permanently deleted:[/red] {path}")
255
+ else:
256
+ response = client.secrets().get(secret_info["id"])
257
+ if not response.data:
258
+ err_console.print("[red]Failed to get secret for deletion.[/red]")
259
+ raise typer.Exit(1)
260
+
261
+ deleted_key = mark_deleted(path)
262
+ project_ids = (
263
+ [str(response.data.project_id)] if response.data.project_id else None
264
+ )
265
+ client.secrets().update(
266
+ organization_id=settings.organization_id,
267
+ id=secret_info["id"],
268
+ key=deleted_key,
269
+ value=response.data.value,
270
+ note=response.data.note,
271
+ project_ids=project_ids,
272
+ )
273
+ console.print(f"[red]Deleted:[/red] {path}")
274
+
275
+
276
+ @app.command()
277
+ def restore(
278
+ path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
279
+ ):
280
+ """Restore a soft-deleted secret."""
281
+ settings = get_settings()
282
+ client = get_client()
283
+ deleted_key = mark_deleted(path)
284
+ secret_info = find_secret_by_key(client, deleted_key)
285
+ if not secret_info:
286
+ err_console.print(f"[red]Deleted secret not found:[/red] {path}")
287
+ raise typer.Exit(1)
288
+
289
+ response = client.secrets().get(secret_info["id"])
290
+ if not response.data:
291
+ err_console.print("[red]Failed to get secret for restoration.[/red]")
292
+ raise typer.Exit(1)
293
+
294
+ project_ids = [str(response.data.project_id)] if response.data.project_id else None
295
+ client.secrets().update(
296
+ organization_id=settings.organization_id,
297
+ id=secret_info["id"],
298
+ key=path,
299
+ value=response.data.value,
300
+ note=response.data.note,
301
+ project_ids=project_ids,
302
+ )
303
+ console.print(f"[green]Restored:[/green] {path}")
304
+
305
+
306
+ @app.command()
307
+ def projects():
308
+ """List all projects."""
309
+ settings = get_settings()
310
+ client = get_client()
311
+ response = client.projects().list(settings.organization_id)
312
+ if not response.data or not response.data.data:
313
+ console.print("[dim]No projects found.[/dim]")
314
+ return
315
+
316
+ table = Table(show_header=True, header_style="bold")
317
+ table.add_column("Project", style="cyan")
318
+
319
+ for project in response.data.data:
320
+ table.add_row(project.name)
321
+
322
+ console.print(table)
323
+
324
+
325
+ @app.command()
326
+ def export(
327
+ project: str | None = typer.Option(
328
+ None,
329
+ "--project",
330
+ "-p",
331
+ help="Project name (defaults to current directory name)",
332
+ ),
333
+ env: str | None = typer.Option(None, "--env", "-e", help="Filter by environment"),
334
+ output: Path = typer.Option(
335
+ Path(".env"), "--output", "-o", help="Output file path (default: .env)"
336
+ ),
337
+ ):
338
+ """Export project secrets to a .env file."""
339
+ from vaultuner.export import export_secrets
340
+
341
+ project_name = project or Path.cwd().name
342
+ added_count, skipped_count = export_secrets(project_name, output, env)
343
+
344
+ if added_count == 0 and skipped_count == 0:
345
+ console.print(f"[dim]No secrets found for project '{project_name}'.[/dim]")
346
+ else:
347
+ console.print(
348
+ f"[green]Exported to {output}:[/green] {added_count} added, {skipped_count} already present"
349
+ )
350
+
351
+
352
+ @app.command("import")
353
+ def import_env(
354
+ project: str | None = typer.Option(
355
+ None,
356
+ "--project",
357
+ "-p",
358
+ help="Project name (defaults to current directory name)",
359
+ ),
360
+ env: str | None = typer.Option(None, "--env", "-e", help="Environment for secrets"),
361
+ input_file: Path = typer.Option(
362
+ Path(".env"), "--input", "-i", help="Input file path (default: .env)"
363
+ ),
364
+ yes: bool = typer.Option(False, "--yes", "-y", help="Import all without prompting"),
365
+ ):
366
+ """Import secrets from a .env file to the secret store."""
367
+ from vaultuner.import_env import (
368
+ build_secret_path,
369
+ env_var_to_secret_name,
370
+ parse_env_entries,
371
+ )
372
+
373
+ if not input_file.exists():
374
+ err_console.print(f"[red]File not found:[/red] {input_file}")
375
+ raise typer.Exit(1)
376
+
377
+ project_name = project or Path.cwd().name
378
+ entries = parse_env_entries(input_file)
379
+
380
+ if not entries:
381
+ console.print(f"[dim]No entries found in {input_file}.[/dim]")
382
+ return
383
+
384
+ settings = get_settings()
385
+ client = get_client()
386
+
387
+ # Phase 1: Collect secrets to import
388
+ to_import: list[tuple[str, str]] = []
389
+ skipped_count = 0
390
+
391
+ for var_name, value in entries:
392
+ secret_name = env_var_to_secret_name(var_name)
393
+ secret_path = build_secret_path(project_name, env, secret_name)
394
+
395
+ # Check if secret already exists
396
+ existing = find_secret_by_key(client, secret_path)
397
+ if existing:
398
+ console.print(f"[dim]Already exists:[/dim] {secret_path}")
399
+ skipped_count += 1
400
+ continue
401
+
402
+ if not yes:
403
+ console.print(f"\n[cyan]{var_name}[/cyan] [dim]({len(value)} chars)[/dim]")
404
+ console.print(f" → [green]{secret_path}[/green]")
405
+ if not typer.confirm("Store this secret?", default=True):
406
+ skipped_count += 1
407
+ continue
408
+
409
+ to_import.append((secret_path, value))
410
+
411
+ if not to_import:
412
+ console.print("\n[dim]Nothing to import.[/dim]")
413
+ return
414
+
415
+ # Phase 2: Import all approved secrets
416
+ console.print(f"\n[cyan]Importing {len(to_import)} secrets...[/cyan]")
417
+ project_id = get_or_create_project(client, DEFAULT_PROJECT_NAME)
418
+ created_count = 0
419
+
420
+ for secret_path, value in to_import:
421
+ response = client.secrets().create(
422
+ organization_id=settings.organization_id,
423
+ key=secret_path,
424
+ value=value,
425
+ note=None,
426
+ project_ids=[project_id],
427
+ )
428
+ if response.data:
429
+ created_count += 1
430
+ console.print(f"[green]Created:[/green] {secret_path}")
431
+
432
+ console.print(
433
+ f"\n[green]Import complete:[/green] {created_count} created, {skipped_count} skipped"
434
+ )
vaultuner/client.py ADDED
@@ -0,0 +1,60 @@
1
+ # ABOUTME: Bitwarden Secrets Manager client wrapper.
2
+ # ABOUTME: Handles authentication and provides helper functions for secrets/projects.
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from bitwarden_sdk import BitwardenClient, DeviceType, client_settings_from_dict
8
+
9
+ from vaultuner.config import get_settings
10
+
11
+
12
+ def get_client() -> BitwardenClient:
13
+ """Create and authenticate a Bitwarden client."""
14
+ settings = get_settings()
15
+ client = BitwardenClient(
16
+ client_settings_from_dict(
17
+ {
18
+ "apiUrl": settings.api_url,
19
+ "identityUrl": settings.identity_url,
20
+ "deviceType": DeviceType.SDK,
21
+ "userAgent": "vaultuner",
22
+ }
23
+ )
24
+ )
25
+ with tempfile.NamedTemporaryFile(
26
+ mode="w", suffix=".json", delete=False, delete_on_close=False
27
+ ) as f:
28
+ state_path = Path(f.name)
29
+ client.auth().login_access_token(
30
+ settings.access_token.get_secret_value(), str(state_path)
31
+ )
32
+ state_path.unlink(missing_ok=True)
33
+ return client
34
+
35
+
36
+ def get_or_create_project(client: BitwardenClient, project_name: str) -> str:
37
+ """Get project ID by name, creating it if it doesn't exist."""
38
+ settings = get_settings()
39
+ response = client.projects().list(settings.organization_id)
40
+ if response.data and response.data.data:
41
+ for project in response.data.data:
42
+ if project.name == project_name:
43
+ return str(project.id)
44
+
45
+ result = client.projects().create(settings.organization_id, project_name)
46
+ if not result.data:
47
+ raise RuntimeError(f"Failed to create project: {project_name}")
48
+ return str(result.data.id)
49
+
50
+
51
+ def find_secret_by_key(client: BitwardenClient, key: str) -> dict | None:
52
+ """Find a secret by its key name."""
53
+ settings = get_settings()
54
+ response = client.secrets().list(settings.organization_id)
55
+ if not response.data or not response.data.data:
56
+ return None
57
+ for secret in response.data.data:
58
+ if secret.key == key:
59
+ return {"id": str(secret.id), "key": secret.key}
60
+ return None
vaultuner/config.py ADDED
@@ -0,0 +1,104 @@
1
+ # ABOUTME: Configuration settings for Bitwarden Secrets Manager.
2
+ # ABOUTME: Loads credentials from keychain (preferred) or environment variables.
3
+
4
+ import sys
5
+
6
+ import keyring
7
+ from pydantic import SecretStr
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+ SERVICE_NAME = "vaultuner"
11
+ DEFAULT_PROJECT_NAME = "vaultuner"
12
+
13
+
14
+ def _require_darwin() -> None:
15
+ """Ensure we're running on macOS where keyring is supported."""
16
+ if sys.platform != "darwin":
17
+ raise SystemExit(
18
+ "Keyring storage is only supported on macOS. "
19
+ "Use environment variables BWS_ACCESS_TOKEN and BWS_ORGANIZATION_ID instead."
20
+ )
21
+
22
+
23
+ def get_keyring_value(key: str) -> str | None:
24
+ """Get a value from the system keychain. Returns None on non-darwin."""
25
+ if sys.platform != "darwin":
26
+ return None
27
+ return keyring.get_password(SERVICE_NAME, key)
28
+
29
+
30
+ def set_keyring_value(key: str, value: str) -> None:
31
+ """Store a value in the system keychain."""
32
+ _require_darwin()
33
+ keyring.set_password(SERVICE_NAME, key, value)
34
+
35
+
36
+ def delete_keyring_value(key: str) -> None:
37
+ """Delete a value from the system keychain."""
38
+ _require_darwin()
39
+ try:
40
+ keyring.delete_password(SERVICE_NAME, key)
41
+ except keyring.errors.PasswordDeleteError:
42
+ pass
43
+
44
+
45
+ class Settings(BaseSettings):
46
+ model_config = SettingsConfigDict(env_prefix="BWS_")
47
+
48
+ access_token: SecretStr
49
+ organization_id: str
50
+ api_url: str = "https://vault.bitwarden.com/api"
51
+ identity_url: str = "https://vault.bitwarden.com/identity"
52
+
53
+ @classmethod
54
+ def settings_customise_sources(
55
+ cls,
56
+ settings_cls,
57
+ init_settings,
58
+ env_settings,
59
+ dotenv_settings,
60
+ file_secret_settings,
61
+ ):
62
+ """Load from keychain first, then fall back to env vars."""
63
+ return (
64
+ init_settings,
65
+ KeyringSettingsSource(settings_cls),
66
+ env_settings,
67
+ dotenv_settings,
68
+ file_secret_settings,
69
+ )
70
+
71
+
72
+ class KeyringSettingsSource:
73
+ """Custom settings source that reads from system keychain."""
74
+
75
+ def __init__(self, settings_cls):
76
+ self.settings_cls = settings_cls
77
+
78
+ def __call__(self):
79
+ values = {}
80
+ access_token = get_keyring_value("bws_access_token")
81
+ if access_token:
82
+ values["access_token"] = access_token
83
+ org_id = get_keyring_value("bws_organization_id")
84
+ if org_id:
85
+ values["organization_id"] = org_id
86
+ return values
87
+
88
+
89
+ _settings: Settings | None = None
90
+
91
+
92
+ def get_settings() -> Settings:
93
+ """Load settings lazily, with helpful error message if not configured."""
94
+ global _settings
95
+ if _settings is None:
96
+ try:
97
+ _settings = Settings()
98
+ except Exception:
99
+ raise SystemExit(
100
+ "Credentials not configured. Run:\n"
101
+ " vaultuner config set access-token <token>\n"
102
+ " vaultuner config set organization-id <org-id>"
103
+ )
104
+ return _settings
vaultuner/export.py ADDED
@@ -0,0 +1,105 @@
1
+ # ABOUTME: Export project secrets to .env file format.
2
+ # ABOUTME: Handles parsing existing .env files and appending new secrets.
3
+
4
+ from pathlib import Path
5
+
6
+ from vaultuner.client import get_client
7
+ from vaultuner.config import get_settings
8
+ from vaultuner.models import SecretPath, is_deleted
9
+
10
+
11
+ def secret_name_to_env_var(name: str) -> str:
12
+ """Convert a secret name to an environment variable name."""
13
+ return name.upper().replace("-", "_")
14
+
15
+
16
+ def parse_env_file(path: Path) -> set[str]:
17
+ """Parse a .env file and return the set of defined variable names."""
18
+ defined_vars: set[str] = set()
19
+ if not path.exists():
20
+ return defined_vars
21
+
22
+ for line in path.read_text().splitlines():
23
+ line = line.strip()
24
+ if not line or line.startswith("#"):
25
+ continue
26
+ if "=" in line:
27
+ var_name = line.split("=", 1)[0].strip()
28
+ defined_vars.add(var_name)
29
+ return defined_vars
30
+
31
+
32
+ def export_secrets(
33
+ project_name: str,
34
+ output: Path,
35
+ env: str | None = None,
36
+ ) -> tuple[int, int]:
37
+ """
38
+ Export secrets for a project to a .env file.
39
+
40
+ Returns a tuple of (added_count, skipped_count).
41
+ """
42
+ settings = get_settings()
43
+ client = get_client()
44
+ response = client.secrets().list(settings.organization_id)
45
+
46
+ if not response.data or not response.data.data:
47
+ return 0, 0
48
+
49
+ # Find secrets matching the project (and optionally env)
50
+ matching_secrets: list[tuple[SecretPath, str]] = []
51
+ for secret in response.data.data:
52
+ key = secret.key
53
+ if is_deleted(key):
54
+ continue
55
+
56
+ try:
57
+ path = SecretPath.parse(key)
58
+ except ValueError:
59
+ continue
60
+
61
+ if path.project != project_name:
62
+ continue
63
+ if path.env != env:
64
+ continue
65
+
66
+ # Fetch the actual value
67
+ secret_response = client.secrets().get(secret.id)
68
+ if secret_response.data:
69
+ matching_secrets.append((path, secret_response.data.value))
70
+
71
+ if not matching_secrets:
72
+ return 0, 0
73
+
74
+ # Parse existing .env to find already-defined variables
75
+ existing_vars = parse_env_file(output)
76
+
77
+ # Build the lines to append
78
+ lines_to_append: list[str] = []
79
+ added_count = 0
80
+ skipped_count = 0
81
+
82
+ for path, value in matching_secrets:
83
+ env_var = secret_name_to_env_var(path.name)
84
+ escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
85
+ env_line = f'{env_var}="{escaped_value}"'
86
+
87
+ if env_var in existing_vars:
88
+ lines_to_append.append(f"# Already defined above, from {path}:")
89
+ lines_to_append.append(f"# {env_line}")
90
+ skipped_count += 1
91
+ else:
92
+ lines_to_append.append(env_line)
93
+ existing_vars.add(env_var)
94
+ added_count += 1
95
+
96
+ # Append to file
97
+ if lines_to_append:
98
+ with output.open("a") as f:
99
+ if output.exists() and output.stat().st_size > 0:
100
+ # Ensure we start on a new line
101
+ f.write("\n")
102
+ f.write("\n".join(lines_to_append))
103
+ f.write("\n")
104
+
105
+ return added_count, skipped_count
@@ -0,0 +1,45 @@
1
+ # ABOUTME: Import secrets from .env file to Bitwarden Secrets Manager.
2
+ # ABOUTME: Parses .env files and interactively stores secrets.
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def env_var_to_secret_name(var_name: str) -> str:
8
+ """Convert an environment variable name to a secret name.
9
+
10
+ This is the inverse of secret_name_to_env_var() from export.py.
11
+ """
12
+ return var_name.lower().replace("_", "-")
13
+
14
+
15
+ def parse_env_entries(path: Path) -> list[tuple[str, str]]:
16
+ """Parse a .env file and return list of (name, value) tuples."""
17
+ entries: list[tuple[str, str]] = []
18
+ if not path.exists():
19
+ return entries
20
+
21
+ for line in path.read_text().splitlines():
22
+ line = line.strip()
23
+ if not line or line.startswith("#"):
24
+ continue
25
+ if "=" not in line:
26
+ continue
27
+
28
+ var_name, _, value = line.partition("=")
29
+ var_name = var_name.strip()
30
+ value = value.strip()
31
+
32
+ # Remove surrounding quotes if present
33
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
34
+ value = value[1:-1]
35
+
36
+ entries.append((var_name, value))
37
+
38
+ return entries
39
+
40
+
41
+ def build_secret_path(project: str, env: str | None, name: str) -> str:
42
+ """Build a secret path from components."""
43
+ if env:
44
+ return f"{project}/{env}/{name}"
45
+ return f"{project}/{name}"
vaultuner/models.py ADDED
@@ -0,0 +1,55 @@
1
+ # ABOUTME: Data models for vaultuner.
2
+ # ABOUTME: SecretPath represents the PROJECT/[ENV/]SECRET naming convention.
3
+
4
+ from pydantic import BaseModel
5
+
6
+ DELETED_PREFIX = "_deleted_/"
7
+
8
+
9
+ def is_deleted(key: str) -> bool:
10
+ """Check if a secret key is marked as deleted."""
11
+ return key.startswith(DELETED_PREFIX)
12
+
13
+
14
+ def mark_deleted(key: str) -> str:
15
+ """Mark a secret key as deleted by adding the prefix."""
16
+ return f"{DELETED_PREFIX}{key}"
17
+
18
+
19
+ def unmark_deleted(key: str) -> str:
20
+ """Remove the deleted prefix from a secret key."""
21
+ return key.removeprefix(DELETED_PREFIX)
22
+
23
+
24
+ class SecretPath(BaseModel):
25
+ project: str
26
+ name: str
27
+ env: str | None = None
28
+
29
+ @classmethod
30
+ def parse(cls, path: str) -> "SecretPath":
31
+ """Parse a path like 'project/env/name' or 'project/name'."""
32
+ parts = path.split("/")
33
+
34
+ if any(not part for part in parts):
35
+ raise ValueError(
36
+ f"Invalid path format: {path}. Path segments cannot be empty."
37
+ )
38
+
39
+ if len(parts) == 3:
40
+ return cls(project=parts[0], env=parts[1], name=parts[2])
41
+ elif len(parts) == 2:
42
+ return cls(project=parts[0], name=parts[1])
43
+ else:
44
+ raise ValueError(
45
+ f"Invalid path format: {path}. Expected PROJECT/[ENV/]NAME"
46
+ )
47
+
48
+ def to_key(self) -> str:
49
+ """Convert to Bitwarden secret key format."""
50
+ if self.env:
51
+ return f"{self.project}/{self.env}/{self.name}"
52
+ return f"{self.project}/{self.name}"
53
+
54
+ def __str__(self) -> str:
55
+ return self.to_key()
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.3
2
+ Name: vaultuner
3
+ Version: 0.1.6
4
+ Summary: Bitwarden Secrets Manager CLI with PROJECT/[ENV/]SECRET naming
5
+ Keywords: bitwarden,secrets,cli,secrets-manager,devops
6
+ Author: David Poblador
7
+ License: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Utilities
17
+ Requires-Dist: bitwarden-sdk>=1.0.0
18
+ Requires-Dist: keyring>=25.7.0
19
+ Requires-Dist: pydantic>=2.12.5
20
+ Requires-Dist: pydantic-settings>=2.12.0
21
+ Requires-Dist: rich>=14.3.2
22
+ Requires-Dist: typer>=0.21.1
23
+ Requires-Python: >=3.11, <3.13
24
+ Description-Content-Type: text/markdown
25
+
26
+ # vaultuner
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/vaultuner)](https://pypi.org/project/vaultuner/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+ [![Python 3.11-3.12](https://img.shields.io/badge/python-3.11--3.12-blue.svg)](https://www.python.org/downloads/)
31
+
32
+ **Human-readable secrets for Bitwarden Secrets Manager.**
33
+
34
+ Vaultuner replaces cryptic UUIDs with intuitive paths like `myapp/prod/db-password`. Your secrets, organized the way you actually think about them.
35
+
36
+ ## The Problem
37
+
38
+ ```bash
39
+ # Bitwarden's default CLI
40
+ bws secret get 550e8400-e29b-41d4-a716-446655440000
41
+ ```
42
+
43
+ You shouldn't need to memorize UUIDs or dig through dashboards to find secrets.
44
+
45
+ ## The Solution
46
+
47
+ ```bash
48
+ # With vaultuner
49
+ vaultuner get myapp/prod/db-password
50
+ ```
51
+
52
+ Secrets organized by project and environment. Instantly memorable. Zero cognitive overhead.
53
+
54
+ ## Features
55
+
56
+ - **Path-based naming** - `project/env/secret` instead of UUIDs
57
+ - **Environment isolation** - Keep dev, staging, and prod secrets separate
58
+ - **`.env` sync** - Export to and import from `.env` files seamlessly
59
+ - **Soft delete** - Recover accidentally deleted secrets
60
+ - **Keychain storage** - Credentials secured in macOS Keychain
61
+
62
+ ## Quick Start
63
+
64
+ ### Install
65
+
66
+ ```bash
67
+ uv tool install vaultuner
68
+ ```
69
+
70
+ Or run without installing:
71
+
72
+ ```bash
73
+ uvx vaultuner list
74
+ ```
75
+
76
+ ### Configure
77
+
78
+ ```bash
79
+ vaultuner config set access-token <your-token>
80
+ vaultuner config set organization-id <your-org-id>
81
+ ```
82
+
83
+ Get credentials from [Bitwarden Secrets Manager](https://vault.bitwarden.com/).
84
+
85
+ ### Use
86
+
87
+ ```bash
88
+ # Create secrets
89
+ vaultuner set myapp/api-key "sk-abc123"
90
+ vaultuner set myapp/prod/db-password "hunter2"
91
+
92
+ # Retrieve
93
+ vaultuner get myapp/prod/db-password -v
94
+
95
+ # List everything
96
+ vaultuner list
97
+
98
+ # Export for local dev
99
+ vaultuner export -p myapp -e dev -o .env
100
+ ```
101
+
102
+ ## Commands
103
+
104
+ | Command | Description |
105
+ |---------|-------------|
106
+ | `list` | List secrets with project/env filtering |
107
+ | `get` | Retrieve a secret value |
108
+ | `set` | Create or update a secret |
109
+ | `delete` | Soft-delete (recoverable) |
110
+ | `restore` | Recover a deleted secret |
111
+ | `export` | Export to `.env` file |
112
+ | `import` | Import from `.env` file |
113
+ | `projects` | List all projects |
114
+ | `config` | Manage stored credentials |
115
+
116
+ ## Naming Convention
117
+
118
+ ```
119
+ PROJECT/SECRET # Project-level secret
120
+ PROJECT/ENV/SECRET # Environment-specific secret
121
+ ```
122
+
123
+ Examples:
124
+ ```
125
+ myapp/api-key # Shared across environments
126
+ myapp/prod/db-password # Production only
127
+ myapp/dev/db-password # Development only
128
+ ```
129
+
130
+ ## Requirements
131
+
132
+ - Python 3.11 or 3.12 (bitwarden-sdk limitation)
133
+ - macOS (Keychain integration)
134
+ - Bitwarden Secrets Manager account
135
+
136
+ ## Documentation
137
+
138
+ Full docs at [alltuner.github.io/vaultuner](https://alltuner.github.io/vaultuner)
139
+
140
+ ## License
141
+
142
+ MIT
143
+
144
+ ---
145
+
146
+ Built at [All Tuner Labs](https://alltuner.com) by [David Poblador i Garcia](https://davidpoblador.com)
@@ -0,0 +1,11 @@
1
+ vaultuner/__init__.py,sha256=-3HqB8FyWviChoTlnXIdqc7xneT_vQa2G0P8L7ffpcE,129
2
+ vaultuner/cli.py,sha256=yXvuovmVcZjAB2DsESN7MGwsveL9wzfaGTp7QLkGtek,13772
3
+ vaultuner/client.py,sha256=kJ4JI01MWOfKcmhBf3EqfRA8opMvL3bdIaWy-5Tp35A,2091
4
+ vaultuner/config.py,sha256=12aSjzPuwTG_3CHe8dwj56RkrKRg1_OZS8SWNfGjOq0,3030
5
+ vaultuner/export.py,sha256=U0zhXfdohC__PHLT8hKs0S2M8Ql_7p_ug2sJV-YwtQc,3143
6
+ vaultuner/import_env.py,sha256=XYnm-Tyo9h9a62K6LGibMMSYFNJBHSK3gzOmMqh8kOk,1353
7
+ vaultuner/models.py,sha256=X1oFKZ2171jJdsteIwiL7ZL-gkn_cW4Qxckz8C5vI8g,1595
8
+ vaultuner-0.1.6.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
9
+ vaultuner-0.1.6.dist-info/entry_points.txt,sha256=n6WLg_ojVObz5N6rB-w6SVS-4jsKkDBpJAu3IqNJx_Q,49
10
+ vaultuner-0.1.6.dist-info/METADATA,sha256=S3v-ptBya2YbVlzWuuWgBj51x90VjZ5L2yQXSFO67so,3801
11
+ vaultuner-0.1.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vaultuner = vaultuner.cli:app
3
+