lxw-cli 0.1.0__tar.gz → 0.1.2__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.
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/PKG-INFO +31 -9
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/README.md +30 -8
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/pyproject.toml +1 -1
- lxw_cli-0.1.2/src/lxw_cli/__init__.py +8 -0
- lxw_cli-0.1.2/src/lxw_cli/commands/mcp.py +293 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/config.py +3 -1
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/services.py +11 -7
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/mcp_server.py +30 -5
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_cli.py +77 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_config.py +19 -0
- lxw_cli-0.1.2/uv.lock +1873 -0
- lxw_cli-0.1.0/.claude/scheduled_tasks.lock +0 -1
- lxw_cli-0.1.0/src/lxw_cli/__init__.py +0 -1
- lxw_cli-0.1.0/src/lxw_cli/commands/mcp.py +0 -167
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.env.example +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.gitattributes +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.github/workflows/publish.yml +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.github/workflows/test.yml +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.gitignore +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/LICENSE +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/docs/tui-screenshot.svg +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/__main__.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/cli.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/__init__.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/_common.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/articles.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/contacts.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/delivery_notes.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/invoices.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/orders.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/quotations.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/vouchers.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/__init__.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/client.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/constants.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/errors.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/models.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/output.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/__init__.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/app.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/detail.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/entities.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/order_form.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/__init__.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/conftest.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/contacts_flat.txt +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/contacts_grouped.txt +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/invoices_list.txt +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_cli_snapshots.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_client.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_mcp_server.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_output.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_resolve.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_services.py +0 -0
- {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lxw-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)
|
|
5
5
|
Project-URL: Homepage, https://github.com/OEM-Mrks/lxw-cli
|
|
6
6
|
Project-URL: Documentation, https://developers.lexware.io/
|
|
@@ -71,6 +71,22 @@ Alternativ klassisch in ein Environment:
|
|
|
71
71
|
pip install lxw-cli
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
### Windows (cmd, ohne Vorinstallation)
|
|
75
|
+
|
|
76
|
+
Für ein frisches Windows 11 ohne uv und ohne Python — die vier Zeilen laufen
|
|
77
|
+
per Copy-Paste in `cmd.exe` durch (volle Pfade, damit kein Terminal-Neustart
|
|
78
|
+
nötig ist; uv lädt sich ein passendes Python selbst):
|
|
79
|
+
|
|
80
|
+
```cmd
|
|
81
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
82
|
+
"%USERPROFILE%\.local\bin\uv.exe" tool install lxw-cli
|
|
83
|
+
"%USERPROFILE%\.local\bin\uv.exe" tool update-shell
|
|
84
|
+
"%USERPROFILE%\.local\bin\lxw.exe" profile
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Ab dem nächsten cmd-Fenster reicht dann überall einfach `lxw`. Der API-Key
|
|
88
|
+
wird beim ersten Start abgefragt und unter `%APPDATA%\lexware\.env` gespeichert.
|
|
89
|
+
|
|
74
90
|
### Aus dem Quellcode (Entwicklung)
|
|
75
91
|
|
|
76
92
|
```bash
|
|
@@ -333,18 +349,22 @@ Technisch spricht die TUI ausschließlich den UI-freien Core (`lxw_cli.core`)
|
|
|
333
349
|
|
|
334
350
|
## Claude-Integration (MCP-Server)
|
|
335
351
|
|
|
336
|
-
Das CLI bringt einen eingebauten MCP-Server mit, mit dem **Claude Code**
|
|
352
|
+
Das CLI bringt einen eingebauten MCP-Server mit, mit dem **Claude Code**, **Claude Desktop** und **Cowork** deine Lexware-Daten direkt abfragen können.
|
|
337
353
|
|
|
338
354
|
### Setup in einem Schritt
|
|
339
355
|
|
|
340
356
|
```bash
|
|
341
|
-
lxw mcp install-claude
|
|
357
|
+
lxw mcp install-claude # Claude Code
|
|
358
|
+
lxw mcp install-desktop # Claude Desktop + Cowork
|
|
342
359
|
```
|
|
343
360
|
|
|
344
|
-
|
|
361
|
+
Die beiden Frontends haben getrennte Konfigurationen: `install-claude` registriert den Server bei **Claude Code** (via `claude mcp add`, Scope `user`), `install-desktop` trägt ihn in die `claude_desktop_config.json` von **Claude Desktop** ein — das deckt auch **Cowork** ab. **Wichtig:** `install-desktop` bei beendetem Claude Desktop ausführen (Quit, nicht nur Fenster schließen) — die laufende App schreibt ihre Konfiguration aus dem Arbeitsspeicher zurück und überschreibt externe Änderungen. Danach Desktop starten.
|
|
362
|
+
|
|
363
|
+
Der API-Key wird dabei **nicht** an Claude übergeben — der Server liest ihn selbst aus `~/.config/lexware/.env` (chmod 600; Windows: `%APPDATA%\lexware\.env`); falls er dort noch fehlt, legen die Install-Befehle ihn einmalig ab. Prüfen:
|
|
345
364
|
|
|
346
365
|
```bash
|
|
347
|
-
|
|
366
|
+
lxw mcp status # zeigt Registrierung für Claude Code und Desktop
|
|
367
|
+
claude mcp list # 'lexware' muss in der Liste erscheinen (Claude Code)
|
|
348
368
|
```
|
|
349
369
|
|
|
350
370
|
Öffne Claude Code in einem Projekt und frag z.B.:
|
|
@@ -375,10 +395,12 @@ Die `list_*`-Tools bieten dieselben Optionen wie die CLI:
|
|
|
375
395
|
### Weitere mcp-Befehle
|
|
376
396
|
|
|
377
397
|
```bash
|
|
378
|
-
lxw mcp status
|
|
379
|
-
lxw mcp install-claude --force
|
|
380
|
-
lxw mcp
|
|
381
|
-
lxw mcp
|
|
398
|
+
lxw mcp status # zeigt Registrierung (Claude Code + Desktop)
|
|
399
|
+
lxw mcp install-claude --force # Re-Install Claude Code (z.B. nach Pfad-Wechsel)
|
|
400
|
+
lxw mcp install-desktop --force # Re-Install Claude Desktop/Cowork
|
|
401
|
+
lxw mcp uninstall-claude # aus Claude Code entfernen
|
|
402
|
+
lxw mcp uninstall-desktop # aus Claude Desktop entfernen
|
|
403
|
+
lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
|
|
382
404
|
```
|
|
383
405
|
|
|
384
406
|
## Out of Scope (v1)
|
|
@@ -36,6 +36,22 @@ Alternativ klassisch in ein Environment:
|
|
|
36
36
|
pip install lxw-cli
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
### Windows (cmd, ohne Vorinstallation)
|
|
40
|
+
|
|
41
|
+
Für ein frisches Windows 11 ohne uv und ohne Python — die vier Zeilen laufen
|
|
42
|
+
per Copy-Paste in `cmd.exe` durch (volle Pfade, damit kein Terminal-Neustart
|
|
43
|
+
nötig ist; uv lädt sich ein passendes Python selbst):
|
|
44
|
+
|
|
45
|
+
```cmd
|
|
46
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
47
|
+
"%USERPROFILE%\.local\bin\uv.exe" tool install lxw-cli
|
|
48
|
+
"%USERPROFILE%\.local\bin\uv.exe" tool update-shell
|
|
49
|
+
"%USERPROFILE%\.local\bin\lxw.exe" profile
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Ab dem nächsten cmd-Fenster reicht dann überall einfach `lxw`. Der API-Key
|
|
53
|
+
wird beim ersten Start abgefragt und unter `%APPDATA%\lexware\.env` gespeichert.
|
|
54
|
+
|
|
39
55
|
### Aus dem Quellcode (Entwicklung)
|
|
40
56
|
|
|
41
57
|
```bash
|
|
@@ -298,18 +314,22 @@ Technisch spricht die TUI ausschließlich den UI-freien Core (`lxw_cli.core`)
|
|
|
298
314
|
|
|
299
315
|
## Claude-Integration (MCP-Server)
|
|
300
316
|
|
|
301
|
-
Das CLI bringt einen eingebauten MCP-Server mit, mit dem **Claude Code**
|
|
317
|
+
Das CLI bringt einen eingebauten MCP-Server mit, mit dem **Claude Code**, **Claude Desktop** und **Cowork** deine Lexware-Daten direkt abfragen können.
|
|
302
318
|
|
|
303
319
|
### Setup in einem Schritt
|
|
304
320
|
|
|
305
321
|
```bash
|
|
306
|
-
lxw mcp install-claude
|
|
322
|
+
lxw mcp install-claude # Claude Code
|
|
323
|
+
lxw mcp install-desktop # Claude Desktop + Cowork
|
|
307
324
|
```
|
|
308
325
|
|
|
309
|
-
|
|
326
|
+
Die beiden Frontends haben getrennte Konfigurationen: `install-claude` registriert den Server bei **Claude Code** (via `claude mcp add`, Scope `user`), `install-desktop` trägt ihn in die `claude_desktop_config.json` von **Claude Desktop** ein — das deckt auch **Cowork** ab. **Wichtig:** `install-desktop` bei beendetem Claude Desktop ausführen (Quit, nicht nur Fenster schließen) — die laufende App schreibt ihre Konfiguration aus dem Arbeitsspeicher zurück und überschreibt externe Änderungen. Danach Desktop starten.
|
|
327
|
+
|
|
328
|
+
Der API-Key wird dabei **nicht** an Claude übergeben — der Server liest ihn selbst aus `~/.config/lexware/.env` (chmod 600; Windows: `%APPDATA%\lexware\.env`); falls er dort noch fehlt, legen die Install-Befehle ihn einmalig ab. Prüfen:
|
|
310
329
|
|
|
311
330
|
```bash
|
|
312
|
-
|
|
331
|
+
lxw mcp status # zeigt Registrierung für Claude Code und Desktop
|
|
332
|
+
claude mcp list # 'lexware' muss in der Liste erscheinen (Claude Code)
|
|
313
333
|
```
|
|
314
334
|
|
|
315
335
|
Öffne Claude Code in einem Projekt und frag z.B.:
|
|
@@ -340,10 +360,12 @@ Die `list_*`-Tools bieten dieselben Optionen wie die CLI:
|
|
|
340
360
|
### Weitere mcp-Befehle
|
|
341
361
|
|
|
342
362
|
```bash
|
|
343
|
-
lxw mcp status
|
|
344
|
-
lxw mcp install-claude --force
|
|
345
|
-
lxw mcp
|
|
346
|
-
lxw mcp
|
|
363
|
+
lxw mcp status # zeigt Registrierung (Claude Code + Desktop)
|
|
364
|
+
lxw mcp install-claude --force # Re-Install Claude Code (z.B. nach Pfad-Wechsel)
|
|
365
|
+
lxw mcp install-desktop --force # Re-Install Claude Desktop/Cowork
|
|
366
|
+
lxw mcp uninstall-claude # aus Claude Code entfernen
|
|
367
|
+
lxw mcp uninstall-desktop # aus Claude Desktop entfernen
|
|
368
|
+
lxw mcp serve # läuft direkt — wird von Claude intern aufgerufen
|
|
347
369
|
```
|
|
348
370
|
|
|
349
371
|
## Out of Scope (v1)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""lxw-cli — die Version kommt aus den Paket-Metadaten (pyproject.toml)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("lxw-cli")
|
|
7
|
+
except PackageNotFoundError: # uninstalliert, z.B. direkt aus dem Quellbaum
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Subcommand `lxw mcp …` — manages Claude Code/Desktop integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from dotenv import dotenv_values
|
|
15
|
+
|
|
16
|
+
from lxw_cli.config import ENV_KEY, global_env_path, load_config, store_key
|
|
17
|
+
from lxw_cli.output import console, err_console
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True)
|
|
20
|
+
|
|
21
|
+
MCP_NAME = "lexware"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def desktop_config_path() -> Path:
|
|
25
|
+
"""Location of Claude Desktop's `claude_desktop_config.json`.
|
|
26
|
+
|
|
27
|
+
Claude Desktop (and Cowork) read MCP servers from this file — registering
|
|
28
|
+
via `claude mcp add` only covers Claude Code. `CLAUDE_DESKTOP_CONFIG`
|
|
29
|
+
overrides the path (mostly for tests).
|
|
30
|
+
"""
|
|
31
|
+
override = os.environ.get("CLAUDE_DESKTOP_CONFIG")
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
if sys.platform == "darwin":
|
|
35
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / (
|
|
36
|
+
"claude_desktop_config.json"
|
|
37
|
+
)
|
|
38
|
+
if sys.platform == "win32":
|
|
39
|
+
appdata = os.environ.get("APPDATA")
|
|
40
|
+
root = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
|
41
|
+
return root / "Claude" / "claude_desktop_config.json"
|
|
42
|
+
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_desktop_config(path: Path) -> dict[str, Any]:
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
return {}
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
err_console.print(
|
|
52
|
+
f"[red]Fehler:[/red] {path} enthält kein gültiges JSON ({exc}). "
|
|
53
|
+
"Bitte die Datei prüfen — sie wird nicht angefasst."
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(code=2) from exc
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
err_console.print(f"[red]Fehler:[/red] {path} ist kein JSON-Objekt.")
|
|
58
|
+
raise typer.Exit(code=2)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check_claude() -> None:
|
|
63
|
+
if not shutil.which("claude"):
|
|
64
|
+
err_console.print(
|
|
65
|
+
"[red]Fehler:[/red] `claude` CLI nicht gefunden. "
|
|
66
|
+
"Installiere zuerst Claude Code (https://claude.com/claude-code)."
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(code=2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _mcp_command() -> list[str]:
|
|
72
|
+
"""Build the command Claude will use to launch the MCP server.
|
|
73
|
+
|
|
74
|
+
Prefer the `lxw-mcp` console script when it's on PATH (works for
|
|
75
|
+
pipx-installed setups). Otherwise fall back to the current interpreter
|
|
76
|
+
plus `-m lxw_cli.mcp_server` so the venv path is captured even when
|
|
77
|
+
`.venv/bin` isn't on the system PATH.
|
|
78
|
+
"""
|
|
79
|
+
on_path = shutil.which("lxw-mcp")
|
|
80
|
+
if on_path:
|
|
81
|
+
return [on_path]
|
|
82
|
+
# Look for the entry-point next to the running python (typical venv layout;
|
|
83
|
+
# on Windows console scripts get an .exe suffix)
|
|
84
|
+
exe_suffix = ".exe" if sys.platform == "win32" else ""
|
|
85
|
+
candidate = Path(sys.executable).parent / f"lxw-mcp{exe_suffix}"
|
|
86
|
+
if candidate.exists():
|
|
87
|
+
return [str(candidate)]
|
|
88
|
+
return [sys.executable, "-m", "lxw_cli.mcp_server"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_global_key(api_key: str) -> None:
|
|
92
|
+
"""Make sure the MCP server can resolve the key from the global config.
|
|
93
|
+
|
|
94
|
+
The key is deliberately NOT passed via `claude mcp add -e …` — that would
|
|
95
|
+
expose it in the process list and store a second plaintext copy in the
|
|
96
|
+
Claude config. The server resolves it itself from the global `.env`
|
|
97
|
+
(chmod 600), so we only have to guarantee it exists there.
|
|
98
|
+
"""
|
|
99
|
+
path = global_env_path()
|
|
100
|
+
existing = (dotenv_values(path).get(ENV_KEY) or "").strip() if path.is_file() else ""
|
|
101
|
+
if not existing:
|
|
102
|
+
stored = store_key(api_key)
|
|
103
|
+
err_console.print(
|
|
104
|
+
f"[dim]API-Key in {stored} hinterlegt (wird vom MCP-Server gelesen).[/dim]"
|
|
105
|
+
)
|
|
106
|
+
elif existing != api_key:
|
|
107
|
+
err_console.print(
|
|
108
|
+
f"[yellow]Hinweis:[/yellow] {path} enthält einen anderen API-Key — "
|
|
109
|
+
"der MCP-Server verwendet den dort hinterlegten."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("install-claude")
|
|
114
|
+
def install_claude(
|
|
115
|
+
scope: str = typer.Option(
|
|
116
|
+
"user", "--scope", help="user (global), project (per repo), oder local."
|
|
117
|
+
),
|
|
118
|
+
force: bool = typer.Option(
|
|
119
|
+
False, "--force", help="Vorher entfernen, falls bereits registriert."
|
|
120
|
+
),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Registriert den Lexware-MCP-Server bei Claude Code."""
|
|
123
|
+
_check_claude()
|
|
124
|
+
config = load_config() # validates LEXWARE_API_KEY
|
|
125
|
+
_ensure_global_key(config.api_key)
|
|
126
|
+
|
|
127
|
+
server_cmd = _mcp_command()
|
|
128
|
+
|
|
129
|
+
if force:
|
|
130
|
+
subprocess.run(
|
|
131
|
+
["claude", "mcp", "remove", MCP_NAME, "--scope", scope],
|
|
132
|
+
check=False,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
cmd = [
|
|
137
|
+
"claude",
|
|
138
|
+
"mcp",
|
|
139
|
+
"add",
|
|
140
|
+
MCP_NAME,
|
|
141
|
+
"--scope",
|
|
142
|
+
scope,
|
|
143
|
+
"--",
|
|
144
|
+
*server_cmd,
|
|
145
|
+
]
|
|
146
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
147
|
+
if result.returncode != 0:
|
|
148
|
+
err_console.print(f"[red]claude mcp add fehlgeschlagen:[/red]\n{result.stderr}")
|
|
149
|
+
if "already exists" in (result.stderr or "").lower():
|
|
150
|
+
err_console.print(
|
|
151
|
+
"[yellow]Hinweis:[/yellow] Mit `--force` kannst du die "
|
|
152
|
+
"bestehende Registrierung überschreiben."
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(code=result.returncode)
|
|
155
|
+
console.print(
|
|
156
|
+
f"[green]✓[/green] Lexware MCP-Server bei Claude Code registriert "
|
|
157
|
+
f"(scope={scope})."
|
|
158
|
+
)
|
|
159
|
+
console.print(
|
|
160
|
+
"Test: Öffne Claude Code und frag z.B. "
|
|
161
|
+
"[bold]\"Wie viele offene Rechnungen habe ich?\"[/bold]"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command("uninstall-claude")
|
|
166
|
+
def uninstall_claude(
|
|
167
|
+
scope: str = typer.Option("user", "--scope", help="Gleicher Scope wie beim Install."),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Entfernt den Lexware-MCP-Server aus Claude Code."""
|
|
170
|
+
_check_claude()
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
["claude", "mcp", "remove", MCP_NAME, "--scope", scope],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
err_console.print(f"[red]Fehler:[/red] {result.stderr}")
|
|
178
|
+
raise typer.Exit(code=result.returncode)
|
|
179
|
+
console.print(f"[green]✓[/green] Lexware MCP-Server entfernt (scope={scope}).")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("install-desktop")
|
|
183
|
+
def install_desktop(
|
|
184
|
+
force: bool = typer.Option(
|
|
185
|
+
False, "--force", help="Bestehenden 'lexware'-Eintrag überschreiben."
|
|
186
|
+
),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Registriert den Lexware-MCP-Server bei Claude Desktop (und damit Cowork).
|
|
189
|
+
|
|
190
|
+
Claude Desktop liest MCP-Server aus `claude_desktop_config.json` —
|
|
191
|
+
`install-claude` deckt nur Claude Code ab. Wichtig: Claude Desktop vorher
|
|
192
|
+
komplett beenden — die laufende App schreibt die Datei aus dem
|
|
193
|
+
Arbeitsspeicher zurück und überschreibt externe Änderungen.
|
|
194
|
+
"""
|
|
195
|
+
config = load_config() # validates LEXWARE_API_KEY
|
|
196
|
+
_ensure_global_key(config.api_key)
|
|
197
|
+
|
|
198
|
+
path = desktop_config_path()
|
|
199
|
+
if not path.parent.is_dir():
|
|
200
|
+
err_console.print(
|
|
201
|
+
f"[red]Fehler:[/red] {path.parent} existiert nicht — ist Claude "
|
|
202
|
+
"Desktop installiert? (https://claude.com/download)"
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(code=2)
|
|
205
|
+
|
|
206
|
+
data = _load_desktop_config(path)
|
|
207
|
+
servers = data.setdefault("mcpServers", {})
|
|
208
|
+
if MCP_NAME in servers and not force:
|
|
209
|
+
err_console.print(
|
|
210
|
+
f"[yellow]Hinweis:[/yellow] '{MCP_NAME}' ist bereits in {path} "
|
|
211
|
+
"eingetragen. Mit `--force` überschreiben."
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
# Absolute path: GUI apps don't inherit the shell PATH, so a bare
|
|
216
|
+
# 'lxw-mcp' would not resolve when Desktop spawns the server.
|
|
217
|
+
command, *args = _mcp_command()
|
|
218
|
+
entry: dict[str, Any] = {"command": command}
|
|
219
|
+
if args:
|
|
220
|
+
entry["args"] = args
|
|
221
|
+
servers[MCP_NAME] = entry
|
|
222
|
+
|
|
223
|
+
path.write_text(
|
|
224
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
225
|
+
)
|
|
226
|
+
console.print(
|
|
227
|
+
f"[green]✓[/green] Lexware MCP-Server in {path} eingetragen."
|
|
228
|
+
)
|
|
229
|
+
console.print(
|
|
230
|
+
"[bold]Wichtig:[/bold] Läuft Claude Desktop gerade, kann es diese "
|
|
231
|
+
"Änderung beim nächsten Speichern überschreiben — den Befehl am besten "
|
|
232
|
+
"bei beendeter App ausführen (Quit, nicht nur Fenster schließen) und "
|
|
233
|
+
"Desktop danach neu starten. Gilt auch für Cowork-Sessions."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("uninstall-desktop")
|
|
238
|
+
def uninstall_desktop() -> None:
|
|
239
|
+
"""Entfernt den Lexware-MCP-Server aus Claude Desktop."""
|
|
240
|
+
path = desktop_config_path()
|
|
241
|
+
data = _load_desktop_config(path)
|
|
242
|
+
servers = data.get("mcpServers") or {}
|
|
243
|
+
if MCP_NAME not in servers:
|
|
244
|
+
console.print(f"[yellow]✗[/yellow] '{MCP_NAME}' ist in {path} nicht eingetragen.")
|
|
245
|
+
raise typer.Exit(code=1)
|
|
246
|
+
del servers[MCP_NAME]
|
|
247
|
+
path.write_text(
|
|
248
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
249
|
+
)
|
|
250
|
+
console.print(f"[green]✓[/green] Lexware MCP-Server aus {path} entfernt.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.command("status")
|
|
254
|
+
def status() -> None:
|
|
255
|
+
"""Zeigt, wo der Lexware-MCP-Server registriert ist (Claude Code + Desktop)."""
|
|
256
|
+
# Claude Code (via `claude mcp list`)
|
|
257
|
+
if shutil.which("claude"):
|
|
258
|
+
result = subprocess.run(["claude", "mcp", "list"], capture_output=True, text=True)
|
|
259
|
+
if result.returncode == 0 and MCP_NAME in result.stdout:
|
|
260
|
+
for line in result.stdout.splitlines():
|
|
261
|
+
if MCP_NAME in line:
|
|
262
|
+
console.print(f"[green]✓[/green] Claude Code: {line.strip()}")
|
|
263
|
+
else:
|
|
264
|
+
console.print(
|
|
265
|
+
"[yellow]✗[/yellow] Claude Code: nicht registriert. "
|
|
266
|
+
"Setup: [bold]lxw mcp install-claude[/bold]"
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
console.print("[dim]– Claude Code: `claude` CLI nicht gefunden.[/dim]")
|
|
270
|
+
|
|
271
|
+
# Claude Desktop (claude_desktop_config.json)
|
|
272
|
+
path = desktop_config_path()
|
|
273
|
+
if not path.is_file():
|
|
274
|
+
console.print(f"[dim]– Claude Desktop: keine Konfiguration ({path}).[/dim]")
|
|
275
|
+
return
|
|
276
|
+
servers = _load_desktop_config(path).get("mcpServers") or {}
|
|
277
|
+
if MCP_NAME in servers:
|
|
278
|
+
entry = servers[MCP_NAME]
|
|
279
|
+
cmd = " ".join([entry.get("command", "?"), *entry.get("args", [])])
|
|
280
|
+
console.print(f"[green]✓[/green] Claude Desktop: {cmd}")
|
|
281
|
+
else:
|
|
282
|
+
console.print(
|
|
283
|
+
"[yellow]✗[/yellow] Claude Desktop: nicht registriert. "
|
|
284
|
+
"Setup: [bold]lxw mcp install-desktop[/bold]"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command("serve")
|
|
289
|
+
def serve() -> None:
|
|
290
|
+
"""Startet den MCP-Server (intern von Claude Code aufgerufen — selten manuell nötig)."""
|
|
291
|
+
from lxw_cli.mcp_server import run as run_server
|
|
292
|
+
|
|
293
|
+
run_server()
|
|
@@ -116,7 +116,9 @@ def _apply_project_env(path: Path) -> None:
|
|
|
116
116
|
file=sys.stderr,
|
|
117
117
|
)
|
|
118
118
|
for key, value in values.items():
|
|
119
|
-
|
|
119
|
+
# Skip empty values: a bare `LEXWARE_API_KEY=` line (e.g. a filled-in
|
|
120
|
+
# copy of .env.example left blank) must not mask the global config.
|
|
121
|
+
if value and key not in os.environ:
|
|
120
122
|
os.environ[key] = value
|
|
121
123
|
|
|
122
124
|
|
|
@@ -403,15 +403,15 @@ def article_line_item(
|
|
|
403
403
|
) -> dict[str, Any]:
|
|
404
404
|
"""Build a `custom` line item from a raw article record.
|
|
405
405
|
|
|
406
|
-
The Public API has no article-referencing line-item type, so name,
|
|
407
|
-
price are copied from the article. Prices are always
|
|
408
|
-
record carries `netPrice` regardless of its leading
|
|
409
|
-
with `taxConditions.taxType = "net"` in the document
|
|
410
|
-
overrides the article's list price (e.g. a manually
|
|
411
|
-
TUI form).
|
|
406
|
+
The Public API has no article-referencing line-item type, so name,
|
|
407
|
+
description, unit and price are copied from the article. Prices are always
|
|
408
|
+
sent net (the article record carries `netPrice` regardless of its leading
|
|
409
|
+
price), which pairs with `taxConditions.taxType = "net"` in the document
|
|
410
|
+
body. `net_price` overrides the article's list price (e.g. a manually
|
|
411
|
+
edited price in the TUI form).
|
|
412
412
|
"""
|
|
413
413
|
price = article.get("price") or {}
|
|
414
|
-
|
|
414
|
+
item = {
|
|
415
415
|
"type": "custom",
|
|
416
416
|
"name": article.get("title") or article.get("articleNumber") or "Position",
|
|
417
417
|
"quantity": quantity,
|
|
@@ -422,6 +422,10 @@ def article_line_item(
|
|
|
422
422
|
"taxRatePercentage": price.get("taxRate", 19),
|
|
423
423
|
},
|
|
424
424
|
}
|
|
425
|
+
description = (article.get("description") or "").strip()
|
|
426
|
+
if description:
|
|
427
|
+
item["description"] = description
|
|
428
|
+
return item
|
|
425
429
|
|
|
426
430
|
|
|
427
431
|
def build_order_confirmation_body(
|
|
@@ -21,7 +21,12 @@ mcp: FastMCP = FastMCP(
|
|
|
21
21
|
instructions=(
|
|
22
22
|
"Lexware Office API. Most document tools accept either a UUID or a "
|
|
23
23
|
"voucher number (e.g. invoice number 'FB2600682'). PDF download tools "
|
|
24
|
-
"save the file to ~/Downloads/lexware/ by default and return the path."
|
|
24
|
+
"save the file to ~/Downloads/lexware/ by default and return the path. "
|
|
25
|
+
"When creating documents (invoice/quotation/order confirmation/delivery "
|
|
26
|
+
"note) with line items based on an article: fetch the article first and "
|
|
27
|
+
"copy its `description` into the line item's optional `description` "
|
|
28
|
+
"field — otherwise the article description is missing on the printed "
|
|
29
|
+
"document."
|
|
25
30
|
),
|
|
26
31
|
)
|
|
27
32
|
|
|
@@ -314,7 +319,12 @@ def download_delivery_note_pdf(identifier: str, output_dir: str | None = None) -
|
|
|
314
319
|
|
|
315
320
|
@mcp.tool
|
|
316
321
|
def create_invoice_draft(body: dict[str, Any]) -> dict[str, Any]:
|
|
317
|
-
"""Create an invoice as draft. See https://developers.lexware.io/docs/ for body schema.
|
|
322
|
+
"""Create an invoice as draft. See https://developers.lexware.io/docs/ for body schema.
|
|
323
|
+
|
|
324
|
+
Line items support an optional `description` (long text shown under the
|
|
325
|
+
position name on the PDF). When a line item is based on an article, copy
|
|
326
|
+
the article's `description` into it.
|
|
327
|
+
"""
|
|
318
328
|
return services.create_invoice(_client_get(), body)
|
|
319
329
|
|
|
320
330
|
|
|
@@ -342,19 +352,34 @@ def create_article(body: dict[str, Any]) -> dict[str, Any]:
|
|
|
342
352
|
|
|
343
353
|
@mcp.tool
|
|
344
354
|
def create_quotation_draft(body: dict[str, Any]) -> dict[str, Any]:
|
|
345
|
-
"""Create a quotation as draft.
|
|
355
|
+
"""Create a quotation as draft.
|
|
356
|
+
|
|
357
|
+
Line items support an optional `description` (long text shown under the
|
|
358
|
+
position name on the PDF). When a line item is based on an article, copy
|
|
359
|
+
the article's `description` into it.
|
|
360
|
+
"""
|
|
346
361
|
return services.create_quotation(_client_get(), body)
|
|
347
362
|
|
|
348
363
|
|
|
349
364
|
@mcp.tool
|
|
350
365
|
def create_order_confirmation_draft(body: dict[str, Any]) -> dict[str, Any]:
|
|
351
|
-
"""Create an order confirmation (Auftrag) as draft.
|
|
366
|
+
"""Create an order confirmation (Auftrag) as draft.
|
|
367
|
+
|
|
368
|
+
Line items support an optional `description` (long text shown under the
|
|
369
|
+
position name on the PDF). When a line item is based on an article, copy
|
|
370
|
+
the article's `description` into it.
|
|
371
|
+
"""
|
|
352
372
|
return services.create_order_confirmation(_client_get(), body)
|
|
353
373
|
|
|
354
374
|
|
|
355
375
|
@mcp.tool
|
|
356
376
|
def create_delivery_note_draft(body: dict[str, Any]) -> dict[str, Any]:
|
|
357
|
-
"""Create a delivery note as draft.
|
|
377
|
+
"""Create a delivery note as draft.
|
|
378
|
+
|
|
379
|
+
Line items support an optional `description` (long text shown under the
|
|
380
|
+
position name on the PDF). When a line item is based on an article, copy
|
|
381
|
+
the article's `description` into it.
|
|
382
|
+
"""
|
|
358
383
|
return services.create_delivery_note(_client_get(), body)
|
|
359
384
|
|
|
360
385
|
|
|
@@ -79,6 +79,83 @@ def test_mcp_install_claude_keeps_key_out_of_argv(
|
|
|
79
79
|
assert "LEXWARE_API_KEY=test-key" in global_env_path().read_text(encoding="utf-8")
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def desktop_config(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
|
84
|
+
"""Point the desktop config at a tmp file pre-filled with foreign entries."""
|
|
85
|
+
import lxw_cli.commands.mcp as mcp_cmd_mod
|
|
86
|
+
|
|
87
|
+
monkeypatch.setenv("LEXWARE_CONFIG_DIR", str(tmp_path / "cfg"))
|
|
88
|
+
monkeypatch.setattr(
|
|
89
|
+
mcp_cmd_mod.shutil, "which", lambda name: f"/usr/bin/{name}"
|
|
90
|
+
)
|
|
91
|
+
path = tmp_path / "Claude" / "claude_desktop_config.json"
|
|
92
|
+
path.parent.mkdir(parents=True)
|
|
93
|
+
path.write_text(
|
|
94
|
+
json.dumps(
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {"other": {"command": "npx", "args": ["x"]}},
|
|
97
|
+
"preferences": {"keep": True},
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
monkeypatch.setenv("CLAUDE_DESKTOP_CONFIG", str(path))
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_mcp_install_desktop_writes_config(runner: CliRunner, desktop_config) -> None:
|
|
107
|
+
result = runner.invoke(app, ["mcp", "install-desktop"])
|
|
108
|
+
assert result.exit_code == 0, result.stderr
|
|
109
|
+
|
|
110
|
+
data = json.loads(desktop_config.read_text(encoding="utf-8"))
|
|
111
|
+
entry = data["mcpServers"]["lexware"]
|
|
112
|
+
# Absolute command path — Desktop doesn't inherit the shell PATH.
|
|
113
|
+
assert entry["command"] == "/usr/bin/lxw-mcp"
|
|
114
|
+
# Foreign servers and unrelated settings survive the merge untouched.
|
|
115
|
+
assert data["mcpServers"]["other"] == {"command": "npx", "args": ["x"]}
|
|
116
|
+
assert data["preferences"] == {"keep": True}
|
|
117
|
+
# The API key never lands in the desktop config …
|
|
118
|
+
assert "test-key" not in desktop_config.read_text(encoding="utf-8")
|
|
119
|
+
# … but in the global .env, where the server reads it.
|
|
120
|
+
from lxw_cli.config import global_env_path
|
|
121
|
+
|
|
122
|
+
assert "LEXWARE_API_KEY=test-key" in global_env_path().read_text(encoding="utf-8")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_mcp_install_desktop_existing_needs_force(
|
|
126
|
+
runner: CliRunner, desktop_config
|
|
127
|
+
) -> None:
|
|
128
|
+
assert runner.invoke(app, ["mcp", "install-desktop"]).exit_code == 0
|
|
129
|
+
# Second run without --force refuses …
|
|
130
|
+
assert runner.invoke(app, ["mcp", "install-desktop"]).exit_code == 1
|
|
131
|
+
# … with --force it overwrites.
|
|
132
|
+
result = runner.invoke(app, ["mcp", "install-desktop", "--force"])
|
|
133
|
+
assert result.exit_code == 0, result.stderr
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_mcp_uninstall_desktop_removes_only_lexware(
|
|
137
|
+
runner: CliRunner, desktop_config
|
|
138
|
+
) -> None:
|
|
139
|
+
assert runner.invoke(app, ["mcp", "install-desktop"]).exit_code == 0
|
|
140
|
+
result = runner.invoke(app, ["mcp", "uninstall-desktop"])
|
|
141
|
+
assert result.exit_code == 0, result.stderr
|
|
142
|
+
data = json.loads(desktop_config.read_text(encoding="utf-8"))
|
|
143
|
+
assert "lexware" not in data["mcpServers"]
|
|
144
|
+
assert "other" in data["mcpServers"]
|
|
145
|
+
# Removing again is a clean no-op error, not a crash.
|
|
146
|
+
assert runner.invoke(app, ["mcp", "uninstall-desktop"]).exit_code == 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_mcp_install_desktop_leaves_broken_json_untouched(
|
|
150
|
+
runner: CliRunner, desktop_config
|
|
151
|
+
) -> None:
|
|
152
|
+
desktop_config.write_text("{not json", encoding="utf-8")
|
|
153
|
+
result = runner.invoke(app, ["mcp", "install-desktop"])
|
|
154
|
+
assert result.exit_code == 2
|
|
155
|
+
# The corrupt file was not overwritten.
|
|
156
|
+
assert desktop_config.read_text(encoding="utf-8") == "{not json"
|
|
157
|
+
|
|
158
|
+
|
|
82
159
|
@respx.mock
|
|
83
160
|
def test_invoices_list_default_caps_at_25(runner: CliRunner) -> None:
|
|
84
161
|
page0 = [{"id": f"a{i}"} for i in range(100)]
|