nocfo-cli 1.0.1__tar.gz → 1.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.
Files changed (30) hide show
  1. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/PKG-INFO +27 -7
  2. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/README.md +26 -6
  3. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/pyproject.toml +1 -1
  4. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/_helpers.py +34 -3
  5. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/accounts.py +7 -0
  6. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/businesses.py +15 -1
  7. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/contacts.py +7 -0
  8. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/documents.py +7 -0
  9. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/invoices.py +7 -0
  10. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/products.py +7 -0
  11. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +7 -0
  12. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/reports.py +116 -12
  13. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/tags.py +7 -0
  14. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/output.py +42 -6
  15. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/auth.py +119 -7
  16. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/LICENSE +0 -0
  17. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/__init__.py +0 -0
  18. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/api_client.py +0 -0
  19. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
  20. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/app.py +0 -0
  21. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  22. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  23. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  24. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
  25. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  26. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/context.py +0 -0
  27. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/config.py +0 -0
  28. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/__init__.py +0 -0
  29. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/server.py +0 -0
  30. {nocfo_cli-1.0.1 → nocfo_cli-1.2.0}/src/nocfo_toolkit/openapi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nocfo-cli
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -117,7 +117,7 @@ Open Claude Desktop config and add:
117
117
  "mcpServers": {
118
118
  "nocfo": {
119
119
  "command": "uvx",
120
- "args": ["nocfo-cli", "mcp"],
120
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
121
121
  "env": {
122
122
  "NOCFO_API_TOKEN": "your_token_here"
123
123
  }
@@ -140,10 +140,23 @@ Then restart Claude Desktop.
140
140
 
141
141
  ## Local Setup (Cursor)
142
142
 
143
- 1. Open **Cursor Settings MCP Add Server**
144
- 2. Use command: `nocfo mcp` (or `uvx nocfo-cli mcp`)
145
- 3. Add env var `NOCFO_API_TOKEN=<your_token>`
146
- 4. Save and test with a simple prompt like "List my businesses"
143
+ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "nocfo": {
149
+ "command": "uvx",
150
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
151
+ "env": {
152
+ "NOCFO_API_TOKEN": "your_token_here"
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ Then test with a simple prompt like "List my businesses".
147
160
 
148
161
  ---
149
162
 
@@ -153,7 +166,14 @@ Then restart Claude Desktop.
153
166
  nocfo user me
154
167
  nocfo businesses list
155
168
  nocfo invoices list --business <business_slug>
156
- nocfo reports balance-sheet --business <business_slug> --date-to 2026-12-31
169
+ nocfo reports balance-sheet --business <business_slug> --date-at 2026-12-31
170
+ nocfo reports balance-sheet-short --business <business_slug> --date-at 2026-12-31
171
+ nocfo reports income-statement --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
172
+ nocfo reports income-statement-short --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
173
+ nocfo reports ledger --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
174
+ nocfo reports journal --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
175
+ nocfo reports vat --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
176
+ nocfo reports equity-changes --business <business_slug> --date-at 2026-12-31
157
177
  ```
158
178
 
159
179
  JSON output:
@@ -92,7 +92,7 @@ Open Claude Desktop config and add:
92
92
  "mcpServers": {
93
93
  "nocfo": {
94
94
  "command": "uvx",
95
- "args": ["nocfo-cli", "mcp"],
95
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
96
96
  "env": {
97
97
  "NOCFO_API_TOKEN": "your_token_here"
98
98
  }
@@ -115,10 +115,23 @@ Then restart Claude Desktop.
115
115
 
116
116
  ## Local Setup (Cursor)
117
117
 
118
- 1. Open **Cursor Settings MCP Add Server**
119
- 2. Use command: `nocfo mcp` (or `uvx nocfo-cli mcp`)
120
- 3. Add env var `NOCFO_API_TOKEN=<your_token>`
121
- 4. Save and test with a simple prompt like "List my businesses"
118
+ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "nocfo": {
124
+ "command": "uvx",
125
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
126
+ "env": {
127
+ "NOCFO_API_TOKEN": "your_token_here"
128
+ }
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ Then test with a simple prompt like "List my businesses".
122
135
 
123
136
  ---
124
137
 
@@ -128,7 +141,14 @@ Then restart Claude Desktop.
128
141
  nocfo user me
129
142
  nocfo businesses list
130
143
  nocfo invoices list --business <business_slug>
131
- nocfo reports balance-sheet --business <business_slug> --date-to 2026-12-31
144
+ nocfo reports balance-sheet --business <business_slug> --date-at 2026-12-31
145
+ nocfo reports balance-sheet-short --business <business_slug> --date-at 2026-12-31
146
+ nocfo reports income-statement --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
147
+ nocfo reports income-statement-short --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
148
+ nocfo reports ledger --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
149
+ nocfo reports journal --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
150
+ nocfo reports vat --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
151
+ nocfo reports equity-changes --business <business_slug> --date-at 2026-12-31
132
152
  ```
133
153
 
134
154
  JSON output:
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "nocfo-cli"
7
- version = "1.0.1"
7
+ version = "1.2.0"
8
8
  description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
9
9
  authors = ["NoCFO"]
10
10
  readme = "README.md"
@@ -4,18 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
+ from collections.abc import Sequence
7
8
  from typing import Any
8
9
 
9
10
  import typer
10
11
 
12
+ from rich.text import Text
13
+
11
14
  from nocfo_toolkit.api_client import NocfoApiError
12
15
  from nocfo_toolkit.cli.context import CommandContext
13
- from nocfo_toolkit.cli.output import print_data, print_error
16
+ from nocfo_toolkit.cli.output import console, print_data, print_error
14
17
 
15
18
  _CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]")
16
19
  _SAFE_QUERY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]+$")
17
20
  _MAX_JSON_BODY_CHARS = 200_000
18
21
  _READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
22
+ _DEFAULT_PAGE_SIZE = 20
19
23
 
20
24
 
21
25
  def parse_key_value_pairs(pairs: list[str] | None) -> dict[str, Any]:
@@ -70,13 +74,40 @@ async def run_list(
70
74
  *,
71
75
  path: str,
72
76
  params: dict[str, Any] | None = None,
77
+ columns: Sequence[str] | None = None,
78
+ page_size: int = _DEFAULT_PAGE_SIZE,
79
+ fetch_all: bool = False,
73
80
  ) -> None:
74
81
  """Execute list command and print output."""
75
82
 
76
83
  client = command_ctx.api_client()
77
84
  try:
78
- results = await client.list_paginated(path, params=params)
79
- print_data(results, command_ctx.config.output_format)
85
+ if fetch_all:
86
+ results = await client.list_paginated(path, params=params)
87
+ print_data(results, command_ctx.config.output_format, columns=columns)
88
+ return
89
+
90
+ query = dict(params or {})
91
+ query.setdefault("page_size", page_size)
92
+ query.setdefault("page", 1)
93
+ page_data = await client.request("GET", path, params=query)
94
+
95
+ if isinstance(page_data, dict) and "results" in page_data:
96
+ results = page_data.get("results") or []
97
+ count = page_data.get("count", len(results))
98
+ print_data(results, command_ctx.config.output_format, columns=columns)
99
+ if page_data.get("next"):
100
+ console.print(
101
+ Text(
102
+ f"Showing {len(results)} of {count} results. "
103
+ "Use --all to fetch everything or --limit N to adjust.",
104
+ style="dim",
105
+ )
106
+ )
107
+ elif isinstance(page_data, list):
108
+ print_data(page_data, command_ctx.config.output_format, columns=columns)
109
+ else:
110
+ raise NocfoApiError("Expected list or paginated object response.")
80
111
  except NocfoApiError as exc:
81
112
  print_error(str(exc))
82
113
  raise typer.Exit(code=1) from exc
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage accounts.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "number", "name", "type", "default_vat_rate")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_accounts(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query", help="Filters as key=value."),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_accounts(
27
31
  command_ctx,
28
32
  path=f"/v1/business/{business}/account/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -15,14 +15,28 @@ from nocfo_toolkit.cli.commands._helpers import (
15
15
  app = typer.Typer(help="Manage businesses.")
16
16
 
17
17
 
18
+ _LIST_COLUMNS = ("slug", "name", "country", "created_at")
19
+
20
+
18
21
  @app.command("list")
19
22
  def list_businesses(
20
23
  ctx: typer.Context,
21
24
  query: list[str] = typer.Option(None, "--query", help="Filters as key=value."),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
22
27
  ) -> None:
23
28
  command_ctx = get_context(ctx)
24
29
  params = parse_key_value_pairs(query)
25
- run_async(run_list(command_ctx, path="/v1/business/", params=params))
30
+ run_async(
31
+ run_list(
32
+ command_ctx,
33
+ path="/v1/business/",
34
+ params=params,
35
+ columns=_LIST_COLUMNS,
36
+ page_size=limit,
37
+ fetch_all=all_pages,
38
+ )
39
+ )
26
40
 
27
41
 
28
42
  @app.command("get")
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage contacts.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "name", "contact_business_id", "type", "invoicing_email")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_contacts(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_contacts(
27
31
  command_ctx,
28
32
  path=f"/v1/business/{business}/contacts/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage bookkeeping documents.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "number", "date", "description", "balance")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_documents(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_documents(
27
31
  command_ctx,
28
32
  path=f"/v1/business/{business}/document/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage sales invoices.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "invoice_number", "friendly_name", "total_amount", "status")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_invoices(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_invoices(
27
31
  command_ctx,
28
32
  path=f"/v1/invoicing/{business}/invoice/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage invoicing products.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "code", "name", "amount", "vat_rate")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_products(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_products(
27
31
  command_ctx,
28
32
  path=f"/v1/invoicing/{business}/product/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage purchase invoices.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "sender_name", "invoicing_date", "amount", "is_paid")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_purchase_invoices(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_purchase_invoices(
27
31
  command_ctx,
28
32
  path=f"/v1/invoicing/{business}/purchase_invoice/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from typing import Any
6
7
 
7
8
  import typer
8
9
 
9
- from nocfo_toolkit.cli.commands._helpers import run_request
10
+ from nocfo_toolkit.api_client import NocfoApiError
10
11
  from nocfo_toolkit.cli.context import get_context, run_async
12
+ from nocfo_toolkit.cli.output import print_data, print_error
11
13
 
12
14
  app = typer.Typer(help="Generate accounting reports.")
13
15
 
@@ -16,7 +18,7 @@ def _run_json_report(
16
18
  ctx: typer.Context,
17
19
  *,
18
20
  business: str,
19
- report_type: str,
21
+ path: str,
20
22
  columns: list[dict[str, Any]],
21
23
  extend_accounts: bool,
22
24
  append_comparison_columns: bool,
@@ -24,7 +26,6 @@ def _run_json_report(
24
26
  ) -> None:
25
27
  command_ctx = get_context(ctx)
26
28
  body: dict[str, Any] = {
27
- "type": report_type,
28
29
  "columns": columns,
29
30
  "extend_accounts": extend_accounts,
30
31
  "append_comparison_columns": append_comparison_columns,
@@ -33,15 +34,45 @@ def _run_json_report(
33
34
  body["tag_ids"] = tag_ids
34
35
 
35
36
  run_async(
36
- run_request(
37
- command_ctx,
38
- method="POST",
39
- path=f"/v1/business/{business}/report/json/",
37
+ _run_report_request(
38
+ command_ctx=command_ctx,
39
+ path=f"/v1/business/{business}/report/{path}/",
40
40
  body=body,
41
41
  )
42
42
  )
43
43
 
44
44
 
45
+ async def _run_report_request(
46
+ *,
47
+ command_ctx,
48
+ path: str,
49
+ body: dict[str, Any],
50
+ ) -> None:
51
+ client = command_ctx.api_client()
52
+ try:
53
+ result = await client.request("POST", path, json_body=body)
54
+
55
+ # Some report endpoints can return JSON encoded as a string.
56
+ if isinstance(result, str):
57
+ try:
58
+ parsed = json.loads(result)
59
+ if isinstance(parsed, (dict, list)):
60
+ result = parsed
61
+ except json.JSONDecodeError:
62
+ pass
63
+
64
+ if isinstance(result, dict):
65
+ result.pop("report_type", None)
66
+
67
+ if result is not None:
68
+ print_data(result, command_ctx.config.output_format)
69
+ except NocfoApiError as exc:
70
+ print_error(str(exc))
71
+ raise typer.Exit(code=1) from exc
72
+ finally:
73
+ await client.close()
74
+
75
+
45
76
  @app.command("balance-sheet")
46
77
  def balance_sheet(
47
78
  ctx: typer.Context,
@@ -58,7 +89,7 @@ def balance_sheet(
58
89
  _run_json_report(
59
90
  ctx=ctx,
60
91
  business=business,
61
- report_type="BALANCE_SHEET",
92
+ path="balance-sheet",
62
93
  columns=[{"date_at": date_at}],
63
94
  extend_accounts=extend_accounts,
64
95
  append_comparison_columns=append_comparison_columns,
@@ -83,7 +114,7 @@ def income_statement(
83
114
  _run_json_report(
84
115
  ctx=ctx,
85
116
  business=business,
86
- report_type="INCOME_STATEMENT",
117
+ path="income-statement",
87
118
  columns=[{"date_from": date_from, "date_to": date_to}],
88
119
  extend_accounts=extend_accounts,
89
120
  append_comparison_columns=append_comparison_columns,
@@ -102,7 +133,7 @@ def ledger(
102
133
  _run_json_report(
103
134
  ctx=ctx,
104
135
  business=business,
105
- report_type="LEDGER",
136
+ path="ledger",
106
137
  columns=[{"date_from": date_from, "date_to": date_to}],
107
138
  extend_accounts=False,
108
139
  append_comparison_columns=False,
@@ -121,7 +152,7 @@ def journal(
121
152
  _run_json_report(
122
153
  ctx=ctx,
123
154
  business=business,
124
- report_type="JOURNAL",
155
+ path="journal",
125
156
  columns=[{"date_from": date_from, "date_to": date_to}],
126
157
  extend_accounts=False,
127
158
  append_comparison_columns=False,
@@ -140,9 +171,82 @@ def vat(
140
171
  _run_json_report(
141
172
  ctx=ctx,
142
173
  business=business,
143
- report_type="VAT_REPORT",
174
+ path="vat-report",
144
175
  columns=[{"date_from": date_from, "date_to": date_to}],
145
176
  extend_accounts=False,
146
177
  append_comparison_columns=False,
147
178
  tag_ids=tag_id or None,
148
179
  )
180
+
181
+
182
+ @app.command("balance-sheet-short")
183
+ def balance_sheet_short(
184
+ ctx: typer.Context,
185
+ business: str = typer.Option(..., "--business"),
186
+ date_at: str = typer.Option(..., "--date-at"),
187
+ extend_accounts: bool = typer.Option(
188
+ True, "--extend-accounts/--no-extend-accounts"
189
+ ),
190
+ append_comparison_columns: bool = typer.Option(
191
+ True, "--append-comparison-columns/--no-append-comparison-columns"
192
+ ),
193
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
194
+ ) -> None:
195
+ _run_json_report(
196
+ ctx=ctx,
197
+ business=business,
198
+ path="balance-sheet-short",
199
+ columns=[{"date_at": date_at}],
200
+ extend_accounts=extend_accounts,
201
+ append_comparison_columns=append_comparison_columns,
202
+ tag_ids=tag_id or None,
203
+ )
204
+
205
+
206
+ @app.command("income-statement-short")
207
+ def income_statement_short(
208
+ ctx: typer.Context,
209
+ business: str = typer.Option(..., "--business"),
210
+ date_from: str = typer.Option(..., "--date-from"),
211
+ date_to: str = typer.Option(..., "--date-to"),
212
+ extend_accounts: bool = typer.Option(
213
+ True, "--extend-accounts/--no-extend-accounts"
214
+ ),
215
+ append_comparison_columns: bool = typer.Option(
216
+ True, "--append-comparison-columns/--no-append-comparison-columns"
217
+ ),
218
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
219
+ ) -> None:
220
+ _run_json_report(
221
+ ctx=ctx,
222
+ business=business,
223
+ path="income-statement-short",
224
+ columns=[{"date_from": date_from, "date_to": date_to}],
225
+ extend_accounts=extend_accounts,
226
+ append_comparison_columns=append_comparison_columns,
227
+ tag_ids=tag_id or None,
228
+ )
229
+
230
+
231
+ @app.command("equity-changes")
232
+ def equity_changes(
233
+ ctx: typer.Context,
234
+ business: str = typer.Option(..., "--business"),
235
+ date_at: str = typer.Option(..., "--date-at"),
236
+ extend_accounts: bool = typer.Option(
237
+ False, "--extend-accounts/--no-extend-accounts"
238
+ ),
239
+ append_comparison_columns: bool = typer.Option(
240
+ False, "--append-comparison-columns/--no-append-comparison-columns"
241
+ ),
242
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
243
+ ) -> None:
244
+ _run_json_report(
245
+ ctx=ctx,
246
+ business=business,
247
+ path="equity-changes",
248
+ columns=[{"date_at": date_at}],
249
+ extend_accounts=extend_accounts,
250
+ append_comparison_columns=append_comparison_columns,
251
+ tag_ids=tag_id or None,
252
+ )
@@ -14,12 +14,16 @@ from nocfo_toolkit.cli.commands._helpers import (
14
14
 
15
15
  app = typer.Typer(help="Manage tags.")
16
16
 
17
+ _LIST_COLUMNS = ("id", "name", "color")
18
+
17
19
 
18
20
  @app.command("list")
19
21
  def list_tags(
20
22
  ctx: typer.Context,
21
23
  business: str = typer.Option(..., "--business"),
22
24
  query: list[str] = typer.Option(None, "--query"),
25
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results per page."),
26
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
23
27
  ) -> None:
24
28
  command_ctx = get_context(ctx)
25
29
  run_async(
@@ -27,6 +31,9 @@ def list_tags(
27
31
  command_ctx,
28
32
  path=f"/v1/business/{business}/tags/",
29
33
  params=parse_key_value_pairs(query),
34
+ columns=_LIST_COLUMNS,
35
+ page_size=limit,
36
+ fetch_all=all_pages,
30
37
  )
31
38
  )
32
39
 
@@ -3,25 +3,34 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from collections.abc import Sequence
6
7
  from typing import Any
7
8
 
8
9
  from rich.console import Console
9
10
  from rich.table import Table
11
+ from rich.text import Text
10
12
 
11
13
  from nocfo_toolkit.config import OutputFormat
12
14
 
13
15
  console = Console()
14
16
 
17
+ _MAX_TABLE_COLUMNS = 10
15
18
 
16
- def print_data(data: Any, output_format: OutputFormat = OutputFormat.TABLE) -> None:
19
+
20
+ def print_data(
21
+ data: Any,
22
+ output_format: OutputFormat = OutputFormat.TABLE,
23
+ *,
24
+ columns: Sequence[str] | None = None,
25
+ ) -> None:
17
26
  """Render data as JSON or a table-like output."""
18
27
 
19
28
  if output_format == OutputFormat.JSON:
20
- console.print_json(data=json.dumps(data, default=str))
29
+ console.print_json(json=json.dumps(data, default=str))
21
30
  return
22
31
 
23
32
  if isinstance(data, list):
24
- _print_list(data)
33
+ _print_list(data, columns=columns)
25
34
  return
26
35
 
27
36
  if isinstance(data, dict):
@@ -46,19 +55,43 @@ def _print_dict(data: dict[str, Any]) -> None:
46
55
  console.print(table)
47
56
 
48
57
 
49
- def _print_list(items: list[Any]) -> None:
58
+ def _print_list(
59
+ items: list[Any],
60
+ *,
61
+ columns: Sequence[str] | None = None,
62
+ ) -> None:
50
63
  if not items:
51
64
  console.print("No results.")
52
65
  return
53
66
 
54
67
  if all(isinstance(item, dict) for item in items):
55
- keys = sorted({key for item in items for key in item.keys()})
68
+ all_keys = sorted({key for item in items for key in item.keys()})
69
+
70
+ if columns:
71
+ keys = [k for k in columns if k in all_keys]
72
+ else:
73
+ keys = all_keys
74
+
75
+ total = len(all_keys)
76
+ truncated = len(keys) > _MAX_TABLE_COLUMNS
77
+ if truncated:
78
+ keys = keys[:_MAX_TABLE_COLUMNS]
79
+
56
80
  table = Table(show_header=True, header_style="bold")
57
81
  for key in keys:
58
82
  table.add_column(str(key))
59
83
  for item in items:
60
84
  table.add_row(*[_value_to_text(item.get(key)) for key in keys])
61
85
  console.print(table)
86
+
87
+ if truncated:
88
+ console.print(
89
+ Text(
90
+ f"Showing {len(keys)} of {total} fields. "
91
+ "Use --output json to see all.",
92
+ style="dim",
93
+ )
94
+ )
62
95
  return
63
96
 
64
97
  for item in items:
@@ -69,5 +102,8 @@ def _value_to_text(value: Any) -> str:
69
102
  if value is None:
70
103
  return ""
71
104
  if isinstance(value, (dict, list)):
72
- return json.dumps(value, ensure_ascii=True)
105
+ text = json.dumps(value, ensure_ascii=False, default=str)
106
+ if len(text) > 60:
107
+ return text[:57] + "..."
108
+ return text
73
109
  return str(value)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import hashlib
7
+ import inspect
7
8
  import json
8
9
  import os
9
10
  import time
@@ -11,11 +12,13 @@ from dataclasses import dataclass
11
12
  from typing import Any, Literal, cast
12
13
 
13
14
  import httpx
14
- from fastmcp.server.auth import RemoteAuthProvider
15
+ from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
15
16
  from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
16
17
  from fastmcp.server.auth.providers.jwt import JWTVerifier
17
18
  from fastmcp.server.dependencies import get_access_token
18
19
  from fastmcp.tools.tool import Tool
20
+ from starlette.responses import JSONResponse
21
+ from starlette.routing import Route
19
22
 
20
23
  from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
21
24
 
@@ -54,7 +57,7 @@ class RemoteOAuthConfig:
54
57
  """OAuth verifier + metadata configuration for remote MCP auth."""
55
58
 
56
59
  authorization_servers: tuple[str, ...]
57
- verifier_mode: Literal["jwt", "introspection"]
60
+ verifier_mode: Literal["jwt", "introspection", "userinfo"]
58
61
  jwt_jwks_uri: str | None
59
62
  jwt_issuer: str | None
60
63
  jwt_audience: tuple[str, ...]
@@ -64,14 +67,16 @@ class RemoteOAuthConfig:
64
67
  introspection_client_auth_method: Literal[
65
68
  "client_secret_basic", "client_secret_post"
66
69
  ]
70
+ userinfo_url: str | None
67
71
  required_scopes: tuple[str, ...]
68
72
 
69
73
  @classmethod
70
74
  def from_env(cls, config: ToolkitConfig) -> RemoteOAuthConfig:
71
75
  verifier_mode = (_env("NOCFO_MCP_TOKEN_VERIFIER") or "jwt").lower()
72
- if verifier_mode not in {"jwt", "introspection"}:
76
+ if verifier_mode not in {"jwt", "introspection", "userinfo"}:
73
77
  raise MCPAuthConfigurationError(
74
- "NOCFO_MCP_TOKEN_VERIFIER must be 'jwt' or 'introspection'."
78
+ "NOCFO_MCP_TOKEN_VERIFIER must be 'jwt', 'introspection', or "
79
+ "'userinfo'."
75
80
  )
76
81
 
77
82
  authorization_servers = tuple(
@@ -82,7 +87,7 @@ class RemoteOAuthConfig:
82
87
  jwt_audience = tuple(_split_csv(_env("NOCFO_MCP_JWT_AUDIENCE")))
83
88
 
84
89
  verifier_mode_typed = cast(
85
- Literal["jwt", "introspection"],
90
+ Literal["jwt", "introspection", "userinfo"],
86
91
  verifier_mode,
87
92
  )
88
93
  introspection_client_auth_method = (
@@ -109,6 +114,8 @@ class RemoteOAuthConfig:
109
114
  Literal["client_secret_basic", "client_secret_post"],
110
115
  introspection_client_auth_method,
111
116
  ),
117
+ userinfo_url=_env("NOCFO_MCP_USERINFO_URL")
118
+ or f"{config.base_url.rstrip('/')}/identity/o/api/userinfo",
112
119
  required_scopes=required_scopes,
113
120
  )
114
121
 
@@ -125,6 +132,16 @@ class RemoteOAuthConfig:
125
132
  required_scopes=list(self.required_scopes) or None,
126
133
  )
127
134
 
135
+ if self.verifier_mode == "userinfo":
136
+ if not self.userinfo_url:
137
+ raise MCPAuthConfigurationError(
138
+ "Missing NOCFO_MCP_USERINFO_URL for userinfo verifier mode."
139
+ )
140
+ return UserInfoTokenVerifier(
141
+ userinfo_url=self.userinfo_url,
142
+ required_scopes=list(self.required_scopes) or None,
143
+ )
144
+
128
145
  if not self.introspection_url:
129
146
  raise MCPAuthConfigurationError(
130
147
  "Missing NOCFO_MCP_INTROSPECTION_URL for introspection verifier mode."
@@ -145,6 +162,55 @@ class RemoteOAuthConfig:
145
162
  )
146
163
 
147
164
 
165
+ class UserInfoTokenVerifier(TokenVerifier):
166
+ """Validate opaque OAuth access tokens via OIDC userinfo endpoint."""
167
+
168
+ def __init__(
169
+ self, *, userinfo_url: str, required_scopes: list[str] | None = None
170
+ ) -> None:
171
+ super().__init__(required_scopes=required_scopes)
172
+ self._userinfo_url = userinfo_url
173
+
174
+ async def verify_token(self, token: str) -> AccessToken | None:
175
+ try:
176
+ async with httpx.AsyncClient(timeout=5.0) as client:
177
+ response = await client.get(
178
+ self._userinfo_url,
179
+ headers={
180
+ "Authorization": f"Bearer {token}",
181
+ "Accept": "application/json",
182
+ },
183
+ )
184
+ except httpx.HTTPError:
185
+ return None
186
+
187
+ if response.status_code != 200:
188
+ return None
189
+
190
+ payload = response.json() if response.content else {}
191
+ if not isinstance(payload, dict):
192
+ return None
193
+
194
+ # allauth returns opaque access tokens; if scopes are not explicitly
195
+ # included in userinfo payload, treat configured required scopes as granted.
196
+ scope_value = payload.get("scope")
197
+ scopes = (
198
+ [s for s in scope_value.split(" ") if s]
199
+ if isinstance(scope_value, str) and scope_value.strip()
200
+ else list(self.required_scopes or [])
201
+ )
202
+ if self.required_scopes and not set(self.required_scopes).issubset(set(scopes)):
203
+ return None
204
+
205
+ client_id = payload.get("azp") or payload.get("client_id") or "nocfo-userinfo"
206
+ return AccessToken(
207
+ token=token,
208
+ client_id=str(client_id),
209
+ scopes=scopes,
210
+ claims=payload,
211
+ )
212
+
213
+
148
214
  class JwtExchangeAuth(httpx.Auth):
149
215
  """Exchange incoming OAuth bearer to NoCFO JWT for downstream API calls."""
150
216
 
@@ -239,11 +305,57 @@ class JwtExchangeAuth(httpx.Auth):
239
305
  yield request
240
306
 
241
307
 
308
+ class _CleanUrlAuthProvider(RemoteAuthProvider):
309
+ """RemoteAuthProvider that strips Pydantic AnyHttpUrl trailing slashes.
310
+
311
+ Pydantic v2 normalises bare-host URLs (``https://host`` →
312
+ ``https://host/``). MCP clients concatenate this with
313
+ ``/.well-known/…`` paths, producing double-slash URLs that 404 on
314
+ most identity providers. This subclass wraps the protected-resource
315
+ metadata route so the serialised JSON contains slash-free URLs.
316
+ """
317
+
318
+ def get_routes(self, mcp_path: str | None = None) -> list[Route]:
319
+ routes = super().get_routes(mcp_path)
320
+ return [self._clean_metadata_route(r) for r in routes]
321
+
322
+ @staticmethod
323
+ def _clean_metadata_route(route: Route) -> Route:
324
+ if "oauth-protected-resource" not in (route.path or ""):
325
+ return route
326
+
327
+ original = route.endpoint
328
+ # Some Starlette routes wrap endpoints as ASGI callables
329
+ # (scope, receive, send). Only wrap request-style endpoints.
330
+ try:
331
+ if len(inspect.signature(original).parameters) != 1:
332
+ return route
333
+ except (TypeError, ValueError):
334
+ return route
335
+
336
+ async def _strip_trailing_slashes(request): # type: ignore[no-untyped-def]
337
+ response = await original(request)
338
+ body = json.loads(response.body)
339
+ for key in ("resource", "authorization_servers"):
340
+ val = body.get(key)
341
+ if isinstance(val, str):
342
+ body[key] = val.rstrip("/")
343
+ elif isinstance(val, list):
344
+ body[key] = [
345
+ v.rstrip("/") if isinstance(v, str) else v for v in val
346
+ ]
347
+ return JSONResponse(body, headers=dict(response.headers))
348
+
349
+ return Route(
350
+ route.path, endpoint=_strip_trailing_slashes, methods=route.methods
351
+ )
352
+
353
+
242
354
  def build_remote_auth_provider(
243
355
  *,
244
356
  config: ToolkitConfig,
245
357
  options: MCPAuthOptions,
246
- ) -> RemoteAuthProvider:
358
+ ) -> _CleanUrlAuthProvider:
247
359
  """Create a FastMCP RemoteAuthProvider for connector OAuth bearer verification."""
248
360
 
249
361
  if options.mode != "oauth":
@@ -258,7 +370,7 @@ def build_remote_auth_provider(
258
370
 
259
371
  remote = RemoteOAuthConfig.from_env(config)
260
372
  verifier = remote.build_verifier()
261
- return RemoteAuthProvider(
373
+ return _CleanUrlAuthProvider(
262
374
  token_verifier=verifier,
263
375
  authorization_servers=list(remote.authorization_servers),
264
376
  base_url=options.mcp_base_url,
File without changes