paper-stuff 0.2.0__tar.gz
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.
- paper_stuff-0.2.0/.gitignore +67 -0
- paper_stuff-0.2.0/PKG-INFO +13 -0
- paper_stuff-0.2.0/pyproject.toml +24 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/__init__.py +3 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/client.py +145 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/__init__.py +0 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/api_key.py +93 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/auth.py +82 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/batch.py +76 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/dashboard.py +28 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/document.py +412 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/filing.py +78 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/group.py +113 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/health.py +18 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/mcp.py +32 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/settings.py +53 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/commands/tax.py +72 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/config.py +148 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/decorators.py +49 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/errors.py +74 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/main.py +145 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/mcp_server.py +316 -0
- paper_stuff-0.2.0/src/paper_stuff_cli/output.py +139 -0
- paper_stuff-0.2.0/tests/__init__.py +0 -0
- paper_stuff-0.2.0/tests/conftest.py +17 -0
- paper_stuff-0.2.0/tests/test_client.py +76 -0
- paper_stuff-0.2.0/tests/test_commands/__init__.py +0 -0
- paper_stuff-0.2.0/tests/test_commands/test_auth.py +103 -0
- paper_stuff-0.2.0/tests/test_commands/test_batch.py +69 -0
- paper_stuff-0.2.0/tests/test_commands/test_document.py +183 -0
- paper_stuff-0.2.0/tests/test_config.py +98 -0
- paper_stuff-0.2.0/tests/test_mcp_protocol.py +68 -0
- paper_stuff-0.2.0/tests/test_mcp_stdio_smoke.py +35 -0
- paper_stuff-0.2.0/tests/test_mcp_tools.py +276 -0
- paper_stuff-0.2.0/tests/test_output.py +43 -0
- paper_stuff-0.2.0/uv.lock +1050 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
*.pyc
|
|
3
|
+
*.pyo
|
|
4
|
+
*.pyd
|
|
5
|
+
*.pyw
|
|
6
|
+
*.pyz
|
|
7
|
+
*.pywz
|
|
8
|
+
*.pyzwz
|
|
9
|
+
*.pyzwzw
|
|
10
|
+
*.pyzwzwz
|
|
11
|
+
*.pyzwzwzw
|
|
12
|
+
*.pyzwzwzwz
|
|
13
|
+
__pycache__/
|
|
14
|
+
.venv/
|
|
15
|
+
|
|
16
|
+
# Environment files
|
|
17
|
+
.env
|
|
18
|
+
.env.local
|
|
19
|
+
env/*.env
|
|
20
|
+
!env/*.env.example
|
|
21
|
+
|
|
22
|
+
# Node.js
|
|
23
|
+
node_modules/
|
|
24
|
+
.next/
|
|
25
|
+
*.log
|
|
26
|
+
|
|
27
|
+
# HTTPS certificates
|
|
28
|
+
certs/
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
31
|
+
*.crt
|
|
32
|
+
|
|
33
|
+
# Secrets (service account keys, etc.)
|
|
34
|
+
secrets/
|
|
35
|
+
|
|
36
|
+
# Logs directory (contains sensitive Azure response data)
|
|
37
|
+
logs/
|
|
38
|
+
*.log
|
|
39
|
+
|
|
40
|
+
# Terraform
|
|
41
|
+
*.tfstate
|
|
42
|
+
*.tfstate.*
|
|
43
|
+
.terraform/
|
|
44
|
+
terraform.tfvars
|
|
45
|
+
secrets.auto.tfvars
|
|
46
|
+
!*.tfvars.example
|
|
47
|
+
|
|
48
|
+
# Test databases
|
|
49
|
+
test.db
|
|
50
|
+
|
|
51
|
+
# Database backups (never commit dumps — they contain all user PII)
|
|
52
|
+
backups/
|
|
53
|
+
*.dump
|
|
54
|
+
*.sql.gz
|
|
55
|
+
|
|
56
|
+
# Fixtures (may contain PII)
|
|
57
|
+
fixtures/
|
|
58
|
+
|
|
59
|
+
# Prompts (may contain secrets)
|
|
60
|
+
prompts/
|
|
61
|
+
|
|
62
|
+
# IDE
|
|
63
|
+
.idea/
|
|
64
|
+
.vscode/
|
|
65
|
+
|
|
66
|
+
# Local dev FileStore
|
|
67
|
+
.local-storage/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: paper-stuff
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CLI and MCP server for Paper Stuff document management
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx<1.0,>=0.28
|
|
7
|
+
Requires-Dist: mcp<2,>=1.8
|
|
8
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
9
|
+
Requires-Dist: typer[all]<1.0,>=0.15
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "paper-stuff"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "CLI and MCP server for Paper Stuff document management"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"typer[all]>=0.15,<1.0",
|
|
8
|
+
"httpx>=0.28,<1.0",
|
|
9
|
+
"pydantic>=2.0,<3.0",
|
|
10
|
+
"mcp>=1.8,<2", # 1.8 = first release with streamable HTTP (--transport http); tested on 1.30
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest>=8.0", "respx>=0.22", "pytest-cov>=5.0"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
paper-stuff = "paper_stuff_cli.main:app"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/paper_stuff_cli"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""HTTP client: auth injection, error mapping, verbose logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from paper_stuff_cli.config import redact_key, resolve_config
|
|
11
|
+
from paper_stuff_cli.errors import CLIError, cli_error_from_exception, cli_error_from_status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIClient:
|
|
15
|
+
"""Paper Stuff API client with auth injection and error handling."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
api_url: str | None = None,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
token: str | None = None,
|
|
23
|
+
profile: str | None = None,
|
|
24
|
+
verbose: bool = False,
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
):
|
|
27
|
+
resolved = resolve_config(
|
|
28
|
+
cli_api_url=api_url,
|
|
29
|
+
cli_api_key=api_key,
|
|
30
|
+
cli_token=token,
|
|
31
|
+
cli_profile=profile,
|
|
32
|
+
)
|
|
33
|
+
self.api_url = resolved["api_url"].rstrip("/")
|
|
34
|
+
self._api_key = resolved["api_key"]
|
|
35
|
+
self._token = resolved["token"]
|
|
36
|
+
self._profile = profile
|
|
37
|
+
self._verbose = verbose
|
|
38
|
+
self._client = httpx.Client(
|
|
39
|
+
base_url=self.api_url,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
follow_redirects=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def reload_credentials(self) -> None:
|
|
45
|
+
"""Re-resolve key/token from the config chain (env, dir config, profile) — used after a 401."""
|
|
46
|
+
resolved = resolve_config(cli_profile=self._profile)
|
|
47
|
+
self._api_key, self._token = resolved["api_key"], resolved["token"]
|
|
48
|
+
|
|
49
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
50
|
+
"""Build auth headers. Priority: API key > JWT token."""
|
|
51
|
+
if self._api_key:
|
|
52
|
+
return {"X-API-Key": self._api_key}
|
|
53
|
+
if self._token:
|
|
54
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
def _log_request(self, method: str, path: str) -> None:
|
|
58
|
+
if self._verbose:
|
|
59
|
+
auth_info = ""
|
|
60
|
+
if self._api_key:
|
|
61
|
+
auth_info = f" [key={redact_key(self._api_key)}]"
|
|
62
|
+
elif self._token:
|
|
63
|
+
auth_info = " [jwt=****]"
|
|
64
|
+
print(f" {method} {self.api_url}{path}{auth_info}", file=sys.stderr)
|
|
65
|
+
|
|
66
|
+
def _log_response(self, resp: httpx.Response) -> None:
|
|
67
|
+
if self._verbose:
|
|
68
|
+
print(f" → {resp.status_code} ({len(resp.content)} bytes)", file=sys.stderr)
|
|
69
|
+
|
|
70
|
+
def _raise_for_status(self, resp: httpx.Response) -> None:
|
|
71
|
+
"""Raise a CLIError from the response body if the status is non-2xx."""
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
detail = ""
|
|
74
|
+
try:
|
|
75
|
+
body = resp.json()
|
|
76
|
+
detail = body.get("detail", "") if isinstance(body, dict) else str(body)
|
|
77
|
+
except (ValueError, KeyError):
|
|
78
|
+
detail = resp.text[:200]
|
|
79
|
+
raise cli_error_from_status(resp.status_code, detail, resp.headers.get("Retry-After"))
|
|
80
|
+
|
|
81
|
+
def _handle_response(self, resp: httpx.Response) -> Any:
|
|
82
|
+
"""Parse JSON response; raise CLIError on non-2xx."""
|
|
83
|
+
self._log_response(resp)
|
|
84
|
+
self._raise_for_status(resp)
|
|
85
|
+
if resp.status_code == 204 or not resp.content:
|
|
86
|
+
return {}
|
|
87
|
+
return resp.json()
|
|
88
|
+
|
|
89
|
+
def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
json: Any = None,
|
|
95
|
+
params: dict[str, Any] | None = None,
|
|
96
|
+
data: Any = None,
|
|
97
|
+
files: Any = None,
|
|
98
|
+
headers: dict[str, str] | None = None,
|
|
99
|
+
timeout: float | None = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Make an authenticated API request. ``timeout`` overrides the client default for this call only."""
|
|
102
|
+
merged_headers = {**self._auth_headers(), **(headers or {})}
|
|
103
|
+
self._log_request(method, path)
|
|
104
|
+
extra = {"timeout": timeout} if timeout is not None else {}
|
|
105
|
+
try:
|
|
106
|
+
resp = self._client.request(
|
|
107
|
+
method,
|
|
108
|
+
path,
|
|
109
|
+
json=json,
|
|
110
|
+
params=params,
|
|
111
|
+
content=data,
|
|
112
|
+
files=files,
|
|
113
|
+
headers=merged_headers,
|
|
114
|
+
**extra,
|
|
115
|
+
)
|
|
116
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
117
|
+
raise cli_error_from_exception(exc)
|
|
118
|
+
return self._handle_response(resp)
|
|
119
|
+
|
|
120
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
121
|
+
return self.request("GET", path, **kwargs)
|
|
122
|
+
|
|
123
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
124
|
+
return self.request("POST", path, **kwargs)
|
|
125
|
+
|
|
126
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
127
|
+
return self.request("PATCH", path, **kwargs)
|
|
128
|
+
|
|
129
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
130
|
+
return self.request("DELETE", path, **kwargs)
|
|
131
|
+
|
|
132
|
+
def download(self, path: str) -> httpx.Response:
|
|
133
|
+
"""Download a file (returns raw response for streaming)."""
|
|
134
|
+
merged_headers = self._auth_headers()
|
|
135
|
+
self._log_request("GET", path)
|
|
136
|
+
try:
|
|
137
|
+
resp = self._client.get(path, headers=merged_headers)
|
|
138
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
139
|
+
raise cli_error_from_exception(exc)
|
|
140
|
+
self._log_response(resp)
|
|
141
|
+
self._raise_for_status(resp)
|
|
142
|
+
return resp
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
self._client.close()
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""api-key — create, list, rename, revoke."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from paper_stuff_cli.decorators import handle_cli_errors
|
|
10
|
+
from paper_stuff_cli.output import print_error, print_success
|
|
11
|
+
|
|
12
|
+
api_key_app = typer.Typer(help="API key management.")
|
|
13
|
+
|
|
14
|
+
_KEY_COLUMNS = [
|
|
15
|
+
("ID", "id"),
|
|
16
|
+
("Name", "name"),
|
|
17
|
+
("Prefix", "prefix"),
|
|
18
|
+
("Scopes", "scopes"),
|
|
19
|
+
("Created", "created_at"),
|
|
20
|
+
("Last Used", "last_used_at"),
|
|
21
|
+
("Expires", "expires_at"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_VALID_SCOPES = {"upload", "read", "read_detail", "write", "delete", "export", "admin"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@api_key_app.command()
|
|
28
|
+
@handle_cli_errors
|
|
29
|
+
def create(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
name: str = typer.Argument(help="Key name"),
|
|
32
|
+
group_id: Optional[str] = typer.Option(None, "--group-id", help="Group ID"),
|
|
33
|
+
expires_in_days: Optional[int] = typer.Option(None, "--expires-in-days", help="Expiry in days"),
|
|
34
|
+
scopes: Optional[list[str]] = typer.Option(None, "--scope", help="Scopes (repeatable). Valid: upload, read, read_detail, write, delete, export, admin"),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Create a new API key."""
|
|
37
|
+
client = ctx.obj["client"]
|
|
38
|
+
fmt = ctx.obj["format"]
|
|
39
|
+
body: dict = {"name": name}
|
|
40
|
+
if group_id:
|
|
41
|
+
body["group_id"] = group_id
|
|
42
|
+
if expires_in_days:
|
|
43
|
+
body["expires_in_days"] = expires_in_days
|
|
44
|
+
if scopes:
|
|
45
|
+
invalid = set(scopes) - _VALID_SCOPES
|
|
46
|
+
if invalid:
|
|
47
|
+
print_error("VALIDATION_ERROR", f"Invalid scopes: {', '.join(sorted(invalid))}", 400, fmt=fmt)
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
body["scopes"] = list(set(scopes))
|
|
50
|
+
data = client.post("/api-keys", json=body)
|
|
51
|
+
print_success(data, fmt=fmt)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@api_key_app.command("list")
|
|
55
|
+
@handle_cli_errors
|
|
56
|
+
def list_keys(ctx: typer.Context) -> None:
|
|
57
|
+
"""List all API keys."""
|
|
58
|
+
client = ctx.obj["client"]
|
|
59
|
+
fmt = ctx.obj["format"]
|
|
60
|
+
data = client.get("/api-keys")
|
|
61
|
+
items = data.get("api_keys", [])
|
|
62
|
+
# Format scopes for table display
|
|
63
|
+
for item in items:
|
|
64
|
+
s = item.get("scopes")
|
|
65
|
+
item["scopes"] = ", ".join(s) if s else "all"
|
|
66
|
+
print_success(items, fmt=fmt, columns=_KEY_COLUMNS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@api_key_app.command()
|
|
70
|
+
@handle_cli_errors
|
|
71
|
+
def rename(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
key_id: str = typer.Argument(help="API key ID"),
|
|
74
|
+
name: str = typer.Argument(help="New name"),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Rename an API key."""
|
|
77
|
+
client = ctx.obj["client"]
|
|
78
|
+
fmt = ctx.obj["format"]
|
|
79
|
+
data = client.patch(f"/api-keys/{key_id}", json={"name": name})
|
|
80
|
+
print_success(data, fmt=fmt)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@api_key_app.command()
|
|
84
|
+
@handle_cli_errors
|
|
85
|
+
def revoke(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
key_id: str = typer.Argument(help="API key ID"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Revoke an API key."""
|
|
90
|
+
client = ctx.obj["client"]
|
|
91
|
+
fmt = ctx.obj["format"]
|
|
92
|
+
data = client.delete(f"/api-keys/{key_id}")
|
|
93
|
+
print_success(data, fmt=fmt)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""auth — login, status, logout."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from paper_stuff_cli.errors import CLIError
|
|
6
|
+
from paper_stuff_cli.config import (
|
|
7
|
+
load_global_config,
|
|
8
|
+
redact_key,
|
|
9
|
+
save_global_config,
|
|
10
|
+
set_profile_value,
|
|
11
|
+
)
|
|
12
|
+
from paper_stuff_cli.output import print_error, print_message, print_success
|
|
13
|
+
|
|
14
|
+
auth_app = typer.Typer(help="Authentication management.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@auth_app.command()
|
|
18
|
+
def login(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
api_key: str = typer.Option(..., "--api-key", help="API key (pst_...)"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Store an API key and verify it works."""
|
|
23
|
+
client = ctx.obj["client"]
|
|
24
|
+
client._api_key = api_key
|
|
25
|
+
fmt = ctx.obj["format"]
|
|
26
|
+
quiet = ctx.obj["quiet"]
|
|
27
|
+
profile = ctx.obj["profile"]
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
data = client.get("/user/settings")
|
|
31
|
+
set_profile_value(profile, "api_key", api_key)
|
|
32
|
+
set_profile_value(profile, "api_url", client.api_url)
|
|
33
|
+
print_message(f"Authenticated. Key stored in profile '{profile}'.", quiet=quiet)
|
|
34
|
+
print_success({"authenticated": True, "profile": profile}, fmt=fmt)
|
|
35
|
+
except CLIError as e:
|
|
36
|
+
print_error("UNAUTHORIZED", f"API key validation failed: {e}", fmt=fmt)
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@auth_app.command()
|
|
41
|
+
def status(ctx: typer.Context) -> None:
|
|
42
|
+
"""Show current authentication state."""
|
|
43
|
+
client = ctx.obj["client"]
|
|
44
|
+
fmt = ctx.obj["format"]
|
|
45
|
+
profile = ctx.obj["profile"]
|
|
46
|
+
|
|
47
|
+
info = {
|
|
48
|
+
"profile": profile,
|
|
49
|
+
"api_url": client.api_url,
|
|
50
|
+
"api_key": redact_key(client._api_key),
|
|
51
|
+
"jwt": "set" if client._token else "(not set)",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Try to verify auth
|
|
55
|
+
if client._api_key or client._token:
|
|
56
|
+
try:
|
|
57
|
+
client.get("/user/settings")
|
|
58
|
+
info["authenticated"] = True
|
|
59
|
+
except CLIError:
|
|
60
|
+
info["authenticated"] = False
|
|
61
|
+
else:
|
|
62
|
+
info["authenticated"] = False
|
|
63
|
+
|
|
64
|
+
print_success(info, fmt=fmt)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@auth_app.command()
|
|
68
|
+
def logout(ctx: typer.Context) -> None:
|
|
69
|
+
"""Clear stored credentials from the active profile."""
|
|
70
|
+
profile = ctx.obj["profile"]
|
|
71
|
+
fmt = ctx.obj["format"]
|
|
72
|
+
quiet = ctx.obj["quiet"]
|
|
73
|
+
|
|
74
|
+
config = load_global_config()
|
|
75
|
+
profiles = config.get("profiles", {})
|
|
76
|
+
if profile in profiles:
|
|
77
|
+
profiles[profile].pop("api_key", None)
|
|
78
|
+
profiles[profile].pop("token", None)
|
|
79
|
+
save_global_config(config)
|
|
80
|
+
|
|
81
|
+
print_message(f"Credentials cleared for profile '{profile}'.", quiet=quiet)
|
|
82
|
+
print_success({"logged_out": True, "profile": profile}, fmt=fmt)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""batch — bulk operations on multiple documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from paper_stuff_cli.decorators import handle_cli_errors
|
|
10
|
+
from paper_stuff_cli.output import print_success
|
|
11
|
+
|
|
12
|
+
batch_app = typer.Typer(help="Batch operations on documents.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@batch_app.command()
|
|
16
|
+
@handle_cli_errors
|
|
17
|
+
def delete(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
ids: list[str] = typer.Argument(help="Document IDs"),
|
|
20
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Delete multiple documents."""
|
|
23
|
+
client = ctx.obj["client"]
|
|
24
|
+
fmt = ctx.obj["format"]
|
|
25
|
+
if not yes:
|
|
26
|
+
typer.confirm(f"Delete {len(ids)} documents?", abort=True)
|
|
27
|
+
data = client.post("/documents/batch/delete", json={"document_ids": ids})
|
|
28
|
+
print_success(data, fmt=fmt)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@batch_app.command("payment-status")
|
|
32
|
+
@handle_cli_errors
|
|
33
|
+
def payment_status(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
status: str = typer.Argument(help="PAID or UNPAID"),
|
|
36
|
+
ids: list[str] = typer.Argument(help="Document IDs"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Update payment status for multiple documents."""
|
|
39
|
+
client = ctx.obj["client"]
|
|
40
|
+
fmt = ctx.obj["format"]
|
|
41
|
+
data = client.post(
|
|
42
|
+
"/documents/batch/payment-status",
|
|
43
|
+
json={"document_ids": ids, "payment_status": status.upper()},
|
|
44
|
+
)
|
|
45
|
+
print_success(data, fmt=fmt)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@batch_app.command()
|
|
49
|
+
@handle_cli_errors
|
|
50
|
+
def category(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
ids: list[str] = typer.Argument(help="Document IDs"),
|
|
53
|
+
main: str = typer.Option(..., "--main", help="Main category"),
|
|
54
|
+
sub: Optional[str] = typer.Option(None, "--sub", help="Subcategory"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Update category for multiple documents."""
|
|
57
|
+
client = ctx.obj["client"]
|
|
58
|
+
fmt = ctx.obj["format"]
|
|
59
|
+
body: dict = {"document_ids": ids, "main_category": main}
|
|
60
|
+
if sub:
|
|
61
|
+
body["subcategory"] = sub
|
|
62
|
+
data = client.post("/documents/batch/category", json=body)
|
|
63
|
+
print_success(data, fmt=fmt)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@batch_app.command()
|
|
67
|
+
@handle_cli_errors
|
|
68
|
+
def review(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
ids: list[str] = typer.Argument(help="Document IDs"),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Mark multiple documents as reviewed."""
|
|
73
|
+
client = ctx.obj["client"]
|
|
74
|
+
fmt = ctx.obj["format"]
|
|
75
|
+
data = client.post("/documents/batch/review", json={"document_ids": ids})
|
|
76
|
+
print_success(data, fmt=fmt)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""dashboard — show dashboard summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from paper_stuff_cli.decorators import handle_cli_errors
|
|
10
|
+
from paper_stuff_cli.output import print_success
|
|
11
|
+
|
|
12
|
+
dashboard_app = typer.Typer(help="Dashboard overview.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dashboard_app.callback(invoke_without_command=True)
|
|
16
|
+
@handle_cli_errors
|
|
17
|
+
def dashboard(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
year: Optional[int] = typer.Option(None, "--year", help="Filter by year"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Show dashboard summary."""
|
|
22
|
+
client = ctx.obj["client"]
|
|
23
|
+
fmt = ctx.obj["format"]
|
|
24
|
+
params: dict = {}
|
|
25
|
+
if year:
|
|
26
|
+
params["year"] = year
|
|
27
|
+
data = client.get("/dashboard/summary", params=params)
|
|
28
|
+
print_success(data, fmt=fmt)
|