threadkeeper 0.4.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.
- threadkeeper/__init__.py +8 -0
- threadkeeper/_mcp.py +6 -0
- threadkeeper/_setup.py +299 -0
- threadkeeper/adapters/__init__.py +40 -0
- threadkeeper/adapters/_hook_helpers.py +72 -0
- threadkeeper/adapters/base.py +152 -0
- threadkeeper/adapters/claude_code.py +178 -0
- threadkeeper/adapters/claude_desktop.py +128 -0
- threadkeeper/adapters/codex.py +259 -0
- threadkeeper/adapters/copilot.py +195 -0
- threadkeeper/adapters/gemini.py +169 -0
- threadkeeper/adapters/vscode.py +144 -0
- threadkeeper/brief.py +735 -0
- threadkeeper/config.py +216 -0
- threadkeeper/curator.py +390 -0
- threadkeeper/db.py +474 -0
- threadkeeper/embeddings.py +232 -0
- threadkeeper/extract_daemon.py +125 -0
- threadkeeper/helpers.py +101 -0
- threadkeeper/i18n.py +342 -0
- threadkeeper/identity.py +237 -0
- threadkeeper/ingest.py +507 -0
- threadkeeper/lessons.py +170 -0
- threadkeeper/nudges.py +257 -0
- threadkeeper/process_health.py +202 -0
- threadkeeper/review_prompts.py +207 -0
- threadkeeper/search_proxy.py +160 -0
- threadkeeper/server.py +55 -0
- threadkeeper/shadow_review.py +358 -0
- threadkeeper/skill_watcher.py +96 -0
- threadkeeper/spawn_budget.py +246 -0
- threadkeeper/tools/__init__.py +2 -0
- threadkeeper/tools/concepts.py +111 -0
- threadkeeper/tools/consolidate.py +222 -0
- threadkeeper/tools/core_memory.py +109 -0
- threadkeeper/tools/correlation.py +116 -0
- threadkeeper/tools/curator.py +121 -0
- threadkeeper/tools/dialectic.py +359 -0
- threadkeeper/tools/dialog.py +131 -0
- threadkeeper/tools/distill.py +184 -0
- threadkeeper/tools/extract.py +411 -0
- threadkeeper/tools/graph.py +183 -0
- threadkeeper/tools/invariants.py +177 -0
- threadkeeper/tools/lessons.py +110 -0
- threadkeeper/tools/missed_spawns.py +142 -0
- threadkeeper/tools/peers.py +579 -0
- threadkeeper/tools/pickup.py +148 -0
- threadkeeper/tools/probes.py +251 -0
- threadkeeper/tools/process_health.py +90 -0
- threadkeeper/tools/session.py +34 -0
- threadkeeper/tools/shadow_review.py +106 -0
- threadkeeper/tools/skills.py +856 -0
- threadkeeper/tools/spawn.py +871 -0
- threadkeeper/tools/style.py +44 -0
- threadkeeper/tools/threads.py +299 -0
- threadkeeper-0.4.0.dist-info/METADATA +351 -0
- threadkeeper-0.4.0.dist-info/RECORD +61 -0
- threadkeeper-0.4.0.dist-info/WHEEL +5 -0
- threadkeeper-0.4.0.dist-info/entry_points.txt +2 -0
- threadkeeper-0.4.0.dist-info/licenses/LICENSE +21 -0
- threadkeeper-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""GitHub Copilot CLI adapter.
|
|
2
|
+
|
|
3
|
+
Config: ~/.copilot/mcp-config.json — JSON with a top-level `mcpServers`
|
|
4
|
+
section (same key as Claude / Gemini, contrary to older bundles that
|
|
5
|
+
shipped an unused `servers` key).
|
|
6
|
+
|
|
7
|
+
Instructions: Copilot doesn't have a stable single "global" file. Its
|
|
8
|
+
conventionPaths table (from the CLI bundle):
|
|
9
|
+
|
|
10
|
+
AGENTS.md in cwd and walked-up parents
|
|
11
|
+
CLAUDE.md in cwd, parents, and `.claude/` subdir
|
|
12
|
+
GEMINI.md in cwd and parents
|
|
13
|
+
copilot-instructions.md in `.github/` subdir, walked up
|
|
14
|
+
|
|
15
|
+
Walking up from any project under `~`, Copilot reaches `~/.claude/CLAUDE.md`
|
|
16
|
+
(which our setup manages) and picks it up automatically. We also write
|
|
17
|
+
the canonically-named `~/.copilot/copilot-instructions.md` as a stable
|
|
18
|
+
location user-level symlinks/projects can point at.
|
|
19
|
+
|
|
20
|
+
Transcripts: ~/.copilot/session-store.db — a sqlite database. Schema:
|
|
21
|
+
sessions(id, cwd, repository, host_type, branch, summary, created_at, updated_at)
|
|
22
|
+
turns(id, session_id, turn_index, user_message, assistant_response, timestamp)
|
|
23
|
+
|
|
24
|
+
Each row in `turns` corresponds to ONE user/assistant exchange. We
|
|
25
|
+
split it into two NormalizedMessage records during ingest so the rest
|
|
26
|
+
of the pipeline (FTS, semantic, skill scan) doesn't have to know
|
|
27
|
+
about the merged-turn shape.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import shutil
|
|
33
|
+
import sqlite3
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Iterator
|
|
37
|
+
|
|
38
|
+
from .base import CLIAdapter, NormalizedMessage
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ts(s: str) -> int:
|
|
42
|
+
try:
|
|
43
|
+
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
|
|
44
|
+
except Exception:
|
|
45
|
+
try:
|
|
46
|
+
# Copilot stores timestamps via sqlite's datetime('now')
|
|
47
|
+
# which yields 'YYYY-MM-DD HH:MM:SS' (no T, no tz).
|
|
48
|
+
return int(datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp())
|
|
49
|
+
except Exception:
|
|
50
|
+
import time
|
|
51
|
+
return int(time.time())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CopilotAdapter(CLIAdapter):
|
|
55
|
+
name = "copilot"
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.config_path = Path("~/.copilot/mcp-config.json").expanduser()
|
|
59
|
+
self.session_db = Path("~/.copilot/session-store.db").expanduser()
|
|
60
|
+
self._instructions = Path("~/.copilot/copilot-instructions.md").expanduser()
|
|
61
|
+
# Copilot loads hooks from `~/.copilot/hooks.json` (or
|
|
62
|
+
# `~/.copilot/hooks/hooks.json`). The bundle's schema accepts
|
|
63
|
+
# the same Claude-Code-compatible shape (`_vsCodeCompat` mode),
|
|
64
|
+
# so we reuse the helper but write to a dedicated file rather
|
|
65
|
+
# than into mcp-config.json.
|
|
66
|
+
self._hooks_path = Path("~/.copilot/hooks.json").expanduser()
|
|
67
|
+
|
|
68
|
+
def instructions_path(self):
|
|
69
|
+
return self._instructions
|
|
70
|
+
|
|
71
|
+
def hooks_supported(self) -> bool:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def register_hooks(self, specs, dry_run=False) -> str:
|
|
75
|
+
from ._hook_helpers import install_claude_style_hooks
|
|
76
|
+
return install_claude_style_hooks(
|
|
77
|
+
self._hooks_path, specs, dry_run=dry_run,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def is_installed(self) -> bool:
|
|
81
|
+
if self.config_path.exists() or self.session_db.exists():
|
|
82
|
+
return True
|
|
83
|
+
return (
|
|
84
|
+
shutil.which("copilot") is not None
|
|
85
|
+
or shutil.which("gh") is not None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ----- MCP registration ---------------------------------------------
|
|
89
|
+
def register_mcp_server(
|
|
90
|
+
self, name, command, args, env, dry_run=False
|
|
91
|
+
) -> str:
|
|
92
|
+
cfg: dict
|
|
93
|
+
if self.config_path.exists():
|
|
94
|
+
try:
|
|
95
|
+
cfg = json.loads(self.config_path.read_text())
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
return "copilot: malformed mcp-config.json — refused"
|
|
98
|
+
else:
|
|
99
|
+
cfg = {}
|
|
100
|
+
# Copilot v1.0.43+ schema validates the top-level `mcpServers`
|
|
101
|
+
# key (same as Claude/Gemini). Older bundles shipped with
|
|
102
|
+
# `servers` documented; the validator now rejects that file.
|
|
103
|
+
# If we see a legacy `servers` block, migrate its contents into
|
|
104
|
+
# `mcpServers` AND drop the legacy key so the file is valid.
|
|
105
|
+
legacy = cfg.pop("servers", None)
|
|
106
|
+
if isinstance(legacy, dict):
|
|
107
|
+
cfg.setdefault("mcpServers", {})
|
|
108
|
+
for k, v in legacy.items():
|
|
109
|
+
cfg["mcpServers"].setdefault(k, v)
|
|
110
|
+
servers = cfg.setdefault("mcpServers", {})
|
|
111
|
+
entry = {
|
|
112
|
+
"command": command,
|
|
113
|
+
"args": list(args),
|
|
114
|
+
}
|
|
115
|
+
if env:
|
|
116
|
+
entry["env"] = dict(env)
|
|
117
|
+
existing = servers.get(name)
|
|
118
|
+
if existing == entry and legacy is None:
|
|
119
|
+
return "copilot: already current"
|
|
120
|
+
servers[name] = entry
|
|
121
|
+
if not dry_run:
|
|
122
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
124
|
+
if legacy is not None:
|
|
125
|
+
return f"copilot: migrated legacy 'servers' → 'mcpServers' + {'added' if not existing else 'updated'} {name}"
|
|
126
|
+
return f"copilot: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
|
|
127
|
+
|
|
128
|
+
def unregister_mcp_server(self, name, dry_run=False) -> str:
|
|
129
|
+
if not self.config_path.exists():
|
|
130
|
+
return "copilot: nothing to remove"
|
|
131
|
+
cfg = json.loads(self.config_path.read_text())
|
|
132
|
+
servers = (cfg.get("mcpServers") or cfg.get("servers") or {})
|
|
133
|
+
if name not in servers:
|
|
134
|
+
return "copilot: not present"
|
|
135
|
+
if dry_run:
|
|
136
|
+
return f"copilot: would remove {name}"
|
|
137
|
+
servers.pop(name)
|
|
138
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
139
|
+
return f"copilot: removed {name}"
|
|
140
|
+
|
|
141
|
+
# ----- Transcript ingestion -----------------------------------------
|
|
142
|
+
# Copilot stores transcripts in a single sqlite DB rather than per-
|
|
143
|
+
# session jsonl files. We treat the DB itself as the single
|
|
144
|
+
# "transcript file" — ingest's mtime-based incremental scheme works
|
|
145
|
+
# because Copilot updates the file as new turns land.
|
|
146
|
+
def session_dir(self):
|
|
147
|
+
return self.session_db.parent
|
|
148
|
+
|
|
149
|
+
def transcript_files(self) -> list[Path]:
|
|
150
|
+
if self.session_db.exists():
|
|
151
|
+
return [self.session_db]
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
|
|
155
|
+
if not fp.exists():
|
|
156
|
+
return
|
|
157
|
+
try:
|
|
158
|
+
conn = sqlite3.connect(f"file:{fp}?mode=ro&immutable=0", uri=True)
|
|
159
|
+
except sqlite3.OperationalError:
|
|
160
|
+
return
|
|
161
|
+
try:
|
|
162
|
+
rows = conn.execute(
|
|
163
|
+
"SELECT session_id, turn_index, user_message, "
|
|
164
|
+
"assistant_response, timestamp FROM turns "
|
|
165
|
+
"ORDER BY session_id, turn_index"
|
|
166
|
+
).fetchall()
|
|
167
|
+
except sqlite3.OperationalError:
|
|
168
|
+
conn.close()
|
|
169
|
+
return
|
|
170
|
+
conn.close()
|
|
171
|
+
for sess_id, turn_idx, user_msg, asst_msg, ts in rows:
|
|
172
|
+
created = _ts(ts or "")
|
|
173
|
+
if user_msg:
|
|
174
|
+
yield NormalizedMessage(
|
|
175
|
+
uuid=f"copilot:{sess_id}:{turn_idx}:u",
|
|
176
|
+
session_id=sess_id,
|
|
177
|
+
role="user",
|
|
178
|
+
content=user_msg,
|
|
179
|
+
model="",
|
|
180
|
+
created_at=created,
|
|
181
|
+
raw={"turn_index": turn_idx},
|
|
182
|
+
)
|
|
183
|
+
if asst_msg:
|
|
184
|
+
yield NormalizedMessage(
|
|
185
|
+
uuid=f"copilot:{sess_id}:{turn_idx}:a",
|
|
186
|
+
session_id=sess_id,
|
|
187
|
+
role="assistant",
|
|
188
|
+
content=asst_msg,
|
|
189
|
+
model="",
|
|
190
|
+
created_at=created,
|
|
191
|
+
raw={"turn_index": turn_idx},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
ADAPTER = CopilotAdapter()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Google Gemini CLI adapter.
|
|
2
|
+
|
|
3
|
+
Config: ~/.gemini/settings.json with an `mcpServers` section (same
|
|
4
|
+
shape as Claude Code's, JSON).
|
|
5
|
+
|
|
6
|
+
Transcripts: ~/.gemini/tmp/<user>/chats/session-<ts>-<id>.jsonl
|
|
7
|
+
Each line is a JSON object. First line is session-meta
|
|
8
|
+
({"sessionId", "projectHash", "startTime", ...}). Subsequent lines:
|
|
9
|
+
{"id": <msg-uuid>, "timestamp": ..., "type": "info"|"user"|"model"|...,
|
|
10
|
+
"content": <string or structured>}
|
|
11
|
+
We pick `type in {"user", "model"}` as turns; map "model" → "assistant".
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import getpass
|
|
16
|
+
import json
|
|
17
|
+
import shutil
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterator
|
|
21
|
+
|
|
22
|
+
from .base import CLIAdapter, NormalizedMessage
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ts(s: str) -> int:
|
|
26
|
+
try:
|
|
27
|
+
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
|
|
28
|
+
except Exception:
|
|
29
|
+
import time
|
|
30
|
+
return int(time.time())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _coerce_text(content) -> str:
|
|
34
|
+
"""Gemini content may be: a plain string, a list of segments, or a
|
|
35
|
+
dict with text/parts. Coerce to a single string."""
|
|
36
|
+
if isinstance(content, str):
|
|
37
|
+
return content
|
|
38
|
+
if isinstance(content, dict):
|
|
39
|
+
if isinstance(content.get("text"), str):
|
|
40
|
+
return content["text"]
|
|
41
|
+
if isinstance(content.get("parts"), list):
|
|
42
|
+
return "\n".join(_coerce_text(p) for p in content["parts"])
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
return "\n".join(_coerce_text(x) for x in content if x is not None)
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GeminiAdapter(CLIAdapter):
|
|
49
|
+
name = "gemini"
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.config_path = Path("~/.gemini/settings.json").expanduser()
|
|
53
|
+
self._instructions = Path("~/.gemini/GEMINI.md").expanduser()
|
|
54
|
+
# The "chats" tmp tree is per-OS-user; resolve at instance time.
|
|
55
|
+
self.chats_root = Path(
|
|
56
|
+
f"~/.gemini/tmp/{getpass.getuser()}/chats"
|
|
57
|
+
).expanduser()
|
|
58
|
+
|
|
59
|
+
def instructions_path(self):
|
|
60
|
+
return self._instructions
|
|
61
|
+
|
|
62
|
+
def hooks_supported(self) -> bool:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def register_hooks(self, specs, dry_run=False) -> str:
|
|
66
|
+
# Gemini reads `settings.hooks` in the same shape as Claude
|
|
67
|
+
# Code (the bundle even ships `gemini hooks migrate --from-claude`),
|
|
68
|
+
# so the Claude-style helper Just Works for the same file.
|
|
69
|
+
from ._hook_helpers import install_claude_style_hooks
|
|
70
|
+
return install_claude_style_hooks(
|
|
71
|
+
self.config_path, specs, dry_run=dry_run,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def is_installed(self) -> bool:
|
|
75
|
+
if self.config_path.exists() or self.chats_root.parent.exists():
|
|
76
|
+
return True
|
|
77
|
+
return shutil.which("gemini") is not None
|
|
78
|
+
|
|
79
|
+
# ----- MCP registration ---------------------------------------------
|
|
80
|
+
def register_mcp_server(
|
|
81
|
+
self, name, command, args, env, dry_run=False
|
|
82
|
+
) -> str:
|
|
83
|
+
cfg: dict
|
|
84
|
+
if self.config_path.exists():
|
|
85
|
+
try:
|
|
86
|
+
cfg = json.loads(self.config_path.read_text())
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
return "gemini: malformed settings.json — refused"
|
|
89
|
+
else:
|
|
90
|
+
cfg = {}
|
|
91
|
+
servers = cfg.setdefault("mcpServers", {})
|
|
92
|
+
entry = {
|
|
93
|
+
"command": command,
|
|
94
|
+
"args": list(args),
|
|
95
|
+
}
|
|
96
|
+
if env:
|
|
97
|
+
entry["env"] = dict(env)
|
|
98
|
+
existing = servers.get(name)
|
|
99
|
+
if existing == entry:
|
|
100
|
+
return "gemini: already current"
|
|
101
|
+
servers[name] = entry
|
|
102
|
+
if not dry_run:
|
|
103
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
105
|
+
return f"gemini: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
|
|
106
|
+
|
|
107
|
+
def unregister_mcp_server(self, name, dry_run=False) -> str:
|
|
108
|
+
if not self.config_path.exists():
|
|
109
|
+
return "gemini: nothing to remove"
|
|
110
|
+
cfg = json.loads(self.config_path.read_text())
|
|
111
|
+
servers = (cfg.get("mcpServers") or {})
|
|
112
|
+
if name not in servers:
|
|
113
|
+
return "gemini: not present"
|
|
114
|
+
if dry_run:
|
|
115
|
+
return f"gemini: would remove {name}"
|
|
116
|
+
servers.pop(name)
|
|
117
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
118
|
+
return f"gemini: removed {name}"
|
|
119
|
+
|
|
120
|
+
# ----- Transcript ingestion -----------------------------------------
|
|
121
|
+
def session_dir(self):
|
|
122
|
+
return self.chats_root
|
|
123
|
+
|
|
124
|
+
def transcript_files(self) -> list[Path]:
|
|
125
|
+
if not self.chats_root.exists():
|
|
126
|
+
return []
|
|
127
|
+
return list(self.chats_root.glob("session-*.jsonl"))
|
|
128
|
+
|
|
129
|
+
def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
|
|
130
|
+
sess_id = ""
|
|
131
|
+
try:
|
|
132
|
+
with fp.open("r", encoding="utf-8", errors="replace") as f:
|
|
133
|
+
for line in f:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
obj = json.loads(line)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
continue
|
|
141
|
+
# session-meta line: first record carries sessionId.
|
|
142
|
+
if "sessionId" in obj and "projectHash" in obj:
|
|
143
|
+
sess_id = obj.get("sessionId") or ""
|
|
144
|
+
continue
|
|
145
|
+
typ = obj.get("type")
|
|
146
|
+
if typ == "model":
|
|
147
|
+
role = "assistant"
|
|
148
|
+
elif typ == "user":
|
|
149
|
+
role = "user"
|
|
150
|
+
else:
|
|
151
|
+
continue
|
|
152
|
+
uuid = obj.get("id")
|
|
153
|
+
if not uuid:
|
|
154
|
+
continue
|
|
155
|
+
text = _coerce_text(obj.get("content", ""))
|
|
156
|
+
yield NormalizedMessage(
|
|
157
|
+
uuid=uuid,
|
|
158
|
+
session_id=sess_id,
|
|
159
|
+
role=role,
|
|
160
|
+
content=text,
|
|
161
|
+
model=obj.get("model") or "",
|
|
162
|
+
created_at=_ts(obj.get("timestamp", "")),
|
|
163
|
+
raw=obj,
|
|
164
|
+
)
|
|
165
|
+
except OSError:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
ADAPTER = GeminiAdapter()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""VS Code adapter — registers thread-keeper in VS Code's user-level
|
|
2
|
+
MCP host (Code 1.93+).
|
|
3
|
+
|
|
4
|
+
Modern VS Code ships built-in MCP host support, consumed by every
|
|
5
|
+
MCP-aware extension running in the same window: GitHub Copilot Chat,
|
|
6
|
+
the Claude IDE extension, the OpenAI Codex extension, Continue, Cline,
|
|
7
|
+
and others. All of them read the same user-level file:
|
|
8
|
+
|
|
9
|
+
macOS: ~/Library/Application Support/Code/User/mcp.json
|
|
10
|
+
Linux: ~/.config/Code/User/mcp.json
|
|
11
|
+
Windows: %APPDATA%/Code/User/mcp.json
|
|
12
|
+
|
|
13
|
+
Schema (note `servers`, NOT `mcpServers` — VS Code chose its own key):
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
"inputs": [ … prompt-string inputs for secrets … ],
|
|
17
|
+
"servers": {
|
|
18
|
+
"<name>": {
|
|
19
|
+
"type": "stdio" | "http" | "sse",
|
|
20
|
+
"command": "...",
|
|
21
|
+
"args": [...],
|
|
22
|
+
"env": {...}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Hooks: VS Code has no SessionStart-style shell hook the way Claude Code
|
|
28
|
+
or Gemini does. Instructions file: per-workspace `.github/instructions/*`
|
|
29
|
+
or `copilot-instructions.md`, not a single global file we can write. So
|
|
30
|
+
this adapter is MCP-registration-only — no hooks, no instructions, no
|
|
31
|
+
transcript ingest.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Iterator
|
|
41
|
+
|
|
42
|
+
from .base import CLIAdapter, NormalizedMessage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_config_path() -> Path:
|
|
46
|
+
"""Per-OS default location for VS Code's user-level mcp.json.
|
|
47
|
+
|
|
48
|
+
Overridable via VSCODE_MCP_JSON env var (used by tests)."""
|
|
49
|
+
env = os.environ.get("VSCODE_MCP_JSON")
|
|
50
|
+
if env:
|
|
51
|
+
return Path(env).expanduser()
|
|
52
|
+
if sys.platform == "darwin":
|
|
53
|
+
return Path(
|
|
54
|
+
"~/Library/Application Support/Code/User/mcp.json"
|
|
55
|
+
).expanduser()
|
|
56
|
+
if sys.platform == "win32":
|
|
57
|
+
appdata = os.environ.get("APPDATA") or "~/AppData/Roaming"
|
|
58
|
+
return Path(appdata).expanduser() / "Code" / "User" / "mcp.json"
|
|
59
|
+
# linux / freebsd / etc.
|
|
60
|
+
return Path("~/.config/Code/User/mcp.json").expanduser()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _vscode_user_dir() -> Path:
|
|
64
|
+
"""The User/ directory itself — used to probe install presence even
|
|
65
|
+
when mcp.json hasn't been created yet."""
|
|
66
|
+
return _default_config_path().parent
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class VSCodeAdapter(CLIAdapter):
|
|
70
|
+
name = "vscode"
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self.config_path = _default_config_path()
|
|
74
|
+
|
|
75
|
+
# ----------------------------- detection -----------------------------
|
|
76
|
+
def is_installed(self) -> bool:
|
|
77
|
+
# mcp.json already exists OR VS Code is on disk in some form
|
|
78
|
+
# (User/ profile dir, .app bundle, `code` on PATH).
|
|
79
|
+
if self.config_path.exists() or _vscode_user_dir().exists():
|
|
80
|
+
return True
|
|
81
|
+
if sys.platform == "darwin":
|
|
82
|
+
if Path("/Applications/Visual Studio Code.app").exists():
|
|
83
|
+
return True
|
|
84
|
+
return shutil.which("code") is not None
|
|
85
|
+
|
|
86
|
+
# ----------------------------- mcp -----------------------------------
|
|
87
|
+
def register_mcp_server(
|
|
88
|
+
self, name, command, args, env, dry_run=False
|
|
89
|
+
) -> str:
|
|
90
|
+
cfg: dict
|
|
91
|
+
if self.config_path.exists():
|
|
92
|
+
try:
|
|
93
|
+
cfg = json.loads(self.config_path.read_text())
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
return "vscode: malformed mcp.json — refused"
|
|
96
|
+
else:
|
|
97
|
+
cfg = {}
|
|
98
|
+
servers = cfg.setdefault("servers", {})
|
|
99
|
+
entry: dict = {
|
|
100
|
+
"type": "stdio",
|
|
101
|
+
"command": command,
|
|
102
|
+
"args": list(args),
|
|
103
|
+
}
|
|
104
|
+
if env:
|
|
105
|
+
entry["env"] = dict(env)
|
|
106
|
+
existing = servers.get(name)
|
|
107
|
+
if existing == entry:
|
|
108
|
+
return "vscode: already current"
|
|
109
|
+
servers[name] = entry
|
|
110
|
+
if not dry_run:
|
|
111
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
113
|
+
return f"vscode: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
|
|
114
|
+
|
|
115
|
+
def unregister_mcp_server(self, name, dry_run=False) -> str:
|
|
116
|
+
if not self.config_path.exists():
|
|
117
|
+
return "vscode: nothing to remove"
|
|
118
|
+
try:
|
|
119
|
+
cfg = json.loads(self.config_path.read_text())
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
return "vscode: malformed mcp.json — refused"
|
|
122
|
+
servers = (cfg.get("servers") or {})
|
|
123
|
+
if name not in servers:
|
|
124
|
+
return "vscode: not present"
|
|
125
|
+
if dry_run:
|
|
126
|
+
return f"vscode: would remove {name}"
|
|
127
|
+
servers.pop(name)
|
|
128
|
+
self.config_path.write_text(json.dumps(cfg, indent=2))
|
|
129
|
+
return f"vscode: removed {name}"
|
|
130
|
+
|
|
131
|
+
# ----------------------------- transcripts ---------------------------
|
|
132
|
+
# VS Code itself doesn't keep a unified MCP transcript on disk —
|
|
133
|
+
# individual extensions handle chat history their own way (Copilot
|
|
134
|
+
# via GitHub services; Claude IDE via ~/.claude/projects; etc.).
|
|
135
|
+
# The Claude-IDE side is already covered by the claude_code adapter,
|
|
136
|
+
# so there's nothing for the vscode adapter to ingest on top.
|
|
137
|
+
def transcript_files(self) -> list[Path]:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
|
|
141
|
+
return iter(())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
ADAPTER = VSCodeAdapter()
|