ai-memory-cli 0.1.8__tar.gz → 0.1.11__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.8
3
+ Version: 0.1.11
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -69,6 +69,8 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
69
69
 
70
70
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
71
71
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
72
+ On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
73
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
72
74
 
73
75
  ## Background agent
74
76
 
@@ -47,6 +47,8 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
47
47
 
48
48
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
49
49
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
50
+ On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
51
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
50
52
 
51
53
  ## Background agent
52
54
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-memory-cli"
7
- version = "0.1.8"
7
+ version = "0.1.11"
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.8"
3
+ __version__ = "0.1.11"
@@ -43,6 +43,7 @@ SHELL_NOT_FOUND_PATTERNS = [
43
43
  "command not found",
44
44
  ]
45
45
  CLEAR_COMMANDS = {"cls", "clear"}
46
+ INTERACTIVE_SHELLS = {"powershell", "pwsh", "cmd", "bash", "sh", "zsh", "fish"}
46
47
 
47
48
 
48
49
  def utc_now() -> str:
@@ -77,7 +78,7 @@ def read_json(path: Path, default: Any) -> Any:
77
78
  if not path.exists():
78
79
  return default
79
80
  try:
80
- return json.loads(path.read_text(encoding="utf-8"))
81
+ return json.loads(path.read_text(encoding="utf-8-sig"))
81
82
  except (OSError, json.JSONDecodeError):
82
83
  return default
83
84
 
@@ -326,6 +327,98 @@ def is_clear_command(command: str) -> bool:
326
327
  return normalize_command(command).lower() in CLEAR_COMMANDS
327
328
 
328
329
 
