keystash 0.1.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.
keystash/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """keystash — local-first encrypted vault for API keys, tokens and passwords."""
2
+
3
+ __version__ = "0.1.0"
keystash/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m keystash`."""
2
+
3
+ from .cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
keystash/cli.py ADDED
@@ -0,0 +1,602 @@
1
+ """keystash command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+
19
+ from . import __version__, clipboard
20
+ from .gen import generate
21
+ from .model import Entry, parse_expires
22
+ from .search import search as fuzzy_search
23
+ from .vault import (
24
+ BAD_PASSWORD,
25
+ Vault,
26
+ VaultError,
27
+ )
28
+
29
+ app = typer.Typer(
30
+ name="keystash",
31
+ help="Local-first encrypted vault for API keys, tokens and passwords.",
32
+ no_args_is_help=True,
33
+ add_completion=False,
34
+ )
35
+ console = Console()
36
+ err_console = Console(stderr=True)
37
+
38
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
39
+
40
+
41
+ class State:
42
+ vault_path: Optional[Path] = None
43
+
44
+
45
+ state = State()
46
+
47
+
48
+ def version_callback(value: bool) -> None:
49
+ if value:
50
+ console.print(f"keystash {__version__}")
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback(context_settings=CONTEXT_SETTINGS)
55
+ def main(
56
+ vault: Optional[Path] = typer.Option(
57
+ None,
58
+ "--vault",
59
+ "-V",
60
+ help="Path to the vault file (default: $KEYSTASH_VAULT or ~/.keystash/vault.json).",
61
+ envvar="KEYSTASH_VAULT",
62
+ ),
63
+ version: bool = typer.Option(
64
+ False, "--version", callback=version_callback, is_eager=True
65
+ ),
66
+ ) -> None:
67
+ state.vault_path = vault
68
+
69
+
70
+ # ---------------------------------------------------------------- helpers
71
+
72
+
73
+ def resolve_vault() -> Vault:
74
+ return Vault(state.vault_path)
75
+
76
+
77
+ def ask_password() -> str:
78
+ password = os.environ.get("KEYSTASH_PASSWORD")
79
+ if password is not None:
80
+ return password
81
+ try:
82
+ return getpass.getpass("Master password: ")
83
+ except (EOFError, KeyboardInterrupt):
84
+ raise typer.Exit(1) from None
85
+
86
+
87
+ def fail(message: str, code: int = 1) -> "typer.Exit":
88
+ err_console.print(f"[red]error:[/red] {message}")
89
+ return typer.Exit(code)
90
+
91
+
92
+ def load_vault() -> Vault:
93
+ vault = resolve_vault()
94
+ try:
95
+ vault.load(ask_password())
96
+ except VaultError as e:
97
+ if e.code == BAD_PASSWORD:
98
+ raise fail(str(e)) from None
99
+ raise fail(str(e)) from None
100
+ return vault
101
+
102
+
103
+ def _mask(secret: str) -> str:
104
+ if len(secret) <= 8:
105
+ return "•" * len(secret)
106
+ return secret[:4] + "•" * (len(secret) - 8) + secret[-4:]
107
+
108
+
109
+ def _shell_quote(value: str) -> str:
110
+ return "'" + value.replace("'", "'\\''") + "'"
111
+
112
+
113
+ def _expiry_markup(entry: Entry) -> str:
114
+ if entry.expires_at is None:
115
+ return "[dim]—[/dim]"
116
+ left = entry.days_left() or 0
117
+ if entry.is_expired():
118
+ return f"[red]{entry.expires_at} (expired)[/red]"
119
+ if left <= 7:
120
+ return f"[yellow]{entry.expires_at} ({left}d)[/yellow]"
121
+ return str(entry.expires_at)
122
+
123
+
124
+ def _parse_tags(raw: Optional[str]) -> List[str]:
125
+ if not raw:
126
+ return []
127
+ return [t.strip() for t in raw.split(",") if t.strip()]
128
+
129
+
130
+ def _confirm(message: str) -> bool:
131
+ return typer.confirm(message, default=False)
132
+
133
+
134
+ # ---------------------------------------------------------------- commands
135
+
136
+
137
+ @app.command()
138
+ def init() -> None:
139
+ """Create a new vault with a master password."""
140
+ vault = resolve_vault()
141
+ if vault.exists():
142
+ raise fail(f"Vault already exists at {vault.path}")
143
+ if os.environ.get("KEYSTASH_PASSWORD"):
144
+ password = os.environ["KEYSTASH_PASSWORD"]
145
+ else:
146
+ password = getpass.getpass("Set master password: ")
147
+ confirm = getpass.getpass("Confirm master password: ")
148
+ if password != confirm:
149
+ raise fail("Passwords do not match.")
150
+ if not password:
151
+ raise fail("Master password cannot be empty.")
152
+ vault.create(password)
153
+ console.print(f"[green]Vault created at {vault.path}[/green]")
154
+ console.print(
155
+ "Tip: point [bold]KEYSTASH_VAULT[/bold] at a cloud-synced folder "
156
+ "(iCloud/SeaDrive/Dropbox) to keep multiple machines in sync — the file "
157
+ "is encrypted, so syncing it is safe."
158
+ )
159
+
160
+
161
+ @app.command()
162
+ def add(
163
+ name: str = typer.Argument(..., help="Entry name, e.g. openai-prod."),
164
+ secret: Optional[str] = typer.Option(
165
+ None, "--secret", "-s", help="The secret value (will prompt if omitted)."
166
+ ),
167
+ generate_len: Optional[int] = typer.Option(
168
+ None, "--generate", "-g", help="Generate a random secret of this length instead."
169
+ ),
170
+ username: Optional[str] = typer.Option(None, "--username", "-u"),
171
+ url: Optional[str] = typer.Option(None, "--url"),
172
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags."),
173
+ notes: Optional[str] = typer.Option(None, "--notes", "-n"),
174
+ env_var: Optional[str] = typer.Option(
175
+ None, "--env-var", "-e", help="Env var name used by `run` and `env`."
176
+ ),
177
+ expires: Optional[str] = typer.Option(
178
+ None, "--expires", help="Expiry date, YYYY-MM-DD."
179
+ ),
180
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing entry."),
181
+ ) -> None:
182
+ """Add a new entry."""
183
+ if expires:
184
+ try:
185
+ parse_expires(expires)
186
+ except ValueError:
187
+ raise fail("--expires must be YYYY-MM-DD") from None
188
+ vault = load_vault()
189
+ if secret is None and generate_len is None:
190
+ secret = getpass.getpass(f"Secret for '{name}': ")
191
+ if generate_len is not None:
192
+ try:
193
+ secret = generate(generate_len)
194
+ except ValueError as e:
195
+ raise fail(str(e)) from None
196
+ if not secret:
197
+ raise fail("Secret cannot be empty.")
198
+ entry = Entry(
199
+ name=name,
200
+ secret=secret,
201
+ username=username or "",
202
+ url=url or "",
203
+ tags=_parse_tags(tags),
204
+ notes=notes or "",
205
+ env_var=env_var or "",
206
+ expires_at=parse_expires(expires),
207
+ )
208
+ try:
209
+ vault.add(entry, overwrite=force)
210
+ except VaultError as e:
211
+ raise fail(str(e)) from None
212
+ vault.save(ask_password())
213
+ console.print(f"[green]Added[/green] {name} [dim]→ env {entry.default_env_var}[/dim]")
214
+
215
+
216
+ @app.command()
217
+ def get(
218
+ name: str = typer.Argument(...),
219
+ copy: bool = typer.Option(False, "--copy", "-c", help="Copy to clipboard instead of printing."),
220
+ reveal: bool = typer.Option(False, "--reveal", "-r", help="Show the full secret."),
221
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Print only the raw secret (for scripting)."),
222
+ ) -> None:
223
+ """Show / copy an entry's secret."""
224
+ vault = load_vault()
225
+ try:
226
+ entry = vault.get(name)
227
+ except VaultError as e:
228
+ raise fail(str(e), code=3) from None
229
+ if entry.is_expired() and not quiet:
230
+ console.print(
231
+ f"[yellow]warning:[/yellow] '{entry.name}' expired on {entry.expires_at}",
232
+ style="dim",
233
+ )
234
+ if copy:
235
+ if clipboard.copy(entry.secret):
236
+ console.print(
237
+ f"[green]Copied '{entry.name}' to clipboard[/green] "
238
+ f"[dim](auto-clears in {clipboard.CLEAR_AFTER_SECONDS}s)[/dim]"
239
+ )
240
+ else:
241
+ raise fail("No clipboard helper found (pbcopy/wl-copy/xclip/clip).")
242
+ return
243
+ if quiet:
244
+ typer.echo(entry.secret)
245
+ return
246
+ shown = entry.secret if reveal else _mask(entry.secret)
247
+ body = (
248
+ f"[bold]secret:[/bold] {shown}\n"
249
+ f"[bold]env:[/bold] {entry.default_env_var}\n"
250
+ f"[bold]username:[/bold] {entry.username or '—'}\n"
251
+ f"[bold]url:[/bold] {entry.url or '—'}\n"
252
+ f"[bold]tags:[/bold] {', '.join(entry.tags) or '—'}\n"
253
+ f"[bold]expires:[/bold] {entry.expires_at or '—'}\n"
254
+ f"[bold]notes:[/bold] {entry.notes or '—'}"
255
+ )
256
+ console.print(Panel(body, title=entry.name, subtitle=f"updated {entry.updated_at:%Y-%m-%d}"))
257
+ if not reveal:
258
+ console.print("[dim]Use --reveal to show, --copy to copy.[/dim]")
259
+
260
+
261
+ @app.command("ls")
262
+ def list_entries(
263
+ query: Optional[str] = typer.Argument(None, help="Fuzzy search pattern."),
264
+ tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag."),
265
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
266
+ ) -> None:
267
+ """List entries, optionally fuzzy-searching."""
268
+ vault = load_vault()
269
+ entries = fuzzy_search(vault.entries, query or "")
270
+ if tag:
271
+ entries = [e for e in entries if tag in e.tags]
272
+ if json_out:
273
+ typer.echo(
274
+ json.dumps(
275
+ [
276
+ {
277
+ "name": e.name,
278
+ "env_var": e.default_env_var,
279
+ "tags": e.tags,
280
+ "expires_at": e.expires_at.isoformat() if e.expires_at else None,
281
+ "expired": e.is_expired(),
282
+ }
283
+ for e in entries
284
+ ],
285
+ indent=2,
286
+ )
287
+ )
288
+ return
289
+ if not entries:
290
+ console.print("[dim]No matching entries.[/dim]")
291
+ return
292
+ table = Table(title=f"{vault.path} · {len(entries)} entry(ies)")
293
+ table.add_column("Name", style="cyan")
294
+ table.add_column("Env var", style="green")
295
+ table.add_column("Tags")
296
+ table.add_column("Expires")
297
+ table.add_column("Updated", style="dim")
298
+ for e in entries:
299
+ table.add_row(
300
+ e.name,
301
+ e.default_env_var,
302
+ ", ".join(e.tags),
303
+ _expiry_markup(e),
304
+ f"{e.updated_at:%Y-%m-%d}",
305
+ )
306
+ console.print(table)
307
+
308
+
309
+ @app.command()
310
+ def rm(
311
+ name: str = typer.Argument(...),
312
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
313
+ ) -> None:
314
+ """Delete an entry."""
315
+ vault = load_vault()
316
+ try:
317
+ vault.get(name)
318
+ except VaultError as e:
319
+ raise fail(str(e), code=3) from None
320
+ if not force and not _confirm(f"Delete '{name}' permanently?"):
321
+ raise typer.Abort()
322
+ vault.remove(name)
323
+ vault.save(ask_password())
324
+ console.print(f"[green]Deleted[/green] {name}")
325
+
326
+
327
+ @app.command()
328
+ def edit(
329
+ name: str = typer.Argument(...),
330
+ secret: Optional[str] = typer.Option(None, "--secret", "-s", help="Replace the secret."),
331
+ username: Optional[str] = typer.Option(None, "--username", "-u"),
332
+ url: Optional[str] = typer.Option(None, "--url"),
333
+ tags: Optional[str] = typer.Option(None, "--tags", "-t"),
334
+ notes: Optional[str] = typer.Option(None, "--notes", "-n"),
335
+ env_var: Optional[str] = typer.Option(None, "--env-var", "-e"),
336
+ expires: Optional[str] = typer.Option(None, "--expires"),
337
+ clear_expires: bool = typer.Option(False, "--clear-expires"),
338
+ ) -> None:
339
+ """Update fields of an existing entry (only provided fields change)."""
340
+ if expires:
341
+ try:
342
+ parse_expires(expires)
343
+ except ValueError:
344
+ raise fail("--expires must be YYYY-MM-DD") from None
345
+ vault = load_vault()
346
+ try:
347
+ entry = vault.get(name)
348
+ except VaultError as e:
349
+ raise fail(str(e), code=3) from None
350
+ changes = {}
351
+ if secret is not None:
352
+ changes["secret"] = secret
353
+ if username is not None:
354
+ changes["username"] = username
355
+ if url is not None:
356
+ changes["url"] = url
357
+ if tags is not None:
358
+ changes["tags"] = _parse_tags(tags)
359
+ if notes is not None:
360
+ changes["notes"] = notes
361
+ if env_var is not None:
362
+ changes["env_var"] = env_var
363
+ if clear_expires:
364
+ changes["expires_at"] = None
365
+ elif expires is not None:
366
+ changes["expires_at"] = parse_expires(expires)
367
+ if not changes:
368
+ raise fail("Nothing to change — pass at least one field option.")
369
+ vault.add(entry.with_updates(**changes), overwrite=True)
370
+ vault.save(ask_password())
371
+ console.print(f"[green]Updated[/green] {name}")
372
+
373
+
374
+ @app.command()
375
+ def gen(
376
+ length: int = typer.Argument(24, min=8, help="Secret length."),
377
+ no_symbols: bool = typer.Option(False, "--no-symbols", help="Alphanumeric only."),
378
+ save_name: Optional[str] = typer.Option(
379
+ None, "--save", help="Save the generated secret as a new entry with this name."
380
+ ),
381
+ tags: Optional[str] = typer.Option(None, "--tags", "-t"),
382
+ expires: Optional[str] = typer.Option(None, "--expires"),
383
+ ) -> None:
384
+ """Generate a strong random secret (optionally save it)."""
385
+ try:
386
+ value = generate(length, symbols=not no_symbols)
387
+ except ValueError as e:
388
+ raise fail(str(e)) from None
389
+ if not save_name:
390
+ typer.echo(value)
391
+ return
392
+ vault = load_vault()
393
+ entry = Entry(
394
+ name=save_name,
395
+ secret=value,
396
+ tags=_parse_tags(tags),
397
+ expires_at=parse_expires(expires),
398
+ )
399
+ try:
400
+ vault.add(entry, overwrite=False)
401
+ except VaultError as e:
402
+ raise fail(str(e)) from None
403
+ vault.save(ask_password())
404
+ console.print(f"[green]Generated & saved[/green] {save_name} ({length} chars)")
405
+
406
+
407
+ def _select_entries(vault: Vault, names: List[str], tag: Optional[str]) -> List[Entry]:
408
+ if names:
409
+ missing = [n for n in names if n not in vault.entries]
410
+ if missing:
411
+ raise fail(f"No such entr{'y' if len(missing) == 1 else 'ies'}: {', '.join(missing)}", code=3)
412
+ return [vault.entries[n] for n in names]
413
+ if tag:
414
+ selected = [e for e in vault.entries.values() if tag in e.tags]
415
+ if not selected:
416
+ raise fail(f"No entries tagged '{tag}'.", code=3)
417
+ return selected
418
+ raise fail("Specify entry names or --tag.")
419
+
420
+
421
+ @app.command()
422
+ def env(
423
+ names: List[str] = typer.Argument(None, help="Entry names."),
424
+ tag: Optional[str] = typer.Option(None, "--tag", help="All entries with this tag."),
425
+ ) -> None:
426
+ """Print `export` lines so you can run: eval "$(keystash env openai)". """
427
+ vault = load_vault()
428
+ for entry in _select_entries(vault, list(names or []), tag):
429
+ typer.echo(f"export {entry.default_env_var}={_shell_quote(entry.secret)}")
430
+
431
+
432
+ @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
433
+ def run(
434
+ ctx: typer.Context,
435
+ name: List[str] = typer.Option(
436
+ None, "--name", "-n", help="Entry to inject (repeatable)."
437
+ ),
438
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Inject all entries with this tag."),
439
+ quiet: bool = typer.Option(True, "--quiet/--no-quiet", help="Hide which vars are injected."),
440
+ ) -> None:
441
+ """Run a command with selected secrets injected as env vars.
442
+
443
+ Everything after `--` is the command to run:
444
+
445
+ keystash run -n openai -n anthropic -- python train.py
446
+ keystash run --tag llm -- python train.py
447
+ """
448
+ cmd = list(ctx.args)
449
+ if cmd and cmd[0] == "--": # defensive: some click versions keep the separator
450
+ cmd = cmd[1:]
451
+ if not cmd:
452
+ raise fail("Usage: keystash run [-n NAME]... [--tag TAG] -- COMMAND [ARGS...]")
453
+ vault = load_vault()
454
+ selected = _select_entries(vault, name, tag)
455
+ environ = dict(os.environ)
456
+ for entry in selected:
457
+ environ[entry.default_env_var] = entry.secret
458
+ if not quiet:
459
+ console.print(f"[dim]+ {entry.default_env_var}[/dim]")
460
+ try:
461
+ completed = subprocess.run(cmd, env=environ, check=False)
462
+ except FileNotFoundError:
463
+ raise fail(f"Command not found: {cmd[0]}") from None
464
+ raise typer.Exit(completed.returncode)
465
+
466
+
467
+ @app.command("import")
468
+ def import_entries(
469
+ file: Path = typer.Argument(..., exists=True, readable=True, help=".env or JSON file."),
470
+ prefix: str = typer.Option("", "--prefix", help="Strip this prefix from names."),
471
+ tags: Optional[str] = typer.Option("imported", "--tags", "-t"),
472
+ force: bool = typer.Option(False, "--force", "-f"),
473
+ ) -> None:
474
+ """Bulk-import entries from a .env or JSON file (and delete the source file's risk)."""
475
+ text = file.read_text(encoding="utf-8")
476
+ pairs: List[tuple[str, str]] = []
477
+ if file.suffix == ".json":
478
+ try:
479
+ data = json.loads(text)
480
+ except json.JSONDecodeError:
481
+ raise fail("Invalid JSON file.") from None
482
+ items = data.items() if isinstance(data, dict) else None
483
+ if items is None:
484
+ raise fail("JSON must be an object mapping name → secret.")
485
+ for k, v in items:
486
+ if isinstance(v, dict) and "secret" in v:
487
+ pairs.append((str(k), str(v["secret"])))
488
+ else:
489
+ pairs.append((str(k), str(v)))
490
+ else:
491
+ for line in text.splitlines():
492
+ line = line.strip()
493
+ if not line or line.startswith("#") or "=" not in line:
494
+ continue
495
+ key, _, value = line.partition("=")
496
+ value = value.strip().strip("'\"")
497
+ pairs.append((key.strip(), value))
498
+ if prefix:
499
+ pairs = [(k[len(prefix):] if k.startswith(prefix) else k, v) for k, v in pairs]
500
+ vault = load_vault()
501
+ now = datetime.now(timezone.utc)
502
+ count = 0
503
+ for name, secret in pairs:
504
+ if not name or not secret:
505
+ continue
506
+ entry = Entry(
507
+ name=name,
508
+ secret=secret,
509
+ tags=_parse_tags(tags),
510
+ created_at=now,
511
+ updated_at=now,
512
+ )
513
+ vault.add(entry, overwrite=force)
514
+ count += 1
515
+ vault.save(ask_password())
516
+ console.print(f"[green]Imported {count} entr{'y' if count == 1 else 'ies'}[/green]")
517
+ console.print(
518
+ "[yellow]Remember to delete the plaintext source file and remove it from any git history.[/yellow]"
519
+ )
520
+
521
+
522
+ @app.command()
523
+ def export(
524
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to file instead of stdout."),
525
+ format: str = typer.Option("json", "--format", "-f", help="json or dotenv."),
526
+ include_secrets: bool = typer.Option(False, "--with-secrets", help="Include secret values."),
527
+ ) -> None:
528
+ """Export entries (metadata by default, secrets only with --with-secrets)."""
529
+ if format not in ("json", "dotenv"):
530
+ raise fail("--format must be json or dotenv.")
531
+ vault = load_vault()
532
+ entries = sorted(vault.entries.values(), key=lambda e: e.name)
533
+ if format == "dotenv":
534
+ lines = []
535
+ for e in entries:
536
+ if include_secrets:
537
+ lines.append(f"{e.default_env_var}={e.secret}")
538
+ else:
539
+ lines.append(f"{e.default_env_var}=")
540
+ payload = "\n".join(lines) + "\n"
541
+ else:
542
+ payload = json.dumps(
543
+ {
544
+ e.name: {
545
+ **{k: v for k, v in e.to_dict().items() if k != "secret"},
546
+ "secret": e.secret if include_secrets else None,
547
+ }
548
+ for e in entries
549
+ },
550
+ indent=2,
551
+ )
552
+ if out:
553
+ out.write_text(payload, encoding="utf-8")
554
+ console.print(f"[green]Exported {len(entries)} entries to {out}[/green]")
555
+ else:
556
+ typer.echo(payload, nl=False)
557
+
558
+
559
+ @app.command()
560
+ def status() -> None:
561
+ """Show vault status and expiring entries."""
562
+ vault = resolve_vault()
563
+ if not vault.exists():
564
+ raise fail(f"No vault at {vault.path} — run `keystash init` first.", code=3)
565
+ console.print(f"[bold]Vault:[/bold] {vault.path}")
566
+ vault.load(ask_password())
567
+ total = len(vault.entries)
568
+ expired = [e for e in vault.entries.values() if e.is_expired()]
569
+ soon = [
570
+ e
571
+ for e in vault.entries.values()
572
+ if not e.is_expired() and e.days_left() is not None and (e.days_left() or 0) <= 7
573
+ ]
574
+ console.print(f"[bold]Entries:[/bold] {total}")
575
+ if expired:
576
+ console.print(
577
+ Panel(
578
+ "\n".join(f"• {e.name} — expired {e.expires_at}" for e in expired),
579
+ title="[red]Expired[/red]",
580
+ )
581
+ )
582
+ if soon:
583
+ console.print(
584
+ Panel(
585
+ "\n".join(f"• {e.name} — {e.days_left()}d left" for e in soon),
586
+ title="[yellow]Expiring within 7 days[/yellow]",
587
+ )
588
+ )
589
+ if not expired and not soon:
590
+ console.print("[green]No expired or expiring entries.[/green]")
591
+
592
+
593
+ def cli() -> None:
594
+ try:
595
+ app()
596
+ except VaultError as e: # defensive: surface as friendly error
597
+ err_console.print(f"[red]error:[/red] {e}")
598
+ sys.exit(1)
599
+
600
+
601
+ if __name__ == "__main__":
602
+ cli()
keystash/clipboard.py ADDED
@@ -0,0 +1,65 @@
1
+ """Clipboard copy with best-effort delayed clear.
2
+
3
+ The delayed clear runs in a detached child process so it survives this CLI's
4
+ exit. Best-effort by design: if no clipboard helper is found, copy still
5
+ succeeds and we simply skip the auto-clear.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+
14
+ CLEAR_AFTER_SECONDS = 30
15
+
16
+
17
+ def _copy_cmd() -> list[str] | None:
18
+ if sys.platform == "darwin":
19
+ if shutil.which("pbcopy"):
20
+ return ["pbcopy"]
21
+ return None
22
+ if sys.platform == "win32":
23
+ if shutil.which("clip"):
24
+ return ["clip"]
25
+ return None
26
+ for candidate in ("wl-copy", "xclip", "xsel"):
27
+ if shutil.which(candidate):
28
+ return [candidate]
29
+ return None
30
+
31
+
32
+ def _clear_cmd(seconds: int) -> list[str] | None:
33
+ if sys.platform == "darwin" and shutil.which("pbcopy"):
34
+ return ["bash", "-c", f'sleep {seconds}; printf "" | pbcopy']
35
+ if sys.platform == "win32" and shutil.which("clip"):
36
+ return ["cmd", "/c", f"timeout /t {seconds} >nul & cls & echo off | clip"]
37
+ for wl, xs in (("wl-copy", "wl-copy"), ("xclip", "xclip"), ("xsel", "xsel")):
38
+ if shutil.which(wl):
39
+ if xs == "xclip":
40
+ return ["bash", "-c", f'sleep {seconds}; printf "" | xclip -selection clipboard']
41
+ if xs == "xsel":
42
+ return ["bash", "-c", f'sleep {seconds}; printf "" | xsel --clipboard --input']
43
+ return ["bash", "-c", f'sleep {seconds}; printf "" | wl-copy']
44
+ return None
45
+
46
+
47
+ def copy(text: str, *, auto_clear_after: int = CLEAR_AFTER_SECONDS) -> bool:
48
+ cmd = _copy_cmd()
49
+ if cmd is None:
50
+ return False
51
+ subprocess.run(cmd, input=text.encode("utf-8"), check=True)
52
+ if auto_clear_after > 0:
53
+ clear = _clear_cmd(auto_clear_after)
54
+ if clear:
55
+ try:
56
+ subprocess.Popen(
57
+ clear,
58
+ stdin=subprocess.DEVNULL,
59
+ stdout=subprocess.DEVNULL,
60
+ stderr=subprocess.DEVNULL,
61
+ start_new_session=True,
62
+ )
63
+ except OSError:
64
+ pass
65
+ return True
keystash/gen.py ADDED
@@ -0,0 +1,33 @@
1
+ """Random secret generation backed by the `secrets` module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import string
7
+
8
+ AMBIGUOUS = set("Il1O0o|`'\";:,.")
9
+
10
+
11
+ def generate(
12
+ length: int = 24,
13
+ *,
14
+ symbols: bool = True,
15
+ no_ambiguous: bool = True,
16
+ ) -> str:
17
+ if length < 8:
18
+ raise ValueError("length must be at least 8")
19
+ pools = [string.ascii_lowercase, string.ascii_uppercase, string.digits]
20
+ if symbols:
21
+ pools.append("!@#$%^&*()-_=+[]{}<>?")
22
+ if no_ambiguous:
23
+ pools = ["".join(c for c in pool if c not in AMBIGUOUS) for pool in pools]
24
+ alphabet = "".join(pools)
25
+ # Guarantee at least one char from every class, then fill randomly.
26
+ chars = [secrets.choice(pool) for pool in pools]
27
+ chars += [secrets.choice(alphabet) for _ in range(length - len(chars))]
28
+ secrets.SystemRandom().shuffle(chars)
29
+ return "".join(chars)
30
+
31
+
32
+ def token_hex_compat(nbytes: int = 32) -> str:
33
+ return secrets.token_hex(nbytes)
keystash/model.py ADDED
@@ -0,0 +1,81 @@
1
+ """Entry model: one stored secret with metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field, replace
7
+ from datetime import date, datetime, timezone
8
+ from typing import Any, Optional
9
+
10
+ ENTRY_VERSION = 1
11
+
12
+
13
+ def parse_expires(value: Optional[str]) -> Optional[date]:
14
+ """Parse a user-supplied expiry as YYYY-MM-DD. Returns None for empty input."""
15
+ if not value:
16
+ return None
17
+ return date.fromisoformat(value.strip())
18
+
19
+
20
+ @dataclass
21
+ class Entry:
22
+ name: str
23
+ secret: str
24
+ username: str = ""
25
+ url: str = ""
26
+ tags: list[str] = field(default_factory=list)
27
+ notes: str = ""
28
+ env_var: str = ""
29
+ expires_at: Optional[date] = None
30
+ created_at: datetime = field(
31
+ default_factory=lambda: datetime.now(timezone.utc)
32
+ )
33
+ updated_at: datetime = field(
34
+ default_factory=lambda: datetime.now(timezone.utc)
35
+ )
36
+
37
+ @property
38
+ def default_env_var(self) -> str:
39
+ if self.env_var:
40
+ return self.env_var
41
+ cleaned = re.sub(r"[^A-Za-z0-9]+", "_", self.name).strip("_").upper()
42
+ return cleaned or "KEYSTASH_ENTRY"
43
+
44
+ def is_expired(self, today: Optional[date] = None) -> bool:
45
+ if self.expires_at is None:
46
+ return False
47
+ return self.expires_at <= (today or date.today())
48
+
49
+ def days_left(self, today: Optional[date] = None) -> Optional[int]:
50
+ if self.expires_at is None:
51
+ return None
52
+ return (self.expires_at - (today or date.today())).days
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "version": ENTRY_VERSION,
57
+ "name": self.name,
58
+ "secret": self.secret,
59
+ "username": self.username,
60
+ "url": self.url,
61
+ "tags": list(self.tags),
62
+ "notes": self.notes,
63
+ "env_var": self.env_var,
64
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
65
+ "created_at": self.created_at.isoformat(),
66
+ "updated_at": self.updated_at.isoformat(),
67
+ }
68
+
69
+ @classmethod
70
+ def from_dict(cls, data: dict[str, Any]) -> "Entry":
71
+ known = {f for f in cls.__dataclass_fields__} # noqa: C416
72
+ payload = {k: v for k, v in data.items() if k in known and k != "version"}
73
+ payload["expires_at"] = parse_expires(payload.get("expires_at"))
74
+ payload["created_at"] = datetime.fromisoformat(payload["created_at"])
75
+ payload["updated_at"] = datetime.fromisoformat(payload["updated_at"])
76
+ payload["tags"] = [str(t) for t in payload.get("tags") or []]
77
+ return cls(**payload)
78
+
79
+ def with_updates(self, **changes: Any) -> "Entry":
80
+ changes["updated_at"] = datetime.now(timezone.utc)
81
+ return replace(self, **changes)
keystash/search.py ADDED
@@ -0,0 +1,47 @@
1
+ """Dependency-free fuzzy scoring used by `ls` and selection prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from .model import Entry
8
+
9
+
10
+ def _is_subsequence(needle: str, haystack: str) -> bool:
11
+ it = iter(haystack)
12
+ return all(ch in it for ch in needle)
13
+
14
+
15
+ def score_entry(query: str, entry: Entry) -> Optional[int]:
16
+ """Return a relevance score, or None when the entry does not match at all."""
17
+ q = query.strip().lower()
18
+ if not q:
19
+ return 0
20
+ name = entry.name.lower()
21
+ if name == q:
22
+ return 100
23
+ if name.startswith(q):
24
+ return 90
25
+ if q in name:
26
+ return 80
27
+ for tag in entry.tags:
28
+ if tag.lower() == q:
29
+ return 75
30
+ if q in tag.lower():
31
+ return 70
32
+ for text in (entry.username, entry.url, entry.notes, entry.default_env_var):
33
+ if text and q in text.lower():
34
+ return 50
35
+ if _is_subsequence(q, name):
36
+ return 60
37
+ return None
38
+
39
+
40
+ def search(entries: dict, query: str) -> list[Entry]:
41
+ scored = []
42
+ for entry in entries.values():
43
+ s = score_entry(query, entry)
44
+ if s is not None:
45
+ scored.append((s, entry.name.lower(), entry))
46
+ scored.sort(key=lambda t: (-t[0], t[1]))
47
+ return [entry for _, _, entry in scored]
keystash/vault.py ADDED
@@ -0,0 +1,143 @@
1
+ """Encrypted vault storage.
2
+
3
+ On-disk format (single JSON file, safe to sync through any cloud drive):
4
+
5
+ {
6
+ "version": 1,
7
+ "kdf": {"name": "PBKDF2-HMAC-SHA256", "salt": "<hex>", "iterations": 600000},
8
+ "cipher": "Fernet",
9
+ "payload": "<fernet token>"
10
+ }
11
+
12
+ The Fernet token encrypts a JSON document ``{"entries": {name: entry_dict}}``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import os
20
+ import stat
21
+ from pathlib import Path
22
+ from typing import Dict, Optional
23
+
24
+ from cryptography.fernet import Fernet, InvalidToken
25
+ from cryptography.hazmat.primitives import hashes
26
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
27
+
28
+ from .model import Entry
29
+
30
+ VAULT_VERSION = 1
31
+ DEFAULT_ITERATIONS = 600_000
32
+ DEFAULT_VAULT_PATH = Path(os.environ.get("KEYSTASH_VAULT", "~/.keystash/vault.json"))
33
+
34
+ VAULT_EXISTS = "vault_exists"
35
+ BAD_PASSWORD = "bad_password"
36
+ NOT_FOUND = "not_found"
37
+
38
+
39
+ class VaultError(Exception):
40
+ """Raised with a machine-readable ``code`` and a human message."""
41
+
42
+ def __init__(self, code: str, message: str) -> None:
43
+ super().__init__(message)
44
+ self.code = code
45
+
46
+
47
+ def derive_key(password: str, salt: bytes, iterations: int) -> bytes:
48
+ kdf = PBKDF2HMAC(
49
+ algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations
50
+ )
51
+ return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))
52
+
53
+
54
+ class Vault:
55
+ def __init__(self, path: Optional[os.PathLike | str] = None) -> None:
56
+ self.path = Path(path or DEFAULT_VAULT_PATH).expanduser()
57
+ self.entries: Dict[str, Entry] = {}
58
+ self.iterations = DEFAULT_ITERATIONS
59
+
60
+ # -- lifecycle ---------------------------------------------------------
61
+
62
+ def exists(self) -> bool:
63
+ return self.path.exists()
64
+
65
+ def create(self, password: str) -> None:
66
+ if self.exists():
67
+ raise VaultError(VAULT_EXISTS, f"Vault already exists at {self.path}")
68
+ self._write(password, {})
69
+
70
+ def load(self, password: str) -> None:
71
+ raw = self._read_file()
72
+ salt = bytes.fromhex(raw["kdf"]["salt"])
73
+ self.iterations = int(raw["kdf"].get("iterations", DEFAULT_ITERATIONS))
74
+ key = derive_key(password, salt, self.iterations)
75
+ try:
76
+ plaintext = Fernet(key).decrypt(raw["payload"].encode("ascii"))
77
+ except InvalidToken:
78
+ raise VaultError(BAD_PASSWORD, "Wrong master password.") from None
79
+ data = json.loads(plaintext.decode("utf-8"))
80
+ self.entries = {
81
+ name: Entry.from_dict(d) for name, d in data.get("entries", {}).items()
82
+ }
83
+
84
+ def save(self, password: str) -> None:
85
+ if not self.exists():
86
+ raise VaultError(NOT_FOUND, "Vault not initialized — run `keystash init` first.")
87
+ self._write(password, self.entries)
88
+
89
+ # -- entry operations --------------------------------------------------
90
+
91
+ def add(self, entry: Entry, *, overwrite: bool = False) -> None:
92
+ if entry.name in self.entries and not overwrite:
93
+ raise VaultError(
94
+ VAULT_EXISTS, f"Entry '{entry.name}' already exists (use --force to overwrite)."
95
+ )
96
+ self.entries[entry.name] = entry
97
+
98
+ def get(self, name: str) -> Entry:
99
+ if name not in self.entries:
100
+ raise VaultError(NOT_FOUND, f"No entry named '{name}'.")
101
+ return self.entries[name]
102
+
103
+ def remove(self, name: str) -> Entry:
104
+ entry = self.get(name)
105
+ del self.entries[name]
106
+ return entry
107
+
108
+ # -- internals -----------------------------------------------------------
109
+
110
+ def _write(self, password: str, entries: Dict[str, Entry]) -> None:
111
+ salt = os.urandom(16)
112
+ key = derive_key(password, salt, DEFAULT_ITERATIONS)
113
+ payload = json.dumps(
114
+ {"entries": {name: e.to_dict() for name, e in entries.items()}}
115
+ ).encode("utf-8")
116
+ doc = {
117
+ "version": VAULT_VERSION,
118
+ "kdf": {
119
+ "name": "PBKDF2-HMAC-SHA256",
120
+ "salt": salt.hex(),
121
+ "iterations": DEFAULT_ITERATIONS,
122
+ },
123
+ "cipher": "Fernet",
124
+ "payload": Fernet(key).encrypt(payload).decode("ascii"),
125
+ }
126
+ self.path.parent.mkdir(parents=True, exist_ok=True)
127
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
128
+ tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
129
+ try:
130
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600
131
+ except OSError:
132
+ pass
133
+ tmp.replace(self.path)
134
+
135
+ def _read_file(self) -> dict:
136
+ if not self.exists():
137
+ raise VaultError(
138
+ NOT_FOUND, f"No vault at {self.path} — run `keystash init` first."
139
+ )
140
+ try:
141
+ return json.loads(self.path.read_text(encoding="utf-8"))
142
+ except json.JSONDecodeError:
143
+ raise VaultError("corrupt", f"Vault file {self.path} is not valid JSON.") from None
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: keystash
3
+ Version: 0.1.0
4
+ Summary: Local-first encrypted vault for API keys, tokens and passwords — one file, fuzzy search, env injection, expiry tracking.
5
+ Project-URL: Homepage, https://github.com/thu-lawyer/keystash
6
+ Project-URL: Repository, https://github.com/thu-lawyer/keystash
7
+ Project-URL: Issues, https://github.com/thu-lawyer/keystash/issues
8
+ Project-URL: Changelog, https://github.com/thu-lawyer/keystash/releases
9
+ Author: thu-lawyer
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-key,cli,developer-tools,password,password-manager,secrets,token,vault
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Security
27
+ Classifier: Topic :: Security :: Cryptography
28
+ Classifier: Topic :: Utilities
29
+ Requires-Python: >=3.9
30
+ Requires-Dist: cryptography>=41.0
31
+ Requires-Dist: rich>=13.0
32
+ Requires-Dist: typer>=0.12
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # keystash 🔑
39
+
40
+ **Local-first encrypted vault for API keys, tokens and passwords — one file, fuzzy search, env injection, expiry tracking.**
41
+
42
+ Your LLM API keys, cloud tokens and passwords are scattered across `.env` files, shell
43
+ histories and notes apps. `keystash` puts them in **one encrypted file** that you own:
44
+ no server, no account, no subscription. Sync that file with iCloud / Dropbox / SeaDrive /
45
+ Syncthing — it's ciphertext, so syncing it is safe.
46
+
47
+ ```bash
48
+ pip install keystash
49
+ ```
50
+
51
+ ## Why keystash
52
+
53
+ | | keystash | pass / gopass | Bitwarden / 1Password | Infisical / Vault |
54
+ |---|---|---|---|---|
55
+ | Setup | `pip install` + one password | GPG key ceremony | Account + app | Self-host a server |
56
+ | Storage | one encrypted file you own | many GPG files | vendor cloud | server |
57
+ | Offline | ✅ always | ✅ | partial | ❌ |
58
+ | Dev workflow (env injection, `run`) | ✅ built-in | ❌ | ❌ | ✅ (heavy) |
59
+ | Token expiry tracking | ✅ built-in | ❌ | ❌ | enterprise |
60
+
61
+ ## Quick start
62
+
63
+ ```bash
64
+ # 1. Create your vault (encrypted with AES-128-CBC + HMAC, PBKDF2-HMAC-SHA256 600k iters)
65
+ keystash init
66
+
67
+ # 2. Store a secret
68
+ keystash add openai --secret sk-... --tags llm,prod --expires 2027-01-31 --env-var OPENAI_API_KEY
69
+
70
+ # 3. Retrieve it
71
+ keystash get openai # masked preview
72
+ keystash get openai -c # → clipboard, auto-clears after 30 s
73
+ keystash get openai -q # raw secret for scripts: export K=$(keystash get openai -q)
74
+
75
+ # 4. Find things (fuzzy)
76
+ keystash ls # everything, expiry warnings included
77
+ keystash ls oprod # fuzzy: matches openai-prod
78
+ keystash ls --tag llm --json
79
+
80
+ # 5. Inject secrets into any command — nothing touches your shell or disk
81
+ keystash run -n openai -n anthropic -- python train.py
82
+ keystash run --tag llm -- python train.py
83
+ eval "$(keystash env --tag llm)" # or export them explicitly
84
+
85
+ # 6. Migrate off plaintext .env files
86
+ keystash import .env # then delete the .env file
87
+ ```
88
+
89
+ ## Commands
90
+
91
+ | Command | Purpose |
92
+ |---|---|
93
+ | `init` | Create the vault |
94
+ | `add NAME` | Store a secret (`-s` value, `-g LEN` to generate, `-t` tags, `--expires YYYY-MM-DD`, `--env-var`) |
95
+ | `get NAME` | Show (`-r` reveal), copy (`-c`), raw output (`-q`) |
96
+ | `ls [QUERY]` | List / fuzzy search (`--tag`, `--json`) |
97
+ | `edit NAME` | Update any field in place |
98
+ | `rm NAME` | Delete an entry |
99
+ | `gen [LEN]` | Generate a strong secret (`--save NAME` to store it) |
100
+ | `env NAME…` / `--tag` | Print `export` lines for shell eval |
101
+ | `run -n NAME… -- CMD` | Run a command with secrets injected as env vars (`--tag` selects by tag) |
102
+ | `import FILE` | Bulk-import `.env` or JSON |
103
+ | `export` | Export metadata (or secrets with `--with-secrets`) as JSON / dotenv |
104
+ | `status` | Vault health + expired / expiring-soon report |
105
+
106
+ ## Multi-machine sync
107
+
108
+ The vault is a single encrypted file. Point `KEYSTASH_VAULT` at any synced folder:
109
+
110
+ ```bash
111
+ export KEYSTASH_VAULT="~/CloudStorage/SeaDrive/vault.json" # or iCloud, Dropbox, …
112
+ ```
113
+
114
+ Each write re-encrypts with a fresh random salt, so last-writer-wins applies —
115
+ prefer one writer per vault at a time, like any sync file.
116
+
117
+ ## Security model
118
+
119
+ - **Cipher:** Fernet (AES-128-CBC + HMAC-SHA256, encrypt-then-MAC) via `cryptography`.
120
+ - **Key derivation:** PBKDF2-HMAC-SHA256, 600 000 iterations, per-save 128-bit random salt.
121
+ - **File mode:** `0600`; nothing is ever written to disk in plaintext.
122
+ - **Clipboard:** copied secrets are auto-cleared after 30 s (best effort, detached process).
123
+ - **No network.** No telemetry. The CLI is fully offline.
124
+ - Secrets live in process memory only while a command runs; Python cannot guarantee
125
+ zeroization after exit — the same is true of any CLI in a GC'd language.
126
+
127
+ ## Environment variables
128
+
129
+ | Variable | Purpose |
130
+ |---|---|
131
+ | `KEYSTASH_VAULT` | Vault file path (also `--vault`) |
132
+ | `KEYSTASH_PASSWORD` | Master password (for scripts/CI; prefer the interactive prompt) |
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ git clone https://github.com/thu-lawyer/keystash && cd keystash
138
+ uv pip install -e ".[dev]"
139
+ pytest
140
+ ```
141
+
142
+ ## License
143
+
144
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ keystash/__init__.py,sha256=R-dspzH6j4hthcyppwpqypHfLVyU6TwAtfRh_a2wOQA,106
2
+ keystash/__main__.py,sha256=d1PJkDvFKuZeJLHZhFW8W5OO_JTOQqtLlz4cB4rz0Jg,94
3
+ keystash/cli.py,sha256=kY76YnEpHzC9dWJL669VPMXFwD8qIYBVqcawoyz0VYc,20325
4
+ keystash/clipboard.py,sha256=yy9I8UpoCocluJbbPYIM8tAPc5mysEvH-nil_oQU7F0,2179
5
+ keystash/gen.py,sha256=BrGIET7JIpHvUgS78DGRFN8myY7gfciCEIHT5xhX6AQ,977
6
+ keystash/model.py,sha256=tjGY3OxNaRTgk4resbfTirvbNiKOEISHYQacJaxX78U,2764
7
+ keystash/search.py,sha256=JQKTB7M2z5XzyldIPc3ormN8ginRlPo6ojcTkTi2nUE,1298
8
+ keystash/vault.py,sha256=9EL7Zt4c7IIFJ43j8rmyAeihKprWhZ2xE688iv0KRQw,4965
9
+ keystash-0.1.0.dist-info/METADATA,sha256=ImeDftCF73fwTAOdDFsy7h-a6XGCoYVz4Z00udrBWpg,5674
10
+ keystash-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ keystash-0.1.0.dist-info/entry_points.txt,sha256=Rawsg87kLbU7PCS15qYqIrrtxmTzn8n-dn9WvWD7pAE,46
12
+ keystash-0.1.0.dist-info/licenses/LICENSE,sha256=IfKtxamU913_kDwh_BNnyTWw_gcb0ypWPUANmrpe9CY,1067
13
+ keystash-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keystash = keystash.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thu-lawyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.