context-system 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phuripat Kongsakban
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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: context-system
3
+ Version: 0.1.0
4
+ Summary: Installer for the context system: MCP server, vector store, skill, commands, hook
5
+ Requires-Python: >=3.12
6
+ License-File: LICENSE.txt
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "context-system"
3
+ version = "0.1.0"
4
+ description = "Installer for the context system: MCP server, vector store, skill, commands, hook"
5
+ requires-python = ">=3.12"
6
+ dependencies = []
7
+
8
+ [project.scripts]
9
+ context-system = "setup:main"
10
+
11
+ # flit_core: the one backend that ships a single-file module without ever executing
12
+ # setup.py (setuptools would run it at build time and start the wizard).
13
+ [build-system]
14
+ requires = ["flit_core>=3.9,<4"]
15
+ build-backend = "flit_core.buildapi"
16
+
17
+ [tool.flit.module]
18
+ name = "setup"
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """Install the context system into a repo: store, MCP entry, skill, commands, hook.
3
+
4
+ The Python twin of install.sh, for machines without a POSIX sh and for running as a
5
+ tool: `uv run context-system` in a checkout, or without one
6
+ `uvx --from git+https://github.com/ZGA2519/context-system context-system`.
7
+ Idempotent, re-run to update an install. An existing .context/memories/ is never touched.
8
+ Run with no answers on a terminal and it asks for them; -y takes the defaults.
9
+ """
10
+ import argparse
11
+ import atexit
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from collections import Counter
19
+ from pathlib import Path
20
+
21
+ REPO_URL = "https://github.com/ZGA2519/context-system.git"
22
+ CLIENT_FLAGS = ["--claude", "--codex", "--gemini", "--agy", "--vscode"]
23
+ MCP_ENTRY = {"command": "uv", "args": ["run", "--directory", ".context", "python", "-m", "context_store.server", "mcp"]}
24
+ HOOK_CMD = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/context-sync.sh'
25
+
26
+ USAGE = "%(prog)s [TARGET_REPO] [-y] [--no-hook] [--claude] [--codex] [--gemini] [--agy] [--vscode]"
27
+ DESCRIPTION = """\
28
+ Installs into TARGET_REPO (default: the current directory):
29
+
30
+ .context/ the store and MCP server
31
+ .mcp.json mcpServers.context-system, merged in
32
+ .claude/skills/context-sync/ the skill
33
+ .claude/commands/context-*.md /context-start-sync, -readonly, /context-stop-sync
34
+ .claude/hooks/context-sync.sh per-prompt loop re-injection
35
+ .claude/settings.json the UserPromptSubmit hook, merged in
36
+ .agent/skills/context-sync/ the same skill, vendor-neutral tree
37
+ .agent/prompts/context-*.md the same commands, called prompts there
38
+
39
+ --no-hook skip the last two; the skill alone drives the loop
40
+ --claude --codex --gemini --agy --vscode
41
+ also register the server with those clients, via .context/setup.sh
42
+
43
+ Anything not given is asked for interactively when there is a terminal. -y (--yes)
44
+ answers every question with its default instead: the current directory, the hook on,
45
+ no extra clients."""
46
+
47
+ if os.name == "nt":
48
+ os.system("") # ponytail: the documented hack that turns on ANSI escapes in conhost
49
+ sys.stdout.reconfigure(errors="replace") # glyphs below must not crash a cp1252 pipe
50
+ if sys.stdout.isatty() and not os.environ.get("NO_COLOR"):
51
+ B, D, R, C, G, Y, M = "\033[1m", "\033[2m", "\033[0m", "\033[36m", "\033[32m", "\033[33m", "\033[35m"
52
+ else:
53
+ B = D = R = C = G = Y = M = ""
54
+
55
+ count = Counter()
56
+
57
+
58
+ def sec(title):
59
+ print(f"\n{M}▌{R} {B}{title}{R}")
60
+
61
+
62
+ def say(path, status):
63
+ """One row; glyph and colour follow the status verb, counted for the summary."""
64
+ if "already" in status or status.startswith("kept"):
65
+ glyph, colour, kind = f"{D}•", D, "same"
66
+ elif status.startswith("skipped"):
67
+ glyph, colour, kind = f"{Y}!", Y, "skip"
68
+ else:
69
+ verbs = ("added", "registered", "created", "replaced", "removed")
70
+ glyph, colour, kind = f"{G}✔", G if any(v in status for v in verbs) else "", "new"
71
+ count[kind] += 1
72
+ print(f" {glyph}{R} {C}{path:<28}{R} {colour}{status}{R}")
73
+
74
+
75
+ def load_json(p):
76
+ if p.exists() and p.read_text().strip():
77
+ try:
78
+ return json.loads(p.read_text())
79
+ except json.JSONDecodeError as e:
80
+ sys.exit(f"{p} is not valid JSON ({e}); fix or move it and re-run")
81
+ return {}
82
+
83
+
84
+ def save_json(p, doc):
85
+ p.write_text(json.dumps(doc, indent=2) + "\n")
86
+
87
+
88
+ # --- prompts ---------------------------------------------------------------
89
+ # Plain line prompts, so they work in every terminal cmd.exe included. Only asked
90
+ # for what the flags left open. Ctrl-C or EOF anywhere cancels with nothing written.
91
+ def ask(prompt):
92
+ try:
93
+ return input(prompt).strip()
94
+ except (EOFError, KeyboardInterrupt):
95
+ print(f"\n {Y}! cancelled, nothing written{R}")
96
+ sys.exit(130)
97
+
98
+
99
+ def done(title, answer):
100
+ print(f"{G}✔{R} {title} {D}›{R} {C}{answer}{R}")
101
+
102
+
103
+ def options(items):
104
+ for i, (label, hint) in enumerate(items, 1):
105
+ print(f" {M}{i}{R} {label:<14} {D}{hint}{R}")
106
+
107
+
108
+ def ask_select(title, items, default=1):
109
+ print(f"{M}?{R} {B}{title}{R}")
110
+ options(items)
111
+ while True:
112
+ a = ask(f" {D}number ({default}):{R} ") or str(default)
113
+ if a.isdigit() and 1 <= int(a) <= len(items):
114
+ done(title, items[int(a) - 1][0])
115
+ return int(a)
116
+ print(f" {Y}! pick 1-{len(items)}{R}")
117
+
118
+
119
+ def ask_multi(title, items):
120
+ print(f"{M}?{R} {B}{title}{R}")
121
+ options(items)
122
+ while True:
123
+ picks = ask(f" {D}numbers, space separated (none):{R} ").split()
124
+ if all(p.isdigit() and 1 <= int(p) <= len(items) for p in picks):
125
+ picks = sorted({int(p) for p in picks})
126
+ done(title, ", ".join(items[p - 1][0] for p in picks) or "none")
127
+ return picks
128
+ print(f" {Y}! pick from 1-{len(items)}{R}")
129
+
130
+
131
+ def ask_yesno(title, default=True):
132
+ while True:
133
+ a = ask(f"{M}?{R} {B}{title}{R} {D}({'Y/n' if default else 'y/N'}){R} ").lower()
134
+ if a in ("", "y", "yes", "n", "no"):
135
+ yes = default if a == "" else a[0] == "y"
136
+ done(title, "yes" if yes else "no")
137
+ return yes
138
+
139
+
140
+ def ask_path(src):
141
+ """The target, asked until it is a real directory that is not the checkout."""
142
+ default = "" if Path.cwd() == src else str(Path.cwd())
143
+ while True:
144
+ a = ask(f"{M}?{R} {B}install into which repo?{R} {D}{f'({default})' if default else ''}{R} ") or default
145
+ p = Path(a).expanduser()
146
+ if not a:
147
+ why = "a path is needed"
148
+ elif not p.is_dir():
149
+ why = "no such directory"
150
+ elif p.resolve() == src:
151
+ why = "that is the context-system checkout; pass the repo to install into"
152
+ else:
153
+ done("install into which repo?", p.resolve())
154
+ return p.resolve()
155
+ print(f" {Y}! {why}{R}")
156
+
157
+
158
+ def source():
159
+ """The checkout this file sits in, or a fresh shallow clone when installed as a tool."""
160
+ src = Path(__file__).resolve().parent
161
+ if (src / ".context").is_dir():
162
+ return src
163
+ tmp = tempfile.TemporaryDirectory(prefix="context-system-")
164
+ atexit.register(tmp.cleanup)
165
+ try:
166
+ subprocess.run(["git", "clone", "--quiet", "--depth", "1", REPO_URL, tmp.name], check=True)
167
+ except (OSError, subprocess.CalledProcessError) as e:
168
+ sys.exit(f"install: {src} is not a context-system checkout and cloning {REPO_URL} failed: {e}")
169
+ return Path(tmp.name)
170
+
171
+
172
+ def main(argv=None):
173
+ ap = argparse.ArgumentParser(usage=USAGE, description=DESCRIPTION,
174
+ formatter_class=argparse.RawDescriptionHelpFormatter)
175
+ ap.add_argument("target", nargs="?", default="", metavar="TARGET_REPO")
176
+ ap.add_argument("-y", "--yes", action="store_true")
177
+ ap.add_argument("--no-hook", dest="hook", action="store_false", default=None)
178
+ for f in CLIENT_FLAGS:
179
+ ap.add_argument(f, dest="clients", action="append_const", const=f, default=None)
180
+ a = ap.parse_args(argv)
181
+ got_clients = a.clients is not None
182
+ clients = a.clients or []
183
+
184
+ src = source()
185
+ target = Path(a.target).expanduser() if a.target else None
186
+
187
+ if not a.yes and sys.stdin.isatty() and sys.stdout.isatty() and (
188
+ target is None or a.hook is None or not got_clients):
189
+ print(f"\n{M}▌{R} {B}context-system{R} {D}installer{R}\n")
190
+ if target is None:
191
+ target = ask_path(src)
192
+ if a.hook is None:
193
+ a.hook = ask_select("how should the sync loop stay on?", [
194
+ ("hook + skill", "re-injected every prompt, survives compaction (recommended)"),
195
+ ("skill only", "no hook installed, the skill alone drives it")]) == 1
196
+ if not got_clients:
197
+ picks = ask_multi("register the server with other clients?", [
198
+ ("codex", "codex mcp add"), ("gemini", "gemini mcp add"),
199
+ ("antigravity", "agy mcp add"), ("vs code", "code --add-mcp")])
200
+ clients = [CLIENT_FLAGS[p] for p in picks] # 1-based picks skip --claude at index 0
201
+ print(f" {D}claude code is covered by .mcp.json, written either way{R}")
202
+ print(f"\n {C}{target}{R} {D}·{R} hook {'on' if a.hook else 'off'} {D}·{R} clients {' '.join(clients) or 'none'}")
203
+ if (target / ".context/memories").is_dir():
204
+ print(f" {D}an install is already there; .context/memories/ is kept as is{R}")
205
+ print()
206
+ if not ask_yesno("write it?"):
207
+ print(f" {Y}! cancelled, nothing written{R}")
208
+ sys.exit(130)
209
+ print()
210
+
211
+ hook = True if a.hook is None else a.hook
212
+ target = target or Path.cwd()
213
+ if not target.is_dir():
214
+ sys.exit(f"install: no such directory: {target}")
215
+ target = target.resolve()
216
+ if target == src:
217
+ sys.exit("install: target is the source checkout; pass the repo to install into")
218
+
219
+ print(f"{M}▌{R} {B}context-system{R} {D}→{R} {target}\n")
220
+
221
+ # --- .context/ ---------------------------------------------------------
222
+ # Everything but memories/, which is the user's data and is handled separately below.
223
+ def skip(d, names):
224
+ top = Path(d) == src / ".context"
225
+ return {n for n in names if n in ("__pycache__", ".DS_Store") or top and (
226
+ n in (".venv", ".pytest_cache", ".sync-on", "memories") or n.startswith("index.db"))}
227
+ shutil.copytree(src / ".context", target / ".context", ignore=skip, dirs_exist_ok=True)
228
+ say(".context/", "server, store code, pyproject")
229
+
230
+ # --- .context/memories/ ------------------------------------------------
231
+ # If memories/ is already there we do not touch it at all. Only a fresh install gets the seeds.
232
+ mem = target / ".context/memories"
233
+ if mem.exists():
234
+ if not mem.is_dir():
235
+ sys.exit(f"install: {mem} exists but is not a directory")
236
+ n = sum(1 for f in mem.rglob("*.jsonl") if f.is_file())
237
+ say(".context/memories/", f"kept as is, {n} store{'' if n == 1 else 's'} already there")
238
+ else:
239
+ mem.mkdir(parents=True)
240
+ seeds = sorted((src / ".context/memories").glob("*.jsonl"))
241
+ for f in seeds:
242
+ shutil.copy(f, mem)
243
+ say(".context/memories/", "created, empty: " + " ".join(f.name for f in seeds))
244
+
245
+ # --- .mcp.json ---------------------------------------------------------
246
+ p = target / ".mcp.json"
247
+ doc = load_json(p)
248
+ servers = doc.setdefault("mcpServers", {})
249
+ # the server was called "context" before; drop that key so a re-install does not
250
+ # leave two entries launching two processes against the same store
251
+ legacy = servers.get("context")
252
+ stale = bool(legacy) and legacy.get("command") == "uv" and any(".context" in str(x) for x in legacy.get("args", []))
253
+ if stale:
254
+ del servers["context"]
255
+ if servers.get("context-system") == MCP_ENTRY and not stale:
256
+ result = "already present"
257
+ else:
258
+ result = "replaced" if "context-system" in servers else "added"
259
+ servers["context-system"] = MCP_ENTRY
260
+ save_json(p, doc)
261
+ result += ', legacy "context" entry removed' if stale else ""
262
+ say(".mcp.json", f"mcpServers.context-system {result}")
263
+
264
+ # --- skill and commands ------------------------------------------------
265
+ skill = src / "skills/context-sync"
266
+ cmds = sorted(skill.glob("commands/*.md"))
267
+ (target / ".claude/skills/context-sync").mkdir(parents=True, exist_ok=True)
268
+ (target / ".claude/commands").mkdir(parents=True, exist_ok=True)
269
+ shutil.copy(skill / "SKILL.md", target / ".claude/skills/context-sync/SKILL.md")
270
+ say(".claude/skills/context-sync/", "SKILL.md")
271
+ # Older hand-installs nested commands/ and hooks/ under the skill, where nothing reads them.
272
+ for old in ("commands", "hooks"):
273
+ d = target / ".claude/skills/context-sync" / old
274
+ if d.is_dir():
275
+ shutil.rmtree(d)
276
+ say(".claude/skills/context-sync/", f"removed stale {old}/")
277
+ for f in cmds:
278
+ shutil.copy(f, target / ".claude/commands")
279
+ say(".claude/commands/", f"{len(cmds)} slash commands")
280
+
281
+ # --- .agent/ -----------------------------------------------------------
282
+ # The same skill and commands under the vendor-neutral tree some agents read.
283
+ (target / ".agent/skills/context-sync").mkdir(parents=True, exist_ok=True)
284
+ (target / ".agent/prompts").mkdir(parents=True, exist_ok=True)
285
+ shutil.copy(skill / "SKILL.md", target / ".agent/skills/context-sync/SKILL.md")
286
+ say(".agent/skills/context-sync/", "SKILL.md")
287
+ for f in cmds:
288
+ shutil.copy(f, target / ".agent/prompts")
289
+ say(".agent/prompts/", f"{len(cmds)} prompts")
290
+
291
+ # --- hook --------------------------------------------------------------
292
+ if not hook:
293
+ say(".claude/hooks/", "skipped (skill only)")
294
+ else:
295
+ (target / ".claude/hooks").mkdir(parents=True, exist_ok=True)
296
+ h = target / ".claude/hooks/context-sync.sh"
297
+ shutil.copy(skill / "hooks/context-sync.sh", h)
298
+ h.chmod(h.stat().st_mode | 0o111)
299
+ say(".claude/hooks/", "context-sync.sh")
300
+
301
+ p = target / ".claude/settings.json"
302
+ doc = load_json(p)
303
+ groups = doc.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
304
+ if any("context-sync.sh" in x.get("command", "") for g in groups for x in g.get("hooks", [])):
305
+ result = "already registered"
306
+ else:
307
+ groups.append({"hooks": [{"type": "command", "command": HOOK_CMD, "timeout": 5}]})
308
+ save_json(p, doc)
309
+ result = "registered"
310
+ say(".claude/settings.json", f"UserPromptSubmit hook {result}")
311
+
312
+ # --- summary -----------------------------------------------------------
313
+ print(f"\n {G}{count['new']} updated{R} {D}·{R} {D}{count['same']} unchanged{R} {D}·{R} {Y}{count['skip']} skipped{R}")
314
+ if not shutil.which("uv"):
315
+ print(f" {Y}! uv is not on PATH. The server needs it: https://docs.astral.sh/uv/{R}")
316
+
317
+ sec("next, in that repo")
318
+ print(f' {M}1{R} restart Claude Code and approve the "context-system" server (or /mcp)')
319
+ print(f" {M}2{R} {C}/context-start-sync{R} recall + capture every prompt")
320
+ print(f" {C}/context-start-sync-readonly{R} recall only, never writes")
321
+ print(f" {C}/context-stop-sync{R} off")
322
+ print(f" {M}3{R} commit .context/memories/ with your code; the rest of .context/ is gitignored")
323
+
324
+ # --- other agents ------------------------------------------------------
325
+ # Claude Code reads .mcp.json, written above. Every other client is one command
326
+ # away; setup.sh travels with .context/ so teammates without this checkout have it too.
327
+ if clients:
328
+ sec("registering with " + " ".join(clients))
329
+ try:
330
+ subprocess.run(["sh", str(target / ".context/setup.sh"), *clients])
331
+ except OSError:
332
+ print(f" {Y}! no sh on PATH; run .context/setup.sh {' '.join(clients)} from a shell that has one{R}")
333
+ else:
334
+ sec("other clients")
335
+ print(f" .context/setup.sh {D}asks: clients, workspace folder{R}")
336
+ print(f" .context/setup.sh --codex --set-root <folder> {D}--print just lists the commands{R}")
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()