330
+ def parse_cd_command(command: str) -> str | None:
331
+ stripped = command.strip()
332
+ lower = stripped.lower()
333
+ if lower in {"cd..", "chdir.."}:
334
+ return ".."
335
+ if os.name == "nt" and lower.startswith("cd\\"):
336
+ return stripped[2:].strip()
337
+
338
+ match = re.match(r"^(cd|chdir)(?:\s+(.*))?$", stripped, flags=re.IGNORECASE)
339
+ if not match:
340
+ return None
341
+
342
+ target = (match.group(2) or "").strip()
343
+ if os.name == "nt" and target.lower().startswith("/d"):
344
+ target = target[2:].strip()
345
+ if len(target) >= 2 and target[0] == target[-1] and target[0] in {"'", '"'}:
346
+ target = target[1:-1]
347
+ return target
348
+
349
+
350
+ def resolve_cd_target(target: str, cwd: Path, previous_cwd: Path | None) -> tuple[Path | None, str, bool]:
351
+ current = cwd.resolve()
352
+ if not target:
353
+ return current, str(current) + os.linesep, False
354
+ if target == "-":
355
+ if previous_cwd:
356
+ return previous_cwd.resolve(), str(previous_cwd.resolve()) + os.linesep, True
357
+ return None, "ai-memory: no previous directory for cd -\n", True
358
+
359
+ expanded = os.path.expandvars(target)
360
+ if os.name == "nt" and re.fullmatch(r"[A-Za-z]:", expanded):
361
+ expanded = f"{expanded}\\"
362
+
363
+ candidate = Path(expanded).expanduser()
364
+ if not candidate.is_absolute():
365
+ candidate = current / candidate
366
+
367
+ try:
368
+ resolved = candidate.resolve(strict=False)
369
+ except OSError:
370
+ resolved = candidate
371
+
372
+ if not resolved.exists() or not resolved.is_dir():
373
+ if os.name == "nt":
374
+ return None, "The system cannot find the path specified.\n", True
375
+ return None, f"cd: no such file or directory: {target}\n", True
376
+
377
+ return resolved, "", True
378
+
379
+
380
+ def watch_prompt(cwd: Path) -> str:
381
+ return f"ai-memory {cwd.resolve()}> "
382
+
383
+
384
+ def is_interactive_shell_command(command: str) -> bool:
385
+ tokens = normalize_command(command).lower().split()
386
+ if not tokens:
387
+ return False
388
+ launcher = tokens[0].removesuffix(".exe")
389
+ if launcher not in INTERACTIVE_SHELLS:
390
+ return False
391
+ if launcher in {"powershell", "pwsh"}:
392
+ return len(tokens) == 1 or "-noexit" in tokens
393
+ if launcher == "cmd":
394
+ return len(tokens) == 1 or "/k" in tokens
395
+ return len(tokens) == 1
396
+
397
+
398
+ def command_invocation(command: str) -> tuple[str | list[str], bool]:
399
+ if os.name == "nt":
400
+ powershell = shutil.which("powershell") or shutil.which("pwsh")
401
+ if powershell:
402
+ return [powershell, "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], False
403
+ return command, True
404
+
405
+
406
+ def run_external_command(command: str, cwd: Path, capture: bool) -> subprocess.CompletedProcess[str] | int:
407
+ invocation, use_shell = command_invocation(command)
408
+ if capture:
409
+ return subprocess.run(
410
+ invocation,
411
+ shell=use_shell,
412
+ cwd=str(cwd),
413
+ stdin=subprocess.DEVNULL,
414
+ capture_output=True,
415
+ text=True,
416
+ encoding="utf-8",
417
+ errors="replace",
418
+ )
419
+ return subprocess.call(invocation, shell=use_shell, cwd=str(cwd), stdin=subprocess.DEVNULL)
420
+
421
+
329
422
  def clear_console() -> None:
330
423
  command = "cls" if os.name == "nt" else "clear"
331
424
  try:
@@ -341,10 +434,15 @@ def normalize_output(stdout: str, stderr: str) -> str:
341
434
 
342
435
 
343
436
  def selected_shell() -> str:
437
+ if os.name == "nt":
438
+ if shutil.which("powershell"):
439
+ return "powershell"
440
+ if shutil.which("pwsh"):
441
+ return "pwsh"
344
442
  shell = os.getenv("SHELL") or os.getenv("COMSPEC") or ""
345
443
  if shell:
346
444
  return Path(shell).name
347
- return "powershell" if os.name == "nt" else "sh"
445
+ return "sh"
348
446
 
349
447
 
350
448
  def command_line(parts: list[str]) -> str:
@@ -644,30 +742,135 @@ def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool
644
742
  return synced
645
743
 
646
744
 
647
- def capture_command(home: Path, config: dict[str, Any], command: str, include_excluded: bool, source: str) -> int:
745
+ def store_captured_event(
746
+ home: Path,
747
+ config: dict[str, Any],
748
+ command: str,
749
+ stdout: str,
750
+ stderr: str,
751
+ exit_code: int | None,
752
+ started_at: str,
753
+ ended_at: str,
754
+ duration_ms: int,
755
+ cwd: Path,
756
+ source: str,
757
+ extra_metadata: dict[str, Any] | None = None,
758
+ ) -> tuple[dict[str, Any], bool, Path]:
759
+ event = make_terminal_event(
760
+ command=command,
761
+ stdout=stdout,
762
+ stderr=stderr,
763
+ exit_code=exit_code,
764
+ started_at=started_at,
765
+ ended_at=ended_at,
766
+ duration_ms=duration_ms,
767
+ cwd=cwd,
768
+ config=config,
769
+ source=source,
770
+ )
771
+ if extra_metadata:
772
+ event["metadata"].update(extra_metadata)
773
+ created, event_path = store_event(home, event)
774
+ state = "stored" if created else "deduped"
775
+ append_history(home, event, state)
776
+ print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
777
+
778
+ try:
779
+ sync_events(home, config, quiet=True)
780
+ except Exception as exc:
781
+ print(f"ai-memory: sync queued until network/API is available ({exc})", file=sys.stderr)
782
+
783
+ return event, created, event_path
784
+
785
+
786
+ def capture_cd_command(
787
+ home: Path,
788
+ config: dict[str, Any],
789
+ command: str,
790
+ cwd: Path,
791
+ previous_cwd: Path | None,
792
+ source: str,
793
+ ) -> tuple[int, Path, Path | None]:
794
+ require_verified_auth(home, config)
795
+ target = parse_cd_command(command)
796
+ if target is None:
797
+ return 1, cwd, previous_cwd
798
+
799
+ old_cwd = cwd.resolve()
800
+ started_at = utc_now()
801
+ started_monotonic = time.monotonic()
802
+ new_cwd, output, should_track = resolve_cd_target(target, old_cwd, previous_cwd)
803
+ ended_at = utc_now()
804
+ duration_ms = int((time.monotonic() - started_monotonic) * 1000)
805
+
806
+ if output:
807
+ if new_cwd is None:
808
+ print(output, end="", file=sys.stderr)
809
+ else:
810
+ print(output, end="")
811
+
812
+ if new_cwd is None:
813
+ print("ai-memory: skipped invalid cd; nothing was stored.")
814
+ return 1, old_cwd, previous_cwd
815
+
816
+ changed = new_cwd.resolve() != old_cwd
817
+ if should_track:
818
+ store_captured_event(
819
+ home=home,
820
+ config=config,
821
+ command=command,
822
+ stdout=output if new_cwd is not None else "",
823
+ stderr="",
824
+ exit_code=0,
825
+ started_at=started_at,
826
+ ended_at=ended_at,
827
+ duration_ms=duration_ms,
828
+ cwd=old_cwd,
829
+ source=source,
830
+ extra_metadata={
831
+ "builtin": "cd",
832
+ "cwd_changed": changed,
833
+ "new_cwd_hash": sha256_text(str(new_cwd.resolve())),
834
+ "new_cwd_tail": new_cwd.resolve().name,
835
+ },
836
+ )
837
+
838
+ return 0, new_cwd.resolve(), old_cwd if changed else previous_cwd
839
+
840
+
841
+ def default_command_cwd(config: dict[str, Any]) -> Path:
842
+ workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
843
+ return workspace.resolve() if workspace.exists() else Path.cwd().resolve()
844
+
845
+
846
+ def capture_command(
847
+ home: Path,
848
+ config: dict[str, Any],
849
+ command: str,
850
+ include_excluded: bool,
851
+ source: str,
852
+ cwd: Path | None = None,
853
+ ) -> int:
648
854
  if is_clear_command(command):
649
855
  clear_console()
650
856
  return 0
651
857
 
858
+ if is_interactive_shell_command(command):
859
+ print("ai-memory: watch already runs commands through a shell. Type the command directly, for example: ls")
860
+ return 0
861
+
652
862
  require_verified_auth(home, config)
653
- workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
654
- cwd = workspace if workspace.exists() else Path.cwd()
863
+ effective_cwd = cwd.resolve() if cwd else default_command_cwd(config)
655
864
 
656
865
  if is_excluded(command, config) and not include_excluded:
657
866
  print(f"ai-memory: running without capture because this command is excluded: {command}", file=sys.stderr)
658
- return subprocess.call(command, shell=True, cwd=str(cwd))
867
+ return int(run_external_command(command, effective_cwd, capture=False))
659
868
 
660
869
  started_at = utc_now()
661
870
  started_monotonic = time.monotonic()
662
- completed = subprocess.run(
663
- command,
664
- shell=True,
665
- cwd=str(cwd),
666
- capture_output=True,
667
- text=True,
668
- encoding="utf-8",
669
- errors="replace",
670
- )
871
+ completed = run_external_command(command, effective_cwd, capture=True)
872
+ if isinstance(completed, int):
873
+ return completed
671
874
  ended_at = utc_now()
672
875
  duration_ms = int((time.monotonic() - started_monotonic) * 1000)
673
876
 
@@ -680,7 +883,9 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
680
883
  print("ai-memory: skipped invalid command; nothing was stored.")
681
884
  return completed.returncode
682
885
 
683
- event = make_terminal_event(
886
+ store_captured_event(
887
+ home=home,
888
+ config=config,
684
889
  command=command,
685
890
  stdout=completed.stdout or "",
686
891
  stderr=completed.stderr or "",
@@ -688,20 +893,9 @@ def capture_command(home: Path, config: dict[str, Any], command: str, include_ex
688
893
  started_at=started_at,
689
894
  ended_at=ended_at,
690
895
  duration_ms=duration_ms,
691
- cwd=cwd,
692
- config=config,
896
+ cwd=effective_cwd,
693
897
  source=source,
694
898
  )
695
- created, event_path = store_event(home, event)
696
- state = "stored" if created else "deduped"
697
- append_history(home, event, state)
698
- print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
699
-
700
- try:
701
- sync_events(home, config, quiet=True)
702
- except Exception as exc:
703
- print(f"ai-memory: sync queued until network/API is available ({exc})", file=sys.stderr)
704
-
705
899
  return completed.returncode
706
900
 
707
901
 
@@ -867,10 +1061,12 @@ def command_watch(args: argparse.Namespace) -> int:
867
1061
  prompt_for_auth(home, config)
868
1062
  config = load_config(home)
869
1063
 
1064
+ cwd = default_command_cwd(config)
1065
+ previous_cwd: Path | None = None
870
1066
  print("AI Memory watch mode. Type commands to run and capture. Type exit to stop.")
871
1067
  while True:
872
1068
  try:
873
- command = input("ai-memory> ").strip()
1069
+ command = input(watch_prompt(cwd)).strip()
874
1070
  except (EOFError, KeyboardInterrupt):
875
1071
  print()
876
1072
  break
@@ -888,7 +1084,13 @@ def command_watch(args: argparse.Namespace) -> int:
888
1084
  if is_clear_command(command):
889
1085
  clear_console()
890
1086
  continue
891
- capture_command(home, config, command, args.include_excluded, "watch")
1087
+ if is_interactive_shell_command(command):
1088
+ print("ai-memory: do not start a nested shell here. Type commands directly, for example: ls")
1089
+ continue
1090
+ if parse_cd_command(command) is not None:
1091
+ _, cwd, previous_cwd = capture_cd_command(home, config, command, cwd, previous_cwd, "watch")
1092
+ continue
1093
+ capture_command(home, config, command, args.include_excluded, "watch", cwd=cwd)
892
1094
  return 0
893
1095
 
894
1096
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-memory-cli
3
- Version: 0.1.8
3
+ Version: 0.1.11
4
4
  Summary: Python CLI for AI Memory terminal capture and offline sync.
5
5
  Author: AI Memory
6
6
  License-Expression: MIT
@@ -69,6 +69,8 @@ On Windows, `python -m ai_memory_cli ...` is the safest form because it avoids P
69
69
 
70
70
  Inside `watch`, type the real command you want to capture, for example `python --version`. Do not type `python -m ai_memory_cli run -- ...` inside `watch`, or you will capture the nested CLI command too.
71
71
  Use `cls` on Windows or `clear` on Unix shells to clear the watch screen; those control commands are not stored or synced.
72
+ On Windows, commands run through PowerShell, so type `ls` directly. Do not type `powershell` or `cmd` inside `watch`; nested shell launchers are ignored and not stored.
73
+ The watch prompt shows the active folder, for example `ai-memory C:\work\repo>`. Use `cd`, `chdir`, `cd..`, `cd /d D:\path`, `cd ~`, or `cd -` normally; successful directory changes are tracked as hashed terminal events and become the working folder for the next command.
72
74
 
73
75
  ## Background agent
74
76
 
File without changes
File without changes