keepm 0.2.0__py3-none-any.whl
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.
- keepm/__init__.py +1 -0
- keepm/cli.py +18 -0
- keepm/config.py +97 -0
- keepm/database.py +572 -0
- keepm/doctor.py +131 -0
- keepm/instructions.py +226 -0
- keepm/integrations.py +253 -0
- keepm/markdown.py +152 -0
- keepm/models.py +48 -0
- keepm/projects.py +57 -0
- keepm/server.py +450 -0
- keepm/service.py +299 -0
- keepm/setup.py +405 -0
- keepm/storage.py +66 -0
- keepm/sync.py +230 -0
- keepm/watcher.py +113 -0
- keepm-0.2.0.dist-info/METADATA +15 -0
- keepm-0.2.0.dist-info/RECORD +22 -0
- keepm-0.2.0.dist-info/WHEEL +5 -0
- keepm-0.2.0.dist-info/entry_points.txt +2 -0
- keepm-0.2.0.dist-info/licenses/LICENSE +21 -0
- keepm-0.2.0.dist-info/top_level.txt +1 -0
keepm/doctor.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from keepm.database import MemoryDatabase
|
|
7
|
+
from keepm.markdown import parse_memory
|
|
8
|
+
from keepm.storage import MemoryStorage
|
|
9
|
+
from keepm.sync import sha256_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class DoctorFinding:
|
|
14
|
+
code: str
|
|
15
|
+
path: Path | None
|
|
16
|
+
message: str
|
|
17
|
+
severity: str = "error"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _expected_scope(storage: MemoryStorage, path: Path) -> str | None:
|
|
21
|
+
relative = path.resolve().relative_to(storage.config.memory_root.resolve())
|
|
22
|
+
if len(relative.parts) >= 2 and relative.parts[0] == "global":
|
|
23
|
+
return "global"
|
|
24
|
+
if len(relative.parts) >= 3 and relative.parts[0] == "projects":
|
|
25
|
+
return relative.parts[1]
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_doctor(
|
|
30
|
+
storage: MemoryStorage, database: MemoryDatabase
|
|
31
|
+
) -> tuple[DoctorFinding, ...]:
|
|
32
|
+
findings: list[DoctorFinding] = []
|
|
33
|
+
paths = set(storage.discover())
|
|
34
|
+
records = database.all_records()
|
|
35
|
+
records_by_path = {record.path: record for record in records}
|
|
36
|
+
records_by_id = {record.id: record for record in records}
|
|
37
|
+
parsed_by_path = {}
|
|
38
|
+
|
|
39
|
+
for path in sorted(paths):
|
|
40
|
+
try:
|
|
41
|
+
document = parse_memory(path, path.read_text(encoding="utf-8"))
|
|
42
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
43
|
+
findings.append(
|
|
44
|
+
DoctorFinding("invalid_markdown", path, str(error), "error")
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
parsed_by_path[path] = document
|
|
48
|
+
expected = _expected_scope(storage, path)
|
|
49
|
+
if expected is None or document.scope != expected:
|
|
50
|
+
findings.append(
|
|
51
|
+
DoctorFinding(
|
|
52
|
+
"scope_path_mismatch",
|
|
53
|
+
path,
|
|
54
|
+
f"frontmatter scope {document.scope!r} does not match directory "
|
|
55
|
+
f"scope {expected!r}",
|
|
56
|
+
"error",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
ids: dict[str, list[Path]] = {}
|
|
61
|
+
names: dict[tuple[str, str], list[Path]] = {}
|
|
62
|
+
for path, document in parsed_by_path.items():
|
|
63
|
+
ids.setdefault(document.id, []).append(path)
|
|
64
|
+
names.setdefault((document.scope, document.name.casefold()), []).append(path)
|
|
65
|
+
for memory_id, duplicate_paths in ids.items():
|
|
66
|
+
if len(duplicate_paths) > 1:
|
|
67
|
+
for path in duplicate_paths:
|
|
68
|
+
findings.append(
|
|
69
|
+
DoctorFinding(
|
|
70
|
+
"duplicate_id",
|
|
71
|
+
path,
|
|
72
|
+
f"memory id {memory_id!r} appears in {len(duplicate_paths)} files",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
for (scope, name), duplicate_paths in names.items():
|
|
76
|
+
if len(duplicate_paths) > 1:
|
|
77
|
+
for path in duplicate_paths:
|
|
78
|
+
findings.append(
|
|
79
|
+
DoctorFinding(
|
|
80
|
+
"duplicate_name",
|
|
81
|
+
path,
|
|
82
|
+
f"memory name {name!r} appears {len(duplicate_paths)} times "
|
|
83
|
+
f"in scope {scope!r}",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for path in sorted(set(parsed_by_path) - set(records_by_path)):
|
|
88
|
+
findings.append(
|
|
89
|
+
DoctorFinding(
|
|
90
|
+
"markdown_only", path, "valid Markdown file is absent from SQLite"
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
for path in sorted(set(records_by_path) - paths):
|
|
94
|
+
findings.append(
|
|
95
|
+
DoctorFinding("index_only", path, "SQLite row points to a missing file")
|
|
96
|
+
)
|
|
97
|
+
for path in sorted(set(parsed_by_path) & set(records_by_path)):
|
|
98
|
+
try:
|
|
99
|
+
checksum = sha256_file(path)
|
|
100
|
+
except OSError as error:
|
|
101
|
+
findings.append(DoctorFinding("unreadable_file", path, str(error)))
|
|
102
|
+
continue
|
|
103
|
+
if checksum != records_by_path[path].checksum:
|
|
104
|
+
findings.append(
|
|
105
|
+
DoctorFinding(
|
|
106
|
+
"checksum_divergence",
|
|
107
|
+
path,
|
|
108
|
+
"Markdown checksum differs from the SQLite index",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for source_id, target_text, relation_type in database.unresolved_links():
|
|
113
|
+
source = records_by_id.get(source_id)
|
|
114
|
+
findings.append(
|
|
115
|
+
DoctorFinding(
|
|
116
|
+
"unresolved_link",
|
|
117
|
+
source.path if source else None,
|
|
118
|
+
f"{relation_type} WikiLink target {target_text!r} is unresolved",
|
|
119
|
+
"warning",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
severity_order = {"error": 0, "warning": 1, "info": 2}
|
|
124
|
+
findings.sort(
|
|
125
|
+
key=lambda item: (
|
|
126
|
+
severity_order.get(item.severity, 9),
|
|
127
|
+
item.code,
|
|
128
|
+
str(item.path or ""),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return tuple(findings)
|
keepm/instructions.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from keepm.config import MemoryLanguage
|
|
10
|
+
from keepm.integrations import McpTarget
|
|
11
|
+
|
|
12
|
+
START_MARKER = "<!-- keepm:start -->"
|
|
13
|
+
END_MARKER = "<!-- keepm:end -->"
|
|
14
|
+
LEGACY_START_MARKER = "<!-- KeepM Memory: begin -->"
|
|
15
|
+
LEGACY_END_MARKER = "<!-- KeepM Memory: end -->"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InstructionError(RuntimeError):
|
|
19
|
+
"""An Agent instruction file cannot be updated without risking user content."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class InstructionResult:
|
|
24
|
+
target: McpTarget
|
|
25
|
+
path: Path | None
|
|
26
|
+
status: Literal["created", "updated", "unchanged", "failed"]
|
|
27
|
+
detail: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
EN_PROTOCOL = """## KeepM memory protocol
|
|
31
|
+
|
|
32
|
+
Use KeepM for durable memory when its MCP tools are available. Write new memory names, descriptions, and prose in English; keep code, paths, API names, and exact protocol labels unchanged.
|
|
33
|
+
|
|
34
|
+
### At task start
|
|
35
|
+
|
|
36
|
+
1. Call `memory_context` once with a concise description of the current task.
|
|
37
|
+
2. Use `memory_search` and `memory_read` only when that context is insufficient. Do not load the whole Vault.
|
|
38
|
+
3. Do not call `memory_sync` routinely.
|
|
39
|
+
|
|
40
|
+
### While working
|
|
41
|
+
|
|
42
|
+
- Treat Markdown in the configured Vault as the source of truth.
|
|
43
|
+
- Before updating an existing memory, read it and pass its checksum as `expected_checksum`.
|
|
44
|
+
- Do not store tentative hypotheses, routine progress, raw logs, secrets, tokens, or large code dumps.
|
|
45
|
+
|
|
46
|
+
### Before completing a task
|
|
47
|
+
|
|
48
|
+
Write a memory directly with `memory_write` only when the result will be useful in a future session:
|
|
49
|
+
|
|
50
|
+
- a stable user preference or workflow rule;
|
|
51
|
+
- feedback that should prevent a repeated mistake;
|
|
52
|
+
- a project architecture decision, invariant, or important constraint;
|
|
53
|
+
- a reusable diagnosis and fix for a likely recurring problem;
|
|
54
|
+
- a stable project reference needed by future work.
|
|
55
|
+
|
|
56
|
+
Search first when an overlapping memory may exist; update it instead of creating a duplicate. Use `global` for cross-project preferences and workflows, and `project` for repository-specific knowledge. For `feedback` and `project` memories, include the exact labels `Why:` and `How to apply:`. Prefer one concise, actionable memory over several overlapping memories. Do not save routine implementations, temporary workarounds, task status, or facts already covered by memory.
|
|
57
|
+
|
|
58
|
+
### Indexing and recovery
|
|
59
|
+
|
|
60
|
+
- `memory_write` and `memory_delete` update the affected SQLite index immediately; do not call `memory_sync` after them.
|
|
61
|
+
- KeepM reconciles at MCP startup. Context, search, and read tools also refresh a stale index, while the watcher normally handles Obsidian edits.
|
|
62
|
+
- Use `memory_sync(full=false)` only after bulk external changes, watcher failure, or suspected mismatch.
|
|
63
|
+
- Use `memory_doctor` to diagnose problems and `memory_sync(full=true)` only to repair or rebuild the derived index."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ZH_PROTOCOL = """## KeepM 记忆协议
|
|
67
|
+
|
|
68
|
+
当 KeepM MCP 工具可用时,用它管理长期记忆。新记忆的名称、描述和正文默认使用中文;代码、路径、API 名称和协议要求的固定标签保持原文。
|
|
69
|
+
|
|
70
|
+
### 任务开始时
|
|
71
|
+
|
|
72
|
+
1. 使用当前任务的简短描述调用一次 `memory_context`。
|
|
73
|
+
2. 只有返回的上下文不足时才调用 `memory_search` 和 `memory_read`,不要把整个 Vault 读入上下文。
|
|
74
|
+
3. 正常情况下不要调用 `memory_sync`。
|
|
75
|
+
|
|
76
|
+
### 工作过程中
|
|
77
|
+
|
|
78
|
+
- 将配置 Vault 中的 Markdown 视为唯一真源。
|
|
79
|
+
- 更新已有记忆前先读取它,并把返回的 checksum 作为 `expected_checksum`。
|
|
80
|
+
- 不记录尚未验证的猜测、普通进度、原始日志、密钥、令牌或大段代码。
|
|
81
|
+
|
|
82
|
+
### 任务完成前
|
|
83
|
+
|
|
84
|
+
仅当结果对未来会话仍有价值时,才直接调用 `memory_write`:
|
|
85
|
+
|
|
86
|
+
- 稳定的用户偏好或工作流规则;
|
|
87
|
+
- 能避免重复犯错的用户反馈;
|
|
88
|
+
- 项目架构决策、不变量或重要约束;
|
|
89
|
+
- 可能再次遇到的问题及可复用的诊断和修复方法;
|
|
90
|
+
- 后续工作需要的稳定项目资料。
|
|
91
|
+
|
|
92
|
+
如果可能已有相同主题的记忆,先搜索并更新原记忆,避免重复创建。跨项目偏好和工作流使用 `global`,仓库专属知识使用 `project`。`feedback` 和 `project` 记忆必须包含固定标签 `Why:` 与 `How to apply:`。优先记录一条简洁、可执行的记忆,不要拆成多条重叠内容。不要保存常规实现、临时方案、任务状态或已有记忆覆盖的事实。
|
|
93
|
+
|
|
94
|
+
### 索引与恢复
|
|
95
|
+
|
|
96
|
+
- `memory_write` 和 `memory_delete` 会立即更新对应 SQLite 索引,之后不要调用 `memory_sync`。
|
|
97
|
+
- KeepM 会在 MCP 启动时对账;上下文、搜索和读取工具也会刷新过期索引,Obsidian 修改通常由 watcher 处理。
|
|
98
|
+
- 只有批量外部修改、watcher 失败或怀疑不一致时,才使用 `memory_sync(full=false)`。
|
|
99
|
+
- 先用 `memory_doctor` 诊断;只有修复或重建派生索引时才使用 `memory_sync(full=true)`。"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_managed_block(language: MemoryLanguage) -> str:
|
|
103
|
+
if language == "en":
|
|
104
|
+
protocol = EN_PROTOCOL
|
|
105
|
+
elif language == "zh":
|
|
106
|
+
protocol = ZH_PROTOCOL
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError("language must be 'zh' or 'en'")
|
|
109
|
+
return f"{START_MARKER}\n\n{protocol.strip()}\n\n{END_MARKER}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def instruction_path(target: McpTarget, home: Path) -> Path:
|
|
113
|
+
resolved_home = home.expanduser().resolve()
|
|
114
|
+
if target.scope == "user":
|
|
115
|
+
if target.agent == "codex":
|
|
116
|
+
return resolved_home / ".codex" / "AGENTS.md"
|
|
117
|
+
return resolved_home / ".claude" / "CLAUDE.md"
|
|
118
|
+
|
|
119
|
+
assert target.project_dir is not None
|
|
120
|
+
if target.agent == "codex":
|
|
121
|
+
return target.project_dir / "AGENTS.md"
|
|
122
|
+
return target.project_dir / "CLAUDE.md"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _managed_span(content: str, path: Path) -> tuple[int, int] | None:
|
|
126
|
+
pairs = (
|
|
127
|
+
(START_MARKER, END_MARKER),
|
|
128
|
+
(LEGACY_START_MARKER, LEGACY_END_MARKER),
|
|
129
|
+
)
|
|
130
|
+
active: list[tuple[str, str]] = []
|
|
131
|
+
for start, end in pairs:
|
|
132
|
+
start_count = content.count(start)
|
|
133
|
+
end_count = content.count(end)
|
|
134
|
+
if start_count or end_count:
|
|
135
|
+
if start_count != 1 or end_count != 1:
|
|
136
|
+
raise InstructionError(f"malformed KeepM markers in {path}")
|
|
137
|
+
active.append((start, end))
|
|
138
|
+
if not active:
|
|
139
|
+
return None
|
|
140
|
+
if len(active) != 1:
|
|
141
|
+
raise InstructionError(f"multiple KeepM marker formats in {path}")
|
|
142
|
+
start_marker, end_marker = active[0]
|
|
143
|
+
start_index = content.index(start_marker)
|
|
144
|
+
end_index = content.index(end_marker)
|
|
145
|
+
if start_index >= end_index:
|
|
146
|
+
raise InstructionError(f"reversed KeepM markers in {path}")
|
|
147
|
+
return start_index, end_index + len(end_marker)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _replace_or_append(content: str, block: str, path: Path) -> str:
|
|
151
|
+
newline = "\r\n" if "\r\n" in content else "\n"
|
|
152
|
+
localized_block = block.replace("\n", newline)
|
|
153
|
+
span = _managed_span(content, path)
|
|
154
|
+
if span is not None:
|
|
155
|
+
start, end = span
|
|
156
|
+
updated = content[:start] + localized_block + content[end:]
|
|
157
|
+
return updated if updated.endswith(("\n", "\r")) else updated + newline
|
|
158
|
+
if not content:
|
|
159
|
+
return localized_block + newline
|
|
160
|
+
separator = "" if content.endswith(newline * 2) else newline if content.endswith(newline) else newline * 2
|
|
161
|
+
return content + separator + localized_block + newline
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class InstructionInstaller:
|
|
165
|
+
def __init__(self, *, home: Path | None = None) -> None:
|
|
166
|
+
self.home = (home or Path.home()).expanduser().resolve()
|
|
167
|
+
|
|
168
|
+
def install(
|
|
169
|
+
self, target: McpTarget, language: MemoryLanguage
|
|
170
|
+
) -> InstructionResult:
|
|
171
|
+
path = instruction_path(target, self.home)
|
|
172
|
+
block = render_managed_block(language)
|
|
173
|
+
try:
|
|
174
|
+
migrated = self._remove_nested_claude_block(target)
|
|
175
|
+
exists = path.is_file()
|
|
176
|
+
content = path.read_bytes().decode("utf-8") if exists else ""
|
|
177
|
+
updated = _replace_or_append(content, block, path)
|
|
178
|
+
if updated == content:
|
|
179
|
+
return InstructionResult(target, path, "unchanged", "KeepM instructions already match")
|
|
180
|
+
|
|
181
|
+
mode = stat.S_IMODE(path.stat().st_mode) if exists else None
|
|
182
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
temporary = path.with_name(f".{path.name}.keepm.tmp")
|
|
184
|
+
temporary.write_bytes(updated.encode("utf-8"))
|
|
185
|
+
if mode is not None:
|
|
186
|
+
temporary.chmod(mode)
|
|
187
|
+
os.replace(temporary, path)
|
|
188
|
+
status: Literal["created", "updated"] = "updated" if exists else "created"
|
|
189
|
+
detail = f"{status} KeepM instructions"
|
|
190
|
+
if migrated:
|
|
191
|
+
detail += "; migrated old .claude/CLAUDE.md block"
|
|
192
|
+
return InstructionResult(target, path, status, detail)
|
|
193
|
+
except InstructionError:
|
|
194
|
+
raise
|
|
195
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
196
|
+
raise InstructionError(f"cannot update Agent instructions {path}: {error}") from error
|
|
197
|
+
|
|
198
|
+
def _remove_nested_claude_block(self, target: McpTarget) -> bool:
|
|
199
|
+
if target.agent != "claude" or target.scope != "project":
|
|
200
|
+
return False
|
|
201
|
+
assert target.project_dir is not None
|
|
202
|
+
nested = target.project_dir / ".claude" / "CLAUDE.md"
|
|
203
|
+
if not nested.is_file():
|
|
204
|
+
return False
|
|
205
|
+
try:
|
|
206
|
+
content = nested.read_bytes().decode("utf-8")
|
|
207
|
+
span = _managed_span(content, nested)
|
|
208
|
+
if span is None:
|
|
209
|
+
return False
|
|
210
|
+
start, end = span
|
|
211
|
+
updated = content[:start] + content[end:]
|
|
212
|
+
if not updated.strip():
|
|
213
|
+
nested.unlink()
|
|
214
|
+
return True
|
|
215
|
+
mode = stat.S_IMODE(nested.stat().st_mode)
|
|
216
|
+
temporary = nested.with_name(f".{nested.name}.keepm.tmp")
|
|
217
|
+
temporary.write_bytes(updated.encode("utf-8"))
|
|
218
|
+
temporary.chmod(mode)
|
|
219
|
+
os.replace(temporary, nested)
|
|
220
|
+
return True
|
|
221
|
+
except InstructionError:
|
|
222
|
+
raise
|
|
223
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
224
|
+
raise InstructionError(
|
|
225
|
+
f"cannot migrate old Agent instructions {nested}: {error}"
|
|
226
|
+
) from error
|
keepm/integrations.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import tomlkit
|
|
13
|
+
|
|
14
|
+
AgentName = Literal["codex", "claude"]
|
|
15
|
+
McpScope = Literal["user", "project"]
|
|
16
|
+
|
|
17
|
+
SERVER_COMMAND = "uvx"
|
|
18
|
+
SERVER_ARGS = ("--from", "keepm", "keepm")
|
|
19
|
+
CODEX_SERVER = {
|
|
20
|
+
"type": "stdio",
|
|
21
|
+
"command": SERVER_COMMAND,
|
|
22
|
+
"args": list(SERVER_ARGS),
|
|
23
|
+
}
|
|
24
|
+
CLAUDE_SERVER = {"command": SERVER_COMMAND, "args": list(SERVER_ARGS)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IntegrationError(RuntimeError):
|
|
28
|
+
"""An Agent CLI or its configuration could not be safely updated."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RegistrationConflict(IntegrationError):
|
|
32
|
+
"""A named KeepM registration exists but differs from the expected one."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class McpTarget:
|
|
37
|
+
agent: AgentName
|
|
38
|
+
scope: McpScope
|
|
39
|
+
project_dir: Path | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if self.agent not in ("codex", "claude"):
|
|
43
|
+
raise ValueError(f"unsupported Agent: {self.agent}")
|
|
44
|
+
if self.scope not in ("user", "project"):
|
|
45
|
+
raise ValueError(f"unsupported MCP scope: {self.scope}")
|
|
46
|
+
if self.scope == "project":
|
|
47
|
+
if self.project_dir is None:
|
|
48
|
+
raise ValueError("project_dir is required for project-scoped MCP setup")
|
|
49
|
+
object.__setattr__(
|
|
50
|
+
self, "project_dir", self.project_dir.expanduser().resolve()
|
|
51
|
+
)
|
|
52
|
+
elif self.project_dir is not None:
|
|
53
|
+
object.__setattr__(
|
|
54
|
+
self, "project_dir", self.project_dir.expanduser().resolve()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class RegistrationState:
|
|
60
|
+
kind: Literal["missing", "equivalent", "different"]
|
|
61
|
+
detail: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class CommandResult:
|
|
66
|
+
returncode: int
|
|
67
|
+
stdout: str = ""
|
|
68
|
+
stderr: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
Runner = Callable[[Sequence[str], Path | None], CommandResult]
|
|
72
|
+
Which = Callable[[str], str | None]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_subprocess(command: Sequence[str], cwd: Path | None) -> CommandResult:
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
list(command),
|
|
79
|
+
cwd=cwd,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
check=False,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=15,
|
|
84
|
+
)
|
|
85
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as error:
|
|
86
|
+
raise IntegrationError(f"cannot run {' '.join(command)}: {error}") from error
|
|
87
|
+
return CommandResult(result.returncode, result.stdout, result.stderr)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_expected(record: Mapping[str, object], expected: Mapping[str, object]) -> bool:
|
|
91
|
+
if record.get("command") != expected["command"]:
|
|
92
|
+
return False
|
|
93
|
+
args = record.get("args")
|
|
94
|
+
if not isinstance(args, list) or args != expected["args"]:
|
|
95
|
+
return False
|
|
96
|
+
expected_type = expected.get("type")
|
|
97
|
+
return expected_type is None or record.get("type") == expected_type
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AgentRegistrar:
|
|
101
|
+
"""Inspect and register the one canonical KeepM stdio command."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
home: Path | None = None,
|
|
107
|
+
which: Which = shutil.which,
|
|
108
|
+
run: Runner = _run_subprocess,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.home = (home or Path.home()).expanduser().resolve()
|
|
111
|
+
self._which = which
|
|
112
|
+
self._run = run
|
|
113
|
+
|
|
114
|
+
def available_agents(self) -> tuple[AgentName, ...]:
|
|
115
|
+
agents: list[AgentName] = []
|
|
116
|
+
for agent in ("codex", "claude"):
|
|
117
|
+
if self._which(agent):
|
|
118
|
+
agents.append(agent)
|
|
119
|
+
return tuple(agents)
|
|
120
|
+
|
|
121
|
+
def has_uvx(self) -> bool:
|
|
122
|
+
return self._which("uvx") is not None
|
|
123
|
+
|
|
124
|
+
def inspect(self, target: McpTarget) -> RegistrationState:
|
|
125
|
+
if target.agent == "codex" and target.scope == "user":
|
|
126
|
+
return self._inspect_codex_user()
|
|
127
|
+
if target.agent == "codex":
|
|
128
|
+
return self._inspect_codex_project(target)
|
|
129
|
+
return self._inspect_claude(target)
|
|
130
|
+
|
|
131
|
+
def apply(self, target: McpTarget, *, replace: bool = False) -> RegistrationState:
|
|
132
|
+
if not self._which(target.agent):
|
|
133
|
+
raise IntegrationError(f"{target.agent} is not available on PATH")
|
|
134
|
+
state = self.inspect(target)
|
|
135
|
+
if state.kind == "equivalent":
|
|
136
|
+
return state
|
|
137
|
+
if state.kind == "different" and not replace:
|
|
138
|
+
raise RegistrationConflict(state.detail)
|
|
139
|
+
|
|
140
|
+
if target.agent == "codex" and target.scope == "project":
|
|
141
|
+
self._write_codex_project(target)
|
|
142
|
+
return self.inspect(target)
|
|
143
|
+
|
|
144
|
+
if state.kind == "different":
|
|
145
|
+
self._run_checked(self._remove_command(target), target.project_dir)
|
|
146
|
+
self._run_checked(self._add_command(target), target.project_dir)
|
|
147
|
+
updated = self.inspect(target)
|
|
148
|
+
if updated.kind != "equivalent":
|
|
149
|
+
raise IntegrationError(
|
|
150
|
+
f"{target.agent} accepted the KeepM command but did not save the expected configuration"
|
|
151
|
+
)
|
|
152
|
+
return updated
|
|
153
|
+
|
|
154
|
+
def _inspect_codex_user(self) -> RegistrationState:
|
|
155
|
+
result = self._run(["codex", "mcp", "get", "keepm", "--json"], None)
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
return RegistrationState("missing", "Codex has no user-level keepm MCP")
|
|
158
|
+
try:
|
|
159
|
+
payload = json.loads(result.stdout)
|
|
160
|
+
transport = payload["transport"]
|
|
161
|
+
except (json.JSONDecodeError, KeyError, TypeError) as error:
|
|
162
|
+
raise IntegrationError(f"cannot read Codex keepm configuration: {error}") from error
|
|
163
|
+
if isinstance(transport, dict) and _is_expected(transport, CODEX_SERVER):
|
|
164
|
+
return RegistrationState("equivalent", "Codex user MCP already matches KeepM")
|
|
165
|
+
return RegistrationState("different", "Codex user MCP named keepm uses another command")
|
|
166
|
+
|
|
167
|
+
def _inspect_codex_project(self, target: McpTarget) -> RegistrationState:
|
|
168
|
+
config_path = self._codex_project_path(target)
|
|
169
|
+
if not config_path.is_file():
|
|
170
|
+
return RegistrationState("missing", f"missing {config_path}")
|
|
171
|
+
try:
|
|
172
|
+
document = tomlkit.parse(config_path.read_text(encoding="utf-8"))
|
|
173
|
+
servers = document.get("mcp_servers", {})
|
|
174
|
+
record = servers.get("keepm") if isinstance(servers, Mapping) else None
|
|
175
|
+
except (OSError, tomlkit.exceptions.ParseError) as error:
|
|
176
|
+
raise IntegrationError(f"cannot read Codex project config {config_path}: {error}") from error
|
|
177
|
+
if record is None:
|
|
178
|
+
return RegistrationState("missing", f"{config_path} has no keepm MCP")
|
|
179
|
+
if isinstance(record, Mapping) and _is_expected(record, CODEX_SERVER):
|
|
180
|
+
return RegistrationState("equivalent", "Codex project MCP already matches KeepM")
|
|
181
|
+
return RegistrationState("different", "Codex project MCP named keepm uses another command")
|
|
182
|
+
|
|
183
|
+
def _inspect_claude(self, target: McpTarget) -> RegistrationState:
|
|
184
|
+
config_path = self._claude_path(target)
|
|
185
|
+
if not config_path.is_file():
|
|
186
|
+
return RegistrationState("missing", f"missing {config_path}")
|
|
187
|
+
try:
|
|
188
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
189
|
+
servers = payload.get("mcpServers", {})
|
|
190
|
+
record = servers.get("keepm") if isinstance(servers, dict) else None
|
|
191
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
192
|
+
raise IntegrationError(f"cannot read Claude Code config {config_path}: {error}") from error
|
|
193
|
+
if record is None:
|
|
194
|
+
return RegistrationState("missing", f"{config_path} has no keepm MCP")
|
|
195
|
+
if isinstance(record, dict) and _is_expected(record, CLAUDE_SERVER):
|
|
196
|
+
return RegistrationState("equivalent", "Claude Code MCP already matches KeepM")
|
|
197
|
+
return RegistrationState("different", "Claude Code MCP named keepm uses another command")
|
|
198
|
+
|
|
199
|
+
def _write_codex_project(self, target: McpTarget) -> None:
|
|
200
|
+
config_path = self._codex_project_path(target)
|
|
201
|
+
try:
|
|
202
|
+
if config_path.is_file():
|
|
203
|
+
document = tomlkit.parse(config_path.read_text(encoding="utf-8"))
|
|
204
|
+
else:
|
|
205
|
+
document = tomlkit.document()
|
|
206
|
+
servers = document.get("mcp_servers")
|
|
207
|
+
if servers is None:
|
|
208
|
+
servers = tomlkit.table()
|
|
209
|
+
document["mcp_servers"] = servers
|
|
210
|
+
if not isinstance(servers, Mapping):
|
|
211
|
+
raise IntegrationError(
|
|
212
|
+
f"cannot update {config_path}: mcp_servers is not a TOML table"
|
|
213
|
+
)
|
|
214
|
+
keepm = tomlkit.table()
|
|
215
|
+
keepm.add("type", "stdio")
|
|
216
|
+
keepm.add("command", SERVER_COMMAND)
|
|
217
|
+
keepm.add("args", list(SERVER_ARGS))
|
|
218
|
+
servers["keepm"] = keepm
|
|
219
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
temporary = config_path.with_suffix(config_path.suffix + ".tmp")
|
|
221
|
+
temporary.write_text(tomlkit.dumps(document), encoding="utf-8", newline="\n")
|
|
222
|
+
os.replace(temporary, config_path)
|
|
223
|
+
except (OSError, tomlkit.exceptions.ParseError) as error:
|
|
224
|
+
raise IntegrationError(f"cannot update Codex project config {config_path}: {error}") from error
|
|
225
|
+
|
|
226
|
+
def _add_command(self, target: McpTarget) -> list[str]:
|
|
227
|
+
prefix = [target.agent, "mcp", "add"]
|
|
228
|
+
if target.agent == "claude":
|
|
229
|
+
prefix.extend(["--scope", target.scope])
|
|
230
|
+
return [*prefix, "keepm", "--", SERVER_COMMAND, *SERVER_ARGS]
|
|
231
|
+
|
|
232
|
+
def _remove_command(self, target: McpTarget) -> list[str]:
|
|
233
|
+
prefix = [target.agent, "mcp", "remove"]
|
|
234
|
+
if target.agent == "claude":
|
|
235
|
+
prefix.extend(["--scope", target.scope])
|
|
236
|
+
return [*prefix, "keepm"]
|
|
237
|
+
|
|
238
|
+
def _run_checked(self, command: Sequence[str], cwd: Path | None) -> None:
|
|
239
|
+
result = self._run(command, cwd)
|
|
240
|
+
if result.returncode == 0:
|
|
241
|
+
return
|
|
242
|
+
detail = (result.stderr or result.stdout).strip() or "unknown error"
|
|
243
|
+
raise IntegrationError(f"{' '.join(command)} failed: {detail}")
|
|
244
|
+
|
|
245
|
+
def _codex_project_path(self, target: McpTarget) -> Path:
|
|
246
|
+
assert target.project_dir is not None
|
|
247
|
+
return target.project_dir / ".codex" / "config.toml"
|
|
248
|
+
|
|
249
|
+
def _claude_path(self, target: McpTarget) -> Path:
|
|
250
|
+
if target.scope == "user":
|
|
251
|
+
return self.home / ".claude.json"
|
|
252
|
+
assert target.project_dir is not None
|
|
253
|
+
return target.project_dir / ".mcp.json"
|