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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """AI Memory terminal capture CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ ai-memory = ai_memory_cli.cli:main
@@ -0,0 +1 @@
1
+ ai_memory_cli