ai-memory-cli 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.
- ai_memory_cli-0.1.0/LICENSE +21 -0
- ai_memory_cli-0.1.0/PKG-INFO +98 -0
- ai_memory_cli-0.1.0/README.md +76 -0
- ai_memory_cli-0.1.0/pyproject.toml +33 -0
- ai_memory_cli-0.1.0/setup.cfg +4 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli/__init__.py +3 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli/__main__.py +4 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli/cli.py +671 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli.egg-info/PKG-INFO +98 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli.egg-info/SOURCES.txt +11 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli.egg-info/dependency_links.txt +1 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli.egg-info/entry_points.txt +2 -0
- ai_memory_cli-0.1.0/src/ai_memory_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AI Memory
|
|
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,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-memory-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python CLI for AI Memory terminal capture and offline sync.
|
|
5
|
+
Author: AI Memory
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,memory,cli,terminal,rag,developer-tools
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Classifier: Topic :: Terminals
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# AI Memory CLI
|
|
24
|
+
|
|
25
|
+
Standalone Python CLI for terminal capture, hashing, offline queueing, and sync to the temporary FastAPI backend.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
For any user machine after the package is published:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
python -m pip install ai-memory-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Before PyPI publish, install from GitHub:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python -m pip install "ai-memory-cli @ git+https://github.com/YOUR_ORG/ai-memory-cli.git"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local development from this CLI repo:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
python -m pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Basic flow
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
ai-memory auth --token <app-issued-cli-token> --api-url https://api.your-domain.com
|
|
51
|
+
ai-memory init --project my-project --repo owner/repo --workspace .
|
|
52
|
+
ai-memory workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
|
|
53
|
+
ai-memory watch
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use `ai-memory run -- <command>` when you only want to record one command.
|
|
57
|
+
|
|
58
|
+
## Storage
|
|
59
|
+
|
|
60
|
+
The CLI stores config and unsynced events in a separate folder:
|
|
61
|
+
|
|
62
|
+
- Windows: `%USERPROFILE%\.ai-memory-cli`
|
|
63
|
+
- macOS/Linux: `~/.ai-memory-cli`
|
|
64
|
+
|
|
65
|
+
Set `AI_MEMORY_CLI_HOME` to override this location.
|
|
66
|
+
|
|
67
|
+
## Privacy and dedupe
|
|
68
|
+
|
|
69
|
+
The CLI does not send raw commands or raw output to the backend. It sends:
|
|
70
|
+
|
|
71
|
+
- `command_hash`
|
|
72
|
+
- `output_hash`
|
|
73
|
+
- `event_hash`
|
|
74
|
+
- timestamps, exit code, shell, project, repo, and local metadata
|
|
75
|
+
|
|
76
|
+
If the same command produces the same output again, the CLI keeps one event hash and increments `duplicate_count`.
|
|
77
|
+
|
|
78
|
+
## Excluded commands
|
|
79
|
+
|
|
80
|
+
Long-running development commands are not captured by default. They still run, but no hash event is stored.
|
|
81
|
+
|
|
82
|
+
Default excluded patterns include:
|
|
83
|
+
|
|
84
|
+
- `npm run ...`
|
|
85
|
+
- `next dev`
|
|
86
|
+
- `vite`
|
|
87
|
+
- `uvicorn --reload`
|
|
88
|
+
- `python -m uvicorn ... --reload`
|
|
89
|
+
|
|
90
|
+
Use `--include-excluded` on `run` or `watch` if you need to capture them anyway.
|
|
91
|
+
|
|
92
|
+
## Publish
|
|
93
|
+
|
|
94
|
+
After this folder is pushed as its own public GitHub repo, publish to PyPI with:
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
.\scripts\publish.ps1 -Repository pypi
|
|
98
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# AI Memory CLI
|
|
2
|
+
|
|
3
|
+
Standalone Python CLI for terminal capture, hashing, offline queueing, and sync to the temporary FastAPI backend.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
For any user machine after the package is published:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
python -m pip install ai-memory-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Before PyPI publish, install from GitHub:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
python -m pip install "ai-memory-cli @ git+https://github.com/YOUR_ORG/ai-memory-cli.git"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For local development from this CLI repo:
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
python -m pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Basic flow
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
ai-memory auth --token <app-issued-cli-token> --api-url https://api.your-domain.com
|
|
29
|
+
ai-memory init --project my-project --repo owner/repo --workspace .
|
|
30
|
+
ai-memory workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
|
|
31
|
+
ai-memory watch
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use `ai-memory run -- <command>` when you only want to record one command.
|
|
35
|
+
|
|
36
|
+
## Storage
|
|
37
|
+
|
|
38
|
+
The CLI stores config and unsynced events in a separate folder:
|
|
39
|
+
|
|
40
|
+
- Windows: `%USERPROFILE%\.ai-memory-cli`
|
|
41
|
+
- macOS/Linux: `~/.ai-memory-cli`
|
|
42
|
+
|
|
43
|
+
Set `AI_MEMORY_CLI_HOME` to override this location.
|
|
44
|
+
|
|
45
|
+
## Privacy and dedupe
|
|
46
|
+
|
|
47
|
+
The CLI does not send raw commands or raw output to the backend. It sends:
|
|
48
|
+
|
|
49
|
+
- `command_hash`
|
|
50
|
+
- `output_hash`
|
|
51
|
+
- `event_hash`
|
|
52
|
+
- timestamps, exit code, shell, project, repo, and local metadata
|
|
53
|
+
|
|
54
|
+
If the same command produces the same output again, the CLI keeps one event hash and increments `duplicate_count`.
|
|
55
|
+
|
|
56
|
+
## Excluded commands
|
|
57
|
+
|
|
58
|
+
Long-running development commands are not captured by default. They still run, but no hash event is stored.
|
|
59
|
+
|
|
60
|
+
Default excluded patterns include:
|
|
61
|
+
|
|
62
|
+
- `npm run ...`
|
|
63
|
+
- `next dev`
|
|
64
|
+
- `vite`
|
|
65
|
+
- `uvicorn --reload`
|
|
66
|
+
- `python -m uvicorn ... --reload`
|
|
67
|
+
|
|
68
|
+
Use `--include-excluded` on `run` or `watch` if you need to capture them anyway.
|
|
69
|
+
|
|
70
|
+
## Publish
|
|
71
|
+
|
|
72
|
+
After this folder is pushed as its own public GitHub repo, publish to PyPI with:
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
.\scripts\publish.ps1 -Repository pypi
|
|
76
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-memory-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python CLI for AI Memory terminal capture and offline sync."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "AI Memory" }]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
keywords = ["ai", "memory", "cli", "terminal", "rag", "developer-tools"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development",
|
|
25
|
+
"Topic :: Terminals",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
ai-memory = "ai_memory_cli.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
DEFAULT_API_URL = "http://127.0.0.1:8000"
|
|
21
|
+
DEFAULT_EXCLUDES = [
|
|
22
|
+
r"^\s*npm\s+run(\s|$)",
|
|
23
|
+
r"^\s*npm\s+start(\s|$)",
|
|
24
|
+
r"^\s*pnpm\s+(dev|start)(\s|$)",
|
|
25
|
+
r"^\s*yarn\s+(dev|start)(\s|$)",
|
|
26
|
+
r"^\s*bun\s+(dev|start)(\s|$)",
|
|
27
|
+
r"^\s*next\s+dev(\s|$)",
|
|
28
|
+
r"^\s* vite(\s|$)",
|
|
29
|
+
r"^\s*vite(\s|$)",
|
|
30
|
+
r"uvicorn\b.*\s--reload(\s|$)",
|
|
31
|
+
r"python(\.exe)?\s+-m\s+uvicorn\b.*\s--reload(\s|$)",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def utc_now() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).isoformat()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sha256_text(value: str) -> str:
|
|
40
|
+
import hashlib
|
|
41
|
+
|
|
42
|
+
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cli_home() -> Path:
|
|
46
|
+
configured = os.getenv("AI_MEMORY_CLI_HOME")
|
|
47
|
+
if configured:
|
|
48
|
+
return Path(configured).expanduser().resolve()
|
|
49
|
+
return (Path.home() / ".ai-memory-cli").resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_dirs(home: Path) -> None:
|
|
53
|
+
for folder in ["events", "outbox", "sent", "logs"]:
|
|
54
|
+
(home / folder).mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_json(path: Path, default: Any) -> Any:
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return default
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, json.JSONDecodeError):
|
|
63
|
+
return default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_json(path: Path, payload: Any) -> None:
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
69
|
+
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
70
|
+
tmp_path.replace(path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def config_path(home: Path) -> Path:
|
|
74
|
+
return home / "config.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config(home: Path) -> dict[str, Any]:
|
|
78
|
+
ensure_dirs(home)
|
|
79
|
+
config = read_json(config_path(home), {})
|
|
80
|
+
config.setdefault("api_url", DEFAULT_API_URL)
|
|
81
|
+
config.setdefault("project", "")
|
|
82
|
+
config.setdefault("repository", "")
|
|
83
|
+
config.setdefault("workspace_path", ".")
|
|
84
|
+
config.setdefault("exclude_patterns", DEFAULT_EXCLUDES)
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_config(home: Path, config: dict[str, Any]) -> None:
|
|
89
|
+
ensure_dirs(home)
|
|
90
|
+
write_json(config_path(home), config)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_command(command: str) -> str:
|
|
94
|
+
return " ".join(command.strip().split())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_output(stdout: str, stderr: str) -> str:
|
|
98
|
+
combined = f"stdout:\n{stdout}\nstderr:\n{stderr}"
|
|
99
|
+
lines = [line.rstrip() for line in combined.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
|
|
100
|
+
return "\n".join(lines).strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def selected_shell() -> str:
|
|
104
|
+
shell = os.getenv("SHELL") or os.getenv("COMSPEC") or ""
|
|
105
|
+
if shell:
|
|
106
|
+
return Path(shell).name
|
|
107
|
+
return "powershell" if os.name == "nt" else "sh"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def command_line(parts: list[str]) -> str:
|
|
111
|
+
cleaned = list(parts)
|
|
112
|
+
if cleaned and cleaned[0] == "--":
|
|
113
|
+
cleaned = cleaned[1:]
|
|
114
|
+
if os.name == "nt":
|
|
115
|
+
return subprocess.list2cmdline(cleaned)
|
|
116
|
+
import shlex
|
|
117
|
+
|
|
118
|
+
return shlex.join(cleaned)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_excluded(command: str, config: dict[str, Any]) -> bool:
|
|
122
|
+
patterns = config.get("exclude_patterns") or DEFAULT_EXCLUDES
|
|
123
|
+
return any(re.search(pattern, command, flags=re.IGNORECASE) for pattern in patterns)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def api_url(config: dict[str, Any]) -> str:
|
|
127
|
+
return str(config.get("api_url") or DEFAULT_API_URL).rstrip("/")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def require_token(config: dict[str, Any]) -> str:
|
|
131
|
+
token = str(config.get("token") or "").strip()
|
|
132
|
+
if not token:
|
|
133
|
+
raise SystemExit("Run ai-memory auth --token <app-issued-cli-token> first.")
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def http_json(
|
|
138
|
+
method: str,
|
|
139
|
+
url: str,
|
|
140
|
+
payload: dict[str, Any] | None,
|
|
141
|
+
token: str | None,
|
|
142
|
+
timeout: float = 10.0,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
145
|
+
headers = {
|
|
146
|
+
"Accept": "application/json",
|
|
147
|
+
"User-Agent": f"ai-memory-cli/{__version__}",
|
|
148
|
+
}
|
|
149
|
+
if payload is not None:
|
|
150
|
+
headers["Content-Type"] = "application/json"
|
|
151
|
+
if token:
|
|
152
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
153
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
154
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
155
|
+
response_body = response.read().decode("utf-8")
|
|
156
|
+
if not response_body:
|
|
157
|
+
return {}
|
|
158
|
+
return json.loads(response_body)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def make_terminal_event(
|
|
162
|
+
command: str,
|
|
163
|
+
stdout: str,
|
|
164
|
+
stderr: str,
|
|
165
|
+
exit_code: int | None,
|
|
166
|
+
started_at: str,
|
|
167
|
+
ended_at: str,
|
|
168
|
+
duration_ms: int,
|
|
169
|
+
cwd: Path,
|
|
170
|
+
config: dict[str, Any],
|
|
171
|
+
source: str,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
normalized_command = normalize_command(command)
|
|
174
|
+
normalized_output = normalize_output(stdout, stderr)
|
|
175
|
+
command_hash = sha256_text(normalized_command)
|
|
176
|
+
output_hash = sha256_text(normalized_output)
|
|
177
|
+
event_hash = sha256_text(f"v1\0{normalized_command}\0{normalized_output}\0{exit_code}")
|
|
178
|
+
cwd_text = str(cwd.resolve())
|
|
179
|
+
return {
|
|
180
|
+
"event_hash": event_hash,
|
|
181
|
+
"command_hash": command_hash,
|
|
182
|
+
"output_hash": output_hash,
|
|
183
|
+
"started_at": started_at,
|
|
184
|
+
"ended_at": ended_at,
|
|
185
|
+
"observed_at": ended_at,
|
|
186
|
+
"duration_ms": duration_ms,
|
|
187
|
+
"exit_code": exit_code,
|
|
188
|
+
"cwd_hash": sha256_text(cwd_text),
|
|
189
|
+
"shell": selected_shell(),
|
|
190
|
+
"project": str(config.get("project") or ""),
|
|
191
|
+
"repository": str(config.get("repository") or ""),
|
|
192
|
+
"source": source,
|
|
193
|
+
"duplicate_count": 1,
|
|
194
|
+
"metadata": {
|
|
195
|
+
"platform": platform.system(),
|
|
196
|
+
"python": platform.python_version(),
|
|
197
|
+
"stdout_bytes": len(stdout.encode("utf-8", errors="replace")),
|
|
198
|
+
"stderr_bytes": len(stderr.encode("utf-8", errors="replace")),
|
|
199
|
+
"command_length": len(command),
|
|
200
|
+
"cwd_tail": cwd.resolve().name,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def store_event(home: Path, event: dict[str, Any]) -> tuple[bool, Path]:
|
|
206
|
+
ensure_dirs(home)
|
|
207
|
+
event_hash = event["event_hash"]
|
|
208
|
+
event_path = home / "events" / f"{event_hash}.json"
|
|
209
|
+
outbox_path = home / "outbox" / f"{event_hash}.json"
|
|
210
|
+
existing = read_json(event_path, None)
|
|
211
|
+
if existing:
|
|
212
|
+
existing["total_observed_count"] = int(existing.get("total_observed_count", 1)) + 1
|
|
213
|
+
existing["last_observed_at"] = event["observed_at"]
|
|
214
|
+
write_json(event_path, existing)
|
|
215
|
+
|
|
216
|
+
outbound = read_json(outbox_path, None)
|
|
217
|
+
if outbound:
|
|
218
|
+
outbound["duplicate_count"] = int(outbound.get("duplicate_count", 1)) + 1
|
|
219
|
+
outbound["observed_at"] = event["observed_at"]
|
|
220
|
+
else:
|
|
221
|
+
outbound = dict(event)
|
|
222
|
+
outbound["duplicate_count"] = 1
|
|
223
|
+
write_json(outbox_path, outbound)
|
|
224
|
+
return False, event_path
|
|
225
|
+
|
|
226
|
+
stored = dict(event)
|
|
227
|
+
stored["total_observed_count"] = 1
|
|
228
|
+
stored["first_observed_at"] = event["observed_at"]
|
|
229
|
+
stored["last_observed_at"] = event["observed_at"]
|
|
230
|
+
write_json(event_path, stored)
|
|
231
|
+
write_json(outbox_path, event)
|
|
232
|
+
return True, event_path
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def mark_synced(home: Path, event_hash: str, response: dict[str, Any]) -> None:
|
|
236
|
+
outbox_path = home / "outbox" / f"{event_hash}.json"
|
|
237
|
+
event_path = home / "events" / f"{event_hash}.json"
|
|
238
|
+
sent_path = home / "sent" / f"{event_hash}.json"
|
|
239
|
+
outbox_event = read_json(outbox_path, {})
|
|
240
|
+
stored_event = read_json(event_path, {})
|
|
241
|
+
now = utc_now()
|
|
242
|
+
if stored_event:
|
|
243
|
+
stored_event["last_synced_at"] = now
|
|
244
|
+
stored_event["synced_observed_count"] = stored_event.get("total_observed_count", 1)
|
|
245
|
+
write_json(event_path, stored_event)
|
|
246
|
+
write_json(
|
|
247
|
+
sent_path,
|
|
248
|
+
{
|
|
249
|
+
"event_hash": event_hash,
|
|
250
|
+
"synced_at": now,
|
|
251
|
+
"duplicate_count": outbox_event.get("duplicate_count", 1),
|
|
252
|
+
"response": response,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
if outbox_path.exists():
|
|
256
|
+
outbox_path.unlink()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def sync_events(home: Path, config: dict[str, Any], limit: int = 50, quiet: bool = False) -> int:
|
|
260
|
+
ensure_dirs(home)
|
|
261
|
+
token = str(config.get("token") or "").strip()
|
|
262
|
+
if not token:
|
|
263
|
+
if not quiet:
|
|
264
|
+
print("No CLI token saved. Events remain queued until ai-memory auth is configured.")
|
|
265
|
+
return 0
|
|
266
|
+
paths = sorted((home / "outbox").glob("*.json"))[:limit]
|
|
267
|
+
if not paths:
|
|
268
|
+
if not quiet:
|
|
269
|
+
print("No queued terminal events to sync.")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
events = [read_json(path, {}) for path in paths]
|
|
273
|
+
events = [event for event in events if event.get("event_hash")]
|
|
274
|
+
if not events:
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
payload = {
|
|
278
|
+
"events": events,
|
|
279
|
+
"client": {
|
|
280
|
+
"name": "ai-memory-cli",
|
|
281
|
+
"version": __version__,
|
|
282
|
+
"storage_home_hash": sha256_text(str(home)),
|
|
283
|
+
"hostname_hash": sha256_text(platform.node() or "unknown"),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
response = http_json("POST", f"{api_url(config)}/cli/events/terminal", payload, token)
|
|
287
|
+
accepted = {item.get("event_hash") for item in response.get("events", []) if item.get("event_hash")}
|
|
288
|
+
for event in events:
|
|
289
|
+
if event["event_hash"] in accepted:
|
|
290
|
+
mark_synced(home, event["event_hash"], response)
|
|
291
|
+
|
|
292
|
+
synced = len(accepted)
|
|
293
|
+
if not quiet:
|
|
294
|
+
print(f"Synced {synced} terminal event(s).")
|
|
295
|
+
return synced
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def capture_command(home: Path, config: dict[str, Any], command: str, include_excluded: bool, source: str) -> int:
|
|
299
|
+
workspace = Path(str(config.get("workspace_path") or ".")).expanduser()
|
|
300
|
+
cwd = workspace if workspace.exists() else Path.cwd()
|
|
301
|
+
|
|
302
|
+
if is_excluded(command, config) and not include_excluded:
|
|
303
|
+
print(f"ai-memory: running without capture because this command is excluded: {command}", file=sys.stderr)
|
|
304
|
+
return subprocess.call(command, shell=True, cwd=str(cwd))
|
|
305
|
+
|
|
306
|
+
started_at = utc_now()
|
|
307
|
+
started_monotonic = time.monotonic()
|
|
308
|
+
completed = subprocess.run(
|
|
309
|
+
command,
|
|
310
|
+
shell=True,
|
|
311
|
+
cwd=str(cwd),
|
|
312
|
+
capture_output=True,
|
|
313
|
+
text=True,
|
|
314
|
+
encoding="utf-8",
|
|
315
|
+
errors="replace",
|
|
316
|
+
)
|
|
317
|
+
ended_at = utc_now()
|
|
318
|
+
duration_ms = int((time.monotonic() - started_monotonic) * 1000)
|
|
319
|
+
|
|
320
|
+
if completed.stdout:
|
|
321
|
+
print(completed.stdout, end="")
|
|
322
|
+
if completed.stderr:
|
|
323
|
+
print(completed.stderr, end="", file=sys.stderr)
|
|
324
|
+
|
|
325
|
+
event = make_terminal_event(
|
|
326
|
+
command=command,
|
|
327
|
+
stdout=completed.stdout or "",
|
|
328
|
+
stderr=completed.stderr or "",
|
|
329
|
+
exit_code=completed.returncode,
|
|
330
|
+
started_at=started_at,
|
|
331
|
+
ended_at=ended_at,
|
|
332
|
+
duration_ms=duration_ms,
|
|
333
|
+
cwd=cwd,
|
|
334
|
+
config=config,
|
|
335
|
+
source=source,
|
|
336
|
+
)
|
|
337
|
+
created, event_path = store_event(home, event)
|
|
338
|
+
state = "stored" if created else "deduped"
|
|
339
|
+
print(f"ai-memory: {state} terminal hash {event['event_hash'][:12]} at {event_path}")
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
sync_events(home, config, quiet=True)
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
print(f"ai-memory: sync queued until network/API is available ({exc})", file=sys.stderr)
|
|
345
|
+
|
|
346
|
+
return completed.returncode
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def detect_history_file() -> Path | None:
|
|
350
|
+
candidates: list[Path] = []
|
|
351
|
+
appdata = os.getenv("APPDATA")
|
|
352
|
+
if appdata:
|
|
353
|
+
candidates.extend(
|
|
354
|
+
[
|
|
355
|
+
Path(appdata) / "Microsoft" / "Windows" / "PowerShell" / "PSReadLine" / "ConsoleHost_history.txt",
|
|
356
|
+
Path(appdata) / "Microsoft" / "Windows" / "PowerShell" / "PSReadLine" / "Visual Studio Code Host_history.txt",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
candidates.extend([Path.home() / ".bash_history", Path.home() / ".zsh_history"])
|
|
360
|
+
for candidate in candidates:
|
|
361
|
+
if candidate.exists():
|
|
362
|
+
return candidate
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def command_auth(args: argparse.Namespace) -> int:
|
|
367
|
+
home = cli_home()
|
|
368
|
+
config = load_config(home)
|
|
369
|
+
if args.api_url:
|
|
370
|
+
config["api_url"] = args.api_url.rstrip("/")
|
|
371
|
+
config["token"] = args.token.strip()
|
|
372
|
+
config["token_hash"] = sha256_text(args.token.strip())
|
|
373
|
+
config["authed_at"] = utc_now()
|
|
374
|
+
save_config(home, config)
|
|
375
|
+
print(f"Saved CLI auth in {config_path(home)}")
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
health = http_json("GET", f"{api_url(config)}/health", None, None)
|
|
379
|
+
print(f"Connected to API: {health.get('service', api_url(config))}")
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
print(f"Auth saved. API check failed, sync will retry later: {exc}", file=sys.stderr)
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def command_init(args: argparse.Namespace) -> int:
|
|
386
|
+
home = cli_home()
|
|
387
|
+
config = load_config(home)
|
|
388
|
+
if args.api_url:
|
|
389
|
+
config["api_url"] = args.api_url.rstrip("/")
|
|
390
|
+
if args.project:
|
|
391
|
+
config["project"] = args.project
|
|
392
|
+
if args.repo:
|
|
393
|
+
config["repository"] = args.repo
|
|
394
|
+
if args.workspace:
|
|
395
|
+
config["workspace_path"] = args.workspace
|
|
396
|
+
save_config(home, config)
|
|
397
|
+
|
|
398
|
+
token = require_token(config)
|
|
399
|
+
payload = {
|
|
400
|
+
"project": config.get("project") or "memory-project",
|
|
401
|
+
"repository": config.get("repository") or "",
|
|
402
|
+
"workspace_path": config.get("workspace_path") or ".",
|
|
403
|
+
"integrations": ["github", "cli", "editor", "chat", "mcp"],
|
|
404
|
+
}
|
|
405
|
+
try:
|
|
406
|
+
response = http_json("POST", f"{api_url(config)}/projects/init", payload, token)
|
|
407
|
+
project = response.get("project", {})
|
|
408
|
+
print(f"Initialized project: {project.get('id', payload['project'])}")
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
print(f"Project config saved locally. Server init will need retry: {exc}", file=sys.stderr)
|
|
411
|
+
print("Start terminal capture with: ai-memory watch")
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def command_workspace_connect(args: argparse.Namespace) -> int:
|
|
416
|
+
home = cli_home()
|
|
417
|
+
config = load_config(home)
|
|
418
|
+
if args.path:
|
|
419
|
+
config["workspace_path"] = args.path
|
|
420
|
+
if args.repo:
|
|
421
|
+
config["repository"] = args.repo
|
|
422
|
+
save_config(home, config)
|
|
423
|
+
|
|
424
|
+
token = require_token(config)
|
|
425
|
+
payload = {
|
|
426
|
+
"payload": {
|
|
427
|
+
"source": "ai-memory-cli",
|
|
428
|
+
"workspace_path": args.path,
|
|
429
|
+
"repository": args.repo or config.get("repository") or "",
|
|
430
|
+
"branch": args.branch,
|
|
431
|
+
"editor": args.editor,
|
|
432
|
+
"package_manager": args.package_manager,
|
|
433
|
+
"cli_storage_home_hash": sha256_text(str(home)),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
response = http_json("POST", f"{api_url(config)}/workspace/connect", payload, token)
|
|
437
|
+
event = response.get("event", {})
|
|
438
|
+
print(f"Workspace connected: {event.get('id', 'saved')}")
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def command_mcp_connect(args: argparse.Namespace) -> int:
|
|
443
|
+
home = cli_home()
|
|
444
|
+
config = load_config(home)
|
|
445
|
+
token = require_token(config)
|
|
446
|
+
config["mcp_server"] = args.server
|
|
447
|
+
save_config(home, config)
|
|
448
|
+
payload = {
|
|
449
|
+
"payload": {
|
|
450
|
+
"source": "ai-memory-cli",
|
|
451
|
+
"server": args.server,
|
|
452
|
+
"project": config.get("project") or "",
|
|
453
|
+
"repository": config.get("repository") or "",
|
|
454
|
+
"cli_storage_home_hash": sha256_text(str(home)),
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
response = http_json("POST", f"{api_url(config)}/mcp/connect", payload, token)
|
|
458
|
+
event = response.get("event", {})
|
|
459
|
+
print(f"MCP connected: {event.get('id', 'saved')}")
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def command_chat_connect(args: argparse.Namespace) -> int:
|
|
464
|
+
home = cli_home()
|
|
465
|
+
config = load_config(home)
|
|
466
|
+
token = require_token(config)
|
|
467
|
+
config["chat_provider"] = args.provider
|
|
468
|
+
save_config(home, config)
|
|
469
|
+
payload = {
|
|
470
|
+
"payload": {
|
|
471
|
+
"source": "ai-memory-cli",
|
|
472
|
+
"provider": args.provider,
|
|
473
|
+
"project": config.get("project") or "",
|
|
474
|
+
"repository": config.get("repository") or "",
|
|
475
|
+
"cli_storage_home_hash": sha256_text(str(home)),
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
response = http_json("POST", f"{api_url(config)}/chat/connect", payload, token)
|
|
479
|
+
event = response.get("event", {})
|
|
480
|
+
print(f"Chat connected: {event.get('id', 'saved')}")
|
|
481
|
+
return 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def command_run(args: argparse.Namespace) -> int:
|
|
485
|
+
command = command_line(args.command)
|
|
486
|
+
if not command:
|
|
487
|
+
raise SystemExit("Pass a command after --, for example: ai-memory run -- python --version")
|
|
488
|
+
home = cli_home()
|
|
489
|
+
config = load_config(home)
|
|
490
|
+
return capture_command(home, config, command, args.include_excluded, "run")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def command_watch(args: argparse.Namespace) -> int:
|
|
494
|
+
home = cli_home()
|
|
495
|
+
config = load_config(home)
|
|
496
|
+
print("AI Memory watch mode. Type commands to run and capture. Type exit to stop.")
|
|
497
|
+
while True:
|
|
498
|
+
try:
|
|
499
|
+
command = input("ai-memory> ").strip()
|
|
500
|
+
except (EOFError, KeyboardInterrupt):
|
|
501
|
+
print()
|
|
502
|
+
break
|
|
503
|
+
if not command:
|
|
504
|
+
continue
|
|
505
|
+
if command.lower() in {"exit", "quit"}:
|
|
506
|
+
break
|
|
507
|
+
capture_command(home, config, command, args.include_excluded, "watch")
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def command_history_import(args: argparse.Namespace) -> int:
|
|
512
|
+
home = cli_home()
|
|
513
|
+
config = load_config(home)
|
|
514
|
+
history_path = Path(args.path).expanduser() if args.path else detect_history_file()
|
|
515
|
+
if not history_path or not history_path.exists():
|
|
516
|
+
raise SystemExit("No shell history file found. Pass --path <history-file>.")
|
|
517
|
+
|
|
518
|
+
lines = history_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
519
|
+
commands = [line.strip() for line in lines if line.strip()]
|
|
520
|
+
commands = commands[-args.limit :]
|
|
521
|
+
imported = 0
|
|
522
|
+
now = utc_now()
|
|
523
|
+
for command in commands:
|
|
524
|
+
if is_excluded(command, config) and not args.include_excluded:
|
|
525
|
+
continue
|
|
526
|
+
event = make_terminal_event(
|
|
527
|
+
command=command,
|
|
528
|
+
stdout="",
|
|
529
|
+
stderr="",
|
|
530
|
+
exit_code=None,
|
|
531
|
+
started_at=now,
|
|
532
|
+
ended_at=now,
|
|
533
|
+
duration_ms=0,
|
|
534
|
+
cwd=Path.cwd(),
|
|
535
|
+
config=config,
|
|
536
|
+
source="history-import",
|
|
537
|
+
)
|
|
538
|
+
created, _ = store_event(home, event)
|
|
539
|
+
if created:
|
|
540
|
+
imported += 1
|
|
541
|
+
print(f"Imported {imported} hashed history event(s) from {history_path}")
|
|
542
|
+
try:
|
|
543
|
+
sync_events(home, config, quiet=False)
|
|
544
|
+
except Exception as exc:
|
|
545
|
+
print(f"History hashes queued until network/API is available: {exc}", file=sys.stderr)
|
|
546
|
+
return 0
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def command_sync(args: argparse.Namespace) -> int:
|
|
550
|
+
home = cli_home()
|
|
551
|
+
config = load_config(home)
|
|
552
|
+
try:
|
|
553
|
+
sync_events(home, config, limit=args.limit, quiet=False)
|
|
554
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
555
|
+
print(f"Sync failed. Events remain queued: {exc}", file=sys.stderr)
|
|
556
|
+
return 1
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def command_status(_: argparse.Namespace) -> int:
|
|
561
|
+
home = cli_home()
|
|
562
|
+
config = load_config(home)
|
|
563
|
+
ensure_dirs(home)
|
|
564
|
+
outbox_count = len(list((home / "outbox").glob("*.json")))
|
|
565
|
+
event_count = len(list((home / "events").glob("*.json")))
|
|
566
|
+
sent_count = len(list((home / "sent").glob("*.json")))
|
|
567
|
+
print(f"AI Memory CLI {__version__}")
|
|
568
|
+
print(f"Storage: {home}")
|
|
569
|
+
print(f"API: {api_url(config)}")
|
|
570
|
+
print(f"Project: {config.get('project') or '-'}")
|
|
571
|
+
print(f"Repository: {config.get('repository') or '-'}")
|
|
572
|
+
print(f"Workspace: {config.get('workspace_path') or '.'}")
|
|
573
|
+
print(f"Token: {'saved' if config.get('token') else 'missing'}")
|
|
574
|
+
print(f"Events: {event_count} total, {outbox_count} queued, {sent_count} synced receipts")
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def command_doctor(_: argparse.Namespace) -> int:
|
|
579
|
+
home = cli_home()
|
|
580
|
+
config = load_config(home)
|
|
581
|
+
print(f"Python: {platform.python_version()}")
|
|
582
|
+
print(f"Executable: {sys.executable}")
|
|
583
|
+
print(f"Storage writable: {os.access(home, os.W_OK)} ({home})")
|
|
584
|
+
print(f"API: {api_url(config)}")
|
|
585
|
+
try:
|
|
586
|
+
health = http_json("GET", f"{api_url(config)}/health", None, None)
|
|
587
|
+
print(f"API health: ok ({health.get('service')})")
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
print(f"API health: failed ({exc})")
|
|
590
|
+
print(f"Shell: {selected_shell()}")
|
|
591
|
+
print(f"PowerShell: {shutil.which('powershell') or shutil.which('pwsh') or '-'}")
|
|
592
|
+
return 0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
596
|
+
parser = argparse.ArgumentParser(prog="ai-memory", description="AI Memory Python CLI")
|
|
597
|
+
parser.add_argument("--version", action="version", version=f"ai-memory {__version__}")
|
|
598
|
+
subparsers = parser.add_subparsers(dest="command_name", required=True)
|
|
599
|
+
|
|
600
|
+
auth = subparsers.add_parser("auth", help="Save the app-issued CLI token.")
|
|
601
|
+
auth.add_argument("--token", required=True, help="Token generated by the website.")
|
|
602
|
+
auth.add_argument("--api-url", default=DEFAULT_API_URL, help="FastAPI base URL.")
|
|
603
|
+
auth.set_defaults(func=command_auth)
|
|
604
|
+
|
|
605
|
+
init = subparsers.add_parser("init", help="Save project config and call /projects/init.")
|
|
606
|
+
init.add_argument("--project", required=True)
|
|
607
|
+
init.add_argument("--repo", default="")
|
|
608
|
+
init.add_argument("--workspace", default=".")
|
|
609
|
+
init.add_argument("--api-url", default="")
|
|
610
|
+
init.set_defaults(func=command_init)
|
|
611
|
+
|
|
612
|
+
workspace = subparsers.add_parser("workspace", help="Workspace commands.")
|
|
613
|
+
workspace_subparsers = workspace.add_subparsers(dest="workspace_command", required=True)
|
|
614
|
+
workspace_connect = workspace_subparsers.add_parser("connect", help="Connect local workspace metadata.")
|
|
615
|
+
workspace_connect.add_argument("--path", default=".")
|
|
616
|
+
workspace_connect.add_argument("--repo", default="")
|
|
617
|
+
workspace_connect.add_argument("--branch", default="main")
|
|
618
|
+
workspace_connect.add_argument("--editor", default="vscode")
|
|
619
|
+
workspace_connect.add_argument("--package-manager", default="pip")
|
|
620
|
+
workspace_connect.set_defaults(func=command_workspace_connect)
|
|
621
|
+
|
|
622
|
+
mcp = subparsers.add_parser("mcp", help="MCP integration commands.")
|
|
623
|
+
mcp_subparsers = mcp.add_subparsers(dest="mcp_command", required=True)
|
|
624
|
+
mcp_connect = mcp_subparsers.add_parser("connect", help="Connect MCP server metadata.")
|
|
625
|
+
mcp_connect.add_argument("--server", required=True)
|
|
626
|
+
mcp_connect.set_defaults(func=command_mcp_connect)
|
|
627
|
+
|
|
628
|
+
chat = subparsers.add_parser("chat", help="Chat integration commands.")
|
|
629
|
+
chat_subparsers = chat.add_subparsers(dest="chat_command", required=True)
|
|
630
|
+
chat_connect = chat_subparsers.add_parser("connect", help="Connect chat app metadata.")
|
|
631
|
+
chat_connect.add_argument("--provider", required=True)
|
|
632
|
+
chat_connect.set_defaults(func=command_chat_connect)
|
|
633
|
+
|
|
634
|
+
run = subparsers.add_parser("run", help="Run one command and store a hashed terminal event.")
|
|
635
|
+
run.add_argument("--include-excluded", action="store_true")
|
|
636
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
637
|
+
run.set_defaults(func=command_run)
|
|
638
|
+
|
|
639
|
+
watch = subparsers.add_parser("watch", help="Start a managed terminal that captures commands and output.")
|
|
640
|
+
watch.add_argument("--include-excluded", action="store_true")
|
|
641
|
+
watch.set_defaults(func=command_watch)
|
|
642
|
+
|
|
643
|
+
history = subparsers.add_parser("history", help="History import commands.")
|
|
644
|
+
history_subparsers = history.add_subparsers(dest="history_command", required=True)
|
|
645
|
+
history_import = history_subparsers.add_parser("import", help="Hash commands from an existing shell history file.")
|
|
646
|
+
history_import.add_argument("--path", default="")
|
|
647
|
+
history_import.add_argument("--limit", type=int, default=500)
|
|
648
|
+
history_import.add_argument("--include-excluded", action="store_true")
|
|
649
|
+
history_import.set_defaults(func=command_history_import)
|
|
650
|
+
|
|
651
|
+
sync = subparsers.add_parser("sync", help="Sync queued terminal hashes to FastAPI.")
|
|
652
|
+
sync.add_argument("--limit", type=int, default=50)
|
|
653
|
+
sync.set_defaults(func=command_sync)
|
|
654
|
+
|
|
655
|
+
status = subparsers.add_parser("status", help="Show local CLI state.")
|
|
656
|
+
status.set_defaults(func=command_status)
|
|
657
|
+
|
|
658
|
+
doctor = subparsers.add_parser("doctor", help="Check CLI, storage, and API health.")
|
|
659
|
+
doctor.set_defaults(func=command_doctor)
|
|
660
|
+
|
|
661
|
+
return parser
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def main(argv: list[str] | None = None) -> int:
|
|
665
|
+
parser = build_parser()
|
|
666
|
+
args = parser.parse_args(argv)
|
|
667
|
+
try:
|
|
668
|
+
return int(args.func(args))
|
|
669
|
+
except KeyboardInterrupt:
|
|
670
|
+
print("Interrupted.", file=sys.stderr)
|
|
671
|
+
return 130
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-memory-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python CLI for AI Memory terminal capture and offline sync.
|
|
5
|
+
Author: AI Memory
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,memory,cli,terminal,rag,developer-tools
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Classifier: Topic :: Terminals
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# AI Memory CLI
|
|
24
|
+
|
|
25
|
+
Standalone Python CLI for terminal capture, hashing, offline queueing, and sync to the temporary FastAPI backend.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
For any user machine after the package is published:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
python -m pip install ai-memory-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Before PyPI publish, install from GitHub:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python -m pip install "ai-memory-cli @ git+https://github.com/YOUR_ORG/ai-memory-cli.git"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local development from this CLI repo:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
python -m pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Basic flow
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
ai-memory auth --token <app-issued-cli-token> --api-url https://api.your-domain.com
|
|
51
|
+
ai-memory init --project my-project --repo owner/repo --workspace .
|
|
52
|
+
ai-memory workspace connect --path . --repo owner/repo --editor vscode --package-manager pip
|
|
53
|
+
ai-memory watch
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use `ai-memory run -- <command>` when you only want to record one command.
|
|
57
|
+
|
|
58
|
+
## Storage
|
|
59
|
+
|
|
60
|
+
The CLI stores config and unsynced events in a separate folder:
|
|
61
|
+
|
|
62
|
+
- Windows: `%USERPROFILE%\.ai-memory-cli`
|
|
63
|
+
- macOS/Linux: `~/.ai-memory-cli`
|
|
64
|
+
|
|
65
|
+
Set `AI_MEMORY_CLI_HOME` to override this location.
|
|
66
|
+
|
|
67
|
+
## Privacy and dedupe
|
|
68
|
+
|
|
69
|
+
The CLI does not send raw commands or raw output to the backend. It sends:
|
|
70
|
+
|
|
71
|
+
- `command_hash`
|
|
72
|
+
- `output_hash`
|
|
73
|
+
- `event_hash`
|
|
74
|
+
- timestamps, exit code, shell, project, repo, and local metadata
|
|
75
|
+
|
|
76
|
+
If the same command produces the same output again, the CLI keeps one event hash and increments `duplicate_count`.
|
|
77
|
+
|
|
78
|
+
## Excluded commands
|
|
79
|
+
|
|
80
|
+
Long-running development commands are not captured by default. They still run, but no hash event is stored.
|
|
81
|
+
|
|
82
|
+
Default excluded patterns include:
|
|
83
|
+
|
|
84
|
+
- `npm run ...`
|
|
85
|
+
- `next dev`
|
|
86
|
+
- `vite`
|
|
87
|
+
- `uvicorn --reload`
|
|
88
|
+
- `python -m uvicorn ... --reload`
|
|
89
|
+
|
|
90
|
+
Use `--include-excluded` on `run` or `watch` if you need to capture them anyway.
|
|
91
|
+
|
|
92
|
+
## Publish
|
|
93
|
+
|
|
94
|
+
After this folder is pushed as its own public GitHub repo, publish to PyPI with:
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
.\scripts\publish.ps1 -Repository pypi
|
|
98
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ai_memory_cli/__init__.py
|
|
5
|
+
src/ai_memory_cli/__main__.py
|
|
6
|
+
src/ai_memory_cli/cli.py
|
|
7
|
+
src/ai_memory_cli.egg-info/PKG-INFO
|
|
8
|
+
src/ai_memory_cli.egg-info/SOURCES.txt
|
|
9
|
+
src/ai_memory_cli.egg-info/dependency_links.txt
|
|
10
|
+
src/ai_memory_cli.egg-info/entry_points.txt
|
|
11
|
+
src/ai_memory_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_memory_cli
|