zreport 1.0.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.
zreport-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyberelf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ prune tests
zreport-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: zreport
3
+ Version: 1.0.0
4
+ Summary: zreport (Zero Report) 命令行客户端:本地周报工作台的项目/资料/TODO 终端工具
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/cyberelf/pm
7
+ Keywords: weekly-report,cli,todo,whisper
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Office/Business :: Scheduling
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # zreport (Zero Report)
18
+
19
+ Local project management workspace for weekly plans, updates, source materials, GitHub/GitLab activity, generated Markdown reports, and deterministic risk warnings. Accounts are managed by a system administrator; every user only sees their own projects, TODOs, and voice jobs.
20
+
21
+ ## Run
22
+
23
+ Install Python dependencies and start the server (on Windows use the `py` launcher — `py -m pip install -r requirements.txt`, `py run.py` — or run the commands below from Git Bash):
24
+
25
+ ```bash
26
+ python3 -m pip install --user -r requirements.txt
27
+ python3 run.py
28
+ ```
29
+
30
+ Open `http://127.0.0.1:8000` and sign in.
31
+
32
+ ## Accounts
33
+
34
+ - All API access requires login; the login screen appears on first visit.
35
+ - On first startup the service bootstraps an administrator account named `darren`. The initial password comes from `REPORTS_ADMIN_PASSWORD` (`.env` or environment) and defaults to `changeme` — set it before the first start, then change it in 全局设置 → 修改密码.
36
+ - Administrators manage accounts in 全局设置 → 用户管理 (create users, reset passwords, toggle admin/enabled, delete). A user whose projects/TODOs still exist cannot be deleted; the last enabled admin cannot be removed or demoted.
37
+ - Users see only their own data: projects, TODOs, voice jobs, and queue tasks are filtered per account. Appearance preferences and Git tokens are stored per user too.
38
+ - 全局设置 panels for 内部 Agent LLM, 语音 TODO (ASR service), and 任务队列, plus the GitHub/GitLab enable switches and 用户管理, are visible to administrators only.
39
+
40
+ For a steadier background service on port 8765:
41
+
42
+ ```bash
43
+ scripts/start_server.sh
44
+ scripts/stop_server.sh
45
+ ```
46
+
47
+ Set `PORT=9000` to choose another port. These scripts need bash (macOS, Linux, WSL, or Git Bash on Windows).
48
+
49
+ Or keep machine-local settings in a `.env` file at the repo root (git-ignored):
50
+
51
+ ```bash
52
+ PORT=8765
53
+ REPORTS_HOST=10.200.200.3
54
+ ```
55
+
56
+ Both `python3 run.py` and `scripts/install_service.sh` read `.env` for `PORT`, `REPORTS_HOST`, and `REPORTS_FAKE_PROVIDER` defaults. Real environment variables always take precedence over the file.
57
+
58
+ The service also listens on HTTPS port `8443` (set `REPORTS_TLS_PORT` to change or set it empty to disable) with a self-signed certificate generated at `data/tls/`. Phones need this HTTPS listener to use microphone access for voice TODOs: open `https://<host>:8443` and accept the certificate warning once.
59
+
60
+ For the most stable local service, install it as a per-user login service (restarts with the machine, same `.env`/port knobs):
61
+
62
+ ```bash
63
+ PORT=8765 scripts/install_service.sh
64
+ scripts/uninstall_service.sh # add "asr" to remove the voice service instead
65
+ ```
66
+
67
+ One script, three platforms:
68
+
69
+ - macOS: LaunchAgent (launchd), as before.
70
+ - Linux: systemd user service `zreport.service`; run `sudo loginctl enable-linger $USER` once so it also survives logout.
71
+ - Windows: a `.bat` in the Start Menu Startup folder (run the script from Git Bash); a console window appears at login — minimize it. Inside WSL2, use the Linux flow instead.
72
+
73
+ ## Docker Compose
74
+
75
+ An alternative server deployment mode that runs the backend and the local voice model as containers, with the same data layout as the native service:
76
+
77
+ ```bash
78
+ docker compose up -d --build
79
+ curl --noproxy '*' "http://127.0.0.1:${PORT:-8765}/api/auth/state"
80
+ ```
81
+
82
+ - Two services: `reports` (the backend) and `asr` (a local voice model — the whisper.cpp server built from source at `docker/asr/Dockerfile`, pinned by `WHISPER_CPP_VERSION`, default `v1.9.3`). The first build compiles whisper.cpp and takes a few minutes.
83
+ - Data (SQLite, uploads, TLS certificates) lives in a docker-managed named volume (`zreport_reports-data`), never in the checkout and never in the native service's `data/` directory — the volume starts empty and the two modes never share state. It survives `docker compose down`; remove it with `docker compose down -v`, and back it up with `docker compose cp reports:/app/data ./data-backup`. Deployments from before the zreport rename still hold their data in `weekly-reports_reports-data`; copy it across once with `docker run --rm -v weekly-reports_reports-data:/from -v zreport_reports-data:/to alpine sh -c 'cp -a /from/. /to/'`.
84
+ - Host-side settings come from the repo-root `.env` (`PORT`, `REPORTS_TLS_PORT`, `REPORTS_FAKE_PROVIDER`, `REPORTS_QUEUE_CAPACITY`, `REPORTS_QUEUE_PARALLELISM`, `REPORTS_ADMIN_PASSWORD`, and for the asr service `ASR_PORT`, `ASR_MODEL`, `WHISPER_CPP_VERSION`). Inside the container the server binds `0.0.0.0` on fixed ports 8765/8443, published as `${PORT:-8765}` / `${REPORTS_TLS_PORT:-8443}`.
85
+ - The image ships chromium for PDF export with CJK fonts. Set `REPORTS_ADMIN_PASSWORD` in `.env` before the first start so the bootstrapped `darren` admin does not use the default password.
86
+ - Voice TODOs: put the GGML model in `data/models/` (see Voice TODO below) before starting — the `asr` container mounts that directory read-only and serves `/inference` on container port 8766. Set the ASR endpoint in 全局设置 to `http://asr:8766/inference` (container-to-container over the compose network). The port is also published on the host as `${ASR_PORT:-8766}`; if the native whisper service already listens there, set `ASR_PORT` in `.env` to a different host port. To use a natively installed whisper service instead of the container, point the endpoint at `http://host.docker.internal:8766/inference` (reachable through the `host-gateway` mapping).
87
+ - NVIDIA GPU hosts: layer on the GPU override so the `asr` container runs whisper.cpp on the GPU instead of the CPU (needs nvidia-container-toolkit):
88
+
89
+ ```bash
90
+ docker compose -f compose.yaml -f compose.gpu.yaml up -d --build
91
+ ```
92
+
93
+ This builds `docker/asr/Dockerfile.cuda` (same runtime layout, CUDA kernels; the first build compiles for every architecture in the list and takes a while). Extra `.env` knobs: `CUDA_VERSION` (default `12.4.1`) and `CUDA_ARCHITECTURES` (default `75;80;90` — covers Turing through Hopper; set `120` for RTX 50 / Blackwell consumer cards). The large-v3-turbo model fits in roughly 6 GB of VRAM; use a smaller GGML model on smaller cards.
94
+ - The self-signed TLS certificate is generated at startup. `REPORTS_TLS_SAN` (defaulting to `REPORTS_HOST` from `.env`) is added to the certificate SANs so the address phones use is covered.
95
+ - On networks where `deb.debian.org` is unreachable, set `APT_MIRROR` (for example `mirrors.tuna.tsinghua.edu.cn`) in `.env` before building — it applies to both images.
96
+
97
+ ## Local Tools
98
+
99
+ - Git repository activity goes through the GitHub REST API (api.github.com) and GitLab REST API v4 (self-hosted instances supported; the GitLab server address is configured once per account in 全局设置 → Git 集成, defaulting to `https://gitlab.com`). No `gh`/`glab` CLIs are needed.
100
+ - Users configure their own access tokens in 全局设置 → Git 集成 (stored per account, never returned by the API — only a last-4 hint comes back). GitHub supports multiple tokens: an entry with an organization name is used only for that org's repositories (fine-grained token with that org as resource owner), and an entry with an empty organization is the fallback for everything else (classic token, or a personal fine-grained token). Read-only access is enough: fine-grained tokens need `Contents: Read-only`, classic tokens need `repo` (or `public_repo` for public repos only); org repos additionally require org approval (Settings → Third-party access) and, when the org enforces SAML SSO, a per-token SSO authorization. GitLab uses one personal access token with the `read_api` scope (Reporter role for private projects) plus the shared server address from 全局设置; for self-signed certificates, 跳过 SSL 证书校验 in the same panel disables TLS verification for that account's GitLab connections. Without a token only public repositories are reachable, and GitHub's anonymous rate limit is low. Administrators can disable either integration globally.
101
+ - Report generation runs exclusively through the `internal` agent, which calls the configured LLM in-process through langchain's provider bindings. Configure it in 全局设置 → 内部 Agent LLM (admin only): provider (`openai` or `anthropic`), endpoint base URL, model name, and API key. A blank base URL defaults to `https://api.openai.com/v1` / `https://api.anthropic.com`; any OpenAI-compatible or Anthropic-compatible endpoint (LM Studio, gateways) works. The API key is stored only in the local database (never returned by the API) and falls back to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` environment variables when unset.
102
+ - Markdown report rendering uses `markdown-it-py` with a Python-Markdown fallback.
103
+ - Set `REPORTS_FAKE_PROVIDER=1` only for local tests or dry runs that generate a deterministic report without calling the LLM. Normal service startup uses real provider execution.
104
+
105
+ ## Voice TODO
106
+
107
+ - A floating microphone button at the bottom right records while held. The browser only captures audio and converts it to a 16 kHz mono WAV locally (no speech leaves the machine at this stage); Safari and Chrome are supported. Microphone access requires a secure context: use `localhost` or the HTTPS listener (`https://<host>:8443`) from phones.
108
+ - On release, the WAV goes to `POST /api/todos/voice`, which starts a background voice job and returns immediately. Only one voice job runs at a time; while one is active the mic button becomes a stop button that cancels it (`POST /api/voice-jobs/{id}/cancel`), and the progress bubble with the transcript survives page reloads (`GET /api/voice-jobs/active`). The internal agent structures the transcript into one or more TODO items.
109
+ - The ASR service is any OpenAI-compatible transcription endpoint. The bundled choice is the whisper.cpp server (`/inference` on port 8766, large-v3-turbo model), registered as a per-user login service by `scripts/install_asr_service.sh` — same three-platform flow as the main service (macOS LaunchAgent / Linux systemd user unit / Windows Startup-folder `.bat` from Git Bash; inside WSL2 use the Linux flow; `scripts/uninstall_service.sh asr` removes it). It needs two things first:
110
+ 1. a `whisper-server` binary — macOS: `brew install whisper-cpp`; Linux: build once (`git clone https://github.com/ggml-org/whisper.cpp && cmake -S whisper.cpp -B whisper.cpp/build && cmake --build whisper.cpp/build --target whisper-server`, keep `build/bin/whisper-server` on `PATH` or point `WHISPER_SERVER` at it); Windows: a build from the [whisper.cpp releases](https://github.com/ggml-org/whisper.cpp/releases) (or the same CMake build), with `WHISPER_SERVER` pointing at the `.exe`.
111
+ 2. the GGML model under `data/models/` (same file the docker `asr` container mounts):
112
+
113
+ ```bash
114
+ curl -L -o data/models/ggml-large-v3-turbo-q5_0.bin \
115
+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
116
+ # if huggingface.co is unreachable: https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
117
+ ```
118
+ - Transcription requests carry a `language` hint from the 识别语言 dropdown, defaulting to 中文 (`zh`) so short Chinese clips are not mis-detected as English. `自动检测` omits the field so the service decides.
119
+ - Appearance (theme color and light/dark mode) is stored per account server-side; `PUT /api/settings` is a partial update, so each settings panel only writes the keys it manages.
120
+ - If the configured agent fails, the raw transcript still creates TODO item(s) and the UI reports the fallback.
121
+
122
+ ## Uploads
123
+
124
+ Supported project material types:
125
+
126
+ - Markdown: `.md`, `.markdown`
127
+ - Plain text: `.txt`
128
+ - PDF: `.pdf`
129
+
130
+ Markdown and plain text are extracted as UTF-8. PDF files are parsed server-side with pypdf and surfaced in report context with extraction status; scans or unreadable PDFs are marked failed instead of blocking the report.
131
+
132
+ ## Report Context
133
+
134
+ Generated reports use a structured context snapshot for change detection and audit metadata. The internal agent receives the bounded evidence inline (no tool execution, no direct database or file access). The most important current-week evidence is:
135
+
136
+ - `new_materials_this_week`: project materials uploaded or manually entered during the current ISO project week in `Asia/Shanghai`.
137
+ - `git_commits_this_week`: commits from each connected repository's (GitHub or GitLab) configured tracked branches during the current ISO project week.
138
+
139
+ The prompt explicitly asks the model to use these as primary evidence and to say when no new materials or commits exist.
140
+
141
+ ## Tests
142
+
143
+ ```bash
144
+ python3 -m unittest
145
+ openspec validate "add-weekly-project-management-system"
146
+ ```
147
+
148
+ ## CLI Client
149
+
150
+ `zreport` is the standard-library command-line client (`zreport.py` in the repo root). On machines without the checkout, install it from PyPI (`pip install zreport`); otherwise symlink it onto your PATH (`ln -sf "$PWD/zreport.py" ~/.local/bin/zreport`; on Windows create a `zreport.cmd` shim running `@python path\to\zreport.py %*`). Sign in once with the device flow — it prints a URL and a code; open the URL, sign in, and approve (the page is the same one a phone or another machine would use) — then work from the terminal:
151
+
152
+ ```bash
153
+ zreport login --server http://127.0.0.1:8765 # add --insecure once for the self-signed HTTPS port
154
+ zreport whoami
155
+ zreport projects
156
+ zreport materials add 周报系统 --text "本周完成设备授权" --title 进展
157
+ zreport materials add 周报系统 --text - < notes.txt # pipe content through stdin
158
+ zreport materials add 周报系统 --file notes.md 设计稿.pdf
159
+ zreport todos
160
+ zreport todo add "整理部署文档" -d "补充 GPU compose 说明"
161
+ zreport todo status 3 doing
162
+ zreport todo done 3 --project 周报系统 --reason "文档已合并"
163
+ ```
164
+
165
+ - The login exchanges a device code for a long-lived session token (365 days) stored in `<config>/zreport/cli.json` with `0600` permissions; `logout` revokes it server-side. Disabling or deleting a user revokes their CLI sessions too.
166
+ - Requests carry `Authorization: Bearer`, so every authenticated `/api` route works unchanged for CLI clients. The client always bypasses system proxy variables. The global flags `--server`, `--token`, `--insecure`, and `--config` allow scripting without touching the stored credentials.
167
+ - An agent skill (a `SKILL.md` teaching coding agents how to drive the CLI for weekly-report work) ships inside the package: `zreport skill install` writes it to `./.agents/skills/zreport/SKILL.md`, and `zreport skill install --global` to `~/.agents/skills/zreport/SKILL.md`. Point your agent at the `.agents/skills` directory (for Claude Code, symlink it into `~/.claude/skills`).
168
+
169
+ ## Android Client
170
+
171
+ The native Android client lives in its own repository: `../pm-android`
172
+ (voice TODOs, TODO board, weekly report reading; talks to this service's
173
+ HTTPS API). It now needs a login: configure the same account as the web
174
+ UI (server responses require the session cookie). Build and setup
175
+ instructions are in that repo's README; the planning doc moved there too
176
+ (`ANDROID_APP_PLAN.md`).
@@ -0,0 +1,160 @@
1
+ # zreport (Zero Report)
2
+
3
+ Local project management workspace for weekly plans, updates, source materials, GitHub/GitLab activity, generated Markdown reports, and deterministic risk warnings. Accounts are managed by a system administrator; every user only sees their own projects, TODOs, and voice jobs.
4
+
5
+ ## Run
6
+
7
+ Install Python dependencies and start the server (on Windows use the `py` launcher — `py -m pip install -r requirements.txt`, `py run.py` — or run the commands below from Git Bash):
8
+
9
+ ```bash
10
+ python3 -m pip install --user -r requirements.txt
11
+ python3 run.py
12
+ ```
13
+
14
+ Open `http://127.0.0.1:8000` and sign in.
15
+
16
+ ## Accounts
17
+
18
+ - All API access requires login; the login screen appears on first visit.
19
+ - On first startup the service bootstraps an administrator account named `darren`. The initial password comes from `REPORTS_ADMIN_PASSWORD` (`.env` or environment) and defaults to `changeme` — set it before the first start, then change it in 全局设置 → 修改密码.
20
+ - Administrators manage accounts in 全局设置 → 用户管理 (create users, reset passwords, toggle admin/enabled, delete). A user whose projects/TODOs still exist cannot be deleted; the last enabled admin cannot be removed or demoted.
21
+ - Users see only their own data: projects, TODOs, voice jobs, and queue tasks are filtered per account. Appearance preferences and Git tokens are stored per user too.
22
+ - 全局设置 panels for 内部 Agent LLM, 语音 TODO (ASR service), and 任务队列, plus the GitHub/GitLab enable switches and 用户管理, are visible to administrators only.
23
+
24
+ For a steadier background service on port 8765:
25
+
26
+ ```bash
27
+ scripts/start_server.sh
28
+ scripts/stop_server.sh
29
+ ```
30
+
31
+ Set `PORT=9000` to choose another port. These scripts need bash (macOS, Linux, WSL, or Git Bash on Windows).
32
+
33
+ Or keep machine-local settings in a `.env` file at the repo root (git-ignored):
34
+
35
+ ```bash
36
+ PORT=8765
37
+ REPORTS_HOST=10.200.200.3
38
+ ```
39
+
40
+ Both `python3 run.py` and `scripts/install_service.sh` read `.env` for `PORT`, `REPORTS_HOST`, and `REPORTS_FAKE_PROVIDER` defaults. Real environment variables always take precedence over the file.
41
+
42
+ The service also listens on HTTPS port `8443` (set `REPORTS_TLS_PORT` to change or set it empty to disable) with a self-signed certificate generated at `data/tls/`. Phones need this HTTPS listener to use microphone access for voice TODOs: open `https://<host>:8443` and accept the certificate warning once.
43
+
44
+ For the most stable local service, install it as a per-user login service (restarts with the machine, same `.env`/port knobs):
45
+
46
+ ```bash
47
+ PORT=8765 scripts/install_service.sh
48
+ scripts/uninstall_service.sh # add "asr" to remove the voice service instead
49
+ ```
50
+
51
+ One script, three platforms:
52
+
53
+ - macOS: LaunchAgent (launchd), as before.
54
+ - Linux: systemd user service `zreport.service`; run `sudo loginctl enable-linger $USER` once so it also survives logout.
55
+ - Windows: a `.bat` in the Start Menu Startup folder (run the script from Git Bash); a console window appears at login — minimize it. Inside WSL2, use the Linux flow instead.
56
+
57
+ ## Docker Compose
58
+
59
+ An alternative server deployment mode that runs the backend and the local voice model as containers, with the same data layout as the native service:
60
+
61
+ ```bash
62
+ docker compose up -d --build
63
+ curl --noproxy '*' "http://127.0.0.1:${PORT:-8765}/api/auth/state"
64
+ ```
65
+
66
+ - Two services: `reports` (the backend) and `asr` (a local voice model — the whisper.cpp server built from source at `docker/asr/Dockerfile`, pinned by `WHISPER_CPP_VERSION`, default `v1.9.3`). The first build compiles whisper.cpp and takes a few minutes.
67
+ - Data (SQLite, uploads, TLS certificates) lives in a docker-managed named volume (`zreport_reports-data`), never in the checkout and never in the native service's `data/` directory — the volume starts empty and the two modes never share state. It survives `docker compose down`; remove it with `docker compose down -v`, and back it up with `docker compose cp reports:/app/data ./data-backup`. Deployments from before the zreport rename still hold their data in `weekly-reports_reports-data`; copy it across once with `docker run --rm -v weekly-reports_reports-data:/from -v zreport_reports-data:/to alpine sh -c 'cp -a /from/. /to/'`.
68
+ - Host-side settings come from the repo-root `.env` (`PORT`, `REPORTS_TLS_PORT`, `REPORTS_FAKE_PROVIDER`, `REPORTS_QUEUE_CAPACITY`, `REPORTS_QUEUE_PARALLELISM`, `REPORTS_ADMIN_PASSWORD`, and for the asr service `ASR_PORT`, `ASR_MODEL`, `WHISPER_CPP_VERSION`). Inside the container the server binds `0.0.0.0` on fixed ports 8765/8443, published as `${PORT:-8765}` / `${REPORTS_TLS_PORT:-8443}`.
69
+ - The image ships chromium for PDF export with CJK fonts. Set `REPORTS_ADMIN_PASSWORD` in `.env` before the first start so the bootstrapped `darren` admin does not use the default password.
70
+ - Voice TODOs: put the GGML model in `data/models/` (see Voice TODO below) before starting — the `asr` container mounts that directory read-only and serves `/inference` on container port 8766. Set the ASR endpoint in 全局设置 to `http://asr:8766/inference` (container-to-container over the compose network). The port is also published on the host as `${ASR_PORT:-8766}`; if the native whisper service already listens there, set `ASR_PORT` in `.env` to a different host port. To use a natively installed whisper service instead of the container, point the endpoint at `http://host.docker.internal:8766/inference` (reachable through the `host-gateway` mapping).
71
+ - NVIDIA GPU hosts: layer on the GPU override so the `asr` container runs whisper.cpp on the GPU instead of the CPU (needs nvidia-container-toolkit):
72
+
73
+ ```bash
74
+ docker compose -f compose.yaml -f compose.gpu.yaml up -d --build
75
+ ```
76
+
77
+ This builds `docker/asr/Dockerfile.cuda` (same runtime layout, CUDA kernels; the first build compiles for every architecture in the list and takes a while). Extra `.env` knobs: `CUDA_VERSION` (default `12.4.1`) and `CUDA_ARCHITECTURES` (default `75;80;90` — covers Turing through Hopper; set `120` for RTX 50 / Blackwell consumer cards). The large-v3-turbo model fits in roughly 6 GB of VRAM; use a smaller GGML model on smaller cards.
78
+ - The self-signed TLS certificate is generated at startup. `REPORTS_TLS_SAN` (defaulting to `REPORTS_HOST` from `.env`) is added to the certificate SANs so the address phones use is covered.
79
+ - On networks where `deb.debian.org` is unreachable, set `APT_MIRROR` (for example `mirrors.tuna.tsinghua.edu.cn`) in `.env` before building — it applies to both images.
80
+
81
+ ## Local Tools
82
+
83
+ - Git repository activity goes through the GitHub REST API (api.github.com) and GitLab REST API v4 (self-hosted instances supported; the GitLab server address is configured once per account in 全局设置 → Git 集成, defaulting to `https://gitlab.com`). No `gh`/`glab` CLIs are needed.
84
+ - Users configure their own access tokens in 全局设置 → Git 集成 (stored per account, never returned by the API — only a last-4 hint comes back). GitHub supports multiple tokens: an entry with an organization name is used only for that org's repositories (fine-grained token with that org as resource owner), and an entry with an empty organization is the fallback for everything else (classic token, or a personal fine-grained token). Read-only access is enough: fine-grained tokens need `Contents: Read-only`, classic tokens need `repo` (or `public_repo` for public repos only); org repos additionally require org approval (Settings → Third-party access) and, when the org enforces SAML SSO, a per-token SSO authorization. GitLab uses one personal access token with the `read_api` scope (Reporter role for private projects) plus the shared server address from 全局设置; for self-signed certificates, 跳过 SSL 证书校验 in the same panel disables TLS verification for that account's GitLab connections. Without a token only public repositories are reachable, and GitHub's anonymous rate limit is low. Administrators can disable either integration globally.
85
+ - Report generation runs exclusively through the `internal` agent, which calls the configured LLM in-process through langchain's provider bindings. Configure it in 全局设置 → 内部 Agent LLM (admin only): provider (`openai` or `anthropic`), endpoint base URL, model name, and API key. A blank base URL defaults to `https://api.openai.com/v1` / `https://api.anthropic.com`; any OpenAI-compatible or Anthropic-compatible endpoint (LM Studio, gateways) works. The API key is stored only in the local database (never returned by the API) and falls back to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` environment variables when unset.
86
+ - Markdown report rendering uses `markdown-it-py` with a Python-Markdown fallback.
87
+ - Set `REPORTS_FAKE_PROVIDER=1` only for local tests or dry runs that generate a deterministic report without calling the LLM. Normal service startup uses real provider execution.
88
+
89
+ ## Voice TODO
90
+
91
+ - A floating microphone button at the bottom right records while held. The browser only captures audio and converts it to a 16 kHz mono WAV locally (no speech leaves the machine at this stage); Safari and Chrome are supported. Microphone access requires a secure context: use `localhost` or the HTTPS listener (`https://<host>:8443`) from phones.
92
+ - On release, the WAV goes to `POST /api/todos/voice`, which starts a background voice job and returns immediately. Only one voice job runs at a time; while one is active the mic button becomes a stop button that cancels it (`POST /api/voice-jobs/{id}/cancel`), and the progress bubble with the transcript survives page reloads (`GET /api/voice-jobs/active`). The internal agent structures the transcript into one or more TODO items.
93
+ - The ASR service is any OpenAI-compatible transcription endpoint. The bundled choice is the whisper.cpp server (`/inference` on port 8766, large-v3-turbo model), registered as a per-user login service by `scripts/install_asr_service.sh` — same three-platform flow as the main service (macOS LaunchAgent / Linux systemd user unit / Windows Startup-folder `.bat` from Git Bash; inside WSL2 use the Linux flow; `scripts/uninstall_service.sh asr` removes it). It needs two things first:
94
+ 1. a `whisper-server` binary — macOS: `brew install whisper-cpp`; Linux: build once (`git clone https://github.com/ggml-org/whisper.cpp && cmake -S whisper.cpp -B whisper.cpp/build && cmake --build whisper.cpp/build --target whisper-server`, keep `build/bin/whisper-server` on `PATH` or point `WHISPER_SERVER` at it); Windows: a build from the [whisper.cpp releases](https://github.com/ggml-org/whisper.cpp/releases) (or the same CMake build), with `WHISPER_SERVER` pointing at the `.exe`.
95
+ 2. the GGML model under `data/models/` (same file the docker `asr` container mounts):
96
+
97
+ ```bash
98
+ curl -L -o data/models/ggml-large-v3-turbo-q5_0.bin \
99
+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
100
+ # if huggingface.co is unreachable: https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
101
+ ```
102
+ - Transcription requests carry a `language` hint from the 识别语言 dropdown, defaulting to 中文 (`zh`) so short Chinese clips are not mis-detected as English. `自动检测` omits the field so the service decides.
103
+ - Appearance (theme color and light/dark mode) is stored per account server-side; `PUT /api/settings` is a partial update, so each settings panel only writes the keys it manages.
104
+ - If the configured agent fails, the raw transcript still creates TODO item(s) and the UI reports the fallback.
105
+
106
+ ## Uploads
107
+
108
+ Supported project material types:
109
+
110
+ - Markdown: `.md`, `.markdown`
111
+ - Plain text: `.txt`
112
+ - PDF: `.pdf`
113
+
114
+ Markdown and plain text are extracted as UTF-8. PDF files are parsed server-side with pypdf and surfaced in report context with extraction status; scans or unreadable PDFs are marked failed instead of blocking the report.
115
+
116
+ ## Report Context
117
+
118
+ Generated reports use a structured context snapshot for change detection and audit metadata. The internal agent receives the bounded evidence inline (no tool execution, no direct database or file access). The most important current-week evidence is:
119
+
120
+ - `new_materials_this_week`: project materials uploaded or manually entered during the current ISO project week in `Asia/Shanghai`.
121
+ - `git_commits_this_week`: commits from each connected repository's (GitHub or GitLab) configured tracked branches during the current ISO project week.
122
+
123
+ The prompt explicitly asks the model to use these as primary evidence and to say when no new materials or commits exist.
124
+
125
+ ## Tests
126
+
127
+ ```bash
128
+ python3 -m unittest
129
+ openspec validate "add-weekly-project-management-system"
130
+ ```
131
+
132
+ ## CLI Client
133
+
134
+ `zreport` is the standard-library command-line client (`zreport.py` in the repo root). On machines without the checkout, install it from PyPI (`pip install zreport`); otherwise symlink it onto your PATH (`ln -sf "$PWD/zreport.py" ~/.local/bin/zreport`; on Windows create a `zreport.cmd` shim running `@python path\to\zreport.py %*`). Sign in once with the device flow — it prints a URL and a code; open the URL, sign in, and approve (the page is the same one a phone or another machine would use) — then work from the terminal:
135
+
136
+ ```bash
137
+ zreport login --server http://127.0.0.1:8765 # add --insecure once for the self-signed HTTPS port
138
+ zreport whoami
139
+ zreport projects
140
+ zreport materials add 周报系统 --text "本周完成设备授权" --title 进展
141
+ zreport materials add 周报系统 --text - < notes.txt # pipe content through stdin
142
+ zreport materials add 周报系统 --file notes.md 设计稿.pdf
143
+ zreport todos
144
+ zreport todo add "整理部署文档" -d "补充 GPU compose 说明"
145
+ zreport todo status 3 doing
146
+ zreport todo done 3 --project 周报系统 --reason "文档已合并"
147
+ ```
148
+
149
+ - The login exchanges a device code for a long-lived session token (365 days) stored in `<config>/zreport/cli.json` with `0600` permissions; `logout` revokes it server-side. Disabling or deleting a user revokes their CLI sessions too.
150
+ - Requests carry `Authorization: Bearer`, so every authenticated `/api` route works unchanged for CLI clients. The client always bypasses system proxy variables. The global flags `--server`, `--token`, `--insecure`, and `--config` allow scripting without touching the stored credentials.
151
+ - An agent skill (a `SKILL.md` teaching coding agents how to drive the CLI for weekly-report work) ships inside the package: `zreport skill install` writes it to `./.agents/skills/zreport/SKILL.md`, and `zreport skill install --global` to `~/.agents/skills/zreport/SKILL.md`. Point your agent at the `.agents/skills` directory (for Claude Code, symlink it into `~/.claude/skills`).
152
+
153
+ ## Android Client
154
+
155
+ The native Android client lives in its own repository: `../pm-android`
156
+ (voice TODOs, TODO board, weekly report reading; talks to this service's
157
+ HTTPS API). It now needs a login: configure the same account as the web
158
+ UI (server responses require the session cookie). Build and setup
159
+ instructions are in that repo's README; the planning doc moved there too
160
+ (`ANDROID_APP_PLAN.md`).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zreport"
7
+ description = "zreport (Zero Report) 命令行客户端:本地周报工作台的项目/资料/TODO 终端工具"
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.9"
12
+ dynamic = ["version"]
13
+ keywords = ["weekly-report", "cli", "todo", "whisper"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Office/Business :: Scheduling",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/cyberelf/pm"
23
+
24
+ [project.scripts]
25
+ zreport = "zreport:main"
26
+
27
+ # the PyPI package ships the standard-library CLI only; the server
28
+ # (reports_app) stays a local app with its heavier deps installed from
29
+ # requirements.txt
30
+ [tool.setuptools]
31
+ py-modules = ["zreport"]
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "zreport.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: zreport
3
+ Version: 1.0.0
4
+ Summary: zreport (Zero Report) 命令行客户端:本地周报工作台的项目/资料/TODO 终端工具
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/cyberelf/pm
7
+ Keywords: weekly-report,cli,todo,whisper
8
+ Classifier: Environment :: Console
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Office/Business :: Scheduling
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # zreport (Zero Report)
18
+
19
+ Local project management workspace for weekly plans, updates, source materials, GitHub/GitLab activity, generated Markdown reports, and deterministic risk warnings. Accounts are managed by a system administrator; every user only sees their own projects, TODOs, and voice jobs.
20
+
21
+ ## Run
22
+
23
+ Install Python dependencies and start the server (on Windows use the `py` launcher — `py -m pip install -r requirements.txt`, `py run.py` — or run the commands below from Git Bash):
24
+
25
+ ```bash
26
+ python3 -m pip install --user -r requirements.txt
27
+ python3 run.py
28
+ ```
29
+
30
+ Open `http://127.0.0.1:8000` and sign in.
31
+
32
+ ## Accounts
33
+
34
+ - All API access requires login; the login screen appears on first visit.
35
+ - On first startup the service bootstraps an administrator account named `darren`. The initial password comes from `REPORTS_ADMIN_PASSWORD` (`.env` or environment) and defaults to `changeme` — set it before the first start, then change it in 全局设置 → 修改密码.
36
+ - Administrators manage accounts in 全局设置 → 用户管理 (create users, reset passwords, toggle admin/enabled, delete). A user whose projects/TODOs still exist cannot be deleted; the last enabled admin cannot be removed or demoted.
37
+ - Users see only their own data: projects, TODOs, voice jobs, and queue tasks are filtered per account. Appearance preferences and Git tokens are stored per user too.
38
+ - 全局设置 panels for 内部 Agent LLM, 语音 TODO (ASR service), and 任务队列, plus the GitHub/GitLab enable switches and 用户管理, are visible to administrators only.
39
+
40
+ For a steadier background service on port 8765:
41
+
42
+ ```bash
43
+ scripts/start_server.sh
44
+ scripts/stop_server.sh
45
+ ```
46
+
47
+ Set `PORT=9000` to choose another port. These scripts need bash (macOS, Linux, WSL, or Git Bash on Windows).
48
+
49
+ Or keep machine-local settings in a `.env` file at the repo root (git-ignored):
50
+
51
+ ```bash
52
+ PORT=8765
53
+ REPORTS_HOST=10.200.200.3
54
+ ```
55
+
56
+ Both `python3 run.py` and `scripts/install_service.sh` read `.env` for `PORT`, `REPORTS_HOST`, and `REPORTS_FAKE_PROVIDER` defaults. Real environment variables always take precedence over the file.
57
+
58
+ The service also listens on HTTPS port `8443` (set `REPORTS_TLS_PORT` to change or set it empty to disable) with a self-signed certificate generated at `data/tls/`. Phones need this HTTPS listener to use microphone access for voice TODOs: open `https://<host>:8443` and accept the certificate warning once.
59
+
60
+ For the most stable local service, install it as a per-user login service (restarts with the machine, same `.env`/port knobs):
61
+
62
+ ```bash
63
+ PORT=8765 scripts/install_service.sh
64
+ scripts/uninstall_service.sh # add "asr" to remove the voice service instead
65
+ ```
66
+
67
+ One script, three platforms:
68
+
69
+ - macOS: LaunchAgent (launchd), as before.
70
+ - Linux: systemd user service `zreport.service`; run `sudo loginctl enable-linger $USER` once so it also survives logout.
71
+ - Windows: a `.bat` in the Start Menu Startup folder (run the script from Git Bash); a console window appears at login — minimize it. Inside WSL2, use the Linux flow instead.
72
+
73
+ ## Docker Compose
74
+
75
+ An alternative server deployment mode that runs the backend and the local voice model as containers, with the same data layout as the native service:
76
+
77
+ ```bash
78
+ docker compose up -d --build
79
+ curl --noproxy '*' "http://127.0.0.1:${PORT:-8765}/api/auth/state"
80
+ ```
81
+
82
+ - Two services: `reports` (the backend) and `asr` (a local voice model — the whisper.cpp server built from source at `docker/asr/Dockerfile`, pinned by `WHISPER_CPP_VERSION`, default `v1.9.3`). The first build compiles whisper.cpp and takes a few minutes.
83
+ - Data (SQLite, uploads, TLS certificates) lives in a docker-managed named volume (`zreport_reports-data`), never in the checkout and never in the native service's `data/` directory — the volume starts empty and the two modes never share state. It survives `docker compose down`; remove it with `docker compose down -v`, and back it up with `docker compose cp reports:/app/data ./data-backup`. Deployments from before the zreport rename still hold their data in `weekly-reports_reports-data`; copy it across once with `docker run --rm -v weekly-reports_reports-data:/from -v zreport_reports-data:/to alpine sh -c 'cp -a /from/. /to/'`.
84
+ - Host-side settings come from the repo-root `.env` (`PORT`, `REPORTS_TLS_PORT`, `REPORTS_FAKE_PROVIDER`, `REPORTS_QUEUE_CAPACITY`, `REPORTS_QUEUE_PARALLELISM`, `REPORTS_ADMIN_PASSWORD`, and for the asr service `ASR_PORT`, `ASR_MODEL`, `WHISPER_CPP_VERSION`). Inside the container the server binds `0.0.0.0` on fixed ports 8765/8443, published as `${PORT:-8765}` / `${REPORTS_TLS_PORT:-8443}`.
85
+ - The image ships chromium for PDF export with CJK fonts. Set `REPORTS_ADMIN_PASSWORD` in `.env` before the first start so the bootstrapped `darren` admin does not use the default password.
86
+ - Voice TODOs: put the GGML model in `data/models/` (see Voice TODO below) before starting — the `asr` container mounts that directory read-only and serves `/inference` on container port 8766. Set the ASR endpoint in 全局设置 to `http://asr:8766/inference` (container-to-container over the compose network). The port is also published on the host as `${ASR_PORT:-8766}`; if the native whisper service already listens there, set `ASR_PORT` in `.env` to a different host port. To use a natively installed whisper service instead of the container, point the endpoint at `http://host.docker.internal:8766/inference` (reachable through the `host-gateway` mapping).
87
+ - NVIDIA GPU hosts: layer on the GPU override so the `asr` container runs whisper.cpp on the GPU instead of the CPU (needs nvidia-container-toolkit):
88
+
89
+ ```bash
90
+ docker compose -f compose.yaml -f compose.gpu.yaml up -d --build
91
+ ```
92
+
93
+ This builds `docker/asr/Dockerfile.cuda` (same runtime layout, CUDA kernels; the first build compiles for every architecture in the list and takes a while). Extra `.env` knobs: `CUDA_VERSION` (default `12.4.1`) and `CUDA_ARCHITECTURES` (default `75;80;90` — covers Turing through Hopper; set `120` for RTX 50 / Blackwell consumer cards). The large-v3-turbo model fits in roughly 6 GB of VRAM; use a smaller GGML model on smaller cards.
94
+ - The self-signed TLS certificate is generated at startup. `REPORTS_TLS_SAN` (defaulting to `REPORTS_HOST` from `.env`) is added to the certificate SANs so the address phones use is covered.
95
+ - On networks where `deb.debian.org` is unreachable, set `APT_MIRROR` (for example `mirrors.tuna.tsinghua.edu.cn`) in `.env` before building — it applies to both images.
96
+
97
+ ## Local Tools
98
+
99
+ - Git repository activity goes through the GitHub REST API (api.github.com) and GitLab REST API v4 (self-hosted instances supported; the GitLab server address is configured once per account in 全局设置 → Git 集成, defaulting to `https://gitlab.com`). No `gh`/`glab` CLIs are needed.
100
+ - Users configure their own access tokens in 全局设置 → Git 集成 (stored per account, never returned by the API — only a last-4 hint comes back). GitHub supports multiple tokens: an entry with an organization name is used only for that org's repositories (fine-grained token with that org as resource owner), and an entry with an empty organization is the fallback for everything else (classic token, or a personal fine-grained token). Read-only access is enough: fine-grained tokens need `Contents: Read-only`, classic tokens need `repo` (or `public_repo` for public repos only); org repos additionally require org approval (Settings → Third-party access) and, when the org enforces SAML SSO, a per-token SSO authorization. GitLab uses one personal access token with the `read_api` scope (Reporter role for private projects) plus the shared server address from 全局设置; for self-signed certificates, 跳过 SSL 证书校验 in the same panel disables TLS verification for that account's GitLab connections. Without a token only public repositories are reachable, and GitHub's anonymous rate limit is low. Administrators can disable either integration globally.
101
+ - Report generation runs exclusively through the `internal` agent, which calls the configured LLM in-process through langchain's provider bindings. Configure it in 全局设置 → 内部 Agent LLM (admin only): provider (`openai` or `anthropic`), endpoint base URL, model name, and API key. A blank base URL defaults to `https://api.openai.com/v1` / `https://api.anthropic.com`; any OpenAI-compatible or Anthropic-compatible endpoint (LM Studio, gateways) works. The API key is stored only in the local database (never returned by the API) and falls back to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` environment variables when unset.
102
+ - Markdown report rendering uses `markdown-it-py` with a Python-Markdown fallback.
103
+ - Set `REPORTS_FAKE_PROVIDER=1` only for local tests or dry runs that generate a deterministic report without calling the LLM. Normal service startup uses real provider execution.
104
+
105
+ ## Voice TODO
106
+
107
+ - A floating microphone button at the bottom right records while held. The browser only captures audio and converts it to a 16 kHz mono WAV locally (no speech leaves the machine at this stage); Safari and Chrome are supported. Microphone access requires a secure context: use `localhost` or the HTTPS listener (`https://<host>:8443`) from phones.
108
+ - On release, the WAV goes to `POST /api/todos/voice`, which starts a background voice job and returns immediately. Only one voice job runs at a time; while one is active the mic button becomes a stop button that cancels it (`POST /api/voice-jobs/{id}/cancel`), and the progress bubble with the transcript survives page reloads (`GET /api/voice-jobs/active`). The internal agent structures the transcript into one or more TODO items.
109
+ - The ASR service is any OpenAI-compatible transcription endpoint. The bundled choice is the whisper.cpp server (`/inference` on port 8766, large-v3-turbo model), registered as a per-user login service by `scripts/install_asr_service.sh` — same three-platform flow as the main service (macOS LaunchAgent / Linux systemd user unit / Windows Startup-folder `.bat` from Git Bash; inside WSL2 use the Linux flow; `scripts/uninstall_service.sh asr` removes it). It needs two things first:
110
+ 1. a `whisper-server` binary — macOS: `brew install whisper-cpp`; Linux: build once (`git clone https://github.com/ggml-org/whisper.cpp && cmake -S whisper.cpp -B whisper.cpp/build && cmake --build whisper.cpp/build --target whisper-server`, keep `build/bin/whisper-server` on `PATH` or point `WHISPER_SERVER` at it); Windows: a build from the [whisper.cpp releases](https://github.com/ggml-org/whisper.cpp/releases) (or the same CMake build), with `WHISPER_SERVER` pointing at the `.exe`.
111
+ 2. the GGML model under `data/models/` (same file the docker `asr` container mounts):
112
+
113
+ ```bash
114
+ curl -L -o data/models/ggml-large-v3-turbo-q5_0.bin \
115
+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
116
+ # if huggingface.co is unreachable: https://hf-mirror.com/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin
117
+ ```
118
+ - Transcription requests carry a `language` hint from the 识别语言 dropdown, defaulting to 中文 (`zh`) so short Chinese clips are not mis-detected as English. `自动检测` omits the field so the service decides.
119
+ - Appearance (theme color and light/dark mode) is stored per account server-side; `PUT /api/settings` is a partial update, so each settings panel only writes the keys it manages.
120
+ - If the configured agent fails, the raw transcript still creates TODO item(s) and the UI reports the fallback.
121
+
122
+ ## Uploads
123
+
124
+ Supported project material types:
125
+
126
+ - Markdown: `.md`, `.markdown`
127
+ - Plain text: `.txt`
128
+ - PDF: `.pdf`
129
+
130
+ Markdown and plain text are extracted as UTF-8. PDF files are parsed server-side with pypdf and surfaced in report context with extraction status; scans or unreadable PDFs are marked failed instead of blocking the report.
131
+
132
+ ## Report Context
133
+
134
+ Generated reports use a structured context snapshot for change detection and audit metadata. The internal agent receives the bounded evidence inline (no tool execution, no direct database or file access). The most important current-week evidence is:
135
+
136
+ - `new_materials_this_week`: project materials uploaded or manually entered during the current ISO project week in `Asia/Shanghai`.
137
+ - `git_commits_this_week`: commits from each connected repository's (GitHub or GitLab) configured tracked branches during the current ISO project week.
138
+
139
+ The prompt explicitly asks the model to use these as primary evidence and to say when no new materials or commits exist.
140
+
141
+ ## Tests
142
+
143
+ ```bash
144
+ python3 -m unittest
145
+ openspec validate "add-weekly-project-management-system"
146
+ ```
147
+
148
+ ## CLI Client
149
+
150
+ `zreport` is the standard-library command-line client (`zreport.py` in the repo root). On machines without the checkout, install it from PyPI (`pip install zreport`); otherwise symlink it onto your PATH (`ln -sf "$PWD/zreport.py" ~/.local/bin/zreport`; on Windows create a `zreport.cmd` shim running `@python path\to\zreport.py %*`). Sign in once with the device flow — it prints a URL and a code; open the URL, sign in, and approve (the page is the same one a phone or another machine would use) — then work from the terminal:
151
+
152
+ ```bash
153
+ zreport login --server http://127.0.0.1:8765 # add --insecure once for the self-signed HTTPS port
154
+ zreport whoami
155
+ zreport projects
156
+ zreport materials add 周报系统 --text "本周完成设备授权" --title 进展
157
+ zreport materials add 周报系统 --text - < notes.txt # pipe content through stdin
158
+ zreport materials add 周报系统 --file notes.md 设计稿.pdf
159
+ zreport todos
160
+ zreport todo add "整理部署文档" -d "补充 GPU compose 说明"
161
+ zreport todo status 3 doing
162
+ zreport todo done 3 --project 周报系统 --reason "文档已合并"
163
+ ```
164
+
165
+ - The login exchanges a device code for a long-lived session token (365 days) stored in `<config>/zreport/cli.json` with `0600` permissions; `logout` revokes it server-side. Disabling or deleting a user revokes their CLI sessions too.
166
+ - Requests carry `Authorization: Bearer`, so every authenticated `/api` route works unchanged for CLI clients. The client always bypasses system proxy variables. The global flags `--server`, `--token`, `--insecure`, and `--config` allow scripting without touching the stored credentials.
167
+ - An agent skill (a `SKILL.md` teaching coding agents how to drive the CLI for weekly-report work) ships inside the package: `zreport skill install` writes it to `./.agents/skills/zreport/SKILL.md`, and `zreport skill install --global` to `~/.agents/skills/zreport/SKILL.md`. Point your agent at the `.agents/skills` directory (for Claude Code, symlink it into `~/.claude/skills`).
168
+
169
+ ## Android Client
170
+
171
+ The native Android client lives in its own repository: `../pm-android`
172
+ (voice TODOs, TODO board, weekly report reading; talks to this service's
173
+ HTTPS API). It now needs a login: configure the same account as the web
174
+ UI (server responses require the session cookie). Build and setup
175
+ instructions are in that repo's README; the planning doc moved there too
176
+ (`ANDROID_APP_PLAN.md`).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ zreport.py
6
+ zreport.egg-info/PKG-INFO
7
+ zreport.egg-info/SOURCES.txt
8
+ zreport.egg-info/dependency_links.txt
9
+ zreport.egg-info/entry_points.txt
10
+ zreport.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zreport = zreport:main
@@ -0,0 +1 @@
1
+ zreport
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line client (zreport) for the zreport server.
3
+
4
+ Standard library only. Sign in once with the OAuth-style device flow
5
+ (`zreport login`), then work with projects, materials, and TODOs from
6
+ the terminal:
7
+
8
+ python3 zreport.py login --server http://127.0.0.1:8765
9
+ python3 zreport.py projects
10
+ python3 zreport.py materials add my-project --text "shipped device auth" --title progress
11
+ python3 zreport.py materials add my-project --file notes.md spec.pdf
12
+ python3 zreport.py todos
13
+ python3 zreport.py todo add "write deploy docs" -d "include the GPU compose guide"
14
+ python3 zreport.py todo status 3 doing
15
+ python3 zreport.py todo done 3 --project my-project --reason "merged"
16
+
17
+ The token is stored in <config>/zreport/cli.json (0600). Self-signed
18
+ TLS: log in with --insecure once and the choice is remembered.
19
+ """
20
+
21
+ import argparse
22
+ import base64
23
+ import json
24
+ import os
25
+ import ssl
26
+ import sys
27
+ import time
28
+ import unicodedata
29
+ import urllib.error
30
+ import urllib.request
31
+ import webbrowser
32
+ from pathlib import Path
33
+ from urllib.parse import urlparse
34
+
35
+ DEFAULT_SERVER = "http://127.0.0.1:8765"
36
+ __version__ = "1.0.0"
37
+ LOGIN_TIMEOUT_SECONDS = 15 * 60
38
+ MATERIAL_EXTENSIONS = {".md": "text/markdown", ".markdown": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf"}
39
+ TODO_STATUSES = ("todo", "doing")
40
+ # The editable copy lives at skills/zreport/SKILL.md in the repo; this
41
+ # embedded copy ships inside the wheel (the package is a single module), and
42
+ # a test asserts the two stay identical.
43
+ SKILL_MD = """\
44
+ ---
45
+ name: zreport
46
+ description: Summarize and organize weekly-report material with the zreport CLI. Use when the user asks to collect or organize this week's work, draft a weekly report, review project progress or TODO status, submit work notes to a project, or manage TODOs.
47
+ ---
48
+
49
+ # Summarize weekly-report material with zreport
50
+
51
+ zreport is the command-line client of a local weekly-report workspace
52
+ (projects, materials, TODOs, generated weekly reports). Drive the `zreport`
53
+ command only — do not call the server's HTTP API directly.
54
+
55
+ ## Before you start
56
+
57
+ - Server URL: read `server` from `~/.config/zreport/cli.json` when it exists;
58
+ otherwise ask the user. Every command also accepts `--server <URL>`.
59
+ - Sign-in check: run `zreport whoami`. If it reports "Not signed in", run
60
+ `zreport login --server <URL>`: it prints a device code and a `/device`
61
+ URL. The browser approval step belongs to the user — agents cannot
62
+ approve their own device.
63
+
64
+ ## Collect the current state (read-only)
65
+
66
+ - `zreport projects`
67
+ - `zreport todos` and `zreport todos --all` (`--all` includes closed TODOs;
68
+ the PROJECT column shows which project a closed TODO was archived into)
69
+
70
+ Limitation: the CLI has no subcommands yet for reading material bodies or
71
+ archived weekly reports. If the task truly needs them, tell the user to
72
+ open an issue at https://github.com/cyberelf/pm/issues instead of working
73
+ around the CLI.
74
+
75
+ ## Organize the summary
76
+
77
+ - Evidence order: what the user dictates or points at, then active TODOs and
78
+ TODOs closed this week, then archived TODOs (`todos --all`).
79
+ - Time window: ISO week, timezone Asia/Shanghai.
80
+ - Suggested structure: done this week / in progress / blockers and risks /
81
+ next week's plan. State only what the evidence supports; say explicitly
82
+ when nothing new exists instead of padding.
83
+ - Show the draft to the user and get confirmation before writing anything.
84
+
85
+ ## Write back (confirm each item with the user first)
86
+
87
+ - Text material: `echo "..." | zreport materials add <project ID or name> --text - --title "Title"`
88
+ - Attachments: `zreport materials add <project> --file a.md b.pdf`
89
+ (supported: .md .markdown .txt .pdf)
90
+ - TODOs: `zreport todo add "Title" -d "Details"`, then
91
+ `zreport todo status <ID> doing`, then
92
+ `zreport todo done <ID> -p <project> -r "closing note"` (done archives the
93
+ TODO as a material of that project).
94
+ - Server-side constraint: only materials created in the current ISO week can
95
+ be edited or deleted; older ones are locked. Surface server errors as-is.
96
+ """
97
+
98
+
99
+ class CliError(Exception):
100
+ pass
101
+
102
+
103
+ # ---------------------------------------------------------------- config
104
+
105
+
106
+ def default_config_path():
107
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
108
+ return Path(base) / "zreport" / "cli.json"
109
+
110
+
111
+ def load_config(path):
112
+ try:
113
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
114
+ except FileNotFoundError:
115
+ return {}
116
+ except (OSError, ValueError) as exc:
117
+ raise CliError(f"cannot read config {path}: {exc}") from exc
118
+ return data if isinstance(data, dict) else {}
119
+
120
+
121
+ def save_config(path, config):
122
+ path = Path(path)
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ # create with 0600 from the first byte: a plain write_text would briefly
125
+ # expose the token at the umask's default mode before chmod runs; the
126
+ # trailing chmod still tightens files written by older versions
127
+ payload = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
128
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
129
+ with os.fdopen(fd, "wb") as fh:
130
+ fh.write(payload)
131
+ path.chmod(0o600)
132
+
133
+
134
+ # ---------------------------------------------------------------- http
135
+
136
+
137
+ def build_opener(insecure):
138
+ # bypass http_proxy/https_proxy: the service is reached on the LAN or
139
+ # loopback, a system proxy would only break the request
140
+ return urllib.request.build_opener(
141
+ urllib.request.ProxyHandler({}),
142
+ urllib.request.HTTPSHandler(context=ssl._create_unverified_context() if insecure else None),
143
+ )
144
+
145
+
146
+ def api_request(config, method, path, payload=None, timeout=30):
147
+ server = config["server"].rstrip("/")
148
+ # keep urllib's file/ftp handlers out of reach via a crafted --server
149
+ if urlparse(server).scheme not in ("http", "https"):
150
+ raise CliError(f"unsupported server URL: {config['server']} (only http:// or https://)")
151
+ opener = build_opener(not config.get("tls_verify", True))
152
+ data = None
153
+ headers = {}
154
+ if payload is not None:
155
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
156
+ headers["Content-Type"] = "application/json"
157
+ if config.get("token"):
158
+ headers["Authorization"] = f"Bearer {config['token']}"
159
+ request = urllib.request.Request(server + path, data=data, headers=headers, method=method)
160
+ try:
161
+ with opener.open(request, timeout=timeout) as response:
162
+ body = response.read().decode("utf-8")
163
+ except urllib.error.HTTPError as exc:
164
+ detail = exc.read().decode("utf-8", "replace")
165
+ try:
166
+ detail = json.loads(detail).get("error") or detail
167
+ except ValueError:
168
+ pass
169
+ raise CliError(f"HTTP {exc.code}: {detail.strip()}") from exc
170
+ except urllib.error.URLError as exc:
171
+ hint = connection_error_hint(str(exc.reason))
172
+ raise CliError(f"cannot reach {config['server']}: {exc.reason}{hint}") from exc
173
+ try:
174
+ return json.loads(body) if body else {}
175
+ except ValueError:
176
+ preview = body.strip()[:120]
177
+ raise CliError(f"server returned non-JSON content; this is probably not a zreport server: {preview}") from None
178
+
179
+
180
+ def connection_error_hint(reason):
181
+ """Turn bare SSL noise into an actionable hint."""
182
+ if "CERTIFICATE_VERIFY_FAILED" in reason:
183
+ return " (self-signed certificates trigger this: double-check the URL, or pass --insecure to skip verification)"
184
+ if "CERTIFICATE_REQUIRED" in reason:
185
+ return " (the peer demands a client certificate: this is probably not a zreport server, check URL and port)"
186
+ return ""
187
+
188
+
189
+ # ---------------------------------------------------------------- output
190
+
191
+
192
+ def display_width(text):
193
+ return sum(2 if unicodedata.east_asian_width(char) in ("F", "W") else 1 for char in str(text))
194
+
195
+
196
+ def print_table(headers, rows):
197
+ widths = [display_width(header) for header in headers]
198
+ for row in rows:
199
+ for index, cell in enumerate(row):
200
+ widths[index] = max(widths[index], display_width(cell))
201
+
202
+ def format_row(cells):
203
+ padded = ""
204
+ for index, cell in enumerate(cells):
205
+ pad = " " * (widths[index] - display_width(cell))
206
+ padded += str(cell) + pad + (" " if index + 1 < len(cells) else "")
207
+ return padded.rstrip()
208
+
209
+ print(format_row(headers))
210
+ print(" ".join("-" * width for width in widths))
211
+ for row in rows:
212
+ print(format_row(row))
213
+
214
+
215
+ # ---------------------------------------------------------------- login
216
+
217
+
218
+ def cmd_login(args, config, config_path):
219
+ server = (args.server or config.get("server") or DEFAULT_SERVER).rstrip("/")
220
+ login_config = {"server": server, "tls_verify": not args.insecure}
221
+ start = api_request(login_config, "POST", "/api/device/auth/start", payload={}, timeout=30)
222
+ user_code = start["user_code"]
223
+ url = server + start.get("verification_path", "/device")
224
+ print(f"1. Open the authorization page: {url}")
225
+ try:
226
+ webbrowser.open(url)
227
+ except Exception:
228
+ pass
229
+ print(f"2. Sign in and enter the code: {user_code}")
230
+ print("waiting for approval", end="", flush=True)
231
+
232
+ deadline = time.monotonic() + min(int(start.get("expires_in", 900)), LOGIN_TIMEOUT_SECONDS)
233
+ interval = max(1, int(start.get("interval", 5)))
234
+ while time.monotonic() < deadline:
235
+ time.sleep(interval)
236
+ result = api_request(login_config, "POST", "/api/device/auth/poll", payload={"device_code": start["device_code"]}, timeout=30)
237
+ status = result.get("status")
238
+ if status == "pending":
239
+ print(".", end="", flush=True)
240
+ continue
241
+ print()
242
+ if status == "approved":
243
+ config.update(login_config)
244
+ config["token"] = result["access_token"]
245
+ config["user"] = (result.get("user") or {}).get("username")
246
+ save_config(config_path, config)
247
+ print(f"Signed in as {config['user']} (credentials saved to {config_path})")
248
+ return 0
249
+ if status == "denied":
250
+ raise CliError("authorization denied")
251
+ raise CliError("device code expired, log in again")
252
+ print()
253
+ raise CliError("timed out waiting for approval, log in again")
254
+
255
+
256
+ def cmd_logout(args, config, config_path):
257
+ if config.get("token"):
258
+ try:
259
+ api_request(config, "POST", "/api/auth/logout", payload={})
260
+ except CliError:
261
+ pass # token may already be gone server-side; clear locally anyway
262
+ config.pop("token", None)
263
+ config.pop("user", None)
264
+ save_config(config_path, config)
265
+ print("Signed out")
266
+ return 0
267
+
268
+
269
+ def cmd_whoami(args, config, config_path):
270
+ state = api_request(config, "GET", "/api/auth/state")
271
+ user = state.get("current_user")
272
+ if not user:
273
+ print("Not signed in (run the login command)")
274
+ return 1
275
+ role = "admin" if user.get("is_admin") else "user"
276
+ print(f"{user['username']} ({role}) @ {config['server']} (server v{state.get('version', '?')})")
277
+ return 0
278
+
279
+
280
+ # ---------------------------------------------------------------- projects & materials
281
+
282
+
283
+ def require_login(config):
284
+ if not config.get("token"):
285
+ raise CliError("not signed in: run the login command first")
286
+
287
+
288
+ def fetch_projects(config):
289
+ state = api_request(config, "GET", "/api/state")
290
+ return state.get("projects") or []
291
+
292
+
293
+ def resolve_project(config, ref):
294
+ projects = fetch_projects(config)
295
+ for project in projects:
296
+ if str(project["id"]) == str(ref) or project["name"] == ref:
297
+ return project
298
+ raise CliError(f"project not found: {ref} (see the projects command)")
299
+
300
+
301
+ def cmd_projects(args, config, config_path):
302
+ require_login(config)
303
+ rows = [
304
+ (
305
+ project["id"],
306
+ project["name"],
307
+ project["status"],
308
+ project.get("timezone", ""),
309
+ (project.get("updated_at") or "")[:16].replace("T", " "),
310
+ )
311
+ for project in fetch_projects(config)
312
+ ]
313
+ print_table(["ID", "PROJECT", "STATUS", "TIMEZONE", "UPDATED"], rows)
314
+ return 0
315
+
316
+
317
+ def cmd_materials_add(args, config, config_path):
318
+ require_login(config)
319
+ project = resolve_project(config, args.project)
320
+ if args.file:
321
+ files = []
322
+ for raw_path in args.file:
323
+ path = Path(raw_path).expanduser()
324
+ ext = path.suffix.lower()
325
+ if ext not in MATERIAL_EXTENSIONS:
326
+ raise CliError(f"unsupported file type: {path.name} (supported: {' '.join(sorted(MATERIAL_EXTENSIONS))})")
327
+ try:
328
+ raw = path.read_bytes()
329
+ except OSError as exc:
330
+ raise CliError(f"cannot read {path}: {exc}") from exc
331
+ files.append(
332
+ {
333
+ "filename": path.name,
334
+ "content_base64": base64.b64encode(raw).decode("ascii"),
335
+ "content_type": MATERIAL_EXTENSIONS[ext],
336
+ }
337
+ )
338
+ result = api_request(
339
+ config,
340
+ "POST",
341
+ f"/api/projects/{project['id']}/materials",
342
+ payload={"files": files},
343
+ timeout=300,
344
+ )
345
+ print(f"Uploaded {len(files)} attachment(s) to '{project['name']}' (material IDs: {', '.join(str(i) for i in result.get('ids', []))})")
346
+ return 0
347
+ text = args.text
348
+ if text == "-":
349
+ text = sys.stdin.read()
350
+ text = (text or "").strip()
351
+ if not text:
352
+ raise CliError('provide content: --text "..." (or --text - to read stdin), or attach files with --file')
353
+ title = (args.title or "").strip() or "CLI note"
354
+ result = api_request(
355
+ config,
356
+ "POST",
357
+ f"/api/projects/{project['id']}/materials",
358
+ payload={"source_type": "manual", "title": title, "content": text},
359
+ )
360
+ print(f"Submitted text material to '{project['name']}' (material ID: {result.get('id')})")
361
+ return 0
362
+
363
+
364
+ # ---------------------------------------------------------------- todos
365
+
366
+
367
+ def cmd_todos(args, config, config_path):
368
+ require_login(config)
369
+ todos = api_request(config, "GET", "/api/todos").get("todos") or []
370
+ if not args.all:
371
+ todos = [todo for todo in todos if todo["status"] != "closed"]
372
+ rows = [
373
+ (
374
+ todo["id"],
375
+ todo["status"],
376
+ todo.get("project_name") or "-",
377
+ todo["title"],
378
+ )
379
+ for todo in todos
380
+ ]
381
+ print_table(["ID", "STATUS", "PROJECT", "TITLE"], rows)
382
+ return 0
383
+
384
+
385
+ def cmd_todo_add(args, config, config_path):
386
+ require_login(config)
387
+ result = api_request(config, "POST", "/api/todos", payload={"title": args.title, "description": args.description or ""})
388
+ print(f"Created TODO #{result.get('id')}: {args.title}")
389
+ return 0
390
+
391
+
392
+ def cmd_todo_status(args, config, config_path):
393
+ require_login(config)
394
+ api_request(config, "PUT", f"/api/todos/{args.id}", payload={"status": args.status})
395
+ print(f"TODO #{args.id} status set to {args.status}")
396
+ return 0
397
+
398
+
399
+ def cmd_todo_done(args, config, config_path):
400
+ require_login(config)
401
+ project = resolve_project(config, args.project)
402
+ result = api_request(
403
+ config,
404
+ "POST",
405
+ f"/api/todos/{args.id}/close",
406
+ payload={"reason": args.reason, "project_id": project["id"]},
407
+ )
408
+ material_id = result.get("material_id")
409
+ archived = f", archived as material #{material_id} in '{project['name']}'" if material_id else ""
410
+ print(f"TODO #{args.id} done{archived}")
411
+ return 0
412
+
413
+
414
+ # ---------------------------------------------------------------- agent skill
415
+
416
+
417
+ def cmd_skill_install(args, config, config_path):
418
+ base = Path.home() / ".agents" if args.global_install else Path.cwd() / ".agents"
419
+ target = base / "skills" / "zreport" / "SKILL.md"
420
+ target.parent.mkdir(parents=True, exist_ok=True)
421
+ target.write_text(SKILL_MD, encoding="utf-8")
422
+ print(f"Skill installed: {target}")
423
+ print("Point your coding agent at the .agents/skills directory (e.g. symlink it into ~/.claude/skills for Claude Code).")
424
+ return 0
425
+
426
+
427
+ # ---------------------------------------------------------------- parser
428
+
429
+
430
+ def build_parser():
431
+ parser = argparse.ArgumentParser(prog="zreport", description="zreport (Zero Report) command-line client")
432
+ parser.add_argument("--server", help=f"server URL (default {DEFAULT_SERVER}; from saved config after login)")
433
+ parser.add_argument("--token", help="use this Bearer token (default: from saved config)")
434
+ parser.add_argument("--insecure", action="store_true", help="skip TLS certificate verification (self-signed certs)")
435
+ parser.add_argument("--config", help="credentials file path (default <config>/zreport/cli.json)")
436
+ sub = parser.add_subparsers(dest="command", required=True)
437
+
438
+ login = sub.add_parser("login", help="sign in with the device authorization flow")
439
+ login.add_argument("--server", help="server URL, e.g. http://127.0.0.1:8765")
440
+ login.add_argument("--insecure", action="store_true", help="skip TLS certificate verification (self-signed certs)")
441
+ login.set_defaults(func=cmd_login)
442
+
443
+ logout = sub.add_parser("logout", help="sign out and clear local credentials")
444
+ logout.set_defaults(func=cmd_logout)
445
+
446
+ whoami = sub.add_parser("whoami", help="show the signed-in user")
447
+ whoami.set_defaults(func=cmd_whoami)
448
+
449
+ projects = sub.add_parser("projects", help="list projects")
450
+ projects.set_defaults(func=cmd_projects)
451
+
452
+ materials = sub.add_parser("materials", help="project materials")
453
+ materials_sub = materials.add_subparsers(dest="materials_command", required=True)
454
+ materials_add = materials_sub.add_parser("add", help="add material (text or file attachment)")
455
+ materials_add.add_argument("project", help="project ID or name")
456
+ materials_add.add_argument("--text", help="text content; pass - to read from stdin")
457
+ materials_add.add_argument("--title", help="text material title (default: CLI note)")
458
+ materials_add.add_argument("--file", nargs="+", metavar="PATH", help="file attachments (.md .markdown .txt .pdf, multiple allowed)")
459
+ materials_add.set_defaults(func=cmd_materials_add)
460
+
461
+ todos = sub.add_parser("todos", help="list TODOs")
462
+ todos.add_argument("--all", action="store_true", help="include closed TODOs")
463
+ todos.set_defaults(func=cmd_todos)
464
+
465
+ todo = sub.add_parser("todo", help="TODO operations")
466
+ todo_sub = todo.add_subparsers(dest="todo_command", required=True)
467
+ todo_add = todo_sub.add_parser("add", help="create a TODO")
468
+ todo_add.add_argument("title", help="title")
469
+ todo_add.add_argument("-d", "--description", help="details")
470
+ todo_add.set_defaults(func=cmd_todo_add)
471
+
472
+ todo_status = todo_sub.add_parser("status", help="change a TODO's status")
473
+ todo_status.add_argument("id", type=int, help="TODO ID")
474
+ todo_status.add_argument("status", choices=TODO_STATUSES, help="target status")
475
+ todo_status.set_defaults(func=cmd_todo_status)
476
+
477
+ todo_done = todo_sub.add_parser("done", help="close a TODO and archive it to a project")
478
+ todo_done.add_argument("id", type=int, help="TODO ID")
479
+ todo_done.add_argument("-p", "--project", required=True, help="project to archive into (ID or name)")
480
+ todo_done.add_argument("-r", "--reason", default="done", help="closing reason (default: done)")
481
+ todo_done.set_defaults(func=cmd_todo_done)
482
+
483
+ skill = sub.add_parser("skill", help="manage the agent skill for coding agents")
484
+ skill_sub = skill.add_subparsers(dest="skill_command", required=True)
485
+ skill_install = skill_sub.add_parser(
486
+ "install",
487
+ help="install the skill into .agents/skills/zreport/ (current directory, or home with --global)",
488
+ )
489
+ skill_install.add_argument(
490
+ "--global", dest="global_install", action="store_true",
491
+ help="install into ~/.agents instead of ./.agents",
492
+ )
493
+ skill_install.set_defaults(func=cmd_skill_install)
494
+
495
+ return parser
496
+
497
+
498
+ def main(argv=None, config_file=None):
499
+ parser = build_parser()
500
+ args = parser.parse_args(argv)
501
+ config_path = Path(args.config or config_file or default_config_path())
502
+ config = load_config(config_path)
503
+ if args.server:
504
+ config["server"] = args.server.rstrip("/")
505
+ elif not config.get("server"):
506
+ config["server"] = DEFAULT_SERVER
507
+ if args.token:
508
+ config["token"] = args.token
509
+ if args.insecure:
510
+ config["tls_verify"] = False
511
+ if not hasattr(args, "func"):
512
+ parser.print_usage()
513
+ return 2
514
+ try:
515
+ return args.func(args, config, config_path)
516
+ except CliError as exc:
517
+ print(f"error: {exc}", file=sys.stderr)
518
+ return 1
519
+ except KeyboardInterrupt:
520
+ print("\ncancelled", file=sys.stderr)
521
+ return 130
522
+
523
+
524
+ if __name__ == "__main__":
525
+ sys.exit(main())