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/service.py ADDED
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import datetime as dt
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ from keepm.config import KeepMConfig
10
+ from keepm.database import IndexedMemory, MemoryDatabase
11
+ from keepm.doctor import DoctorFinding, run_doctor
12
+ from keepm.markdown import parse_memory, render_memory, slug_filename
13
+ from keepm.models import MemoryDocument, SearchHit, SyncReport
14
+ from keepm.projects import resolve_project_name
15
+ from keepm.storage import MemoryStorage
16
+ from keepm.sync import SyncEngine, sha256_file
17
+
18
+
19
+ class ConcurrentUpdateError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class IndexSyncError(RuntimeError):
24
+ pass
25
+
26
+
27
+ class MemoryService:
28
+ def __init__(
29
+ self,
30
+ config: KeepMConfig,
31
+ storage: MemoryStorage,
32
+ database: MemoryDatabase,
33
+ sync_engine: SyncEngine,
34
+ ) -> None:
35
+ self.config = config
36
+ self.storage = storage
37
+ self.database = database
38
+ self.sync_engine = sync_engine
39
+ self._freshness_lock = asyncio.Lock()
40
+
41
+ @staticmethod
42
+ def _new_id() -> str:
43
+ uuid7 = getattr(uuid, "uuid7", None)
44
+ return str(uuid7() if uuid7 else uuid.uuid4())
45
+
46
+ @staticmethod
47
+ def _project(workspace: Path) -> str:
48
+ return resolve_project_name(workspace.resolve())
49
+
50
+ def _scope_name(self, workspace: Path, scope: str) -> str:
51
+ if scope == "global":
52
+ return "global"
53
+ if scope == "project":
54
+ return self._project(workspace)
55
+ raise ValueError("scope must be 'global' or 'project'")
56
+
57
+ def _query_scopes(self, workspace: Path, scope: str | None = None) -> tuple[str, ...]:
58
+ project = self._project(workspace)
59
+ if scope is None:
60
+ return (project, "global")
61
+ if scope == "project":
62
+ return (project,)
63
+ if scope == "global":
64
+ return ("global",)
65
+ raise ValueError("scope must be 'global', 'project', or omitted")
66
+
67
+ @staticmethod
68
+ def _apply_project_override(
69
+ hits: tuple[SearchHit, ...], project: str
70
+ ) -> tuple[SearchHit, ...]:
71
+ project_names = {
72
+ hit.name.casefold() for hit in hits if hit.scope == project
73
+ }
74
+ seen_ids: set[str] = set()
75
+ filtered: list[SearchHit] = []
76
+ for hit in hits:
77
+ if hit.id in seen_ids:
78
+ continue
79
+ if hit.scope == "global" and hit.name.casefold() in project_names:
80
+ continue
81
+ seen_ids.add(hit.id)
82
+ filtered.append(hit)
83
+ return tuple(filtered)
84
+
85
+ def search(
86
+ self,
87
+ *,
88
+ workspace: Path,
89
+ query: str,
90
+ scope: str | None = None,
91
+ limit: int = 10,
92
+ ) -> tuple[SearchHit, ...]:
93
+ project = self._project(workspace)
94
+ scopes = self._query_scopes(workspace, scope)
95
+ hits = self.database.search(query, scopes=scopes, limit=max(limit * 2, limit))
96
+ if scope is None:
97
+ hits = self._apply_project_override(hits, project)
98
+ return hits[: max(limit, 0)]
99
+
100
+ def context(
101
+ self,
102
+ *,
103
+ workspace: Path,
104
+ query: str,
105
+ limit: int = 10,
106
+ max_chars: int = 6_000,
107
+ ) -> tuple[SearchHit, ...]:
108
+ if limit < 1 or max_chars < 1:
109
+ return ()
110
+ project = self._project(workspace)
111
+ scopes = (project, "global")
112
+ pinned = self.database.list_pinned(scopes=scopes, limit=limit * 2)
113
+ ranked = self.database.search(query, scopes=scopes, limit=limit * 2)
114
+ merged = self._apply_project_override(pinned + ranked, project)
115
+
116
+ bounded: list[SearchHit] = []
117
+ used = 0
118
+ for hit in merged:
119
+ if len(bounded) >= limit or used >= max_chars:
120
+ break
121
+ remaining = max_chars - used
122
+ snippet = hit.snippet[:remaining]
123
+ if not snippet:
124
+ break
125
+ bounded.append(dataclasses.replace(hit, snippet=snippet))
126
+ used += len(snippet)
127
+ return tuple(bounded)
128
+
129
+ def _lookup_record(self, workspace: Path, identifier: str) -> IndexedMemory:
130
+ scopes = self._query_scopes(workspace)
131
+ record = self.database.get_by_id(identifier)
132
+ if record is None:
133
+ record = self.database.get_by_name(identifier, scopes=scopes)
134
+ if record is None and ("/" in identifier or identifier.endswith(".md")):
135
+ candidate = Path(identifier)
136
+ if not candidate.is_absolute():
137
+ candidate = self.config.memory_root / candidate
138
+ safe_path = self.storage.require_safe_path(candidate)
139
+ record = self.database.get_by_path(safe_path)
140
+ if record is None:
141
+ raise LookupError(f"memory not found: {identifier}")
142
+ if record.scope not in scopes:
143
+ raise LookupError(f"memory is outside the active scopes: {identifier}")
144
+ return record
145
+
146
+ def read(self, *, workspace: Path, identifier: str) -> MemoryDocument:
147
+ document, _ = self.read_with_checksum(
148
+ workspace=workspace, identifier=identifier
149
+ )
150
+ return document
151
+
152
+ def read_with_checksum(
153
+ self, *, workspace: Path, identifier: str
154
+ ) -> tuple[MemoryDocument, str]:
155
+ record = self._lookup_record(workspace, identifier)
156
+ try:
157
+ document = parse_memory(
158
+ record.path, record.path.read_text(encoding="utf-8")
159
+ )
160
+ except OSError as error:
161
+ raise LookupError(f"memory file is unavailable: {record.path}") from error
162
+ return document, record.checksum
163
+
164
+ def write(
165
+ self,
166
+ *,
167
+ workspace: Path,
168
+ scope: str,
169
+ name: str,
170
+ description: str,
171
+ memory_type: str,
172
+ body: str,
173
+ tags: tuple[str, ...] = (),
174
+ pinned: bool = False,
175
+ expected_checksum: str | None = None,
176
+ ) -> MemoryDocument:
177
+ target_scope = self._scope_name(workspace, scope)
178
+ logical_name = slug_filename(name).removesuffix(".md")
179
+ existing = self.database.get_by_name(logical_name, scopes=(target_scope,))
180
+ if expected_checksum is not None:
181
+ if existing is None or not existing.path.is_file():
182
+ raise ConcurrentUpdateError("memory changed on disk or no longer exists")
183
+ if sha256_file(existing.path) != expected_checksum:
184
+ raise ConcurrentUpdateError("memory changed on disk since it was read")
185
+
186
+ if existing:
187
+ target = existing.path
188
+ memory_id = existing.id
189
+ created = existing.created
190
+ else:
191
+ target = self.storage.path_for(
192
+ scope=target_scope, filename=slug_filename(logical_name)
193
+ )
194
+ if target.exists():
195
+ raise FileExistsError(f"unindexed memory file already exists: {target}")
196
+ memory_id = self._new_id()
197
+ created = dt.date.today().isoformat()
198
+
199
+ today = dt.date.today().isoformat()
200
+ candidate = MemoryDocument(
201
+ id=memory_id,
202
+ name=logical_name,
203
+ description=description.strip(),
204
+ type=memory_type,
205
+ scope=target_scope,
206
+ created=created,
207
+ updated=today,
208
+ body=body.strip(),
209
+ path=target,
210
+ tags=tuple(tags),
211
+ pinned=pinned,
212
+ )
213
+ rendered = render_memory(candidate)
214
+ validated = parse_memory(target, rendered)
215
+ self.storage.atomic_write(target, rendered)
216
+ report = self.sync_engine.reconcile_paths((target,))
217
+ if report.errors:
218
+ self.database.set_state("dirty", "1")
219
+ raise IndexSyncError(
220
+ "Markdown was saved but the SQLite index update failed: "
221
+ + "; ".join(report.errors)
222
+ )
223
+ self.database.set_state("dirty", "0")
224
+ return validated
225
+
226
+ def delete(self, *, workspace: Path, identifier: str) -> Path:
227
+ record = self._lookup_record(workspace, identifier)
228
+ trashed = self.storage.soft_delete(record.path)
229
+ report = self.sync_engine.reconcile_paths((record.path,))
230
+ if report.errors:
231
+ self.database.set_state("dirty", "1")
232
+ raise IndexSyncError(
233
+ "Markdown was moved to trash but index cleanup failed: "
234
+ + "; ".join(report.errors)
235
+ )
236
+ return trashed
237
+
238
+ def links(
239
+ self, *, workspace: Path, identifier: str, direction: str = "both"
240
+ ) -> dict[str, object]:
241
+ if direction not in {"forward", "backward", "both"}:
242
+ raise ValueError("direction must be 'forward', 'backward', or 'both'")
243
+ record = self._lookup_record(workspace, identifier)
244
+ result: dict[str, object] = {"id": record.id}
245
+ if direction in {"forward", "both"}:
246
+ result["forward"] = self.database.forward_links(record.id)
247
+ if direction in {"backward", "both"}:
248
+ result["backlinks"] = self.database.backlinks(record.id)
249
+ return result
250
+
251
+ def status(self, *, workspace: Path | None = None) -> dict[str, object]:
252
+ records = self.database.all_records()
253
+ return {
254
+ "configured": True,
255
+ "vault_path": str(self.config.vault_path),
256
+ "memory_root": str(self.config.memory_root),
257
+ "language": self.config.language,
258
+ "project": self._project(workspace) if workspace else None,
259
+ "database_path": str(self.database.path),
260
+ "tokenizer": self.database.tokenizer,
261
+ "memory_count": len(records),
262
+ "index_error_count": len(self.database.index_errors()),
263
+ "last_reconcile": self.database.get_state("last_reconcile"),
264
+ "dirty": self.database.get_state("dirty") == "1",
265
+ }
266
+
267
+ def sync(self, *, full: bool = False) -> SyncReport:
268
+ report = (
269
+ self.sync_engine.full_rebuild()
270
+ if full
271
+ else self.sync_engine.quick_reconcile()
272
+ )
273
+ self.database.set_state("dirty", "1" if report.errors else "0")
274
+ return report
275
+
276
+ def doctor(self) -> tuple[DoctorFinding, ...]:
277
+ return run_doctor(self.storage, self.database)
278
+
279
+ async def ensure_fresh(self) -> SyncReport | None:
280
+ threshold = self.config.stale_after_seconds
281
+ last_reconcile = self.database.get_state("last_reconcile")
282
+ stale = last_reconcile is None
283
+ if last_reconcile is not None:
284
+ try:
285
+ last = dt.datetime.fromisoformat(last_reconcile)
286
+ if last.tzinfo is None:
287
+ last = last.replace(tzinfo=dt.timezone.utc)
288
+ age = (dt.datetime.now(dt.timezone.utc) - last).total_seconds()
289
+ stale = age >= threshold
290
+ except ValueError:
291
+ stale = True
292
+ if not stale:
293
+ return None
294
+ async with self._freshness_lock:
295
+ if threshold > 0:
296
+ refreshed = self.database.get_state("last_reconcile")
297
+ if refreshed and refreshed != last_reconcile:
298
+ return None
299
+ return self.sync(full=False)
keepm/setup.py ADDED
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol, cast
10
+
11
+ import questionary
12
+
13
+ from keepm.config import ConfigStore, KeepMConfig, MemoryLanguage
14
+ from keepm.instructions import (
15
+ InstructionError,
16
+ InstructionInstaller,
17
+ InstructionResult,
18
+ )
19
+ from keepm.integrations import (
20
+ AgentName,
21
+ AgentRegistrar,
22
+ IntegrationError,
23
+ McpScope,
24
+ McpTarget,
25
+ RegistrationState,
26
+ )
27
+
28
+
29
+ class SetupError(RuntimeError):
30
+ """The requested setup cannot proceed without a user decision."""
31
+
32
+
33
+ class Prompt(Protocol):
34
+ def ask(self, message: str, *, default: str | None = None) -> str: ...
35
+
36
+ def choose(
37
+ self, message: str, choices: Sequence[str], *, default: str
38
+ ) -> str: ...
39
+
40
+
41
+ PROMPT_STYLE = questionary.Style(
42
+ [
43
+ ("qmark", "fg:#00afff bold"),
44
+ ("question", "fg:#5f87ff bold"),
45
+ ("answer", "fg:#00d787 bold"),
46
+ ("pointer", "fg:#00d787 bold"),
47
+ ("highlighted", "fg:#00d787 bold"),
48
+ ("selected", "fg:#00d7af bold"),
49
+ ("instruction", "fg:#808080"),
50
+ ("text", ""),
51
+ ("disabled", "fg:#666666 italic"),
52
+ ]
53
+ )
54
+
55
+ CHOICE_TITLES = {
56
+ "zh": "中文 (zh)",
57
+ "en": "English (en)",
58
+ "codex": "Codex",
59
+ "claude": "Claude Code",
60
+ "user": "User scope",
61
+ "project": "Project scope",
62
+ "replace": "Replace",
63
+ "keep": "Keep existing",
64
+ "skip": "Skip",
65
+ "cancel": "Cancel",
66
+ }
67
+
68
+
69
+ class TerminalPrompt:
70
+ @staticmethod
71
+ def _required(answer: object) -> object:
72
+ if answer is None:
73
+ raise SetupError("setup cancelled")
74
+ return answer
75
+
76
+ def ask(self, message: str, *, default: str | None = None) -> str:
77
+ answer = questionary.text(
78
+ message,
79
+ default=default or "",
80
+ qmark="?",
81
+ style=PROMPT_STYLE,
82
+ ).ask()
83
+ return str(self._required(answer)).strip()
84
+
85
+ def choose(
86
+ self, message: str, choices: Sequence[str], *, default: str
87
+ ) -> str:
88
+ options = [
89
+ questionary.Choice(CHOICE_TITLES.get(choice, choice), value=choice)
90
+ for choice in choices
91
+ ]
92
+ answer = questionary.select(
93
+ message,
94
+ choices=options,
95
+ default=default,
96
+ pointer="❯",
97
+ qmark="?",
98
+ style=PROMPT_STYLE,
99
+ instruction="(↑/↓ move, enter confirm)",
100
+ ).ask()
101
+ return str(self._required(answer))
102
+
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class SetupRequest:
107
+ vault_path: Path | None = None
108
+ memory_dir: str | None = None
109
+ language: MemoryLanguage | None = None
110
+ agent: AgentName | None = None
111
+ scope: McpScope | None = None
112
+ project_dir: Path = field(default_factory=Path.cwd)
113
+ force: bool = False
114
+ assume_yes: bool = False
115
+ targets: tuple[McpTarget, ...] | None = None
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class TargetResult:
120
+ target: McpTarget
121
+ status: Literal["configured", "already-configured", "skipped", "failed"]
122
+ detail: str
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class SetupResult:
127
+ vault_status: Literal["configured", "already-configured", "kept"]
128
+ config: KeepMConfig
129
+ targets: tuple[TargetResult, ...]
130
+ instructions: tuple[InstructionResult, ...]
131
+
132
+
133
+ def default_language(environment: Mapping[str, str] | None = None) -> MemoryLanguage:
134
+ values = environment if environment is not None else os.environ
135
+ locale_name = next(
136
+ (values[key] for key in ("LC_ALL", "LC_MESSAGES", "LANG") if values.get(key)),
137
+ "",
138
+ )
139
+ return "zh" if locale_name.casefold().startswith("zh") else "en"
140
+
141
+
142
+ class SetupCoordinator:
143
+ def __init__(
144
+ self,
145
+ *,
146
+ store: ConfigStore | None = None,
147
+ registrar: AgentRegistrar | None = None,
148
+ prompt: Prompt | None = None,
149
+ instruction_installer: InstructionInstaller | None = None,
150
+ ) -> None:
151
+ self.store = store or ConfigStore()
152
+ self.registrar = registrar or AgentRegistrar()
153
+ self.prompt = prompt or TerminalPrompt()
154
+ registrar_home = getattr(self.registrar, "home", None)
155
+ self.instruction_installer = instruction_installer or InstructionInstaller(
156
+ home=registrar_home
157
+ )
158
+
159
+ def run(self, request: SetupRequest) -> SetupResult:
160
+ config, vault_status = self._configure_vault(request)
161
+ targets = request.targets
162
+ if targets is None:
163
+ targets = self._prompt_targets(request)
164
+ if targets and not self.registrar.has_uvx():
165
+ raise SetupError("uvx is required to register KeepM; install uv first")
166
+
167
+ results: list[TargetResult] = []
168
+ instruction_results: list[InstructionResult] = []
169
+ for target in targets:
170
+ try:
171
+ target_result = self.register(target, request=request)
172
+ results.append(target_result)
173
+ except IntegrationError as error:
174
+ results.append(TargetResult(target, "failed", str(error)))
175
+ continue
176
+ if target_result.status not in {"configured", "already-configured"}:
177
+ continue
178
+ try:
179
+ instruction_results.append(
180
+ self.instruction_installer.install(target, config.language)
181
+ )
182
+ except InstructionError as error:
183
+ instruction_results.append(
184
+ InstructionResult(target, None, "failed", str(error))
185
+ )
186
+ return SetupResult(
187
+ vault_status=vault_status,
188
+ config=config,
189
+ targets=tuple(results),
190
+ instructions=tuple(instruction_results),
191
+ )
192
+
193
+ def register(self, target: McpTarget, *, request: SetupRequest) -> TargetResult:
194
+ if target.scope == "project":
195
+ assert target.project_dir is not None
196
+ if not target.project_dir.is_dir():
197
+ raise SetupError(f"project directory does not exist: {target.project_dir}")
198
+ state = self.registrar.inspect(target)
199
+ if state.kind == "equivalent":
200
+ return TargetResult(target, "already-configured", state.detail)
201
+ if state.kind == "different":
202
+ if request.force:
203
+ self.registrar.apply(target, replace=True)
204
+ return TargetResult(target, "configured", "replaced existing keepm MCP")
205
+ if request.assume_yes:
206
+ raise SetupError(
207
+ f"{target.agent} {target.scope} has a different keepm MCP; rerun with --force to replace it"
208
+ )
209
+ decision = self.prompt.choose(
210
+ f"{target.agent} {target.scope} already has a different keepm MCP",
211
+ ("replace", "skip", "cancel"),
212
+ default="skip",
213
+ )
214
+ if decision == "cancel":
215
+ raise SetupError("setup cancelled; no remaining MCP registrations were changed")
216
+ if decision == "skip":
217
+ return TargetResult(target, "skipped", state.detail)
218
+ self.registrar.apply(target, replace=True)
219
+ return TargetResult(target, "configured", "replaced existing keepm MCP")
220
+
221
+ self.registrar.apply(target, replace=False)
222
+ return TargetResult(target, "configured", "registered KeepM MCP")
223
+
224
+ def _configure_vault(
225
+ self, request: SetupRequest
226
+ ) -> tuple[KeepMConfig, Literal["configured", "already-configured", "kept"]]:
227
+ existing = self.store.load_optional()
228
+ vault_path = request.vault_path
229
+ if vault_path is None and existing is not None:
230
+ vault_path = existing.vault_path
231
+ if vault_path is None:
232
+ if request.assume_yes:
233
+ raise SetupError("--vault is required with --yes for first-time setup")
234
+ vault_path = Path(self.prompt.ask("Obsidian Vault path")).expanduser()
235
+ memory_dir = request.memory_dir or (
236
+ existing.memory_dir if existing is not None else "Memory"
237
+ )
238
+ selected_language = request.language
239
+ if selected_language is None:
240
+ language_default = existing.language if existing is not None else default_language()
241
+ if request.assume_yes:
242
+ selected_language = language_default
243
+ else:
244
+ selected_language = cast(
245
+ MemoryLanguage,
246
+ self.prompt.choose(
247
+ "Memory language", ("zh", "en"), default=language_default
248
+ ),
249
+ )
250
+ vault_path = vault_path.expanduser().resolve()
251
+ if not vault_path.is_dir():
252
+ raise SetupError(f"vault path must be an existing directory: {vault_path}")
253
+ try:
254
+ desired = KeepMConfig(
255
+ vault_path=vault_path,
256
+ memory_dir=memory_dir,
257
+ stale_after_seconds=(
258
+ existing.stale_after_seconds if existing is not None else 30
259
+ ),
260
+ language=selected_language,
261
+ )
262
+ except ValueError as error:
263
+ raise SetupError(str(error)) from error
264
+
265
+ if existing == desired:
266
+ return existing, "already-configured"
267
+ if existing is not None:
268
+ same_storage = (
269
+ existing.vault_path == desired.vault_path
270
+ and existing.memory_dir == desired.memory_dir
271
+ and existing.stale_after_seconds == desired.stale_after_seconds
272
+ )
273
+ if same_storage:
274
+ return self.store.save(desired), "configured"
275
+ if request.force:
276
+ return self.store.save(desired), "configured"
277
+ if request.assume_yes:
278
+ raise SetupError(
279
+ "KeepM already uses a different Vault configuration; rerun with --force to replace it"
280
+ )
281
+ decision = self.prompt.choose(
282
+ "KeepM already has a different Vault configuration",
283
+ ("replace", "keep", "cancel"),
284
+ default="keep",
285
+ )
286
+ if decision == "cancel":
287
+ raise SetupError("setup cancelled before changing the KeepM Vault")
288
+ if decision == "keep":
289
+ return existing, "kept"
290
+ return self.store.save(desired), "configured"
291
+
292
+ def _prompt_targets(self, request: SetupRequest) -> tuple[McpTarget, ...]:
293
+ agents = self.registrar.available_agents()
294
+ if not agents:
295
+ return ()
296
+ if request.agent is not None:
297
+ if request.agent not in agents:
298
+ raise SetupError(f"{request.agent} is not available on PATH")
299
+ agent = request.agent
300
+ elif request.assume_yes:
301
+ if len(agents) != 1:
302
+ raise SetupError(
303
+ "--agent is required with --yes when multiple Agents are available"
304
+ )
305
+ agent = agents[0]
306
+ else:
307
+ agent = cast(
308
+ AgentName,
309
+ self.prompt.choose(
310
+ "Agent to register",
311
+ agents,
312
+ default=agents[0],
313
+ ),
314
+ )
315
+
316
+ scope = request.scope or "user"
317
+ if request.scope is None and not request.assume_yes:
318
+ scope = cast(
319
+ McpScope,
320
+ self.prompt.choose(
321
+ f"Register KeepM for {agent}",
322
+ ("user", "project"),
323
+ default="user",
324
+ ),
325
+ )
326
+ return (
327
+ McpTarget(
328
+ agent,
329
+ scope,
330
+ request.project_dir if scope == "project" else None,
331
+ ),
332
+ )
333
+
334
+ def _parser() -> argparse.ArgumentParser:
335
+ parser = argparse.ArgumentParser(
336
+ prog="keepm setup", description="Configure KeepM and register its local MCP."
337
+ )
338
+ parser.add_argument("--vault", type=Path, help="existing local Obsidian Vault path")
339
+ parser.add_argument(
340
+ "--memory-dir", help="relative directory inside the Vault (default: Memory)"
341
+ )
342
+ parser.add_argument(
343
+ "--language", choices=("zh", "en"), help="language for Agent instructions and new memories"
344
+ )
345
+ parser.add_argument(
346
+ "--agent", choices=("codex", "claude"), help="Agent to register"
347
+ )
348
+ parser.add_argument(
349
+ "--scope", choices=("user", "project"), help="MCP and instruction scope (default: user)"
350
+ )
351
+ parser.add_argument(
352
+ "--project-dir", type=Path, default=Path.cwd(), help="project for project-scope registration"
353
+ )
354
+ parser.add_argument("--yes", action="store_true", help="accept normal confirmations")
355
+ parser.add_argument(
356
+ "--force", action="store_true", help="replace a different KeepM configuration without prompting"
357
+ )
358
+ return parser
359
+
360
+
361
+ def _print_result(result: SetupResult) -> None:
362
+ print(
363
+ f"KeepM Vault: {result.vault_status} ({result.config.vault_path}, language={result.config.language})"
364
+ )
365
+ for target in result.targets:
366
+ detail = f" - {target.detail}" if target.status == "failed" else ""
367
+ print(
368
+ f"{target.target.agent} ({target.target.scope}): {target.status}{detail}"
369
+ )
370
+ for instruction in result.instructions:
371
+ location = f" ({instruction.path})" if instruction.path is not None else ""
372
+ detail = f" - {instruction.detail}" if instruction.status == "failed" else ""
373
+ print(
374
+ f"{instruction.target.agent} instructions ({instruction.target.scope}): "
375
+ f"{instruction.status}{location}{detail}"
376
+ )
377
+ if not result.targets:
378
+ print("No supported Agent was selected; register KeepM later with `keepm setup`.")
379
+ print("Manual registration: codex mcp add keepm -- uvx --from keepm keepm")
380
+ print(
381
+ "Manual registration: claude mcp add --scope user keepm -- uvx --from keepm keepm"
382
+ )
383
+ print("Restart or open a new Agent session before using the KeepM MCP.")
384
+
385
+
386
+ def main(argv: Sequence[str] | None = None) -> None:
387
+ parser = _parser()
388
+ arguments = parser.parse_args(argv)
389
+ if not arguments.yes and not sys.stdin.isatty():
390
+ parser.error("interactive setup requires a TTY; use --yes for automation")
391
+ request = SetupRequest(
392
+ vault_path=arguments.vault,
393
+ memory_dir=arguments.memory_dir,
394
+ language=cast(MemoryLanguage | None, arguments.language),
395
+ agent=cast(AgentName | None, arguments.agent),
396
+ scope=cast(McpScope | None, arguments.scope),
397
+ project_dir=arguments.project_dir.expanduser().resolve(),
398
+ force=arguments.force,
399
+ assume_yes=arguments.yes,
400
+ )
401
+ try:
402
+ result = SetupCoordinator().run(request)
403
+ except (SetupError, IntegrationError) as error:
404
+ parser.error(str(error))
405
+ _print_result(result)