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.
Files changed (55) hide show
  1. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/PKG-INFO +31 -9
  2. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/README.md +30 -8
  3. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/pyproject.toml +1 -1
  4. lxw_cli-0.1.2/src/lxw_cli/__init__.py +8 -0
  5. lxw_cli-0.1.2/src/lxw_cli/commands/mcp.py +293 -0
  6. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/config.py +3 -1
  7. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/services.py +11 -7
  8. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/mcp_server.py +30 -5
  9. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_cli.py +77 -0
  10. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_config.py +19 -0
  11. lxw_cli-0.1.2/uv.lock +1873 -0
  12. lxw_cli-0.1.0/.claude/scheduled_tasks.lock +0 -1
  13. lxw_cli-0.1.0/src/lxw_cli/__init__.py +0 -1
  14. lxw_cli-0.1.0/src/lxw_cli/commands/mcp.py +0 -167
  15. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.env.example +0 -0
  16. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.gitattributes +0 -0
  17. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  18. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.github/workflows/test.yml +0 -0
  19. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/.gitignore +0 -0
  20. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/LICENSE +0 -0
  21. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/docs/tui-screenshot.svg +0 -0
  22. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/__main__.py +0 -0
  23. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/cli.py +0 -0
  24. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/__init__.py +0 -0
  25. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/_common.py +0 -0
  26. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/articles.py +0 -0
  27. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/contacts.py +0 -0
  28. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/delivery_notes.py +0 -0
  29. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/invoices.py +0 -0
  30. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/orders.py +0 -0
  31. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/quotations.py +0 -0
  32. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/commands/vouchers.py +0 -0
  33. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/__init__.py +0 -0
  34. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/client.py +0 -0
  35. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/constants.py +0 -0
  36. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/errors.py +0 -0
  37. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/core/models.py +0 -0
  38. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/output.py +0 -0
  39. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/__init__.py +0 -0
  40. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/app.py +0 -0
  41. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/detail.py +0 -0
  42. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/entities.py +0 -0
  43. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/src/lxw_cli/tui/order_form.py +0 -0
  44. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/__init__.py +0 -0
  45. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/conftest.py +0 -0
  46. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/contacts_flat.txt +0 -0
  47. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/contacts_grouped.txt +0 -0
  48. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/snapshots/invoices_list.txt +0 -0
  49. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_cli_snapshots.py +0 -0
  50. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_client.py +0 -0
  51. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_mcp_server.py +0 -0
  52. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_output.py +0 -0
  53. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_resolve.py +0 -0
  54. {lxw_cli-0.1.0 → lxw_cli-0.1.2}/tests/test_services.py +0 -0
  55. {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.0
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** (und Claude Desktop) deine Lexware-Daten direkt abfragen kann.
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
- Das registriert den Server bei Claude Code (Scope `user`, also für alle Projekte). Der API-Key wird dabei **nicht** an Claude übergeben — der Server liest ihn selbst aus `~/.config/lexware/.env` (chmod 600); falls er dort noch fehlt, legt `install-claude` ihn einmalig ab. Danach:
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
- claude mcp list # 'lexware' muss in der Liste erscheinen
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 # zeigt aktuelle Registrierung
379
- lxw mcp install-claude --force # Re-Install (z.B. nach API-Key-Wechsel)
380
- lxw mcp uninstall-claude # entfernen
381
- lxw mcp serve # läuft direkt — wird von Claude Code intern aufgerufen
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** (und Claude Desktop) deine Lexware-Daten direkt abfragen kann.
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
- Das registriert den Server bei Claude Code (Scope `user`, also für alle Projekte). Der API-Key wird dabei **nicht** an Claude übergeben — der Server liest ihn selbst aus `~/.config/lexware/.env` (chmod 600); falls er dort noch fehlt, legt `install-claude` ihn einmalig ab. Danach:
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
- claude mcp list # 'lexware' muss in der Liste erscheinen
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 # zeigt aktuelle Registrierung
344
- lxw mcp install-claude --force # Re-Install (z.B. nach API-Key-Wechsel)
345
- lxw mcp uninstall-claude # entfernen
346
- lxw mcp serve # läuft direkt — wird von Claude Code intern aufgerufen
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lxw-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Inoffizielles CLI/TUI/MCP-Tool für die Lexware Office API (vormals lexoffice)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
- if value is not None and key not in os.environ:
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, unit and
407
- price are copied from the article. Prices are always sent net (the article
408
- record carries `netPrice` regardless of its leading price), which pairs
409
- with `taxConditions.taxType = "net"` in the document body. `net_price`
410
- overrides the article's list price (e.g. a manually edited price in the
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
- return {
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)]