mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
- Fixes `AssertionError: assert {'path': '\\tmp\\exported.md'} == {'path': '/tmp/exported.md'}` by checking against both `\tmp\exported.md` and `/tmp/exported.md` in `src/tests/test_api_new_endpoints.py`
- Fixes `AssertionError: assert '/tmp/doc.txt' in '\\tmp\\doc.txt\n'` by checking for both variations in `src/tests/test_cli_entry_add_commands.py`
- Runs `black` to format unformatted tests and CLI code to fix lint/formatting failures.
Co-authored-by: PR0M3TH3AN <[email protected]>
529 lines
17 KiB
Python
529 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import typer
|
|
import click
|
|
|
|
from .common import _get_entry_service, EntryType
|
|
from seedpass.core.agent_export_policy import (
|
|
evaluate_kind_export,
|
|
load_export_policy,
|
|
record_export_policy_event,
|
|
)
|
|
from seedpass.core.entry_types import ALL_ENTRY_TYPES
|
|
from utils.clipboard import ClipboardUnavailableError
|
|
|
|
app = typer.Typer(help="Manage individual entries")
|
|
|
|
|
|
@app.command("list")
|
|
def entry_list(
|
|
ctx: typer.Context,
|
|
sort: str = typer.Option(
|
|
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
|
|
),
|
|
kind: Optional[str] = typer.Option(
|
|
None,
|
|
"--kind",
|
|
help="Filter by entry type",
|
|
click_type=click.Choice(ALL_ENTRY_TYPES),
|
|
),
|
|
archived: bool = typer.Option(False, "--archived", help="Include archived"),
|
|
) -> None:
|
|
"""List entries in the vault."""
|
|
service = _get_entry_service(ctx)
|
|
entries = service.list_entries(
|
|
sort_by=sort,
|
|
filter_kinds=[kind] if kind else None,
|
|
include_archived=archived,
|
|
)
|
|
for idx, label, username, url, is_archived in entries:
|
|
line = f"{idx}: {label}"
|
|
if username:
|
|
line += f" ({username})"
|
|
if url:
|
|
line += f" {url}"
|
|
if is_archived:
|
|
line += " [archived]"
|
|
typer.echo(line)
|
|
|
|
|
|
@app.command("search")
|
|
def entry_search(
|
|
ctx: typer.Context,
|
|
query: str,
|
|
kinds: List[str] = typer.Option(
|
|
None,
|
|
"--kind",
|
|
"-k",
|
|
help="Filter by entry kinds (can be repeated)",
|
|
click_type=click.Choice(ALL_ENTRY_TYPES),
|
|
),
|
|
) -> None:
|
|
"""Search entries."""
|
|
service = _get_entry_service(ctx)
|
|
kinds = list(kinds) if kinds else None
|
|
results = service.search_entries(query, kinds=kinds)
|
|
if not results:
|
|
typer.echo("No matching entries found")
|
|
return
|
|
for idx, label, username, url, _arch, etype in results:
|
|
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
|
|
if username:
|
|
line += f" ({username})"
|
|
if url:
|
|
line += f" {url}"
|
|
typer.echo(line)
|
|
|
|
|
|
@app.command("get")
|
|
def entry_get(ctx: typer.Context, query: str) -> None:
|
|
"""Retrieve a single entry's secret."""
|
|
service = _get_entry_service(ctx)
|
|
try:
|
|
matches = service.search_entries(query)
|
|
if len(matches) == 0:
|
|
typer.echo("No matching entries found")
|
|
raise typer.Exit(code=1)
|
|
if len(matches) > 1:
|
|
typer.echo("Matches:")
|
|
for idx, label, username, _url, _arch, etype in matches:
|
|
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
|
|
if username:
|
|
name += f" ({username})"
|
|
typer.echo(name)
|
|
raise typer.Exit(code=1)
|
|
|
|
index = matches[0][0]
|
|
entry = service.retrieve_entry(index)
|
|
etype = entry.get("type", entry.get("kind"))
|
|
if etype == EntryType.PASSWORD.value:
|
|
length = int(entry.get("length", 12))
|
|
password = service.generate_password(length, index)
|
|
typer.echo(password)
|
|
elif etype == EntryType.TOTP.value:
|
|
code = service.get_totp_code(index)
|
|
typer.echo(code)
|
|
elif etype == EntryType.DOCUMENT.value:
|
|
typer.echo(str(entry.get("content", "")))
|
|
else:
|
|
typer.echo("Unsupported entry type")
|
|
raise typer.Exit(code=1)
|
|
except ClipboardUnavailableError as exc:
|
|
typer.echo(
|
|
f"Clipboard unavailable: {exc}\n"
|
|
"Re-run with '--no-clipboard' to print secrets instead.",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
@app.command("add")
|
|
def entry_add(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
length: int = typer.Option(12, "--length"),
|
|
username: Optional[str] = typer.Option(None, "--username"),
|
|
url: Optional[str] = typer.Option(None, "--url"),
|
|
no_special: bool = typer.Option(
|
|
False, "--no-special", help="Exclude special characters"
|
|
),
|
|
allowed_special_chars: Optional[str] = typer.Option(
|
|
None, "--allowed-special-chars", help="Explicit set of special characters"
|
|
),
|
|
special_mode: Optional[str] = typer.Option(
|
|
None,
|
|
"--special-mode",
|
|
help="Special character mode",
|
|
),
|
|
exclude_ambiguous: bool = typer.Option(
|
|
False,
|
|
"--exclude-ambiguous",
|
|
help="Exclude ambiguous characters",
|
|
),
|
|
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
|
|
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
|
|
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
|
|
min_special: Optional[int] = typer.Option(None, "--min-special"),
|
|
) -> None:
|
|
"""Add a new password entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
kwargs = {}
|
|
if no_special:
|
|
kwargs["include_special_chars"] = False
|
|
if allowed_special_chars is not None:
|
|
kwargs["allowed_special_chars"] = allowed_special_chars
|
|
if special_mode is not None:
|
|
kwargs["special_mode"] = special_mode
|
|
if exclude_ambiguous:
|
|
kwargs["exclude_ambiguous"] = True
|
|
if min_uppercase is not None:
|
|
kwargs["min_uppercase"] = min_uppercase
|
|
if min_lowercase is not None:
|
|
kwargs["min_lowercase"] = min_lowercase
|
|
if min_digits is not None:
|
|
kwargs["min_digits"] = min_digits
|
|
if min_special is not None:
|
|
kwargs["min_special"] = min_special
|
|
|
|
index = service.add_entry(label, length, username, url, **kwargs)
|
|
typer.echo(str(index))
|
|
|
|
|
|
@app.command("add-totp")
|
|
def entry_add_totp(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
|
|
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
|
|
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
|
|
deterministic_totp: bool = typer.Option(
|
|
False, "--deterministic-totp", help="Derive secret deterministically"
|
|
),
|
|
) -> None:
|
|
"""Add a TOTP entry and output the otpauth URI."""
|
|
service = _get_entry_service(ctx)
|
|
uri = service.add_totp(
|
|
label,
|
|
index=index,
|
|
secret=secret,
|
|
period=period,
|
|
digits=digits,
|
|
deterministic=deterministic_totp,
|
|
)
|
|
typer.echo(uri)
|
|
|
|
|
|
@app.command("add-ssh")
|
|
def entry_add_ssh(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add an SSH key entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_ssh_key(
|
|
label,
|
|
index=index,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-pgp")
|
|
def entry_add_pgp(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
|
|
user_id: str = typer.Option("", "--user-id", help="User ID"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a PGP key entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_pgp_key(
|
|
label,
|
|
index=index,
|
|
key_type=key_type,
|
|
user_id=user_id,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-nostr")
|
|
def entry_add_nostr(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a Nostr key entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_nostr_key(
|
|
label,
|
|
index=index,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-seed")
|
|
def entry_add_seed(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
words: int = typer.Option(24, "--words", help="Word count"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a derived seed phrase entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_seed(
|
|
label,
|
|
index=index,
|
|
words=words,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-key-value")
|
|
def entry_add_key_value(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
key: str = typer.Option(..., "--key", help="Key name"),
|
|
value: str = typer.Option(..., "--value", help="Stored value"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a key/value entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_key_value(label, key, value, notes=notes)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-managed-account")
|
|
def entry_add_managed_account(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a managed account seed entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_managed_account(
|
|
label,
|
|
index=index,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("add-document")
|
|
def entry_add_document(
|
|
ctx: typer.Context,
|
|
label: str,
|
|
content: str = typer.Option("", "--content", help="Document text content"),
|
|
file_type: str = typer.Option("txt", "--file-type", help="File extension/type"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Add a document entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.add_document(
|
|
label,
|
|
content,
|
|
file_type=file_type,
|
|
notes=notes,
|
|
)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("import-document")
|
|
def entry_import_document(
|
|
ctx: typer.Context,
|
|
file: str = typer.Option(..., "--file", help="Input file path"),
|
|
label: Optional[str] = typer.Option(None, "--label", help="Title override"),
|
|
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
|
) -> None:
|
|
"""Import a local text file as a document entry and output its index."""
|
|
service = _get_entry_service(ctx)
|
|
idx = service.import_document_file(file, label=label, notes=notes)
|
|
typer.echo(str(idx))
|
|
|
|
|
|
@app.command("export-document")
|
|
def entry_export_document(
|
|
ctx: typer.Context,
|
|
entry_id: int = typer.Option(..., "--entry-id", help="Document entry index"),
|
|
out: Optional[str] = typer.Option(
|
|
None,
|
|
"--out",
|
|
help="Output file path or directory (default: current directory)",
|
|
),
|
|
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite if exists"),
|
|
) -> None:
|
|
"""Export a document entry to a local file and print the destination path."""
|
|
service = _get_entry_service(ctx)
|
|
dest = service.export_document_file(entry_id, out, overwrite=overwrite)
|
|
typer.echo(str(dest))
|
|
|
|
|
|
@app.command("modify")
|
|
def entry_modify(
|
|
ctx: typer.Context,
|
|
entry_id: int,
|
|
label: Optional[str] = typer.Option(None, "--label"),
|
|
username: Optional[str] = typer.Option(None, "--username"),
|
|
url: Optional[str] = typer.Option(None, "--url"),
|
|
notes: Optional[str] = typer.Option(None, "--notes"),
|
|
period: Optional[int] = typer.Option(
|
|
None, "--period", help="TOTP period in seconds"
|
|
),
|
|
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
|
|
key: Optional[str] = typer.Option(None, "--key", help="New key"),
|
|
value: Optional[str] = typer.Option(None, "--value", help="New value"),
|
|
content: Optional[str] = typer.Option(
|
|
None, "--content", help="New document content"
|
|
),
|
|
file_type: Optional[str] = typer.Option(
|
|
None, "--file-type", help="New document file type"
|
|
),
|
|
links_json: Optional[str] = typer.Option(
|
|
None,
|
|
"--links-json",
|
|
help='Replace links with JSON array: [{"target_id":1,"relation":"related_to","note":"..."}]',
|
|
),
|
|
) -> None:
|
|
"""Modify an existing entry."""
|
|
service = _get_entry_service(ctx)
|
|
links = None
|
|
if links_json is not None:
|
|
try:
|
|
parsed = json.loads(links_json)
|
|
except json.JSONDecodeError as exc:
|
|
typer.echo(f"Invalid --links-json: {exc}")
|
|
raise typer.Exit(code=1)
|
|
if not isinstance(parsed, list):
|
|
typer.echo("Invalid --links-json: expected a JSON list")
|
|
raise typer.Exit(code=1)
|
|
links = parsed
|
|
try:
|
|
service.modify_entry(
|
|
entry_id,
|
|
username=username,
|
|
url=url,
|
|
notes=notes,
|
|
label=label,
|
|
period=period,
|
|
digits=digits,
|
|
key=key,
|
|
value=value,
|
|
content=content,
|
|
file_type=file_type,
|
|
links=links,
|
|
)
|
|
except ValueError as e:
|
|
typer.echo(str(e))
|
|
sys.stdout.flush()
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
@app.command("links")
|
|
def entry_links(
|
|
ctx: typer.Context,
|
|
entry_id: int = typer.Option(..., "--entry-id", help="Entry index"),
|
|
) -> None:
|
|
"""List graph links for an entry."""
|
|
service = _get_entry_service(ctx)
|
|
try:
|
|
links = service.get_links(entry_id)
|
|
except ValueError as exc:
|
|
typer.echo(str(exc))
|
|
raise typer.Exit(code=1)
|
|
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
|
|
|
|
|
@app.command("link-add")
|
|
def entry_link_add(
|
|
ctx: typer.Context,
|
|
entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"),
|
|
target_id: int = typer.Option(..., "--target-id", help="Target entry index"),
|
|
relation: str = typer.Option("related_to", "--relation", help="Link relation"),
|
|
note: str = typer.Option("", "--note", help="Optional link note"),
|
|
) -> None:
|
|
"""Create a typed link between entries."""
|
|
service = _get_entry_service(ctx)
|
|
try:
|
|
links = service.add_link(entry_id, target_id, relation=relation, note=note)
|
|
except ValueError as exc:
|
|
typer.echo(str(exc))
|
|
raise typer.Exit(code=1)
|
|
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
|
|
|
|
|
@app.command("link-remove")
|
|
def entry_link_remove(
|
|
ctx: typer.Context,
|
|
entry_id: int = typer.Option(..., "--entry-id", help="Source entry index"),
|
|
target_id: int = typer.Option(..., "--target-id", help="Target entry index"),
|
|
relation: Optional[str] = typer.Option(
|
|
None, "--relation", help="Relation filter (optional)"
|
|
),
|
|
) -> None:
|
|
"""Remove links from source entry to target entry."""
|
|
service = _get_entry_service(ctx)
|
|
try:
|
|
links = service.remove_link(entry_id, target_id, relation=relation)
|
|
except ValueError as exc:
|
|
typer.echo(str(exc))
|
|
raise typer.Exit(code=1)
|
|
typer.echo(json.dumps({"entry_id": entry_id, "links": links}, indent=2))
|
|
|
|
|
|
@app.command("archive")
|
|
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
|
|
"""Archive an entry."""
|
|
service = _get_entry_service(ctx)
|
|
service.archive_entry(entry_id)
|
|
typer.echo(str(entry_id))
|
|
|
|
|
|
@app.command("unarchive")
|
|
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
|
|
"""Restore an archived entry."""
|
|
service = _get_entry_service(ctx)
|
|
service.restore_entry(entry_id)
|
|
typer.echo(str(entry_id))
|
|
|
|
|
|
@app.command("totp-codes")
|
|
def entry_totp_codes(ctx: typer.Context) -> None:
|
|
"""Display all current TOTP codes."""
|
|
service = _get_entry_service(ctx)
|
|
service.display_totp_codes()
|
|
|
|
|
|
@app.command("export-totp")
|
|
def entry_export_totp(
|
|
ctx: typer.Context,
|
|
file: str = typer.Option(..., help="Output file"),
|
|
agent_profile: bool = typer.Option(
|
|
False,
|
|
"--agent-profile",
|
|
help="Apply agent export policy controls",
|
|
),
|
|
) -> None:
|
|
"""Export all TOTP secrets to a JSON file."""
|
|
if agent_profile:
|
|
policy = load_export_policy()
|
|
allowed, reason = evaluate_kind_export(policy, EntryType.TOTP.value)
|
|
if not allowed:
|
|
record_export_policy_event(
|
|
"export_denied",
|
|
{
|
|
"source": "cli:entry_export_totp",
|
|
"kind": EntryType.TOTP.value,
|
|
"reason": reason,
|
|
"file": str(file),
|
|
},
|
|
)
|
|
typer.echo(f"Error: {reason}")
|
|
raise typer.Exit(code=1)
|
|
record_export_policy_event(
|
|
"export_allowed",
|
|
{
|
|
"source": "cli:entry_export_totp",
|
|
"kind": EntryType.TOTP.value,
|
|
"file": str(file),
|
|
},
|
|
)
|
|
service = _get_entry_service(ctx)
|
|
data = service.export_totp_entries()
|
|
Path(file).write_text(json.dumps(data, indent=2))
|
|
typer.echo(str(file))
|