ai-memory-cli 0.1.5__tar.gz → 0.1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -53,6 +53,10 @@ python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vs
53
53
  watch
54
54
  ```
55
55
 
56
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
57
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
58
+ background sync agent once on Windows.
59
+
56
60
  `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
57
61
  On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
58
62
 
@@ -64,12 +68,13 @@ Inside `watch`, type the real command you want to capture, for example `python -
64
68
 
65
69
  ## Background agent
66
70
 
67
- The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
71
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
72
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
68
73
 
69
74
  ```powershell
70
- python -m ai_memory_cli agent install
71
- python -m ai_memory_cli agent start
72
75
  python -m ai_memory_cli agent status
76
+ python -m ai_memory_cli agent stop
77
+ python -m ai_memory_cli agent start
73
78
  ```
74
79
 
75
80
  The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
@@ -31,6 +31,10 @@ python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vs
31
31
  watch
32
32
  ```
33
33
 
34
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
35
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
36
+ background sync agent once on Windows.
37
+
34
38
  `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
35
39
  On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
36
40
 
@@ -42,12 +46,13 @@ Inside `watch`, type the real command you want to capture, for example `python -
42
46
 
43
47
  ## Background agent
44
48
 
45
- The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
49
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
50
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
46
51
 
47
52
  ```powershell
48
- python -m ai_memory_cli agent install
49
- python -m ai_memory_cli agent start
50
53
  python -m ai_memory_cli agent status
54
+ python -m ai_memory_cli agent stop
55
+ python -m ai_memory_cli agent start
51
56
  ```
52
57
 
