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 +21 -0
- zreport-1.0.0/MANIFEST.in +1 -0
- zreport-1.0.0/PKG-INFO +176 -0
- zreport-1.0.0/README.md +160 -0
- zreport-1.0.0/pyproject.toml +34 -0
- zreport-1.0.0/setup.cfg +4 -0
- zreport-1.0.0/zreport.egg-info/PKG-INFO +176 -0
- zreport-1.0.0/zreport.egg-info/SOURCES.txt +10 -0
- zreport-1.0.0/zreport.egg-info/dependency_links.txt +1 -0
- zreport-1.0.0/zreport.egg-info/entry_points.txt +2 -0
- zreport-1.0.0/zreport.egg-info/top_level.txt +1 -0
- zreport-1.0.0/zreport.py +525 -0
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`).
|
zreport-1.0.0/README.md
ADDED
|
@@ -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__" }
|
zreport-1.0.0/setup.cfg
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zreport
|
zreport-1.0.0/zreport.py
ADDED
|
@@ -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())
|