hipcortex 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.
hipcortex/cli.py ADDED
@@ -0,0 +1,480 @@
1
+ """HipCortex CLI — install command auto-configures AI coding assistants.
2
+
3
+ Usage:
4
+ hipcortex install # download binary + configure Claude Code + Cursor
5
+ hipcortex install --url URL # use existing server instead of local binary
6
+ hipcortex start # start the local server (downloads if needed)
7
+ hipcortex status # check server health
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import stat
18
+ import sys
19
+ import urllib.request
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ # ─── Constants ───────────────────────────────────────────────────────────────
24
+
25
+ GITHUB_RELEASES = "https://github.com/farmountain/HipCortex/releases/latest/download"
26
+ INSTALL_DIR = Path.home() / ".hipcortex"
27
+ BINARY_NAME = "hipcortex-server"
28
+ DEFAULT_URL = "http://localhost:3030"
29
+ MANAGED_URL = "https://hipcortex.fly.dev"
30
+
31
+ # Claude Code registration line appended to ~/.claude/CLAUDE.md
32
+ _CLAUDE_REGISTRATION = """
33
+ # hipcortex
34
+ - **hipcortex** (`~/.claude/skills/hipcortex/SKILL.md`) - Persistent memory for AI agents. Store decisions, recall context, GDPR forget. Trigger: `/hipcortex`
35
+ When the user types `/hipcortex`, invoke the Skill tool with `skill: "hipcortex"` before doing anything else.
36
+ """
37
+
38
+ # ─── Platform detection ───────────────────────────────────────────────────────
39
+
40
+ def _detect_platform() -> tuple[str, str]:
41
+ """Return (os_name, arch) matching GitHub release asset names."""
42
+ system = platform.system().lower()
43
+ machine = platform.machine().lower()
44
+
45
+ if system == "darwin":
46
+ os_name = "macos"
47
+ elif system == "linux":
48
+ os_name = "linux"
49
+ elif system == "windows":
50
+ os_name = "windows"
51
+ else:
52
+ raise RuntimeError(f"Unsupported OS: {system}")
53
+
54
+ if machine in ("arm64", "aarch64"):
55
+ arch = "arm64"
56
+ elif machine in ("x86_64", "amd64"):
57
+ arch = "amd64"
58
+ else:
59
+ raise RuntimeError(f"Unsupported architecture: {machine}")
60
+
61
+ return os_name, arch
62
+
63
+
64
+ def _binary_url(os_name: str, arch: str) -> str:
65
+ name = f"hipcortex-{os_name}-{arch}"
66
+ if os_name == "windows":
67
+ name += ".exe"
68
+ return f"{GITHUB_RELEASES}/{name}"
69
+
70
+
71
+ def _binary_path(os_name: str, arch: str) -> Path:
72
+ name = BINARY_NAME
73
+ if os_name == "windows":
74
+ name += ".exe"
75
+ return INSTALL_DIR / name
76
+
77
+ # ─── Download ────────────────────────────────────────────────────────────────
78
+
79
+ def _download_binary(url: str, dest: Path) -> None:
80
+ """Download binary with progress indicator."""
81
+ INSTALL_DIR.mkdir(parents=True, exist_ok=True)
82
+ print(f" Downloading {url.split('/')[-1]} ...", end=" ", flush=True)
83
+ try:
84
+ urllib.request.urlretrieve(url, str(dest))
85
+ dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
86
+ print("✓")
87
+ except urllib.error.HTTPError as e:
88
+ print(f"✗ (HTTP {e.code})")
89
+ raise RuntimeError(
90
+ f"Could not download binary from {url}\n"
91
+ "Check https://github.com/farmountain/HipCortex/releases for available builds."
92
+ ) from e
93
+ except Exception as e:
94
+ print(f"✗ ({e})")
95
+ raise
96
+
97
+ # ─── Skill registration (Claude Code) ────────────────────────────────────────
98
+
99
+ def _skill_dir() -> Path:
100
+ return Path.home() / ".claude" / "skills" / "hipcortex"
101
+
102
+
103
+ def _install_claude_code(server_url: str) -> bool:
104
+ """Write SKILL.md + append to CLAUDE.md. Returns True on success."""
105
+ claude_dir = Path.home() / ".claude"
106
+ if not claude_dir.exists():
107
+ return False # Claude Code not installed
108
+
109
+ # Write skill file
110
+ skill_dir = _skill_dir()
111
+ skill_dir.mkdir(parents=True, exist_ok=True)
112
+ skill_md_src = Path(__file__).parent / "install" / "SKILL.md"
113
+
114
+ # Read template and substitute server URL
115
+ content = skill_md_src.read_text(encoding="utf-8")
116
+ content = content.replace("http://localhost:3030", server_url)
117
+ (skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
118
+
119
+ # Append registration to CLAUDE.md if not already present
120
+ claude_md = claude_dir / "CLAUDE.md"
121
+ existing = claude_md.read_text(encoding="utf-8") if claude_md.exists() else ""
122
+ if "hipcortex" not in existing:
123
+ with claude_md.open("a", encoding="utf-8") as f:
124
+ f.write(_CLAUDE_REGISTRATION)
125
+
126
+ return True
127
+
128
+
129
+ def _uninstall_claude_code() -> None:
130
+ """Remove HipCortex skill from Claude Code."""
131
+ skill_dir = _skill_dir()
132
+ if skill_dir.exists():
133
+ shutil.rmtree(skill_dir)
134
+ claude_md = Path.home() / ".claude" / "CLAUDE.md"
135
+ if claude_md.exists():
136
+ text = claude_md.read_text(encoding="utf-8")
137
+ # Remove the registration block
138
+ marker = "\n# hipcortex"
139
+ if marker in text:
140
+ idx = text.index(marker)
141
+ # Find end of registration block (next \n# or end of file)
142
+ end = text.find("\n# ", idx + 1)
143
+ claude_md.write_text(
144
+ text[:idx] + (text[end:] if end != -1 else ""),
145
+ encoding="utf-8",
146
+ )
147
+
148
+ # ─── MCP registration (Cursor / Windsurf) ────────────────────────────────────
149
+
150
+ def _cursor_mcp_path(global_: bool = False) -> Optional[Path]:
151
+ """Return path to Cursor mcp.json — local (project) or global."""
152
+ if global_:
153
+ # Global Cursor config location by OS
154
+ if platform.system() == "Windows":
155
+ base = Path(os.environ.get("APPDATA", Path.home()))
156
+ elif platform.system() == "Darwin":
157
+ base = Path.home() / "Library" / "Application Support" / "Cursor"
158
+ else:
159
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Cursor"
160
+ mcp_path = base / "mcp.json"
161
+ return mcp_path
162
+ else:
163
+ return Path.cwd() / ".cursor" / "mcp.json"
164
+
165
+
166
+ def _install_cursor(server_url: str, global_: bool = False) -> bool:
167
+ """Write/update .cursor/mcp.json. Returns True on success."""
168
+ mcp_path = _cursor_mcp_path(global_=global_)
169
+ if mcp_path is None:
170
+ return False
171
+
172
+ mcp_path.parent.mkdir(parents=True, exist_ok=True)
173
+
174
+ # Read existing config or start fresh
175
+ existing: dict = {}
176
+ if mcp_path.exists():
177
+ try:
178
+ existing = json.loads(mcp_path.read_text(encoding="utf-8"))
179
+ except json.JSONDecodeError:
180
+ existing = {}
181
+
182
+ # Inject hipcortex MCP server entry
183
+ mcp_servers = existing.setdefault("mcpServers", {})
184
+ mcp_server_py = str(Path.home() / ".hipcortex-mcp" / "server.py")
185
+ mcp_servers["hipcortex"] = {
186
+ "command": sys.executable,
187
+ "args": [mcp_server_py],
188
+ "env": {"HIPCORTEX_URL": server_url},
189
+ }
190
+
191
+ mcp_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
192
+ return True
193
+
194
+
195
+ def _install_vscode(server_url: str) -> bool:
196
+ """Write mcpServers to VS Code settings.json. Returns True on success."""
197
+ if platform.system() == "Windows":
198
+ settings_path = Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "settings.json"
199
+ elif platform.system() == "Darwin":
200
+ settings_path = Path.home() / "Library" / "Application Support" / "Code" / "User" / "settings.json"
201
+ else:
202
+ settings_path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Code" / "User" / "settings.json"
203
+
204
+ if not settings_path.exists():
205
+ return False # VS Code not installed
206
+
207
+ try:
208
+ settings: dict = json.loads(settings_path.read_text(encoding="utf-8"))
209
+ except (json.JSONDecodeError, PermissionError):
210
+ return False
211
+
212
+ mcp_server_py = str(Path.home() / ".hipcortex-mcp" / "server.py")
213
+ mcp_servers = settings.setdefault("mcpServers", {})
214
+ mcp_servers["hipcortex"] = {
215
+ "command": sys.executable,
216
+ "args": [mcp_server_py],
217
+ "env": {"HIPCORTEX_URL": server_url},
218
+ }
219
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
220
+ return True
221
+
222
+ # ─── MCP server script installer ─────────────────────────────────────────────
223
+
224
+ def _install_mcp_server() -> None:
225
+ """Copy the MCP server script to ~/.hipcortex-mcp/server.py."""
226
+ mcp_dir = Path.home() / ".hipcortex-mcp"
227
+ mcp_dir.mkdir(parents=True, exist_ok=True)
228
+ src = Path(__file__).parent.parent.parent / "mcp" / "server.py" # sdk/mcp/server.py
229
+ # Try repo-relative path first, then PyPI-installed package
230
+ if not src.exists():
231
+ import importlib.resources
232
+ try:
233
+ # When installed as a package, look for bundled resource
234
+ with importlib.resources.path("hipcortex.install", "mcp_server.py") as p:
235
+ src = p
236
+ except Exception:
237
+ return # MCP server not bundled, skip
238
+
239
+ if src.exists():
240
+ shutil.copy2(str(src), str(mcp_dir / "server.py"))
241
+
242
+ # ─── Commands ────────────────────────────────────────────────────────────────
243
+
244
+ def cmd_install(args: argparse.Namespace) -> None:
245
+ """Download binary + configure all detected AI coding assistants."""
246
+ print("\nHipCortex installer\n" + "=" * 40)
247
+
248
+ # Determine server URL
249
+ if args.url:
250
+ server_url = args.url.rstrip("/")
251
+ binary_path = None
252
+ print(f"Using existing server: {server_url}")
253
+ else:
254
+ os_name, arch = _detect_platform()
255
+ binary_path = _binary_path(os_name, arch)
256
+ server_url = DEFAULT_URL
257
+
258
+ if binary_path.exists() and not args.force:
259
+ print(f" Binary already at {binary_path} (use --force to re-download)")
260
+ else:
261
+ url = _binary_url(os_name, arch)
262
+ _download_binary(url, binary_path)
263
+
264
+ # Install MCP server script for Cursor/VS Code
265
+ _install_mcp_server()
266
+
267
+ # Register with AI coding assistants
268
+ print("\nRegistering with AI coding assistants:")
269
+ results = []
270
+
271
+ if _install_claude_code(server_url):
272
+ results.append(("Claude Code", "✓", f"~/.claude/skills/hipcortex/"))
273
+ else:
274
+ results.append(("Claude Code", "–", "not found (install from claude.ai/code)"))
275
+
276
+ if _install_cursor(server_url, global_=False):
277
+ results.append(("Cursor (project)", "✓", str(Path.cwd() / ".cursor" / "mcp.json")))
278
+ elif _install_cursor(server_url, global_=True):
279
+ mcp_path = _cursor_mcp_path(global_=True)
280
+ results.append(("Cursor (global)", "✓", str(mcp_path)))
281
+ else:
282
+ results.append(("Cursor", "–", "not found"))
283
+
284
+ if _install_vscode(server_url):
285
+ results.append(("VS Code", "✓", "settings.json"))
286
+ else:
287
+ results.append(("VS Code", "–", "not found"))
288
+
289
+ for name, status, detail in results:
290
+ print(f" {status} {name:<20} {detail}")
291
+
292
+ print()
293
+
294
+ if binary_path:
295
+ print(f"Binary: {binary_path}")
296
+ print(f"Start: hipcortex start")
297
+ print()
298
+
299
+ # Print usage instructions
300
+ claude_ok = any(s == "✓" and "Claude Code" in n for n, s, _ in results)
301
+ cursor_ok = any(s == "✓" and "Cursor" in n for n, s, _ in results)
302
+
303
+ if claude_ok:
304
+ print("Claude Code: type /hipcortex remember 'your note'")
305
+ if cursor_ok:
306
+ print("Cursor: restart and use the hipcortex MCP tools")
307
+ print()
308
+ print(f"Docs: https://github.com/farmountain/HipCortex")
309
+
310
+ # Auto-start the server if binary was downloaded and server isn't already running
311
+ if binary_path and binary_path.exists():
312
+ health_url = f"http://localhost:3030/health"
313
+ already_running = False
314
+ try:
315
+ with urllib.request.urlopen(health_url, timeout=1) as r:
316
+ already_running = r.status == 200
317
+ except Exception:
318
+ pass
319
+
320
+ if not already_running:
321
+ import subprocess as _sp
322
+ data_dir = str(INSTALL_DIR / "data")
323
+ import pathlib as _pl
324
+ _pl.Path(data_dir).mkdir(parents=True, exist_ok=True)
325
+ env = os.environ.copy()
326
+ env["PORT"] = "3030"
327
+ env["DATA_DIR"] = data_dir
328
+ env["RUST_LOG"] = "warn"
329
+ _sp.Popen(
330
+ [str(binary_path)],
331
+ env=env,
332
+ stdout=_sp.DEVNULL,
333
+ stderr=_sp.DEVNULL,
334
+ )
335
+ # Wait for startup
336
+ import time as _time
337
+ print("Starting HipCortex server...", end=" ", flush=True)
338
+ for _ in range(20):
339
+ _time.sleep(0.5)
340
+ try:
341
+ with urllib.request.urlopen(health_url, timeout=1) as r:
342
+ if r.status == 200:
343
+ print("✓ running on http://localhost:3030")
344
+ break
345
+ except Exception:
346
+ pass
347
+ else:
348
+ print("(starting in background)")
349
+
350
+
351
+ def cmd_start(args: argparse.Namespace) -> None:
352
+ """Start the local HipCortex server."""
353
+ try:
354
+ os_name, arch = _detect_platform()
355
+ except RuntimeError as e:
356
+ print(f"Error: {e}", file=sys.stderr)
357
+ sys.exit(1)
358
+
359
+ binary = _binary_path(os_name, arch)
360
+ if not binary.exists():
361
+ print(f"Binary not found at {binary}")
362
+ print("Run: hipcortex install")
363
+ sys.exit(1)
364
+
365
+ port = args.port or int(os.environ.get("PORT", "3030"))
366
+ data_dir = args.data_dir or os.environ.get("DATA_DIR", str(Path.home() / ".hipcortex" / "data"))
367
+ Path(data_dir).mkdir(parents=True, exist_ok=True)
368
+
369
+ env = os.environ.copy()
370
+ env["PORT"] = str(port)
371
+ env["DATA_DIR"] = data_dir
372
+
373
+ print(f"Starting HipCortex on http://localhost:{port}")
374
+ print(f"Data: {data_dir}")
375
+ print("Ctrl+C to stop\n")
376
+
377
+ import subprocess
378
+ proc = subprocess.Popen([str(binary)], env=env)
379
+ # Poll /health until server is ready (max 10 seconds)
380
+ import time
381
+ health_url = f"http://localhost:{port}/health"
382
+ for _ in range(20):
383
+ time.sleep(0.5)
384
+ try:
385
+ with urllib.request.urlopen(health_url, timeout=1) as r:
386
+ if r.status == 200:
387
+ print(f"✓ HipCortex running on http://localhost:{port}")
388
+ print(f" /hipcortex remember 'your note' (Claude Code)")
389
+ print(f" curl {health_url}")
390
+ print()
391
+ break
392
+ except Exception:
393
+ pass
394
+ else:
395
+ print(" Server may still be starting... check health manually.")
396
+ print(f" curl {health_url}")
397
+ print()
398
+ try:
399
+ proc.wait()
400
+ except KeyboardInterrupt:
401
+ proc.terminate()
402
+ proc.wait()
403
+
404
+
405
+ def cmd_status(args: argparse.Namespace) -> None:
406
+ """Check server health."""
407
+ url = args.url or os.environ.get("HIPCORTEX_URL", DEFAULT_URL)
408
+ try:
409
+ import urllib.request
410
+ with urllib.request.urlopen(f"{url}/health", timeout=5) as r:
411
+ print(f"✓ HipCortex running at {url} ({r.read().decode().strip()})")
412
+ except Exception as e:
413
+ print(f"✗ Not reachable at {url}: {e}")
414
+ print(" Run: hipcortex start")
415
+
416
+
417
+ def cmd_uninstall(args: argparse.Namespace) -> None:
418
+ """Remove HipCortex from AI coding assistants and optionally delete binary."""
419
+ print("Uninstalling HipCortex...")
420
+ _uninstall_claude_code()
421
+ print(" ✓ Removed from Claude Code")
422
+
423
+ if args.purge:
424
+ if INSTALL_DIR.exists():
425
+ shutil.rmtree(INSTALL_DIR)
426
+ print(f" ✓ Deleted {INSTALL_DIR}")
427
+ mcp_dir = Path.home() / ".hipcortex-mcp"
428
+ if mcp_dir.exists():
429
+ shutil.rmtree(mcp_dir)
430
+ print(f" ✓ Deleted {mcp_dir}")
431
+
432
+ # ─── Argument parser ──────────────────────────────────────────────────────────
433
+
434
+ def build_parser() -> argparse.ArgumentParser:
435
+ parser = argparse.ArgumentParser(
436
+ prog="hipcortex",
437
+ description="HipCortex memory engine — persistent causal memory for AI agents",
438
+ )
439
+ sub = parser.add_subparsers(dest="command")
440
+
441
+ # install
442
+ p_install = sub.add_parser("install", help="Download binary + configure AI coding assistants")
443
+ p_install.add_argument("--url", help=f"Use an existing server instead of local binary (e.g. {MANAGED_URL})")
444
+ p_install.add_argument("--force", action="store_true", help="Re-download binary even if it exists")
445
+
446
+ # start
447
+ p_start = sub.add_parser("start", help="Start the local HipCortex server")
448
+ p_start.add_argument("--port", type=int, help="Port (default: 3030)")
449
+ p_start.add_argument("--data-dir", help="Data directory (default: ~/.hipcortex/data)")
450
+
451
+ # status
452
+ p_status = sub.add_parser("status", help="Check server health")
453
+ p_status.add_argument("--url", help="Server URL to check")
454
+
455
+ # uninstall
456
+ p_uninstall = sub.add_parser("uninstall", help="Remove HipCortex configuration")
457
+ p_uninstall.add_argument("--purge", action="store_true", help="Also delete downloaded binary and data")
458
+
459
+ return parser
460
+
461
+
462
+ def main() -> None:
463
+ parser = build_parser()
464
+ args = parser.parse_args()
465
+
466
+ if args.command == "install":
467
+ cmd_install(args)
468
+ elif args.command == "start":
469
+ cmd_start(args)
470
+ elif args.command == "status":
471
+ cmd_status(args)
472
+ elif args.command == "uninstall":
473
+ cmd_uninstall(args)
474
+ else:
475
+ parser.print_help()
476
+ sys.exit(1)
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()