53
58
  The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-memory-cli"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Python CLI for AI Memory terminal capture and offline sync."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """AI Memory terminal capture CLI."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.1.6"
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import getpass
4
5
  import json
5
6
  import os
6
7
  import platform
7
8
  import re
9
+ import secrets
8
10
  import shutil
9
11
  import subprocess
10
12
  import sys
@@ -52,6 +54,12 @@ def sha256_text(value: str) -> str:
52
54
  return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
53
55
 
54
56
 
57
+ def sha512_text(value: str) -> str:
58
+ import hashlib
59
+
60
+ return hashlib.sha512(value.encode("utf-8", errors="replace")).hexdigest()
61
+
62
+
55
63
  def cli_home() -> Path:
56
64
  configured = os.getenv("AI_MEMORY_CLI_HOME")
57
65
  if configured:
@@ -126,6 +134,46 @@ def save_config(home: Path, config: dict[str, Any]) -> None:
126
134
  write_json(config_path(home), config)
127
135
 
128
136
 
137
+ def ensure_local_identity(home: Path, config: dict[str, Any]) -> str:
138
+ secret = str(config.get("local_identity_secret") or "").strip()
139
+ if not secret:
140
+ secret = secrets.token_urlsafe(96)
141
+ config["local_identity_secret"] = secret
142
+ config["local_identity_created_at"] = utc_now()
143
+
144
+ local_user_hash = sha512_text(
145
+ "\0".join(
146
+ [
147
+ secret,
148
+ str(home),
149
+ platform.node() or "unknown-host",
150
+ getpass.getuser() or "unknown-user",
151
+ platform.system(),
152
+ ]
153
+ )
154
+ )
155
+ config["local_user_hash"] = local_user_hash
156
+ return local_user_hash
157
+
158
+
159
+ def client_identity(home: Path, config: dict[str, Any]) -> dict[str, Any]:
160
+ local_user_hash = ensure_local_identity(home, config)
161
+ return {
162
+ "name": "ai-memory-cli",
163
+ "version": __version__,
164
+ "local_user_hash": local_user_hash,
165
+ "user_hash": config.get("user_hash") or "",
166
+ "github_user": config.get("github_user") or "",
167
+ "session_id": config.get("session_id") or "",
168
+ "storage_home_hash": sha256_text(str(home)),
169
+ "hostname_hash": sha256_text(platform.node() or "unknown"),
170
+ "username_hash": sha256_text(getpass.getuser() or "unknown"),
171
+ "platform": platform.system(),
172
+ "python": platform.python_version(),
173
+ "auth_verified_at": config.get("auth_verified_at") or "",
174
+ }
175
+
176
+
129
177
  def agent_state_path(home: Path) -> Path:
130
178
  return home / "agent.json"
131
179
 
@@ -200,6 +248,63 @@ def start_detached_agent(interval: int, limit: int) -> int:
200
248
  return int(process.pid)
201
249
 
202
250
 
251
+ def is_process_running(pid: Any) -> bool:
252
+ try:
253
+ pid_int = int(pid)
254
+ except (TypeError, ValueError):
255
+ return False
256
+ if pid_int <= 0:
257
+ return False
258
+
259
+ if os.name == "nt":
260
+ result = subprocess.run(
261
+ ["tasklist", "/FI", f"PID eq {pid_int}", "/FO", "CSV", "/NH"],
262
+ capture_output=True,
263
+ text=True,
264
+ encoding="utf-8",
265
+ errors="replace",
266
+ )
267
+ return result.returncode == 0 and str(pid_int) in result.stdout
268
+
269
+ try:
270
+ os.kill(pid_int, 0)
271
+ return True
272
+ except OSError:
273
+ return False
274
+
275
+
276
+ def ensure_agent_started_once(home: Path, config: dict[str, Any]) -> None:
277
+ agent_config = config.get("agent") if isinstance(config.get("agent"), dict) else {}
278
+ interval = int(agent_config.get("interval_seconds") or DEFAULT_AGENT_INTERVAL_SECONDS)
279
+ limit = int(agent_config.get("limit") or 50)
280
+ state = read_json(agent_state_path(home), {})
281
+
282
+ if is_process_running(state.get("pid")):
283
+ print(f"AI Memory sync agent already running: pid={state.get('pid')}")
284
+ return
285
+
286
+ if os.name == "nt":
287
+ script_path = write_windows_startup_script(interval, limit)
288
+ pid = start_detached_agent(interval, limit)
289
+ config["agent"] = {
290
+ **agent_config,
291
+ "method": "startup",
292
+ "interval_seconds": interval,
293
+ "limit": limit,
294
+ "startup_script": str(script_path),
295
+ "last_started_pid": pid,
296
+ "last_started_at": utc_now(),
297
+ }
298
+ save_config(home, config)
299
+ append_log(home, f"auth started detached agent pid={pid}")
300
+ print(f"AI Memory sync agent started once: pid={pid}")
301
+ print(f"Startup sync installed at: {script_path}")
302
+ return
303
+
304
+ print("Automatic startup agent install is only implemented for Windows.")
305
+ print("Start sync manually with: python -m ai_memory_cli agent run")
306
+
307
+
203
308
  def normalize_command(command: str) -> str:
204
309
  return " ".join(command.strip().split())
205
310
 
@@ -283,6 +388,58 @@ def http_json(
283
388
  return json.loads(response_body)
284
389
 
285
390
 
391
+ def describe_http_error(exc: urllib.error.HTTPError) -> str:
392
+ try:
393
+ body = exc.read().decode("utf-8", errors="replace")
394
+ payload = json.loads(body) if body else {}
395
+ detail = payload.get("detail") if isinstance(payload, dict) else None
396
+ if detail:
397
+ return str(detail)
398
+ if body:
399
+ return body[:500]
400
+ except Exception:
401
+ pass
402
+ return f"HTTP {exc.code} {exc.reason}"
403
+
404
+
405
+ def verify_cli_auth(home: Path, config: dict[str, Any], token: str) -> dict[str, Any]:
406
+ identity = client_identity(home, config)
407
+ response = http_json(
408
+ "POST",
409
+ f"{api_url(config)}/cli/auth/verify",
410
+ {"client": identity},
411
+ token,
412
+ )
413
+ if not response.get("verified"):
414
+ raise RuntimeError("CLI token was not verified by the backend.")
415
+
416
+ config["token"] = token
417
+ config["token_hash"] = sha256_text(token)
418
+ config["token_tail"] = response.get("token_tail") or ""
419
+ config["session_id"] = response.get("session_id") or ""
420
+ config["github_user"] = response.get("github_user") or response.get("github_account_name") or ""
421
+ config["user_hash"] = response.get("user_hash") or ""
422
+ config["bound_local_user_hash"] = response.get("bound_local_user_hash") or identity["local_user_hash"]
423
+ config["auth_verified_at"] = response.get("verified_at") or utc_now()
424
+ config["server_account_storage_dir"] = response.get("account_storage_dir") or ""
425
+ return response
426
+
427
+
428
+ def require_verified_auth(home: Path, config: dict[str, Any]) -> str:
429
+ token = require_token(config)
430
+ if config.get("auth_verified_at") and config.get("user_hash") and config.get("local_user_hash"):
431
+ return token
432
+
433
+ try:
434
+ verify_cli_auth(home, config, token)
435
+ save_config(home, config)
436
+ return token
437
+ except urllib.error.HTTPError as exc:
438
+ raise SystemExit(f"CLI auth is not verified: {describe_http_error(exc)}") from exc
439
+ except Exception as exc:
440
+ raise SystemExit(f"CLI auth is not verified. Run website auth again when the local server is available: {exc}") from exc
441
+
442
+
286
443
  def make_terminal_event(
287
444
  command: str,
288
445
  stdout: str,
@@ -388,6 +545,14 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
388
545
  if not quiet:
389
546
  print("No CLI token saved. Events remain queued until python -m ai_memory_cli auth is configured.")
390
547
  return 0
548
+ if not (config.get("auth_verified_at") and config.get("user_hash")):
549
+ try:
550
+ verify_cli_auth(home, config, token)
551
+ save_config(home, config)
552
+ except Exception as exc:
553
+ if not quiet:
554
+ print(f"CLI auth is not verified yet. Events remain queued: {exc}")
555
+ return 0
391
556
  paths = sorted((home / "outbox").glob("*.json"))[:limit]
392
557
  if not paths:
393
558
  if not quiet:
@@ -401,12 +566,7 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
401
566
 
402
567
  payload = {
403
568
  "events": events,
404
- "client": {
405
- "name": "ai-memory-cli",
406
- "version": __version__,
407
- "storage_home_hash": sha256_text(str(home)),
408
- "hostname_hash": sha256_text(platform.node() or "unknown"),
409
- },
569
+ "client": client_identity(home, config),
410
570
  }
411
571
  response = http_json("POST", f"{api_url(config)}/cli/events/terminal", payload, token)
412
572
  accepted = {item.get("event_hash") for item in response.get("events", []) if item.get("event_hash")}
@@ -421,6 +581,7 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
421
581
 
422
582
 
423
583
  def capture_command(home: Path, config: dict[str, Any], command: str, include_excluded: bool, source: str) -> int:
584
+ require_verified_auth(home, config)
424
585
  workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
425
586
  cwd = workspace if workspace.exists() else Path.cwd()
426
587
 
@@ -498,17 +659,33 @@ def command_auth(args: argparse.Namespace) -> int:
498
659
  config = load_config(home)
499
660
  if args.api_url:
500
661
  config["api_url"] = args.api_url.rstrip("/")
501
- config["token"] = args.token.strip()
502
- config["token_hash"] = sha256_text(args.token.strip())
662
+
663
+ token = args.token.strip()
664
+ config["pending_token_hash"] = sha256_text(token)
665
+ try:
666
+ response = verify_cli_auth(home, config, token)
667
+ except urllib.error.HTTPError as exc:
668
+ save_config(home, config)
669
+ raise SystemExit(f"CLI auth failed: {describe_http_error(exc)}") from exc
670
+ except Exception as exc:
671
+ save_config(home, config)
672
+ raise SystemExit(f"CLI auth failed. Keep the local FastAPI server running and generate a fresh website token: {exc}") from exc
673
+
503
674
  config["authed_at"] = utc_now()
504
675
  save_config(home, config)
505
676
  print(f"Saved CLI auth in {config_path(home)}")
677
+ print(f"GitHub account: {response.get('github_user') or config.get('github_user') or '-'}")
678
+ print(f"Local user hash: {str(config.get('user_hash') or '')[:24]}...")
679
+ if response.get("account_storage_dir"):
680
+ print(f"Server account storage: {response['account_storage_dir']}")
681
+
682
+ if not args.no_agent:
683
+ ensure_agent_started_once(home, config)
506
684
 
507
685
  try:
508
- health = http_json("GET", f"{api_url(config)}/health", None, None)
509
- print(f"Connected to API: {health.get('service', api_url(config))}")
686
+ sync_events(home, config, quiet=True)
510
687
  except Exception as exc:
511
- print(f"Auth saved. API check failed, sync will retry later: {exc}", file=sys.stderr)
688
+ print(f"Auth saved. Sync will retry later: {exc}", file=sys.stderr)
512
689
  return 0
513
690
 
514
691
 
@@ -525,7 +702,7 @@ def command_init(args: argparse.Namespace) -> int:
525
702
  config["workspace_path"] = args.workspace
526
703
  save_config(home, config)
527
704
 
528
- token = require_token(config)
705
+ token = require_verified_auth(home, config)
529
706
  payload = {
530
707
  "project": config.get("project") or "memory-project",
531
708
  "repository": config.get("repository") or "",
@@ -538,7 +715,7 @@ def command_init(args: argparse.Namespace) -> int:
538
715
  print(f"Initialized project: {project.get('id', payload['project'])}")
539
716
  except Exception as exc:
540
717
  print(f"Project config saved locally. Server init will need retry: {exc}", file=sys.stderr)
541
- print("Start terminal capture with: python -m ai_memory_cli watch")
718
+ print("Start terminal capture with: watch")
542
719
  return 0
543
720
 
544
721
 
@@ -551,7 +728,8 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
551
728
  config["repository"] = args.repo
552
729
  save_config(home, config)
553
730
 
554
- token = require_token(config)
731
+ token = require_verified_auth(home, config)
732
+ identity = client_identity(home, config)
555
733
  payload = {
556
734
  "payload": {
557
735
  "source": "ai-memory-cli",
@@ -561,6 +739,9 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
561
739
  "editor": args.editor,
562
740
  "package_manager": args.package_manager,
563
741
  "cli_storage_home_hash": sha256_text(str(home)),
742
+ "local_user_hash": identity["local_user_hash"],
743
+ "user_hash": identity["user_hash"],
744
+ "github_user": identity["github_user"],
564
745
  }
565
746
  }
566
747
  response = http_json("POST", f"{api_url(config)}/workspace/connect", payload, token)
@@ -572,7 +753,8 @@ def command_workspace_connect(args: argparse.Namespace) -> int:
572
753
  def command_mcp_connect(args: argparse.Namespace) -> int:
573
754
  home = cli_home()
574
755
  config = load_config(home)
575
- token = require_token(config)
756
+ token = require_verified_auth(home, config)
757
+ identity = client_identity(home, config)
576
758
  config["mcp_server"] = args.server
577
759
  save_config(home, config)
578
760
  payload = {
@@ -582,6 +764,9 @@ def command_mcp_connect(args: argparse.Namespace) -> int:
582
764
  "project": config.get("project") or "",
583
765
  "repository": config.get("repository") or "",
584
766
  "cli_storage_home_hash": sha256_text(str(home)),
767
+ "local_user_hash": identity["local_user_hash"],
768
+ "user_hash": identity["user_hash"],
769
+ "github_user": identity["github_user"],
585
770
  }
586
771
  }
587
772
  response = http_json("POST", f"{api_url(config)}/mcp/connect", payload, token)
@@ -593,7 +778,8 @@ def command_mcp_connect(args: argparse.Namespace) -> int:
593
778
  def command_chat_connect(args: argparse.Namespace) -> int:
594
779
  home = cli_home()
595
780
  config = load_config(home)
596
- token = require_token(config)
781
+ token = require_verified_auth(home, config)
782
+ identity = client_identity(home, config)
597
783
  config["chat_provider"] = args.provider
598
784
  save_config(home, config)
599
785
  payload = {
@@ -603,6 +789,9 @@ def command_chat_connect(args: argparse.Namespace) -> int:
603
789
  "project": config.get("project") or "",
604
790
  "repository": config.get("repository") or "",
605
791
  "cli_storage_home_hash": sha256_text(str(home)),
792
+ "local_user_hash": identity["local_user_hash"],
793
+ "user_hash": identity["user_hash"],
794
+ "github_user": identity["github_user"],
606
795
  }
607
796
  }
608
797
  response = http_json("POST", f"{api_url(config)}/chat/connect", payload, token)
@@ -648,6 +837,7 @@ def command_watch(args: argparse.Namespace) -> int:
648
837
  def command_history_import(args: argparse.Namespace) -> int:
649
838
  home = cli_home()
650
839
  config = load_config(home)
840
+ require_verified_auth(home, config)
651
841
  history_path = Path(args.path).expanduser() if args.path else detect_history_file()
652
842
  if not history_path or not history_path.exists():
653
843
  raise SystemExit("No shell history file found. Pass --path <history-file>.")
@@ -823,6 +1013,11 @@ def command_agent_start(args: argparse.Namespace) -> int:
823
1013
 
824
1014
  home = cli_home()
825
1015
  config = load_config(home)
1016
+ state = read_json(agent_state_path(home), {})
1017
+ if is_process_running(state.get("pid")):
1018
+ print(f"Agent already running: pid={state.get('pid')}")
1019
+ return 0
1020
+
826
1021
  result = subprocess.run(
827
1022
  ["schtasks", "/Run", "/TN", args.task_name],
828
1023
  capture_output=True,
@@ -925,7 +1120,9 @@ def command_status(_: argparse.Namespace) -> int:
925
1120
  print(f"Project: {config.get('project') or '-'}")
926
1121
  print(f"Repository: {config.get('repository') or '-'}")
927
1122
  print(f"Workspace: {config.get('workspace_path') or '.'}")
928
- print(f"Token: {'saved' if config.get('token') else 'missing'}")
1123
+ print(f"Token: {'verified' if config.get('auth_verified_at') and config.get('user_hash') else 'saved' if config.get('token') else 'missing'}")
1124
+ print(f"GitHub account: {config.get('github_user') or '-'}")
1125
+ print(f"User hash: {str(config.get('user_hash') or '-')[:24]}{'...' if config.get('user_hash') else ''}")
929
1126
  print(f"Events: {event_count} total, {outbox_count} queued, {sent_count} synced receipts")
930
1127
  return 0
931
1128
 
@@ -955,6 +1152,7 @@ def build_parser() -> argparse.ArgumentParser:
955
1152
  auth = subparsers.add_parser("auth", help="Save the app-issued CLI token.")
956
1153
  auth.add_argument("--token", required=True, help="Token generated by the website.")
957
1154
  auth.add_argument("--api-url", default=DEFAULT_API_URL, help="FastAPI base URL.")
1155
+ auth.add_argument("--no-agent", action="store_true", help="Do not auto-start the background sync agent after auth.")
958
1156
  auth.set_defaults(func=command_auth)
959
1157
 
960
1158
  init = subparsers.add_parser("init", help="Save project config and call /projects/init.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -53,6 +53,10 @@ python -m ai_memory_cli workspace connect --path . --repo owner/repo --editor vs
53
53
  watch
54
54
  ```
55
55
 
56
+ The `auth` command verifies the website-issued token with FastAPI before it is saved locally. After verification,
57
+ the CLI stores a SHA-512 user hash for this computer, binds the token to that hash on the server, and starts the
58
+ background sync agent once on Windows.
59
+
56
60
  `watch` is a shortcut for `python -m ai_memory_cli watch`. If Windows Device Guard blocks the generated launcher, keep using `python -m ai_memory_cli watch`.
57
61
  On Windows the shortcut is installed as `watch.cmd`; the Python Scripts folder must be on `PATH` for bare `watch` to resolve.
58
62
 
@@ -64,12 +68,13 @@ Inside `watch`, type the real command you want to capture, for example `python -
64
68
 
65
69
  ## Background agent
66
70
 
67
- The background agent starts at Windows logon and keeps syncing queued terminal hashes whenever the API is reachable:
71
+ After `auth`, the background agent starts once and is installed in the Windows Startup folder so queued terminal
72
+ hashes keep syncing whenever the API is reachable. Use these commands when you need manual control:
68
73
 
69
74
  ```powershell
70
- python -m ai_memory_cli agent install
71
- python -m ai_memory_cli agent start
72
75
  python -m ai_memory_cli agent status
76
+ python -m ai_memory_cli agent stop
77
+ python -m ai_memory_cli agent start
73
78
  ```
74
79
 
75
80
  The agent does not secretly capture every terminal on the computer. Commands are captured when they run through:
File without changes
File without changes