md-todo-hub 0.3.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.
- md_todo_hub-0.3.0/.gitignore +17 -0
- md_todo_hub-0.3.0/LICENSE +21 -0
- md_todo_hub-0.3.0/PKG-INFO +115 -0
- md_todo_hub-0.3.0/README.md +83 -0
- md_todo_hub-0.3.0/docs_mcp/__init__.py +3 -0
- md_todo_hub-0.3.0/docs_mcp/__main__.py +33 -0
- md_todo_hub-0.3.0/docs_mcp/cli.py +264 -0
- md_todo_hub-0.3.0/docs_mcp/config.py +111 -0
- md_todo_hub-0.3.0/docs_mcp/db.py +541 -0
- md_todo_hub-0.3.0/docs_mcp/embed.py +104 -0
- md_todo_hub-0.3.0/docs_mcp/models.py +97 -0
- md_todo_hub-0.3.0/docs_mcp/parser.py +242 -0
- md_todo_hub-0.3.0/docs_mcp/projects.py +286 -0
- md_todo_hub-0.3.0/docs_mcp/search.py +267 -0
- md_todo_hub-0.3.0/docs_mcp/server.py +719 -0
- md_todo_hub-0.3.0/docs_mcp/sync.py +372 -0
- md_todo_hub-0.3.0/docs_mcp/tests/__init__.py +0 -0
- md_todo_hub-0.3.0/docs_mcp/tests/conftest.py +43 -0
- md_todo_hub-0.3.0/docs_mcp/tests/test_config.py +86 -0
- md_todo_hub-0.3.0/docs_mcp/tests/test_models.py +117 -0
- md_todo_hub-0.3.0/docs_mcp/tests/test_parser.py +303 -0
- md_todo_hub-0.3.0/docs_mcp/tests/test_projects.py +237 -0
- md_todo_hub-0.3.0/docs_mcp/tests/test_sync.py +137 -0
- md_todo_hub-0.3.0/pyproject.toml +63 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 daibou007
|
|
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,115 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: md-todo-hub
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Local cross-project todo index over markdown docs, exposed to LLMs via MCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/daibou007/ai_my_notion
|
|
6
|
+
Project-URL: Repository, https://github.com/daibou007/ai_my_notion
|
|
7
|
+
Project-URL: Issues, https://github.com/daibou007/ai_my_notion/issues
|
|
8
|
+
Author-email: daibou007 <daibou007@163.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,llm,markdown,mcp,sqlite,todo
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: mcp<2,>=1.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
26
|
+
Requires-Dist: openai>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
29
|
+
Provides-Extra: semantic
|
|
30
|
+
Requires-Dist: openai>=1.0; extra == 'semantic'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# md-todo-hub
|
|
34
|
+
|
|
35
|
+
把本地多个项目里散落在 Markdown 中的待办项,索引成一个共享的小型数据库(SQLite),
|
|
36
|
+
通过 MCP 提供给大模型使用:跨项目查进度、按优先级排下一步、把完成状态写回原文档。
|
|
37
|
+
|
|
38
|
+
- **Markdown 是唯一事实来源**,索引只是查询层;所有写操作双写数据库与原文件。
|
|
39
|
+
- 支持表格行(`| P1-1 | 标题 | 来源 | 影响 | ⏳ |`)与清单(`- [ ] 文本`)两种格式。
|
|
40
|
+
- 索引库默认在 `~/.md-todo/index.db`,可用 `MD_TODO_HOME` 改位置。
|
|
41
|
+
|
|
42
|
+
## 安装与启动(MCP)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# 直接运行 MCP server(stdio)
|
|
46
|
+
uvx --from md-todo-hub md-todo-hub
|
|
47
|
+
|
|
48
|
+
# 可选:启用语义检索 / 语义查重
|
|
49
|
+
uvx --from "md-todo-hub[semantic]" md-todo-hub
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
MCP 客户端配置示例:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"md-todo-hub": {
|
|
58
|
+
"type": "stdio",
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["--from", "md-todo-hub", "md-todo-hub"],
|
|
61
|
+
"env": { "PYTHONUTF8": "1" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 命令行(同一套索引)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uvx --from md-todo-hub mdtodo register ~/project/foo # 登记并索引一个项目
|
|
71
|
+
uvx --from md-todo-hub mdtodo overview # 跨项目进度总览
|
|
72
|
+
uvx --from md-todo-hub mdtodo next --limit 5 # 下一步该做什么
|
|
73
|
+
uvx --from md-todo-hub mdtodo search "登录" # 全文检索
|
|
74
|
+
uvx --from md-todo-hub mdtodo status P1-1 done # 标记完成(写回 Markdown)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 主要 MCP 工具
|
|
78
|
+
|
|
79
|
+
| 工具 | 作用 |
|
|
80
|
+
|------|------|
|
|
81
|
+
| `register_project` / `unregister_project` / `list_projects` | 项目注册表 |
|
|
82
|
+
| `index_projects` | 增量 / 全量刷新索引 |
|
|
83
|
+
| `get_overview` | 跨项目进度仪表盘 |
|
|
84
|
+
| `get_next_actions` | 按优先级排序的待办队列 |
|
|
85
|
+
| `search_all` | structured / fts / semantic / hybrid 检索 |
|
|
86
|
+
| `locate_item` | 定位 ID 所在项目与文件 |
|
|
87
|
+
| `update_status` | 改状态并双写 Markdown |
|
|
88
|
+
| `sync_from_md` / `reconcile` | Markdown 与索引对账 |
|
|
89
|
+
|
|
90
|
+
## 环境变量
|
|
91
|
+
|
|
92
|
+
| 变量 | 说明 | 默认值 |
|
|
93
|
+
|------|------|--------|
|
|
94
|
+
| `MD_TODO_HOME` | 索引库目录 | `~/.md-todo` |
|
|
95
|
+
| `MD_TODO_DOCS` | 默认 Markdown 根目录 | `<cwd>/docs` |
|
|
96
|
+
| `MD_TODO_LEGACY_DB` | 设为 `1` 回到 v0.2 的单项目库模式 | 关闭 |
|
|
97
|
+
| `EMBEDDING_API_KEY` / `EMBEDDING_BASE_URL` / `EMBEDDING_MODEL` | 可选语义检索 | 未配置则用 FTS5 |
|
|
98
|
+
|
|
99
|
+
## 上架产物(dist/release)
|
|
100
|
+
|
|
101
|
+
| 包 | 文件 | 用途 |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| 连接器 | `dist/release/md-todo-hub-connector-0.3.0.zip` | 开放平台「连接器」类目上传 |
|
|
104
|
+
| 技能 | `dist/release/md-todo-hub-skill-1.0.0.zip` | SkillHub 发布团队 Skill |
|
|
105
|
+
| 专家 | `dist/release/md-todo-steward-expert-1.0.0.zip` | 开放平台「专家」类目上传 |
|
|
106
|
+
|
|
107
|
+
重新生成:`bash scripts/build_release.sh`(zip 顶层目录即包名,解压直接看到 `.codebuddy-plugin/plugin.json`)。
|
|
108
|
+
|
|
109
|
+
## 开发
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python -m pytest docs_mcp/tests/ -v
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
MIT License.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# md-todo-hub
|
|
2
|
+
|
|
3
|
+
把本地多个项目里散落在 Markdown 中的待办项,索引成一个共享的小型数据库(SQLite),
|
|
4
|
+
通过 MCP 提供给大模型使用:跨项目查进度、按优先级排下一步、把完成状态写回原文档。
|
|
5
|
+
|
|
6
|
+
- **Markdown 是唯一事实来源**,索引只是查询层;所有写操作双写数据库与原文件。
|
|
7
|
+
- 支持表格行(`| P1-1 | 标题 | 来源 | 影响 | ⏳ |`)与清单(`- [ ] 文本`)两种格式。
|
|
8
|
+
- 索引库默认在 `~/.md-todo/index.db`,可用 `MD_TODO_HOME` 改位置。
|
|
9
|
+
|
|
10
|
+
## 安装与启动(MCP)
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 直接运行 MCP server(stdio)
|
|
14
|
+
uvx --from md-todo-hub md-todo-hub
|
|
15
|
+
|
|
16
|
+
# 可选:启用语义检索 / 语义查重
|
|
17
|
+
uvx --from "md-todo-hub[semantic]" md-todo-hub
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
MCP 客户端配置示例:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"md-todo-hub": {
|
|
26
|
+
"type": "stdio",
|
|
27
|
+
"command": "uvx",
|
|
28
|
+
"args": ["--from", "md-todo-hub", "md-todo-hub"],
|
|
29
|
+
"env": { "PYTHONUTF8": "1" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 命令行(同一套索引)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uvx --from md-todo-hub mdtodo register ~/project/foo # 登记并索引一个项目
|
|
39
|
+
uvx --from md-todo-hub mdtodo overview # 跨项目进度总览
|
|
40
|
+
uvx --from md-todo-hub mdtodo next --limit 5 # 下一步该做什么
|
|
41
|
+
uvx --from md-todo-hub mdtodo search "登录" # 全文检索
|
|
42
|
+
uvx --from md-todo-hub mdtodo status P1-1 done # 标记完成(写回 Markdown)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 主要 MCP 工具
|
|
46
|
+
|
|
47
|
+
| 工具 | 作用 |
|
|
48
|
+
|------|------|
|
|
49
|
+
| `register_project` / `unregister_project` / `list_projects` | 项目注册表 |
|
|
50
|
+
| `index_projects` | 增量 / 全量刷新索引 |
|
|
51
|
+
| `get_overview` | 跨项目进度仪表盘 |
|
|
52
|
+
| `get_next_actions` | 按优先级排序的待办队列 |
|
|
53
|
+
| `search_all` | structured / fts / semantic / hybrid 检索 |
|
|
54
|
+
| `locate_item` | 定位 ID 所在项目与文件 |
|
|
55
|
+
| `update_status` | 改状态并双写 Markdown |
|
|
56
|
+
| `sync_from_md` / `reconcile` | Markdown 与索引对账 |
|
|
57
|
+
|
|
58
|
+
## 环境变量
|
|
59
|
+
|
|
60
|
+
| 变量 | 说明 | 默认值 |
|
|
61
|
+
|------|------|--------|
|
|
62
|
+
| `MD_TODO_HOME` | 索引库目录 | `~/.md-todo` |
|
|
63
|
+
| `MD_TODO_DOCS` | 默认 Markdown 根目录 | `<cwd>/docs` |
|
|
64
|
+
| `MD_TODO_LEGACY_DB` | 设为 `1` 回到 v0.2 的单项目库模式 | 关闭 |
|
|
65
|
+
| `EMBEDDING_API_KEY` / `EMBEDDING_BASE_URL` / `EMBEDDING_MODEL` | 可选语义检索 | 未配置则用 FTS5 |
|
|
66
|
+
|
|
67
|
+
## 上架产物(dist/release)
|
|
68
|
+
|
|
69
|
+
| 包 | 文件 | 用途 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 连接器 | `dist/release/md-todo-hub-connector-0.3.0.zip` | 开放平台「连接器」类目上传 |
|
|
72
|
+
| 技能 | `dist/release/md-todo-hub-skill-1.0.0.zip` | SkillHub 发布团队 Skill |
|
|
73
|
+
| 专家 | `dist/release/md-todo-steward-expert-1.0.0.zip` | 开放平台「专家」类目上传 |
|
|
74
|
+
|
|
75
|
+
重新生成:`bash scripts/build_release.sh`(zip 顶层目录即包名,解压直接看到 `.codebuddy-plugin/plugin.json`)。
|
|
76
|
+
|
|
77
|
+
## 开发
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m pytest docs_mcp/tests/ -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
MIT License.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Entry point: python -m docs_mcp / md-todo-hub(MCP stdio server)"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .server import mcp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
argv = sys.argv[1:]
|
|
10
|
+
# MCP 客户端不传参数;手动敲命令时给个友好提示,避免看起来"卡住"
|
|
11
|
+
if any(a in ("-h", "--help") for a in argv):
|
|
12
|
+
print(
|
|
13
|
+
"md-todo-hub — 跨项目 Markdown 待办索引(MCP stdio server)\n\n"
|
|
14
|
+
"用法:\n"
|
|
15
|
+
" md-todo-hub 启动 MCP stdio server(供 MCP 客户端调用)\n"
|
|
16
|
+
" md-todo-hub --version 打印版本\n"
|
|
17
|
+
" mdtodo --help 命令行用法(register / overview / next / search)\n\n"
|
|
18
|
+
"该命令本身不输出内容,需由 MCP 客户端拉起;\n"
|
|
19
|
+
"若想直接在终端操作,请用 mdtodo:uvx --from md-todo-hub mdtodo --help"
|
|
20
|
+
)
|
|
21
|
+
return 0
|
|
22
|
+
if any(a in ("-V", "--version") for a in argv):
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
print(f"md-todo-hub {__version__}")
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
mcp.run(transport="stdio")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
sys.exit(main())
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Command line interface for md-todo-hub.
|
|
2
|
+
|
|
3
|
+
The MCP server is the primary interface for agents, but a CLI keeps the same
|
|
4
|
+
index usable from a terminal, a shell script, or a skill that runs without an
|
|
5
|
+
MCP connection.
|
|
6
|
+
|
|
7
|
+
mdtodo register ~/project/foo
|
|
8
|
+
mdtodo index
|
|
9
|
+
mdtodo overview
|
|
10
|
+
mdtodo next --limit 5
|
|
11
|
+
mdtodo done P1-1 --project foo
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .db import TodoDB
|
|
24
|
+
from .projects import (
|
|
25
|
+
index_all,
|
|
26
|
+
index_project,
|
|
27
|
+
locate_item,
|
|
28
|
+
next_actions,
|
|
29
|
+
overview,
|
|
30
|
+
register_project,
|
|
31
|
+
unregister_project,
|
|
32
|
+
)
|
|
33
|
+
from .search import search_todos
|
|
34
|
+
from .sync import update_todo_status
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _db() -> TodoDB:
|
|
38
|
+
return TodoDB(Config.default())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
42
|
+
if not rows:
|
|
43
|
+
print("(empty)")
|
|
44
|
+
return
|
|
45
|
+
widths = [len(h) for h in headers]
|
|
46
|
+
for row in rows:
|
|
47
|
+
for i, cell in enumerate(row):
|
|
48
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
49
|
+
line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
50
|
+
print(line)
|
|
51
|
+
print(" ".join("-" * w for w in widths))
|
|
52
|
+
for row in rows:
|
|
53
|
+
print(" ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
57
|
+
db = _db()
|
|
58
|
+
try:
|
|
59
|
+
project = register_project(db, args.path, name=args.name, docs_dir=args.docs)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
result = index_project(db, project)
|
|
64
|
+
print(f"registered {project.name} -> {project.docs_dir}")
|
|
65
|
+
print(f"indexed {result['parsed']} items from {result['files']} files")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_unregister(args: argparse.Namespace) -> int:
|
|
70
|
+
db = _db()
|
|
71
|
+
if not unregister_project(db, args.project):
|
|
72
|
+
print(f"error: project not registered: {args.project}", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
print(f"removed {args.project}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
79
|
+
db = _db()
|
|
80
|
+
if args.project:
|
|
81
|
+
project = db.get_project(args.project)
|
|
82
|
+
if project is None:
|
|
83
|
+
print(f"error: project not registered: {args.project}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
result = index_project(db, project, incremental=not args.full)
|
|
86
|
+
print(f"{result['project']}: parsed {result['parsed']}, "
|
|
87
|
+
f"files {result['files_changed']}/{result['files']}, "
|
|
88
|
+
f"removed {result['removed']}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
result = index_all(db, incremental=not args.full)
|
|
92
|
+
print(f"{result['projects']} projects, {result['parsed']} items")
|
|
93
|
+
for item in result["results"]:
|
|
94
|
+
print(f" {item.get('project')}: parsed {item.get('parsed', 0)}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_projects(args: argparse.Namespace) -> int:
|
|
99
|
+
db = _db()
|
|
100
|
+
rows = [[p.name, p.path, p.docs_dir, p.last_indexed or "-"] for p in db.list_projects()]
|
|
101
|
+
_print_table(["project", "path", "docs_dir", "last_indexed"], rows)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_overview(args: argparse.Namespace) -> int:
|
|
106
|
+
db = _db()
|
|
107
|
+
data = overview(db)
|
|
108
|
+
if args.json:
|
|
109
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
rows = []
|
|
113
|
+
for p in data["projects"]:
|
|
114
|
+
flag = "" if p["exists"] else " (missing)"
|
|
115
|
+
rows.append([p["name"] + flag, p["pending"], p["done"], p["total"], p["path"]])
|
|
116
|
+
totals = data["totals"]
|
|
117
|
+
rows.append(["TOTAL", totals["pending"], totals["done"], totals["items"], ""])
|
|
118
|
+
_print_table(["project", "pending", "done", "total", "path"], rows)
|
|
119
|
+
|
|
120
|
+
if data["inconsistencies"]:
|
|
121
|
+
print()
|
|
122
|
+
print(f"{len(data['inconsistencies'])} inconsistency(ies) found:")
|
|
123
|
+
for issue in data["inconsistencies"]:
|
|
124
|
+
print(f" - {issue}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_next(args: argparse.Namespace) -> int:
|
|
129
|
+
db = _db()
|
|
130
|
+
actions = next_actions(db, limit=args.limit, project=args.project, priority=args.priority)
|
|
131
|
+
if args.json:
|
|
132
|
+
print(json.dumps(actions, ensure_ascii=False, indent=2))
|
|
133
|
+
return 0
|
|
134
|
+
rows = [
|
|
135
|
+
[a["project"], a["priority"], a["id"], a["title"], f"{a['file']}:{a['line']}"]
|
|
136
|
+
for a in actions
|
|
137
|
+
]
|
|
138
|
+
_print_table(["project", "pri", "id", "title", "location"], rows)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
143
|
+
db = _db()
|
|
144
|
+
items = search_todos(
|
|
145
|
+
db, query=args.query, mode=args.mode, status=args.status,
|
|
146
|
+
priority=args.priority, project=args.project, limit=args.limit,
|
|
147
|
+
)
|
|
148
|
+
if args.json:
|
|
149
|
+
print(json.dumps([i.to_dict() for i in items], ensure_ascii=False, indent=2))
|
|
150
|
+
return 0
|
|
151
|
+
rows = [
|
|
152
|
+
[i.project, i.priority, i.id, i.status, i.title, f"{i.file}:{i.line_start}"]
|
|
153
|
+
for i in items
|
|
154
|
+
]
|
|
155
|
+
_print_table(["project", "pri", "id", "status", "title", "location"], rows)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
160
|
+
db = _db()
|
|
161
|
+
locations = locate_item(db, args.item_id)
|
|
162
|
+
if not locations:
|
|
163
|
+
print(f"error: item not found: {args.item_id}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
for loc in locations:
|
|
166
|
+
project = db.get_project(loc["project"])
|
|
167
|
+
base = Path(project.docs_dir) if project else Path(".")
|
|
168
|
+
print(f"{loc['project']} {loc['status']} {base / loc['file']}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
173
|
+
db = _db()
|
|
174
|
+
locations = locate_item(db, args.item_id)
|
|
175
|
+
if not locations:
|
|
176
|
+
print(f"error: item not found: {args.item_id}", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
if args.project is None and len({loc["project"] for loc in locations}) > 1:
|
|
179
|
+
print("error: item id exists in several projects, pass --project", file=sys.stderr)
|
|
180
|
+
for loc in locations:
|
|
181
|
+
print(f" {loc['project']} ({loc['status']})", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
project = args.project or locations[0]["project"]
|
|
184
|
+
row = db.get_project(project)
|
|
185
|
+
docs_dir = Path(row.docs_dir) if row else Path(args.docs).resolve()
|
|
186
|
+
result = update_todo_status(db, docs_dir, args.item_id, args.new_status, project=project)
|
|
187
|
+
if not result.affected_files:
|
|
188
|
+
print("nothing updated (item not found in markdown)", file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
print(f"{args.item_id} -> {args.new_status} "
|
|
191
|
+
f"({result.total_replacements} line(s) in {', '.join(result.affected_files)})")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
196
|
+
parser = argparse.ArgumentParser(
|
|
197
|
+
prog="mdtodo",
|
|
198
|
+
description="Local cross-project todo index over markdown docs.",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument("--version", action="version", version=f"md-todo-hub {__version__}")
|
|
201
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
202
|
+
|
|
203
|
+
p = sub.add_parser("register", help="register a project and index it")
|
|
204
|
+
p.add_argument("path")
|
|
205
|
+
p.add_argument("--name")
|
|
206
|
+
p.add_argument("--docs", help="markdown root (default: <path>/docs)")
|
|
207
|
+
p.set_defaults(func=cmd_register)
|
|
208
|
+
|
|
209
|
+
p = sub.add_parser("unregister", help="drop a project from the index")
|
|
210
|
+
p.add_argument("project")
|
|
211
|
+
p.set_defaults(func=cmd_unregister)
|
|
212
|
+
|
|
213
|
+
p = sub.add_parser("index", help="refresh the index")
|
|
214
|
+
p.add_argument("--project")
|
|
215
|
+
p.add_argument("--full", action="store_true", help="re-parse everything")
|
|
216
|
+
p.set_defaults(func=cmd_index)
|
|
217
|
+
|
|
218
|
+
p = sub.add_parser("projects", help="list registered projects")
|
|
219
|
+
p.set_defaults(func=cmd_projects)
|
|
220
|
+
|
|
221
|
+
p = sub.add_parser("overview", help="per-project progress dashboard")
|
|
222
|
+
p.add_argument("--json", action="store_true")
|
|
223
|
+
p.set_defaults(func=cmd_overview)
|
|
224
|
+
|
|
225
|
+
p = sub.add_parser("next", help="pending todos ordered by priority")
|
|
226
|
+
p.add_argument("--limit", type=int, default=10)
|
|
227
|
+
p.add_argument("--project")
|
|
228
|
+
p.add_argument("--priority")
|
|
229
|
+
p.add_argument("--json", action="store_true")
|
|
230
|
+
p.set_defaults(func=cmd_next)
|
|
231
|
+
|
|
232
|
+
p = sub.add_parser("search", help="search todos")
|
|
233
|
+
p.add_argument("query", nargs="?", default=None)
|
|
234
|
+
p.add_argument("--mode", default="hybrid",
|
|
235
|
+
choices=["structured", "fts", "semantic", "hybrid"])
|
|
236
|
+
p.add_argument("--project")
|
|
237
|
+
p.add_argument("--status")
|
|
238
|
+
p.add_argument("--priority")
|
|
239
|
+
p.add_argument("--limit", type=int, default=20)
|
|
240
|
+
p.add_argument("--json", action="store_true")
|
|
241
|
+
p.set_defaults(func=cmd_search)
|
|
242
|
+
|
|
243
|
+
p = sub.add_parser("show", help="show where an item lives")
|
|
244
|
+
p.add_argument("item_id")
|
|
245
|
+
p.set_defaults(func=cmd_show)
|
|
246
|
+
|
|
247
|
+
p = sub.add_parser("status", help="set an item's status (dual-writes markdown)")
|
|
248
|
+
p.add_argument("item_id")
|
|
249
|
+
p.add_argument("new_status", choices=["done", "pending", "wontfix"])
|
|
250
|
+
p.add_argument("--project")
|
|
251
|
+
p.add_argument("--docs", default="./docs")
|
|
252
|
+
p.set_defaults(func=cmd_status)
|
|
253
|
+
|
|
254
|
+
return parser
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main(argv: list[str] | None = None) -> int:
|
|
258
|
+
parser = build_parser()
|
|
259
|
+
args = parser.parse_args(argv)
|
|
260
|
+
return args.func(args)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Configuration for docs-mcp.
|
|
2
|
+
|
|
3
|
+
Two storage modes are supported:
|
|
4
|
+
|
|
5
|
+
1. Hub mode (default, v0.3+): a single global index database shared by every
|
|
6
|
+
registered local project, located at ``$MD_TODO_HOME/index.db``
|
|
7
|
+
(default ``~/.md-todo/index.db``). Each todo row carries a ``project``
|
|
8
|
+
dimension so cross-project search and aggregation work out of the box.
|
|
9
|
+
|
|
10
|
+
2. Legacy mode: a per-project database at ``<project>/.docs-todo-mcp/todos.db``.
|
|
11
|
+
Still honoured when ``MD_TODO_LEGACY_DB=1`` is set or when an explicit
|
|
12
|
+
``db_path`` is passed in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_HOME_DIRNAME = ".md-todo"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _home_dir() -> Path:
|
|
24
|
+
"""Resolve the hub directory."""
|
|
25
|
+
env = os.environ.get("MD_TODO_HOME")
|
|
26
|
+
if env:
|
|
27
|
+
return Path(env).expanduser().resolve()
|
|
28
|
+
return Path.home() / DEFAULT_HOME_DIRNAME
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _guess_project_name(path: Path) -> str:
|
|
32
|
+
"""Derive a stable project key from an absolute path."""
|
|
33
|
+
return path.name or "default"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Config:
|
|
38
|
+
docs_dir: Path
|
|
39
|
+
db_path: Path
|
|
40
|
+
project: str = "default"
|
|
41
|
+
project_root: Path | None = None
|
|
42
|
+
embedding_model: str = field(default="text-embedding-3-small")
|
|
43
|
+
embedding_dim: int = 1536
|
|
44
|
+
embedding_base_url: str = field(default="")
|
|
45
|
+
embedding_api_key: str = field(default="")
|
|
46
|
+
|
|
47
|
+
def __post_init__(self):
|
|
48
|
+
"""Override with environment variables if set."""
|
|
49
|
+
# Support any OpenAI-compatible embedding endpoint
|
|
50
|
+
if os.environ.get("EMBEDDING_MODEL"):
|
|
51
|
+
self.embedding_model = os.environ["EMBEDDING_MODEL"]
|
|
52
|
+
if os.environ.get("EMBEDDING_DIM"):
|
|
53
|
+
self.embedding_dim = int(os.environ["EMBEDDING_DIM"])
|
|
54
|
+
if os.environ.get("EMBEDDING_BASE_URL"):
|
|
55
|
+
self.embedding_base_url = os.environ["EMBEDDING_BASE_URL"]
|
|
56
|
+
if os.environ.get("EMBEDDING_API_KEY"):
|
|
57
|
+
self.embedding_api_key = os.environ["EMBEDDING_API_KEY"]
|
|
58
|
+
# Backward compat: OPENAI_BASE_URL / OPENAI_API_KEY
|
|
59
|
+
if not self.embedding_base_url and os.environ.get("OPENAI_BASE_URL"):
|
|
60
|
+
self.embedding_base_url = os.environ["OPENAI_BASE_URL"]
|
|
61
|
+
if not self.embedding_api_key and os.environ.get("OPENAI_API_KEY"):
|
|
62
|
+
self.embedding_api_key = os.environ["OPENAI_API_KEY"]
|
|
63
|
+
if os.environ.get("MD_TODO_PROJECT"):
|
|
64
|
+
self.project = os.environ["MD_TODO_PROJECT"]
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def default(cls) -> "Config":
|
|
68
|
+
"""Hub-mode config anchored at the current working directory."""
|
|
69
|
+
project_root = Path.cwd().resolve()
|
|
70
|
+
docs_env = os.environ.get("MD_TODO_DOCS")
|
|
71
|
+
docs_dir = Path(docs_env).expanduser().resolve() if docs_env else project_root / "docs"
|
|
72
|
+
if os.environ.get("MD_TODO_LEGACY_DB") == "1":
|
|
73
|
+
return cls(
|
|
74
|
+
docs_dir=docs_dir,
|
|
75
|
+
db_path=project_root / ".docs-todo-mcp" / "todos.db",
|
|
76
|
+
project=_guess_project_name(project_root),
|
|
77
|
+
project_root=project_root,
|
|
78
|
+
)
|
|
79
|
+
return cls(
|
|
80
|
+
docs_dir=docs_dir,
|
|
81
|
+
db_path=_home_dir() / "index.db",
|
|
82
|
+
project=_guess_project_name(project_root),
|
|
83
|
+
project_root=project_root,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def for_project(
|
|
88
|
+
cls,
|
|
89
|
+
path: str | Path,
|
|
90
|
+
docs_dir: str | Path | None = None,
|
|
91
|
+
name: str | None = None,
|
|
92
|
+
) -> "Config":
|
|
93
|
+
"""Build a hub-mode config for one registered project."""
|
|
94
|
+
root = Path(path).expanduser().resolve()
|
|
95
|
+
docs = Path(docs_dir).expanduser().resolve() if docs_dir else root / "docs"
|
|
96
|
+
return cls(
|
|
97
|
+
docs_dir=docs,
|
|
98
|
+
db_path=_home_dir() / "index.db",
|
|
99
|
+
project=name or _guess_project_name(root),
|
|
100
|
+
project_root=root,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def home_dir(self) -> Path:
|
|
105
|
+
"""Hub directory that owns the shared index database."""
|
|
106
|
+
return self.db_path.parent
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_hub_mode(self) -> bool:
|
|
110
|
+
"""True when using the shared cross-project index."""
|
|
111
|
+
return self.db_path.name == "index.db"
|