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.
- {ai_memory_cli-0.1.8/src/ai_memory_cli.egg-info → ai_memory_cli-0.1.11}/PKG-INFO +3 -1
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/README.md +2 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/pyproject.toml +1 -1
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli/__init__.py +1 -1
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli/cli.py +232 -30
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11/src/ai_memory_cli.egg-info}/PKG-INFO +3 -1
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/LICENSE +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/bin/watch.cmd +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/setup.cfg +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli/__main__.py +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli.egg-info/SOURCES.txt +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli.egg-info/dependency_links.txt +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli.egg-info/entry_points.txt +0 -0
- {ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-memory-cli
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
|
@@ -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 "
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ai_memory_cli-0.1.8 → ai_memory_cli-0.1.11}/src/ai_memory_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|