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.
@@ -0,0 +1,17 @@
1
+ # docs-mcp database
2
+ .docs-mcp/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.pyc
7
+ .venv/
8
+ *.egg-info/
9
+ dist/*
10
+ !dist/assets/
11
+ !dist/skill/
12
+ !dist/connector/
13
+ !dist/expert/
14
+ !dist/release/
15
+ build/
16
+ build_pkg/
17
+ .pypirc
@@ -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,3 @@
1
+ """md-todo-hub: a local cross-project todo index over markdown docs."""
2
+
3
+ __version__ = "0.3.0"
@@ -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"