mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 04:42:35 +00:00
Add document workflows, entry graph links, and discovery/docs updates
This commit is contained in:
1 parent
64efd1175d
commit
d0ea0d96f6
41 files changed
+2904
-153
No files matched your search
@@ -16,7 +16,8 @@ app = typer.Typer(
|
||||
help=(
|
||||
"SeedPass command line interface. "
|
||||
"Run `seedpass capabilities` for a deterministic feature map "
|
||||
"and `seedpass <group> --help` for group-specific commands."
|
||||
"and `seedpass <group> --help` for group-specific commands, "
|
||||
"including document I/O and entry graph links."
|
||||
),
|
||||
invoke_without_command=True,
|
||||
)
|
||||
|
||||
+484
-1
@@ -113,6 +113,7 @@ DEFAULT_POLICY = {
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
],
|
||||
"label_regex": ".*",
|
||||
"path_regex": "^entry/.*$",
|
||||
@@ -152,6 +153,7 @@ DEFAULT_POLICY = {
|
||||
EntryType.PASSWORD.value,
|
||||
EntryType.TOTP.value,
|
||||
EntryType.KEY_VALUE.value,
|
||||
EntryType.DOCUMENT.value,
|
||||
],
|
||||
"deny_private_reveal": sorted(PRIVATE_KINDS),
|
||||
"allow_export_import": False,
|
||||
@@ -267,6 +269,8 @@ def _resolve_secret_for_kind(
|
||||
return service.get_totp_code(index)
|
||||
if kind == EntryType.KEY_VALUE.value:
|
||||
return str(entry.get("value", ""))
|
||||
if kind == EntryType.DOCUMENT.value:
|
||||
return str(entry.get("content", ""))
|
||||
if kind == EntryType.SEED.value:
|
||||
return pm.entry_manager.get_seed_phrase(index, pm.parent_seed)
|
||||
if kind == EntryType.MANAGED_ACCOUNT.value:
|
||||
@@ -1302,7 +1306,9 @@ def _remediation_actions(findings: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
"export_import_allowed": {
|
||||
"title": "Disable broad export/import for agent profiles",
|
||||
"commands": ["seedpass agent policy-review --file <candidate.json>"],
|
||||
"notes": ["Set export.allow_full_vault=false and keep allow_export_import=false."],
|
||||
"notes": [
|
||||
"Set export.allow_full_vault=false and keep allow_export_import=false."
|
||||
],
|
||||
},
|
||||
"secret_isolation_disabled": {
|
||||
"title": "Enable high-risk secret isolation",
|
||||
@@ -3290,6 +3296,15 @@ def agent_bootstrap_context(ctx: typer.Context) -> None:
|
||||
"agent export-check",
|
||||
"agent export-manifest-verify",
|
||||
],
|
||||
"document_io": [
|
||||
"agent document-import --file <path>",
|
||||
"agent document-export <query>",
|
||||
],
|
||||
"knowledge_graph": [
|
||||
"entry link-add --entry-id <source> --target-id <target>",
|
||||
"entry links --entry-id <source>",
|
||||
"entry link-remove --entry-id <source> --target-id <target>",
|
||||
],
|
||||
"posture": ["agent posture-check", "agent posture-remediate"],
|
||||
"secret_access": ["agent get --fingerprint <fp> <query>"],
|
||||
"discovery": ["seedpass capabilities --format json", "seedpass --help"],
|
||||
@@ -3657,6 +3672,474 @@ def agent_export_manifest_verify(
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@app.command("document-import")
|
||||
def agent_document_import(
|
||||
ctx: typer.Context,
|
||||
file: Path = typer.Option(..., "--file", help="Input file path"),
|
||||
label: Optional[str] = typer.Option(None, "--label", help="Document title override"),
|
||||
notes: str = typer.Option("", "--notes", help="Entry notes"),
|
||||
tag: list[str] = typer.Option(
|
||||
[], "--tag", help="Tag to attach (repeatable)", show_default=False
|
||||
),
|
||||
archived: bool = typer.Option(False, "--archived", help="Import as archived"),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
auth_broker: str = typer.Option(
|
||||
"env",
|
||||
"--auth-broker",
|
||||
help="Non-interactive password broker (env|keyring|command)",
|
||||
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
|
||||
),
|
||||
broker_service: str = typer.Option(
|
||||
"seedpass",
|
||||
"--broker-service",
|
||||
help="Keyring service name when --auth-broker=keyring",
|
||||
),
|
||||
broker_account: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-account",
|
||||
help="Keyring account (defaults to fingerprint)",
|
||||
),
|
||||
broker_command: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-command",
|
||||
help="Command that prints password to stdout for --auth-broker=command",
|
||||
),
|
||||
token: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--token",
|
||||
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
|
||||
),
|
||||
) -> None:
|
||||
"""Import a local file as a document entry with agent policy enforcement."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
password = _agent_password(
|
||||
broker=auth_broker,
|
||||
password_env=password_env,
|
||||
broker_service=broker_service,
|
||||
broker_account=broker_account or str(fingerprint),
|
||||
broker_command=broker_command,
|
||||
)
|
||||
raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN")
|
||||
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
policy = _normalize_policy(policy, strict=False)
|
||||
except ValueError as exc:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "invalid_policy",
|
||||
"detail": str(exc),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not bool(policy.get("allow_export_import", False)):
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value)
|
||||
if not allowed:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": decision,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": decision,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
token_meta: dict[str, Any] | None = None
|
||||
if raw_token:
|
||||
ok, token_reason, token_meta = _validate_token(
|
||||
raw_token,
|
||||
operation="import",
|
||||
kind=EntryType.DOCUMENT.value,
|
||||
label=label or file.stem,
|
||||
consume_use=True,
|
||||
)
|
||||
if not ok:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": token_reason,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_import_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"file": str(file),
|
||||
"reason": token_reason,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
try:
|
||||
index = service.import_document_file(
|
||||
file,
|
||||
label=label,
|
||||
notes=notes,
|
||||
tags=tag,
|
||||
archived=archived,
|
||||
)
|
||||
entry = service.retrieve_entry(index) or {}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "document_import_failed",
|
||||
"detail": str(exc),
|
||||
"file": str(file),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"fingerprint": fingerprint,
|
||||
"index": index,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"label": entry.get("label", label or file.stem),
|
||||
"file_type": entry.get("file_type", file.suffix.lstrip(".").lower()),
|
||||
"source_file": str(file),
|
||||
"policy_decision": decision,
|
||||
}
|
||||
if token_meta is not None:
|
||||
payload["token_id"] = token_meta.get("id")
|
||||
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
|
||||
|
||||
_append_audit_event(
|
||||
"agent_document_import_granted",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": index,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"label": payload["label"],
|
||||
"source_file": str(file),
|
||||
"token_id": (token_meta or {}).get("id"),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@app.command("document-export")
|
||||
def agent_document_export(
|
||||
ctx: typer.Context,
|
||||
query: Optional[str] = typer.Argument(
|
||||
None, help="Document label or index query (omit when using --entry-id)"
|
||||
),
|
||||
entry_id: Optional[int] = typer.Option(
|
||||
None, "--entry-id", help="Document entry index override"
|
||||
),
|
||||
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"),
|
||||
approval_id: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--approval-id",
|
||||
help="Approval id when policy requires export approval",
|
||||
),
|
||||
password_env: str = typer.Option(
|
||||
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
|
||||
),
|
||||
auth_broker: str = typer.Option(
|
||||
"env",
|
||||
"--auth-broker",
|
||||
help="Non-interactive password broker (env|keyring|command)",
|
||||
click_type=click.Choice(["env", "keyring", "command"], case_sensitive=False),
|
||||
),
|
||||
broker_service: str = typer.Option(
|
||||
"seedpass",
|
||||
"--broker-service",
|
||||
help="Keyring service name when --auth-broker=keyring",
|
||||
),
|
||||
broker_account: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-account",
|
||||
help="Keyring account (defaults to fingerprint)",
|
||||
),
|
||||
broker_command: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--broker-command",
|
||||
help="Command that prints password to stdout for --auth-broker=command",
|
||||
),
|
||||
token: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--token",
|
||||
help="Scoped agent token. Defaults to SEEDPASS_AGENT_TOKEN env var.",
|
||||
),
|
||||
) -> None:
|
||||
"""Export a document entry to a local file with agent policy enforcement."""
|
||||
fingerprint = (ctx.obj or {}).get("fingerprint")
|
||||
if not fingerprint:
|
||||
raise typer.BadParameter("Specify target profile with --fingerprint.")
|
||||
if entry_id is None and not query:
|
||||
raise typer.BadParameter("Provide query or --entry-id.")
|
||||
if entry_id is not None and query:
|
||||
raise typer.BadParameter("Provide either query or --entry-id, not both.")
|
||||
|
||||
password = _agent_password(
|
||||
broker=auth_broker,
|
||||
password_env=password_env,
|
||||
broker_service=broker_service,
|
||||
broker_account=broker_account or str(fingerprint),
|
||||
broker_command=broker_command,
|
||||
)
|
||||
raw_token = token or os.getenv("SEEDPASS_AGENT_TOKEN")
|
||||
|
||||
try:
|
||||
policy = _load_policy(strict=True)
|
||||
policy = _normalize_policy(policy, strict=False)
|
||||
except ValueError as exc:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "invalid_policy",
|
||||
"detail": str(exc),
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not bool(policy.get("allow_export_import", False)):
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"reason": "policy_deny:export_import_disabled",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
allowed, decision = evaluate_kind_export(policy, EntryType.DOCUMENT.value)
|
||||
if not allowed:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": decision,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"kind": EntryType.DOCUMENT.value,
|
||||
"reason": decision,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
pm = PasswordManager(fingerprint=fingerprint, password=password)
|
||||
service = EntryService(pm)
|
||||
if entry_id is None:
|
||||
assert query is not None
|
||||
matches = service.search_entries(query, kinds=[EntryType.DOCUMENT.value])
|
||||
if len(matches) != 1:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "ambiguous_or_missing",
|
||||
"match_count": len(matches),
|
||||
"matches": [
|
||||
{
|
||||
"index": idx,
|
||||
"label": label_v,
|
||||
"kind": etype.value,
|
||||
}
|
||||
for idx, label_v, _username, _url, _archived, etype in matches
|
||||
],
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"query": query,
|
||||
"reason": "ambiguous_or_missing",
|
||||
"match_count": len(matches),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
entry_id = int(matches[0][0])
|
||||
|
||||
entry = service.retrieve_entry(entry_id)
|
||||
if not isinstance(entry, dict):
|
||||
payload = {"status": "error", "reason": "entry_not_found", "index": entry_id}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
kind = str(entry.get("type", entry.get("kind", "")))
|
||||
label = str(entry.get("label", ""))
|
||||
if kind != EntryType.DOCUMENT.value:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "entry_kind_mismatch",
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"expected_kind": EntryType.DOCUMENT.value,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
token_meta: dict[str, Any] | None = None
|
||||
if raw_token:
|
||||
ok, token_reason, token_meta = _validate_token(
|
||||
raw_token,
|
||||
operation="export",
|
||||
kind=kind,
|
||||
label=label,
|
||||
consume_use=True,
|
||||
)
|
||||
if not ok:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": token_reason,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": token_reason,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
if approval_required(policy, "export"):
|
||||
if not approval_id:
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": "policy_deny:approval_required",
|
||||
"action": "export",
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": "policy_deny:approval_required",
|
||||
"action": "export",
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
ok, approval_reason = consume_approval(
|
||||
approval_id=approval_id,
|
||||
action="export",
|
||||
resource=f"entry:{kind}:{entry_id}",
|
||||
)
|
||||
if not ok:
|
||||
reason = f"policy_deny:{approval_reason}"
|
||||
payload = {
|
||||
"status": "denied",
|
||||
"reason": reason,
|
||||
"action": "export",
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"index": entry_id,
|
||||
}
|
||||
_append_audit_event(
|
||||
"agent_document_export_denied",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"reason": reason,
|
||||
"action": "export",
|
||||
"approval_id": approval_id,
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
destination = service.export_document_file(entry_id, out, overwrite=overwrite)
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"status": "error",
|
||||
"reason": "document_export_failed",
|
||||
"detail": str(exc),
|
||||
"index": entry_id,
|
||||
}
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
raise typer.Exit(1)
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"output_path": str(destination),
|
||||
"policy_decision": decision,
|
||||
}
|
||||
if token_meta is not None:
|
||||
payload["token_id"] = token_meta.get("id")
|
||||
payload["token_uses_remaining"] = token_meta.get("uses_remaining", 0)
|
||||
|
||||
_append_audit_event(
|
||||
"agent_document_export_granted",
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"index": entry_id,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"output_path": str(destination),
|
||||
"token_id": (token_meta or {}).get("id"),
|
||||
},
|
||||
)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def agent_get(
|
||||
ctx: typer.Context,
|
||||
|
||||
@@ -38,6 +38,7 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"/docs",
|
||||
"/api/v1/export/check",
|
||||
"/api/v1/export/manifest/verify",
|
||||
"/api/v1/entry/{id}/links",
|
||||
"/api/v1/agent/job-profiles",
|
||||
"/api/v1/agent/job-profiles/{job_id}/run",
|
||||
"/api/v1/agent/job-profiles/{job_id}/template",
|
||||
@@ -180,6 +181,30 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"supports_manifest_entry_hash_verification": True,
|
||||
"manifest_mode": "policy_filtered",
|
||||
},
|
||||
"document_io": {
|
||||
"commands": [
|
||||
"entry import-document",
|
||||
"entry export-document",
|
||||
"agent document-import",
|
||||
"agent document-export",
|
||||
],
|
||||
"requires_agent_export_import_policy": True,
|
||||
},
|
||||
"knowledge_graph": {
|
||||
"commands": [
|
||||
"entry link-add",
|
||||
"entry links",
|
||||
"entry link-remove",
|
||||
"entry modify --links-json",
|
||||
],
|
||||
"api_endpoints": [
|
||||
"GET /api/v1/entry/{id}/links",
|
||||
"POST /api/v1/entry/{id}/links",
|
||||
"DELETE /api/v1/entry/{id}/links",
|
||||
],
|
||||
"search_includes_link_metadata": True,
|
||||
"sync_union_merge_fields": ["tags", "custom_fields", "links"],
|
||||
},
|
||||
"redaction": {
|
||||
"default_safe_output": True,
|
||||
"reveal_override": "agent get --reveal",
|
||||
@@ -214,6 +239,8 @@ def build_capabilities() -> dict[str, Any]:
|
||||
"Run `seedpass --help` for top-level commands.",
|
||||
"Run `seedpass <group> --help` for detailed options.",
|
||||
"Use `seedpass capabilities --format json` for machine-readable discovery.",
|
||||
"For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.",
|
||||
"For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.",
|
||||
"After unlock/login, run `seedpass capabilities` before autonomous runs.",
|
||||
"For agent bootstrap context, run `seedpass agent bootstrap-context`.",
|
||||
],
|
||||
@@ -252,6 +279,8 @@ def emit_capabilities(output_format: str) -> str:
|
||||
lines.append("- Safe output redaction default with explicit --reveal")
|
||||
lines.append("- Security posture checks and remediation bundles")
|
||||
lines.append("- Audited access log with chained HMAC signatures")
|
||||
lines.append("- Knowledge graph links: entry link-add/links/link-remove")
|
||||
lines.append("- Document file workflows for users and agents")
|
||||
lines.append("")
|
||||
lines.append("Discovery hints:")
|
||||
for hint in data["help_hints"]:
|
||||
|
||||
@@ -108,6 +108,8 @@ def entry_get(ctx: typer.Context, query: str) -> None:
|
||||
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)
|
||||
@@ -302,6 +304,55 @@ def entry_add_managed_account(
|
||||
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,
|
||||
@@ -316,9 +367,31 @@ def entry_modify(
|
||||
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,
|
||||
@@ -330,6 +403,9 @@ def entry_modify(
|
||||
digits=digits,
|
||||
key=key,
|
||||
value=value,
|
||||
content=content,
|
||||
file_type=file_type,
|
||||
links=links,
|
||||
)
|
||||
except ValueError as e:
|
||||
typer.echo(str(e))
|
||||
@@ -337,6 +413,58 @@ def entry_modify(
|
||||
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."""
|
||||
|
||||
Reference in new issue
Block a user