ventilatepro-cli 0.1.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.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: ventilatepro-cli
3
+ Version: 0.1.0
4
+ Summary: VentilatePro CLI
5
+ Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
+ Project-URL: Documentation, https://ventilatepro.com/exam/cli/
7
+ Project-URL: Support, https://ventilatepro.com/exam/support/
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: typer>=0.16.0
11
+ Requires-Dist: requests>=2.32.0
12
+ Requires-Dist: keyring>=25.6.0
13
+ Requires-Dist: platformdirs>=4.3.0
14
+
15
+ # VentilatePro CLI
16
+
17
+ VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
18
+
19
+ ## Install
20
+
21
+ Published releases install from PyPI:
22
+
23
+ ```bash
24
+ pipx install ventilatepro-cli
25
+ ```
26
+
27
+ Upgrade an existing install:
28
+
29
+ ```bash
30
+ pipx upgrade ventilatepro-cli
31
+ ```
32
+
33
+ Local development install:
34
+
35
+ ```bash
36
+ cd ventilatepro-cli
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ ```bash
43
+ ventilatepro auth login --url http://localhost:8000
44
+ ventilatepro projects list
45
+ ventilatepro notes create --project 1 --body "Captured from terminal"
46
+ ventilatepro notes sync
47
+ ```
48
+
49
+ See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
@@ -0,0 +1,35 @@
1
+ # VentilatePro CLI
2
+
3
+ VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
4
+
5
+ ## Install
6
+
7
+ Published releases install from PyPI:
8
+
9
+ ```bash
10
+ pipx install ventilatepro-cli
11
+ ```
12
+
13
+ Upgrade an existing install:
14
+
15
+ ```bash
16
+ pipx upgrade ventilatepro-cli
17
+ ```
18
+
19
+ Local development install:
20
+
21
+ ```bash
22
+ cd ventilatepro-cli
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ ```bash
29
+ ventilatepro auth login --url http://localhost:8000
30
+ ventilatepro projects list
31
+ ventilatepro notes create --project 1 --body "Captured from terminal"
32
+ ventilatepro notes sync
33
+ ```
34
+
35
+ See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ventilatepro-cli"
7
+ dynamic = ["version"]
8
+ description = "VentilatePro CLI"
9
+ requires-python = ">=3.9"
10
+ readme = "README.md"
11
+ dependencies = [
12
+ "typer>=0.16.0",
13
+ "requests>=2.32.0",
14
+ "keyring>=25.6.0",
15
+ "platformdirs>=4.3.0",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://ventilatepro.com/exam/cli/"
20
+ Documentation = "https://ventilatepro.com/exam/cli/"
21
+ Support = "https://ventilatepro.com/exam/support/"
22
+
23
+ [project.scripts]
24
+ ventilatepro = "ventilatepro.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
28
+ include = ["ventilatepro*"]
29
+
30
+ [tool.setuptools.dynamic]
31
+ version = {attr = "ventilatepro.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,171 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from typer.testing import CliRunner
8
+
9
+ from ventilatepro import auth, config
10
+ from ventilatepro.api_client import ApiError, NetworkError
11
+ from ventilatepro.cli import app
12
+ from ventilatepro.queue import QueueStore
13
+
14
+
15
+ class CliTests(unittest.TestCase):
16
+ def setUp(self):
17
+ self.runner = CliRunner()
18
+
19
+ def test_status_json_reports_not_authenticated(self):
20
+ with tempfile.TemporaryDirectory() as tmpdir:
21
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
22
+ os.environ[config.DATA_ENV_VAR] = tmpdir
23
+ try:
24
+ result = self.runner.invoke(app, ["auth", "status", "--json"])
25
+ self.assertEqual(result.exit_code, 0)
26
+ payload = json.loads(result.stdout)
27
+ self.assertFalse(payload["authenticated"])
28
+ finally:
29
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
30
+ os.environ.pop(config.DATA_ENV_VAR, None)
31
+
32
+ def test_notes_create_queues_on_network_error(self):
33
+ with tempfile.TemporaryDirectory() as tmpdir:
34
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
35
+ os.environ[config.DATA_ENV_VAR] = tmpdir
36
+ try:
37
+ config.set_base_url("http://localhost:8000")
38
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
39
+ config.save_token("vpcli_demo_secret")
40
+
41
+ fake_client = mock.Mock()
42
+ fake_client.create_note.side_effect = NetworkError("connection dropped")
43
+
44
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
45
+ result = self.runner.invoke(
46
+ app,
47
+ ["notes", "create", "--project", "1", "--body", "Captured from CLI"],
48
+ )
49
+
50
+ self.assertEqual(result.exit_code, 0)
51
+ queue_store = QueueStore()
52
+ queued = queue_store.list_unsynced()
53
+ self.assertEqual(len(queued), 1)
54
+ self.assertEqual(queued[0].sync_state, "pending")
55
+ finally:
56
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
57
+ os.environ.pop(config.DATA_ENV_VAR, None)
58
+
59
+ def test_notes_create_does_not_queue_validation_errors(self):
60
+ with tempfile.TemporaryDirectory() as tmpdir:
61
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
62
+ os.environ[config.DATA_ENV_VAR] = tmpdir
63
+ try:
64
+ config.set_base_url("http://localhost:8000")
65
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
66
+ config.save_token("vpcli_demo_secret")
67
+
68
+ fake_client = mock.Mock()
69
+ fake_client.create_note.side_effect = ApiError(400, "Validation failed")
70
+
71
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
72
+ result = self.runner.invoke(
73
+ app,
74
+ ["notes", "create", "--project", "1", "--body", "Captured from CLI"],
75
+ )
76
+
77
+ self.assertNotEqual(result.exit_code, 0)
78
+ queue_store = QueueStore()
79
+ self.assertEqual(queue_store.count(), 0)
80
+ finally:
81
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
82
+ os.environ.pop(config.DATA_ENV_VAR, None)
83
+
84
+ def test_notes_create_derives_title_from_first_non_empty_body_line(self):
85
+ fake_client = mock.Mock()
86
+ fake_client.create_note.return_value = {
87
+ "note": {"note_ref": "general-info:1"},
88
+ "audit_entry_id": 12,
89
+ }
90
+
91
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
92
+ result = self.runner.invoke(
93
+ app,
94
+ ["notes", "create", "--project", "1", "--body", "\n\n First captured line \nSecond line"],
95
+ )
96
+
97
+ self.assertEqual(result.exit_code, 0)
98
+ payload = fake_client.create_note.call_args.args[0]
99
+ self.assertEqual(payload["title"], "First captured line")
100
+ self.assertEqual(payload["body"], "\n\n First captured line \nSecond line")
101
+
102
+ def test_notes_sync_retries_queued_notes_and_marks_them_synced(self):
103
+ with tempfile.TemporaryDirectory() as tmpdir:
104
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
105
+ os.environ[config.DATA_ENV_VAR] = tmpdir
106
+ try:
107
+ queue_store = QueueStore()
108
+ queued = queue_store.enqueue(
109
+ project_id=1,
110
+ title="Queued note",
111
+ body="Queued body",
112
+ tags=["cli"],
113
+ captured_at="2026-03-10T10:00:00-07:00",
114
+ idempotency_key="queued-note-1",
115
+ )
116
+
117
+ fake_client = mock.Mock()
118
+ fake_client.create_note.return_value = {
119
+ "note": {"note_ref": "general-info:99"},
120
+ "audit_entry_id": 25,
121
+ }
122
+
123
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
124
+ result = self.runner.invoke(app, ["notes", "sync", "--project", "1", "--json"])
125
+
126
+ self.assertEqual(result.exit_code, 0)
127
+ payload = json.loads(result.stdout)
128
+ self.assertEqual(payload["total"], 1)
129
+ self.assertEqual(payload["synced"], 1)
130
+ self.assertEqual(payload["failed"], 0)
131
+ self.assertEqual(payload["results"][0]["note_ref"], "general-info:99")
132
+
133
+ synced = queue_store.get(queued.local_id)
134
+ self.assertEqual(synced.sync_state, "synced")
135
+ self.assertEqual(synced.server_note_ref, "general-info:99")
136
+ fake_client.create_note.assert_called_once_with(
137
+ {
138
+ "project": 1,
139
+ "title": "Queued note",
140
+ "body": "Queued body",
141
+ "tags": ["cli"],
142
+ "captured_at": "2026-03-10T10:00:00-07:00",
143
+ },
144
+ idempotency_key="queued-note-1",
145
+ )
146
+ finally:
147
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
148
+ os.environ.pop(config.DATA_ENV_VAR, None)
149
+
150
+ def test_notes_list_json_outputs_server_payload(self):
151
+ fake_client = mock.Mock()
152
+ fake_client.list_notes.return_value = {
153
+ "project_id": 1,
154
+ "count": 1,
155
+ "limit": 20,
156
+ "offset": 0,
157
+ "results": [
158
+ {
159
+ "note_ref": "general-info:1",
160
+ "title": "CLI Note",
161
+ "type": "general-info",
162
+ "source": "cli",
163
+ }
164
+ ],
165
+ }
166
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
167
+ result = self.runner.invoke(app, ["notes", "list", "--project", "1", "--json"])
168
+
169
+ self.assertEqual(result.exit_code, 0)
170
+ payload = json.loads(result.stdout)
171
+ self.assertEqual(payload["results"][0]["note_ref"], "general-info:1")
@@ -0,0 +1,24 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from ventilatepro import config
8
+
9
+
10
+ class ConfigTests(unittest.TestCase):
11
+ def test_token_falls_back_to_file_storage_when_keyring_is_unavailable(self):
12
+ with tempfile.TemporaryDirectory() as tmpdir:
13
+ os.environ[config.CONFIG_ENV_VAR] = tmpdir
14
+ os.environ[config.DATA_ENV_VAR] = tmpdir
15
+ try:
16
+ with mock.patch.object(config, "_try_get_keyring", return_value=None):
17
+ storage = config.save_token("vpcli_demo_secret")
18
+ self.assertEqual(storage, "file")
19
+ self.assertEqual(config.get_token(), "vpcli_demo_secret")
20
+ payload = json.loads(config.get_config_path().read_text(encoding="utf-8"))
21
+ self.assertEqual(payload["token_storage"], "file")
22
+ finally:
23
+ os.environ.pop(config.CONFIG_ENV_VAR, None)
24
+ os.environ.pop(config.DATA_ENV_VAR, None)
@@ -0,0 +1,5 @@
1
+ """VentilatePro CLI package metadata."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import requests
6
+
7
+
8
+ class ApiError(Exception):
9
+ def __init__(self, status_code: int, message: str, payload: Any = None):
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+ self.payload = payload
13
+
14
+
15
+ class NetworkError(Exception):
16
+ pass
17
+
18
+
19
+ def extract_error_message(payload: Any, fallback: str) -> str:
20
+ if isinstance(payload, dict):
21
+ if isinstance(payload.get("detail"), str):
22
+ return payload["detail"]
23
+ error = payload.get("error")
24
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
25
+ return error["message"]
26
+ return " ".join(str(value) for value in payload.values() if value)
27
+ if isinstance(payload, str) and payload.strip():
28
+ return payload
29
+ return fallback
30
+
31
+
32
+ class ApiClient:
33
+ def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 15):
34
+ self.base_url = (base_url or "").rstrip("/")
35
+ self.token = token
36
+ self.timeout = timeout
37
+
38
+ def request(self, method: str, path: str, *, json_payload: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None):
39
+ final_headers = {
40
+ "Accept": "application/json",
41
+ }
42
+ if headers:
43
+ final_headers.update(headers)
44
+ if self.token:
45
+ final_headers["Authorization"] = f"Bearer {self.token}"
46
+ try:
47
+ response = requests.request(
48
+ method=method,
49
+ url=f"{self.base_url}{path}",
50
+ json=json_payload,
51
+ headers=final_headers,
52
+ timeout=self.timeout,
53
+ )
54
+ except requests.RequestException as exc:
55
+ raise NetworkError(str(exc)) from exc
56
+
57
+ payload: Any = None
58
+ if response.content:
59
+ try:
60
+ payload = response.json()
61
+ except ValueError:
62
+ payload = response.text
63
+
64
+ if not response.ok:
65
+ raise ApiError(
66
+ status_code=response.status_code,
67
+ message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
68
+ payload=payload,
69
+ )
70
+ return payload if payload is not None else {}
71
+
72
+ def auth_me(self):
73
+ return self.request("GET", "/api/cli/auth/me/")
74
+
75
+ def projects_list(self):
76
+ return self.request("GET", "/api/cli/projects/")
77
+
78
+ def create_note(self, payload: Dict[str, Any], *, idempotency_key: str):
79
+ return self.request(
80
+ "POST",
81
+ "/api/cli/notes/",
82
+ json_payload=payload,
83
+ headers={"Idempotency-Key": idempotency_key},
84
+ )
85
+
86
+ def list_notes(self, project: int, *, limit: int = 20, offset: int = 0):
87
+ return self.request("GET", f"/api/cli/notes/?project={project}&limit={limit}&offset={offset}")
88
+
89
+ def show_note(self, note_ref: str, *, project: int):
90
+ return self.request("GET", f"/api/cli/notes/{note_ref}/?project={project}")
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from getpass import getpass
4
+ from typing import Tuple
5
+
6
+ from .api_client import ApiClient
7
+ from .config import clear_auth, get_base_url, get_token, save_token, set_base_url
8
+
9
+
10
+ def login(base_url: str | None, token: str | None):
11
+ normalized_url = (base_url or get_base_url() or "").strip().rstrip("/")
12
+ if not normalized_url:
13
+ raise ValueError("API base URL is required. Pass --url or log in with a saved URL.")
14
+
15
+ resolved_token = token or getpass("Personal access token: ").strip()
16
+ if not resolved_token:
17
+ raise ValueError("Personal access token is required.")
18
+
19
+ client = ApiClient(normalized_url, resolved_token)
20
+ identity = client.auth_me()
21
+ token_storage = save_token(resolved_token)
22
+ set_base_url(normalized_url)
23
+ return {
24
+ "status": "authenticated",
25
+ "base_url": normalized_url,
26
+ "token_storage": token_storage,
27
+ "user": identity,
28
+ }
29
+
30
+
31
+ def status():
32
+ base_url = get_base_url()
33
+ token = get_token()
34
+ return {
35
+ "authenticated": bool(base_url and token),
36
+ "base_url": base_url,
37
+ "token_present": bool(token),
38
+ }
39
+
40
+
41
+ def get_authenticated_client() -> Tuple[ApiClient, str]:
42
+ base_url = get_base_url()
43
+ token = get_token()
44
+ if not base_url or not token:
45
+ raise ValueError("Not authenticated. Run `ventilatepro auth login` first.")
46
+ return ApiClient(base_url, token), base_url
47
+
48
+
49
+ def whoami():
50
+ client, _ = get_authenticated_client()
51
+ return client.auth_me()
52
+
53
+
54
+ def logout():
55
+ clear_auth()
56
+ return {"status": "logged_out"}
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+
7
+ from . import auth, notes
8
+ from .api_client import ApiError, NetworkError
9
+ from .queue import QueueStore
10
+
11
+ app = typer.Typer(help="VentilatePro CLI")
12
+ auth_app = typer.Typer(help="Authentication commands")
13
+ projects_app = typer.Typer(help="Project commands")
14
+ notes_app = typer.Typer(help="Notes commands")
15
+
16
+ app.add_typer(auth_app, name="auth")
17
+ app.add_typer(projects_app, name="projects")
18
+ app.add_typer(notes_app, name="notes")
19
+
20
+
21
+ def main():
22
+ app()
23
+
24
+
25
+ def emit(payload, *, json_output: bool, lines: list[str]):
26
+ if json_output:
27
+ typer.echo(json.dumps(payload, indent=2, default=str))
28
+ return
29
+ for line in lines:
30
+ typer.echo(line)
31
+
32
+
33
+ def emit_error(message: str, *, json_output: bool):
34
+ if json_output:
35
+ typer.echo(json.dumps({"error": message}))
36
+ else:
37
+ typer.echo(f"Error: {message}", err=True)
38
+
39
+
40
+ @auth_app.command("login")
41
+ def auth_login(
42
+ url: str = typer.Option(None, "--url", help="VentilatePro base URL"),
43
+ token: str = typer.Option(None, "--token", help="Personal access token"),
44
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
45
+ ):
46
+ try:
47
+ result = auth.login(url, token)
48
+ except (ValueError, ApiError, NetworkError) as exc:
49
+ emit_error(str(exc), json_output=json_output)
50
+ raise typer.Exit(1)
51
+ emit(
52
+ result,
53
+ json_output=json_output,
54
+ lines=[
55
+ f"Authenticated as {result['user'].get('name') or result['user'].get('username')}.",
56
+ f"Base URL: {result['base_url']}",
57
+ f"Token storage: {result['token_storage']}",
58
+ ],
59
+ )
60
+
61
+
62
+ @auth_app.command("status")
63
+ def auth_status(
64
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
65
+ ):
66
+ result = auth.status()
67
+ lines = ["Authenticated." if result["authenticated"] else "Not authenticated."]
68
+ if result.get("base_url"):
69
+ lines.append(f"Base URL: {result['base_url']}")
70
+ emit(result, json_output=json_output, lines=lines)
71
+
72
+
73
+ @auth_app.command("whoami")
74
+ def auth_whoami(
75
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
76
+ ):
77
+ try:
78
+ result = auth.whoami()
79
+ except (ValueError, ApiError, NetworkError) as exc:
80
+ emit_error(str(exc), json_output=json_output)
81
+ raise typer.Exit(1)
82
+ emit(
83
+ result,
84
+ json_output=json_output,
85
+ lines=[
86
+ f"User: {result.get('name') or result.get('username')}",
87
+ f"Email: {result.get('email')}",
88
+ ],
89
+ )
90
+
91
+
92
+ @auth_app.command("logout")
93
+ def auth_logout(
94
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
95
+ ):
96
+ result = auth.logout()
97
+ emit(result, json_output=json_output, lines=["Removed local credentials."])
98
+
99
+
100
+ @projects_app.command("list")
101
+ def projects_list(
102
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
103
+ ):
104
+ try:
105
+ client, _ = auth.get_authenticated_client()
106
+ result = client.projects_list()
107
+ except (ValueError, ApiError, NetworkError) as exc:
108
+ emit_error(str(exc), json_output=json_output)
109
+ raise typer.Exit(1)
110
+
111
+ lines = [f"{project['id']}\t{project['name']}" for project in result]
112
+ if not lines:
113
+ lines = ["No accessible projects found."]
114
+ emit(result, json_output=json_output, lines=lines)
115
+
116
+
117
+ @notes_app.command("create")
118
+ def notes_create(
119
+ project: int = typer.Option(..., "--project", help="Target project ID"),
120
+ title: str = typer.Option(None, "--title", help="Note title"),
121
+ body: str = typer.Option(None, "--body", help="Note body"),
122
+ use_stdin: bool = typer.Option(False, "--stdin", help="Read note body from stdin"),
123
+ tags: str = typer.Option(None, "--tags", help="Comma-separated tags"),
124
+ captured_at: str = typer.Option(None, "--captured-at", help="Capture timestamp in ISO-8601 format"),
125
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
126
+ ):
127
+ try:
128
+ client, _ = auth.get_authenticated_client()
129
+ queue_store = QueueStore()
130
+ body_text = notes.resolve_body(body, use_stdin)
131
+ result = notes.create_note(
132
+ client,
133
+ queue_store,
134
+ project=project,
135
+ title=title,
136
+ body=body_text,
137
+ tags=notes.parse_tags(tags),
138
+ captured_at=captured_at,
139
+ )
140
+ except (ValueError, ApiError, NetworkError) as exc:
141
+ emit_error(str(exc), json_output=json_output)
142
+ raise typer.Exit(1)
143
+
144
+ if result["status"] == "queued":
145
+ emit(
146
+ result,
147
+ json_output=json_output,
148
+ lines=[f"Queued note locally as #{result['local_id']} due to a retryable API failure."],
149
+ )
150
+ return
151
+
152
+ note_ref = (result.get("note") or {}).get("note_ref", "unknown")
153
+ emit(
154
+ result,
155
+ json_output=json_output,
156
+ lines=[f"Created note {note_ref} in project {project}."],
157
+ )
158
+
159
+
160
+ @notes_app.command("sync")
161
+ def notes_sync(
162
+ project: int = typer.Option(None, "--project", help="Only sync queued notes for this project"),
163
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
164
+ ):
165
+ try:
166
+ client, _ = auth.get_authenticated_client()
167
+ result = notes.sync_notes(client, QueueStore(), project=project)
168
+ except (ValueError, ApiError, NetworkError) as exc:
169
+ emit_error(str(exc), json_output=json_output)
170
+ raise typer.Exit(1)
171
+
172
+ emit(
173
+ result,
174
+ json_output=json_output,
175
+ lines=[f"Processed {result['total']} queued notes: {result['synced']} synced, {result['failed']} failed."],
176
+ )
177
+ if result["failed"] > 0:
178
+ raise typer.Exit(1)
179
+
180
+
181
+ @notes_app.command("list")
182
+ def notes_list(
183
+ project: int = typer.Option(..., "--project", help="Target project ID"),
184
+ limit: int = typer.Option(20, "--limit", help="Maximum notes to return"),
185
+ offset: int = typer.Option(0, "--offset", help="Starting offset"),
186
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
187
+ ):
188
+ try:
189
+ client, _ = auth.get_authenticated_client()
190
+ result = client.list_notes(project, limit=limit, offset=offset)
191
+ except (ValueError, ApiError, NetworkError) as exc:
192
+ emit_error(str(exc), json_output=json_output)
193
+ raise typer.Exit(1)
194
+
195
+ lines = [
196
+ f"{item['note_ref']}\t{item['title']}\t{item['type']}\t{item['source']}"
197
+ for item in result.get("results", [])
198
+ ]
199
+ if not lines:
200
+ lines = ["No notes found."]
201
+ emit(result, json_output=json_output, lines=lines)
202
+
203
+
204
+ @notes_app.command("show")
205
+ def notes_show(
206
+ note_ref: str = typer.Argument(..., help="Typed note reference, such as general-info:42"),
207
+ project: int = typer.Option(..., "--project", help="Target project ID"),
208
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
209
+ ):
210
+ try:
211
+ client, _ = auth.get_authenticated_client()
212
+ result = client.show_note(note_ref, project=project)
213
+ except (ValueError, ApiError, NetworkError) as exc:
214
+ emit_error(str(exc), json_output=json_output)
215
+ raise typer.Exit(1)
216
+
217
+ emit(
218
+ result,
219
+ json_output=json_output,
220
+ lines=[
221
+ f"{result['note_ref']} [{result['type']}]",
222
+ f"Title: {result['title']}",
223
+ f"Source: {result['source']}",
224
+ f"Tags: {', '.join(result.get('tags') or []) or 'None'}",
225
+ "",
226
+ result["body"],
227
+ ],
228
+ )
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ APP_DIR_NAME = "ventilatepro"
9
+ CONFIG_ENV_VAR = "VENTILATEPRO_CONFIG_DIR"
10
+ DATA_ENV_VAR = "VENTILATEPRO_DATA_DIR"
11
+ CONFIG_FILENAME = "config.json"
12
+ KEYRING_SERVICE = "ventilatepro-cli"
13
+ KEYRING_USERNAME = "default"
14
+
15
+
16
+ def _fallback_config_dir() -> Path:
17
+ return Path.home() / ".config" / APP_DIR_NAME
18
+
19
+
20
+ def _fallback_data_dir() -> Path:
21
+ return Path.home() / ".local" / "share" / APP_DIR_NAME
22
+
23
+
24
+ def get_config_dir() -> Path:
25
+ override = os.getenv(CONFIG_ENV_VAR)
26
+ if override:
27
+ return Path(override)
28
+ try:
29
+ from platformdirs import user_config_dir
30
+
31
+ return Path(user_config_dir(APP_DIR_NAME, "VentilatePro"))
32
+ except Exception:
33
+ return _fallback_config_dir()
34
+
35
+
36
+ def get_data_dir() -> Path:
37
+ override = os.getenv(DATA_ENV_VAR)
38
+ if override:
39
+ return Path(override)
40
+ try:
41
+ from platformdirs import user_data_dir
42
+
43
+ return Path(user_data_dir(APP_DIR_NAME, "VentilatePro"))
44
+ except Exception:
45
+ return _fallback_data_dir()
46
+
47
+
48
+ def get_config_path() -> Path:
49
+ return get_config_dir() / CONFIG_FILENAME
50
+
51
+
52
+ def ensure_dirs() -> None:
53
+ get_config_dir().mkdir(parents=True, exist_ok=True)
54
+ get_data_dir().mkdir(parents=True, exist_ok=True)
55
+
56
+
57
+ def load_settings() -> Dict[str, Any]:
58
+ path = get_config_path()
59
+ if not path.exists():
60
+ return {}
61
+ try:
62
+ return json.loads(path.read_text(encoding="utf-8"))
63
+ except json.JSONDecodeError:
64
+ return {}
65
+
66
+
67
+ def save_settings(settings: Dict[str, Any]) -> None:
68
+ ensure_dirs()
69
+ get_config_path().write_text(json.dumps(settings, indent=2, sort_keys=True), encoding="utf-8")
70
+
71
+
72
+ def normalize_base_url(base_url: str) -> str:
73
+ return (base_url or "").strip().rstrip("/")
74
+
75
+
76
+ def set_base_url(base_url: str) -> None:
77
+ settings = load_settings()
78
+ settings["base_url"] = normalize_base_url(base_url)
79
+ save_settings(settings)
80
+
81
+
82
+ def get_base_url() -> Optional[str]:
83
+ return normalize_base_url(load_settings().get("base_url", "")) or None
84
+
85
+
86
+ def _try_get_keyring():
87
+ try:
88
+ import keyring
89
+
90
+ return keyring
91
+ except Exception:
92
+ return None
93
+
94
+
95
+ def save_token(token: str) -> str:
96
+ settings = load_settings()
97
+ keyring = _try_get_keyring()
98
+ if keyring is not None:
99
+ try:
100
+ keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, token)
101
+ settings["token_storage"] = "keyring"
102
+ settings.pop("token", None)
103
+ save_settings(settings)
104
+ return "keyring"
105
+ except Exception:
106
+ pass
107
+
108
+ settings["token_storage"] = "file"
109
+ settings["token"] = token
110
+ save_settings(settings)
111
+ return "file"
112
+
113
+
114
+ def get_token() -> Optional[str]:
115
+ settings = load_settings()
116
+ storage = settings.get("token_storage")
117
+ if storage == "keyring":
118
+ keyring = _try_get_keyring()
119
+ if keyring is not None:
120
+ try:
121
+ token = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
122
+ if token:
123
+ return token
124
+ except Exception:
125
+ pass
126
+ return settings.get("token") or None
127
+
128
+
129
+ def delete_token() -> None:
130
+ settings = load_settings()
131
+ keyring = _try_get_keyring()
132
+ if keyring is not None:
133
+ try:
134
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
135
+ except Exception:
136
+ pass
137
+ settings.pop("token", None)
138
+ settings.pop("token_storage", None)
139
+ save_settings(settings)
140
+
141
+
142
+ def clear_auth() -> None:
143
+ settings = load_settings()
144
+ settings.pop("base_url", None)
145
+ settings.pop("token", None)
146
+ settings.pop("token_storage", None)
147
+ delete_token()
148
+ save_settings(settings)
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import uuid
5
+ from typing import Optional
6
+
7
+ from .api_client import ApiError, NetworkError
8
+ from .queue import QueueStore
9
+
10
+ TITLE_MAX_LENGTH = 80
11
+
12
+
13
+ def derive_title(body: str, fallback: str = "Untitled CLI Note") -> str:
14
+ for raw_line in (body or "").splitlines():
15
+ line = raw_line.strip()
16
+ if not line:
17
+ continue
18
+ if line.startswith("# "):
19
+ line = line[2:].strip()
20
+ return line[:TITLE_MAX_LENGTH] or fallback
21
+ return fallback
22
+
23
+
24
+ def parse_tags(tags_csv: Optional[str]) -> list[str]:
25
+ if not tags_csv:
26
+ return []
27
+ seen = set()
28
+ tags = []
29
+ for raw_tag in tags_csv.split(","):
30
+ tag = raw_tag.strip()
31
+ if not tag or tag in seen:
32
+ continue
33
+ seen.add(tag)
34
+ tags.append(tag)
35
+ return tags
36
+
37
+
38
+ def resolve_body(body: Optional[str], use_stdin: bool) -> str:
39
+ if body and use_stdin:
40
+ raise ValueError("Use either --body or --stdin, not both.")
41
+ if use_stdin:
42
+ stdin_body = sys.stdin.read()
43
+ if not stdin_body.strip():
44
+ raise ValueError("No note body received on stdin.")
45
+ return stdin_body
46
+ if not body or not body.strip():
47
+ raise ValueError("Provide --body or --stdin.")
48
+ return body
49
+
50
+
51
+ def build_note_payload(project: int, title: Optional[str], body: str, tags: list[str], captured_at: Optional[str]):
52
+ resolved_title = (title or "").strip() or derive_title(body)
53
+ return {
54
+ "project": project,
55
+ "title": resolved_title,
56
+ "body": body,
57
+ "tags": tags,
58
+ "captured_at": captured_at,
59
+ }
60
+
61
+
62
+ def create_note(client, queue_store: QueueStore, *, project: int, title: Optional[str], body: str, tags: list[str], captured_at: Optional[str]):
63
+ payload = build_note_payload(project, title, body, tags, captured_at)
64
+ idempotency_key = str(uuid.uuid4())
65
+ try:
66
+ response = client.create_note(payload, idempotency_key=idempotency_key)
67
+ return {
68
+ "status": "created",
69
+ "idempotency_key": idempotency_key,
70
+ "project": project,
71
+ "note": response.get("note"),
72
+ "audit_entry_id": response.get("audit_entry_id"),
73
+ }
74
+ except NetworkError as exc:
75
+ queued_note = queue_store.enqueue(
76
+ project_id=project,
77
+ title=payload["title"],
78
+ body=payload["body"],
79
+ tags=payload["tags"],
80
+ captured_at=captured_at,
81
+ idempotency_key=idempotency_key,
82
+ )
83
+ return {
84
+ "status": "queued",
85
+ "project": project,
86
+ "local_id": queued_note.local_id,
87
+ "error": str(exc),
88
+ "idempotency_key": idempotency_key,
89
+ }
90
+ except ApiError as exc:
91
+ if exc.status_code >= 500:
92
+ queued_note = queue_store.enqueue(
93
+ project_id=project,
94
+ title=payload["title"],
95
+ body=payload["body"],
96
+ tags=payload["tags"],
97
+ captured_at=captured_at,
98
+ idempotency_key=idempotency_key,
99
+ )
100
+ return {
101
+ "status": "queued",
102
+ "project": project,
103
+ "local_id": queued_note.local_id,
104
+ "error": str(exc),
105
+ "idempotency_key": idempotency_key,
106
+ }
107
+ raise
108
+
109
+
110
+ def sync_notes(client, queue_store: QueueStore, *, project: Optional[int] = None):
111
+ queued_notes = queue_store.list_unsynced(project)
112
+ results = []
113
+ synced_count = 0
114
+ failed_count = 0
115
+ for queued_note in queued_notes:
116
+ queue_store.mark_syncing(queued_note.local_id)
117
+ try:
118
+ response = client.create_note(
119
+ build_note_payload(
120
+ queued_note.project_id,
121
+ queued_note.title,
122
+ queued_note.body,
123
+ queued_note.tags,
124
+ queued_note.captured_at,
125
+ ),
126
+ idempotency_key=queued_note.idempotency_key,
127
+ )
128
+ note_ref = response.get("note", {}).get("note_ref")
129
+ queue_store.mark_synced(queued_note.local_id, note_ref or "")
130
+ synced_count += 1
131
+ results.append({
132
+ "local_id": queued_note.local_id,
133
+ "status": "synced",
134
+ "note_ref": note_ref,
135
+ "project": queued_note.project_id,
136
+ })
137
+ except (NetworkError, ApiError) as exc:
138
+ queue_store.mark_failed(queued_note.local_id, str(exc))
139
+ failed_count += 1
140
+ results.append({
141
+ "local_id": queued_note.local_id,
142
+ "status": "failed",
143
+ "project": queued_note.project_id,
144
+ "error": str(exc),
145
+ })
146
+ return {
147
+ "total": len(queued_notes),
148
+ "synced": synced_count,
149
+ "failed": failed_count,
150
+ "results": results,
151
+ }
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from .config import get_data_dir
11
+
12
+
13
+ @dataclass
14
+ class QueuedNote:
15
+ local_id: int
16
+ project_id: int
17
+ title: str
18
+ body: str
19
+ tags: list[str]
20
+ captured_at: Optional[str]
21
+ created_at: str
22
+ idempotency_key: str
23
+ sync_state: str
24
+ last_sync_attempt_at: Optional[str]
25
+ last_error: Optional[str]
26
+ server_note_ref: Optional[str]
27
+
28
+
29
+ class QueueStore:
30
+ def __init__(self, db_path: Optional[Path] = None):
31
+ self.db_path = db_path or (get_data_dir() / "queue.sqlite3")
32
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
33
+ self._initialize()
34
+
35
+ def _connect(self):
36
+ return sqlite3.connect(self.db_path)
37
+
38
+ def _initialize(self) -> None:
39
+ with self._connect() as connection:
40
+ connection.execute(
41
+ """
42
+ CREATE TABLE IF NOT EXISTS queued_notes (
43
+ local_id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ project_id INTEGER NOT NULL,
45
+ title TEXT NOT NULL,
46
+ body TEXT NOT NULL,
47
+ tags TEXT NOT NULL,
48
+ captured_at TEXT NULL,
49
+ created_at TEXT NOT NULL,
50
+ idempotency_key TEXT NOT NULL UNIQUE,
51
+ sync_state TEXT NOT NULL,
52
+ last_sync_attempt_at TEXT NULL,
53
+ last_error TEXT NULL,
54
+ server_note_ref TEXT NULL
55
+ )
56
+ """
57
+ )
58
+ connection.execute(
59
+ "CREATE INDEX IF NOT EXISTS queued_notes_sync_state_idx ON queued_notes(sync_state)"
60
+ )
61
+
62
+ def enqueue(
63
+ self,
64
+ *,
65
+ project_id: int,
66
+ title: str,
67
+ body: str,
68
+ tags: list[str],
69
+ captured_at: Optional[str],
70
+ idempotency_key: str,
71
+ ) -> QueuedNote:
72
+ created_at = datetime.now(timezone.utc).isoformat()
73
+ with self._connect() as connection:
74
+ cursor = connection.execute(
75
+ """
76
+ INSERT INTO queued_notes (
77
+ project_id, title, body, tags, captured_at, created_at, idempotency_key, sync_state
78
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
79
+ """,
80
+ (
81
+ project_id,
82
+ title,
83
+ body,
84
+ json.dumps(tags),
85
+ captured_at,
86
+ created_at,
87
+ idempotency_key,
88
+ "pending",
89
+ ),
90
+ )
91
+ local_id = cursor.lastrowid
92
+ return self.get(local_id)
93
+
94
+ def get(self, local_id: int) -> QueuedNote:
95
+ with self._connect() as connection:
96
+ row = connection.execute(
97
+ """
98
+ SELECT local_id, project_id, title, body, tags, captured_at, created_at,
99
+ idempotency_key, sync_state, last_sync_attempt_at, last_error, server_note_ref
100
+ FROM queued_notes
101
+ WHERE local_id = ?
102
+ """,
103
+ (local_id,),
104
+ ).fetchone()
105
+ if row is None:
106
+ raise KeyError(f"Queued note {local_id} not found.")
107
+ return self._row_to_note(row)
108
+
109
+ def list_unsynced(self, project_id: Optional[int] = None) -> List[QueuedNote]:
110
+ query = """
111
+ SELECT local_id, project_id, title, body, tags, captured_at, created_at,
112
+ idempotency_key, sync_state, last_sync_attempt_at, last_error, server_note_ref
113
+ FROM queued_notes
114
+ WHERE sync_state IN ('pending', 'failed')
115
+ """
116
+ params: list[object] = []
117
+ if project_id is not None:
118
+ query += " AND project_id = ?"
119
+ params.append(project_id)
120
+ query += " ORDER BY local_id ASC"
121
+ with self._connect() as connection:
122
+ rows = connection.execute(query, params).fetchall()
123
+ return [self._row_to_note(row) for row in rows]
124
+
125
+ def mark_syncing(self, local_id: int) -> None:
126
+ with self._connect() as connection:
127
+ connection.execute(
128
+ """
129
+ UPDATE queued_notes
130
+ SET sync_state = ?, last_sync_attempt_at = ?, last_error = NULL
131
+ WHERE local_id = ?
132
+ """,
133
+ ("syncing", datetime.now(timezone.utc).isoformat(), local_id),
134
+ )
135
+
136
+ def mark_synced(self, local_id: int, note_ref: str) -> None:
137
+ with self._connect() as connection:
138
+ connection.execute(
139
+ """
140
+ UPDATE queued_notes
141
+ SET sync_state = ?, last_sync_attempt_at = ?, last_error = NULL, server_note_ref = ?
142
+ WHERE local_id = ?
143
+ """,
144
+ ("synced", datetime.now(timezone.utc).isoformat(), note_ref, local_id),
145
+ )
146
+
147
+ def mark_failed(self, local_id: int, error_message: str) -> None:
148
+ with self._connect() as connection:
149
+ connection.execute(
150
+ """
151
+ UPDATE queued_notes
152
+ SET sync_state = ?, last_sync_attempt_at = ?, last_error = ?
153
+ WHERE local_id = ?
154
+ """,
155
+ ("failed", datetime.now(timezone.utc).isoformat(), error_message, local_id),
156
+ )
157
+
158
+ def count(self) -> int:
159
+ with self._connect() as connection:
160
+ row = connection.execute("SELECT COUNT(*) FROM queued_notes").fetchone()
161
+ return int(row[0] if row else 0)
162
+
163
+ def _row_to_note(self, row) -> QueuedNote:
164
+ return QueuedNote(
165
+ local_id=row[0],
166
+ project_id=row[1],
167
+ title=row[2],
168
+ body=row[3],
169
+ tags=json.loads(row[4] or "[]"),
170
+ captured_at=row[5],
171
+ created_at=row[6],
172
+ idempotency_key=row[7],
173
+ sync_state=row[8],
174
+ last_sync_attempt_at=row[9],
175
+ last_error=row[10],
176
+ server_note_ref=row[11],
177
+ )
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: ventilatepro-cli
3
+ Version: 0.1.0
4
+ Summary: VentilatePro CLI
5
+ Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
+ Project-URL: Documentation, https://ventilatepro.com/exam/cli/
7
+ Project-URL: Support, https://ventilatepro.com/exam/support/
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: typer>=0.16.0
11
+ Requires-Dist: requests>=2.32.0
12
+ Requires-Dist: keyring>=25.6.0
13
+ Requires-Dist: platformdirs>=4.3.0
14
+
15
+ # VentilatePro CLI
16
+
17
+ VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
18
+
19
+ ## Install
20
+
21
+ Published releases install from PyPI:
22
+
23
+ ```bash
24
+ pipx install ventilatepro-cli
25
+ ```
26
+
27
+ Upgrade an existing install:
28
+
29
+ ```bash
30
+ pipx upgrade ventilatepro-cli
31
+ ```
32
+
33
+ Local development install:
34
+
35
+ ```bash
36
+ cd ventilatepro-cli
37
+ pip install -e .
38
+ ```
39
+
40
+ ## Commands
41
+
42
+ ```bash
43
+ ventilatepro auth login --url http://localhost:8000
44
+ ventilatepro projects list
45
+ ventilatepro notes create --project 1 --body "Captured from terminal"
46
+ ventilatepro notes sync
47
+ ```
48
+
49
+ See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_cli.py
4
+ tests/test_config.py
5
+ ventilatepro/__init__.py
6
+ ventilatepro/__main__.py
7
+ ventilatepro/api_client.py
8
+ ventilatepro/auth.py
9
+ ventilatepro/cli.py
10
+ ventilatepro/config.py
11
+ ventilatepro/notes.py
12
+ ventilatepro/queue.py
13
+ ventilatepro_cli.egg-info/PKG-INFO
14
+ ventilatepro_cli.egg-info/SOURCES.txt
15
+ ventilatepro_cli.egg-info/dependency_links.txt
16
+ ventilatepro_cli.egg-info/entry_points.txt
17
+ ventilatepro_cli.egg-info/requires.txt
18
+ ventilatepro_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ventilatepro = ventilatepro.cli:main
@@ -0,0 +1,4 @@
1
+ typer>=0.16.0
2
+ requests>=2.32.0
3
+ keyring>=25.6.0
4
+ platformdirs>=4.3.0