amfs-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,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ node_modules/
12
+ .next/
13
+ !uv.lock
14
+ !pnpm-lock.yaml
15
+ .amfs/
16
+ test.py
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: amfs-cli
3
+ Version: 0.1.0
4
+ Summary: AMFS CLI — snapshot, inspect, and manage agent memory
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: amfs
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: rich<14,>=13
10
+ Requires-Dist: typer<1,>=0.12
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "amfs-cli"
3
+ version = "0.1.0"
4
+ description = "AMFS CLI — snapshot, inspect, and manage agent memory"
5
+ requires-python = ">=3.11"
6
+ license = "Apache-2.0"
7
+ dependencies = [
8
+ "amfs",
9
+ "typer>=0.12,<1",
10
+ "rich>=13,<14",
11
+ "httpx>=0.27",
12
+ ]
13
+
14
+ [project.scripts]
15
+ amfs = "amfs_cli.main:app"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/amfs_cli"]
@@ -0,0 +1 @@
1
+ """AMFS CLI — snapshot, inspect, and manage agent memory."""
@@ -0,0 +1,77 @@
1
+ """amfs init — scaffold an AMFS project in one command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+ _DEFAULT_CONFIG = """\
13
+ # AMFS Configuration
14
+ # Docs: https://github.com/raia-live/amfs
15
+
16
+ namespace: {namespace}
17
+
18
+ layers:
19
+ primary:
20
+ adapter: filesystem
21
+ options:
22
+ root: .amfs
23
+ """
24
+
25
+ _GITIGNORE = """\
26
+ # AMFS local memory store
27
+ .amfs/
28
+ """
29
+
30
+
31
+ def init_command(
32
+ directory: Path = typer.Argument(
33
+ Path("."), help="Directory to initialise (default: current dir)"
34
+ ),
35
+ namespace: str = typer.Option(
36
+ "default", "--namespace", "-n", help="Namespace for this project"
37
+ ),
38
+ force: bool = typer.Option(
39
+ False, "--force", "-f", help="Overwrite existing config"
40
+ ),
41
+ ) -> None:
42
+ """Initialise an AMFS project — creates config file and data directory."""
43
+ directory = directory.resolve()
44
+ config_path = directory / "amfs.yaml"
45
+ amfs_dir = directory / ".amfs"
46
+
47
+ if config_path.exists() and not force:
48
+ console.print(
49
+ f"[yellow]amfs.yaml already exists at {config_path}. "
50
+ f"Use --force to overwrite.[/yellow]"
51
+ )
52
+ raise typer.Exit(code=1)
53
+
54
+ directory.mkdir(parents=True, exist_ok=True)
55
+ config_path.write_text(_DEFAULT_CONFIG.format(namespace=namespace), encoding="utf-8")
56
+ amfs_dir.mkdir(exist_ok=True)
57
+
58
+ gitignore = directory / ".gitignore"
59
+ if gitignore.exists():
60
+ content = gitignore.read_text(encoding="utf-8")
61
+ if ".amfs/" not in content:
62
+ with open(gitignore, "a", encoding="utf-8") as f:
63
+ f.write("\n" + _GITIGNORE)
64
+ console.print("[dim]Updated .gitignore with .amfs/[/dim]")
65
+ else:
66
+ gitignore.write_text(_GITIGNORE, encoding="utf-8")
67
+ console.print("[dim]Created .gitignore[/dim]")
68
+
69
+ console.print("[green bold]AMFS initialised![/green bold]")
70
+ console.print(f" Config: {config_path}")
71
+ console.print(f" Data dir: {amfs_dir}")
72
+ console.print(f" Namespace: {namespace}")
73
+ console.print()
74
+ console.print("[dim]Quick start:[/dim]")
75
+ console.print(' from amfs import AgentMemory')
76
+ console.print(' mem = AgentMemory(agent_id="my-agent")')
77
+ console.print(' mem.write("my-service", "key", {"hello": "world"})')
@@ -0,0 +1,139 @@
1
+ """Inspect CLI commands — list, read, and diff memory entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from amfs.config import load_config_or_default
13
+ from amfs.factory import create_adapter_from_config
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+ console = Console()
17
+
18
+
19
+ @app.command("list")
20
+ def list_entries(
21
+ entity: str | None = typer.Argument(None, help="Filter to entity path"),
22
+ config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
23
+ superseded: bool = typer.Option(False, "--superseded", help="Include superseded versions"),
24
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
25
+ ) -> None:
26
+ """List memory entries."""
27
+ cfg = load_config_or_default(config)
28
+ adapter = create_adapter_from_config(cfg)
29
+
30
+ with console.status("[cyan]Loading entries...[/cyan]"):
31
+ entries = adapter.list(entity, include_superseded=superseded)
32
+
33
+ if not entries:
34
+ console.print("[dim]No entries found.[/dim]")
35
+ return
36
+
37
+ if format == "json":
38
+ console.print_json(json.dumps(
39
+ [{"entity_path": e.entity_path, "key": e.key, "version": e.version,
40
+ "confidence": e.confidence, "agent_id": e.provenance.agent_id,
41
+ "written_at": e.provenance.written_at.isoformat()}
42
+ for e in entries],
43
+ default=str,
44
+ ))
45
+ return
46
+
47
+ table = Table(title=f"Memory Entries ({len(entries)})")
48
+ table.add_column("Entity", style="cyan")
49
+ table.add_column("Key", style="green")
50
+ table.add_column("Version", justify="right")
51
+ table.add_column("Confidence", justify="right")
52
+ table.add_column("Agent", style="magenta")
53
+ table.add_column("Written At")
54
+
55
+ for e in entries:
56
+ table.add_row(
57
+ e.entity_path,
58
+ e.key,
59
+ str(e.version),
60
+ f"{e.confidence:.4f}",
61
+ e.provenance.agent_id,
62
+ e.provenance.written_at.strftime("%Y-%m-%d %H:%M:%S"),
63
+ )
64
+
65
+ console.print(table)
66
+
67
+
68
+ @app.command()
69
+ def read(
70
+ entity: str = typer.Argument(..., help="Entity path"),
71
+ key: str = typer.Argument(..., help="Key name"),
72
+ config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
73
+ format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
74
+ ) -> None:
75
+ """Read a specific memory entry and print its value."""
76
+ cfg = load_config_or_default(config)
77
+ adapter = create_adapter_from_config(cfg)
78
+ entry = adapter.read(entity, key)
79
+
80
+ if entry is None:
81
+ console.print(f"[red]Entry not found: {entity}/{key}[/red]")
82
+ raise typer.Exit(code=1)
83
+
84
+ if format == "json":
85
+ console.print_json(json.dumps({
86
+ "entity_path": entry.entity_path, "key": entry.key,
87
+ "value": entry.value, "version": entry.version,
88
+ "confidence": entry.confidence,
89
+ "agent_id": entry.provenance.agent_id,
90
+ "written_at": entry.provenance.written_at.isoformat(),
91
+ }, default=str))
92
+ return
93
+
94
+ console.print(f"[bold]{entity}/{key}[/bold] v{entry.version}")
95
+ console.print(f"Confidence: {entry.confidence:.4f}")
96
+ console.print(f"Agent: {entry.provenance.agent_id} ({entry.provenance.session_id})")
97
+ console.print(f"Written: {entry.provenance.written_at}")
98
+ if entry.ttl_at:
99
+ console.print(f"TTL: {entry.ttl_at}")
100
+ console.print()
101
+ console.print_json(json.dumps(entry.value, default=str))
102
+
103
+
104
+ @app.command()
105
+ def diff(
106
+ entity: str = typer.Argument(..., help="Entity path"),
107
+ key: str = typer.Argument(..., help="Key name"),
108
+ config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
109
+ ) -> None:
110
+ """Show version history diff for a key."""
111
+ cfg = load_config_or_default(config)
112
+ adapter = create_adapter_from_config(cfg)
113
+
114
+ with console.status("[cyan]Loading history...[/cyan]"):
115
+ entries = adapter.list(entity, include_superseded=True)
116
+
117
+ key_entries = sorted(
118
+ [e for e in entries if e.key == key],
119
+ key=lambda e: e.version,
120
+ )
121
+
122
+ if not key_entries:
123
+ console.print(f"[red]No entries found for {entity}/{key}[/red]")
124
+ raise typer.Exit(code=1)
125
+
126
+ for i, entry in enumerate(key_entries):
127
+ console.print(f"[bold]v{entry.version}[/bold] (confidence: {entry.confidence:.4f})")
128
+ console.print(f" Agent: {entry.provenance.agent_id}")
129
+ console.print(f" Written: {entry.provenance.written_at}")
130
+
131
+ if i > 0:
132
+ prev_val = json.dumps(key_entries[i - 1].value, sort_keys=True, default=str)
133
+ curr_val = json.dumps(entry.value, sort_keys=True, default=str)
134
+ if prev_val != curr_val:
135
+ console.print(f" [red]- {prev_val}[/red]")
136
+ console.print(f" [green]+ {curr_val}[/green]")
137
+ else:
138
+ console.print(" [dim](value unchanged)[/dim]")
139
+ console.print()
@@ -0,0 +1,43 @@
1
+ """amfs login — store credentials for remote AMFS access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from amfs_cli.remote import save_credentials, get_client
10
+
11
+ console = Console()
12
+
13
+
14
+ def login_command(
15
+ url: str = typer.Option(..., "--url", "-u", prompt="AMFS server URL", help="AMFS HTTP API URL"),
16
+ api_key: str = typer.Option(..., "--key", "-k", prompt="API key", hide_input=True, help="AMFS API key"),
17
+ ) -> None:
18
+ """Store AMFS credentials for remote CLI access."""
19
+ with console.status("[cyan]Verifying connection...[/cyan]"):
20
+ try:
21
+ client = get_client.__wrapped__ if hasattr(get_client, "__wrapped__") else None # noqa: F841
22
+ import httpx
23
+ with httpx.Client(
24
+ base_url=url.rstrip("/"),
25
+ headers={"X-AMFS-API-Key": api_key},
26
+ timeout=10.0,
27
+ ) as client:
28
+ resp = client.get("/api/v1/health")
29
+ resp.raise_for_status()
30
+ except Exception as exc:
31
+ console.print(f"[red]Connection failed:[/red] {exc}")
32
+ raise typer.Exit(code=1)
33
+
34
+ path = save_credentials(url, api_key)
35
+ console.print(
36
+ Panel(
37
+ f"[green]Credentials saved to[/green] {path}\n\n"
38
+ f" Server: [cyan]{url}[/cyan]\n"
39
+ f" Key: [dim]{api_key[:12]}{'•' * 20}[/dim]",
40
+ title="[bold green]Login successful[/bold green]",
41
+ border_style="green",
42
+ )
43
+ )
@@ -0,0 +1,55 @@
1
+ """AMFS CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from amfs_cli.init import init_command
8
+ from amfs_cli.login import login_command
9
+ from amfs_cli.recall import recall_command
10
+ from amfs_cli.search import search_command
11
+ from amfs_cli.snapshot import app as snapshot_app
12
+ from amfs_cli.inspect import app as inspect_app
13
+ from amfs_cli.stats import stats_command
14
+ from amfs_cli.status import status_command
15
+ from amfs_cli.watch import watch_command
16
+ from amfs_cli.write import write_command
17
+
18
+ app = typer.Typer(
19
+ name="amfs",
20
+ help="AMFS — Agent Memory File System CLI",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ app.command(name="init", help="Initialise an AMFS project")(init_command)
25
+ app.command(name="login", help="Store credentials for remote access")(login_command)
26
+ app.command(name="write", help="Write a memory entry")(write_command)
27
+ app.command(name="search", help="Search memory entries")(search_command)
28
+ app.command(name="recall", help="Recall an agent-scoped entry")(recall_command)
29
+ app.command(name="stats", help="Show memory statistics")(stats_command)
30
+ app.command(name="status", help="Show config and connection health")(status_command)
31
+ app.command(name="watch", help="Live-stream memory changes (SSE)")(watch_command)
32
+ app.add_typer(snapshot_app, name="snapshot", help="Export and restore memory snapshots")
33
+ app.add_typer(inspect_app, name="inspect", help="List, read, and diff memory entries")
34
+
35
+
36
+ def _version_callback(value: bool) -> None:
37
+ if value:
38
+ from importlib.metadata import version
39
+
40
+ typer.echo(f"amfs-cli {version('amfs-cli')}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback()
45
+ def main(
46
+ version: bool = typer.Option(
47
+ False, "--version", "-V", callback=_version_callback,
48
+ is_eager=True, help="Show version and exit",
49
+ ),
50
+ ) -> None:
51
+ """AMFS — Agent Memory File System CLI."""
52
+
53
+
54
+ if __name__ == "__main__":
55
+ app()
@@ -0,0 +1,74 @@
1
+ """amfs recall — recall agent-scoped memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+
11
+ console = Console()
12
+
13
+
14
+ def recall_command(
15
+ entity: str = typer.Argument(..., help="Entity path"),
16
+ key: str = typer.Argument(..., help="Memory key"),
17
+ agent_id: str = typer.Option("cli", "--agent", "-a", help="Agent ID to recall as"),
18
+ format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
19
+ remote: bool = typer.Option(False, "--remote", "-r", help="Recall via HTTP API"),
20
+ ) -> None:
21
+ """Recall an agent-scoped memory entry."""
22
+ if remote:
23
+ from amfs_cli.remote import api_get
24
+
25
+ with console.status("[cyan]Recalling...[/cyan]"):
26
+ result = api_get(
27
+ f"/api/v1/entries/{entity}/{key}",
28
+ params={"agent_id": agent_id},
29
+ )
30
+
31
+ if format == "json":
32
+ console.print_json(json.dumps(result, default=str))
33
+ return
34
+
35
+ if not result:
36
+ console.print(f"[dim]No entry found for {entity}/{key}[/dim]")
37
+ return
38
+
39
+ console.print(Panel(
40
+ f"[bold]{entity}/{key}[/bold] v{result.get('version', '?')}\n"
41
+ f"Confidence: [green]{result.get('confidence', 0):.4f}[/green]\n"
42
+ f"Agent: [magenta]{result.get('agent_id', '')}[/magenta]\n\n"
43
+ f"{json.dumps(result.get('value'), indent=2, default=str)}",
44
+ title="[bold cyan]Recall[/bold cyan]",
45
+ border_style="cyan",
46
+ ))
47
+ else:
48
+ from amfs import AgentMemory
49
+
50
+ with console.status("[cyan]Recalling...[/cyan]"):
51
+ mem = AgentMemory(agent_id=agent_id)
52
+ entry = mem.recall(entity, key)
53
+
54
+ if entry is None:
55
+ console.print(f"[dim]No entry found for {entity}/{key}[/dim]")
56
+ raise typer.Exit(code=1)
57
+
58
+ if format == "json":
59
+ console.print_json(json.dumps({
60
+ "entity_path": entry.entity_path, "key": entry.key,
61
+ "value": entry.value, "version": entry.version,
62
+ "confidence": entry.confidence,
63
+ "agent_id": entry.provenance.agent_id,
64
+ }, default=str))
65
+ return
66
+
67
+ console.print(Panel(
68
+ f"[bold]{entity}/{key}[/bold] v{entry.version}\n"
69
+ f"Confidence: [green]{entry.confidence:.4f}[/green]\n"
70
+ f"Agent: [magenta]{entry.provenance.agent_id}[/magenta]\n\n"
71
+ f"{json.dumps(entry.value, indent=2, default=str)}",
72
+ title="[bold cyan]Recall[/bold cyan]",
73
+ border_style="cyan",
74
+ ))
@@ -0,0 +1,71 @@
1
+ """Shared HTTP client helper for remote AMFS operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ console = Console(stderr=True)
15
+
16
+ _CRED_DIR = Path.home() / ".config" / "amfs"
17
+ _CRED_FILE = _CRED_DIR / "credentials.json"
18
+
19
+
20
+ def _load_credentials() -> dict[str, str]:
21
+ if _CRED_FILE.exists():
22
+ return json.loads(_CRED_FILE.read_text(encoding="utf-8"))
23
+ return {}
24
+
25
+
26
+ def save_credentials(url: str, api_key: str) -> Path:
27
+ _CRED_DIR.mkdir(parents=True, exist_ok=True)
28
+ _CRED_FILE.write_text(
29
+ json.dumps({"url": url, "api_key": api_key}, indent=2),
30
+ encoding="utf-8",
31
+ )
32
+ _CRED_FILE.chmod(0o600)
33
+ return _CRED_FILE
34
+
35
+
36
+ def get_client() -> httpx.Client:
37
+ """Build an httpx client from env vars or stored credentials."""
38
+ url = os.environ.get("AMFS_HTTP_URL")
39
+ api_key = os.environ.get("AMFS_API_KEY")
40
+
41
+ if not url:
42
+ creds = _load_credentials()
43
+ url = creds.get("url")
44
+ api_key = api_key or creds.get("api_key")
45
+
46
+ if not url:
47
+ console.print(
48
+ "[red]No AMFS server configured.[/red]\n"
49
+ "Run [bold]amfs login[/bold] or set AMFS_HTTP_URL.",
50
+ )
51
+ sys.exit(1)
52
+
53
+ headers: dict[str, str] = {}
54
+ if api_key:
55
+ headers["X-AMFS-API-Key"] = api_key
56
+
57
+ return httpx.Client(base_url=url.rstrip("/"), headers=headers, timeout=30.0)
58
+
59
+
60
+ def api_get(path: str, params: dict[str, Any] | None = None) -> Any:
61
+ with get_client() as client:
62
+ resp = client.get(path, params=params)
63
+ resp.raise_for_status()
64
+ return resp.json()
65
+
66
+
67
+ def api_post(path: str, payload: dict[str, Any]) -> Any:
68
+ with get_client() as client:
69
+ resp = client.post(path, json=payload)
70
+ resp.raise_for_status()
71
+ return resp.json()
@@ -0,0 +1,103 @@
1
+ """amfs search — search memory entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+ app = typer.Typer(no_args_is_help=False)
14
+
15
+
16
+ def search_command(
17
+ query: str = typer.Argument(None, help="Search query text"),
18
+ entity: str | None = typer.Option(None, "--entity", "-e", help="Filter by entity path"),
19
+ agent_id: str | None = typer.Option(None, "--agent", "-a", help="Filter by agent ID"),
20
+ min_confidence: float = typer.Option(0.0, "--min-confidence", help="Minimum confidence"),
21
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
22
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
23
+ remote: bool = typer.Option(False, "--remote", "-r", help="Search via HTTP API"),
24
+ ) -> None:
25
+ """Search memory entries by query, entity, agent, or confidence."""
26
+ if remote:
27
+ from amfs_cli.remote import api_post
28
+
29
+ with console.status("[cyan]Searching...[/cyan]"):
30
+ results = api_post("/api/v1/search", {
31
+ "query": query,
32
+ "entity_path": entity,
33
+ "agent_id": agent_id,
34
+ "min_confidence": min_confidence,
35
+ "limit": limit,
36
+ })
37
+
38
+ if format == "json":
39
+ console.print_json(json.dumps(results, default=str))
40
+ return
41
+
42
+ if not results:
43
+ console.print("[dim]No results found.[/dim]")
44
+ return
45
+
46
+ table = Table(title=f"Search Results ({len(results)} entries)")
47
+ table.add_column("Entity", style="cyan")
48
+ table.add_column("Key", style="green")
49
+ table.add_column("Confidence", justify="right")
50
+ table.add_column("Agent", style="magenta")
51
+ table.add_column("Value", max_width=50)
52
+
53
+ for r in results:
54
+ table.add_row(
55
+ r.get("entity_path", ""),
56
+ r.get("key", ""),
57
+ f"{r.get('confidence', 0):.4f}",
58
+ r.get("agent_id", ""),
59
+ str(r.get("value", ""))[:50],
60
+ )
61
+ console.print(table)
62
+ else:
63
+ from amfs import AgentMemory
64
+
65
+ with console.status("[cyan]Searching...[/cyan]"):
66
+ mem = AgentMemory(agent_id="cli")
67
+ results = mem.search(
68
+ query=query,
69
+ entity_path=entity,
70
+ agent_id=agent_id,
71
+ min_confidence=min_confidence,
72
+ limit=limit,
73
+ )
74
+
75
+ if format == "json":
76
+ console.print_json(json.dumps(
77
+ [{"entity_path": e.entity_path, "key": e.key, "value": e.value,
78
+ "confidence": e.confidence, "agent_id": e.provenance.agent_id}
79
+ for e in results],
80
+ default=str,
81
+ ))
82
+ return
83
+
84
+ if not results:
85
+ console.print("[dim]No results found.[/dim]")
86
+ return
87
+
88
+ table = Table(title=f"Search Results ({len(results)} entries)")
89
+ table.add_column("Entity", style="cyan")
90
+ table.add_column("Key", style="green")
91
+ table.add_column("Confidence", justify="right")
92
+ table.add_column("Agent", style="magenta")
93
+ table.add_column("Value", max_width=50)
94
+
95
+ for e in results:
96
+ table.add_row(
97
+ e.entity_path,
98
+ e.key,
99
+ f"{e.confidence:.4f}",
100
+ e.provenance.agent_id,
101
+ str(e.value)[:50],
102
+ )
103
+ console.print(table)
@@ -0,0 +1,62 @@
1
+ """Snapshot CLI commands — export and restore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
10
+
11
+ from amfs.config import load_config_or_default
12
+ from amfs.factory import create_adapter_from_config
13
+ from amfs_core.snapshot import SnapshotExporter, SnapshotImporter
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def export(
21
+ output: Path = typer.Argument(..., help="Output snapshot file path"),
22
+ config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
23
+ entity: str | None = typer.Option(None, "--entity", "-e", help="Filter to entity path"),
24
+ include_superseded: bool = typer.Option(False, "--superseded", help="Include superseded versions"),
25
+ ) -> None:
26
+ """Export current memory state to a JSON snapshot."""
27
+ cfg = load_config_or_default(config)
28
+ adapter = create_adapter_from_config(cfg)
29
+ with Progress(
30
+ SpinnerColumn(),
31
+ TextColumn("[progress.description]{task.description}"),
32
+ BarColumn(),
33
+ TextColumn("{task.completed} entries"),
34
+ console=console,
35
+ ) as progress:
36
+ task = progress.add_task("Exporting...", total=None)
37
+ exporter = SnapshotExporter(adapter)
38
+ count = exporter.export(output, entity_path=entity, include_superseded=include_superseded)
39
+ progress.update(task, completed=count, total=count)
40
+ console.print(f"[green]Exported {count} entries to {output}[/green]")
41
+
42
+
43
+ @app.command()
44
+ def restore(
45
+ input_path: Path = typer.Argument(..., help="Snapshot file to restore"),
46
+ config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
47
+ ) -> None:
48
+ """Restore memory entries from a snapshot file."""
49
+ cfg = load_config_or_default(config)
50
+ adapter = create_adapter_from_config(cfg)
51
+ with Progress(
52
+ SpinnerColumn(),
53
+ TextColumn("[progress.description]{task.description}"),
54
+ BarColumn(),
55
+ TextColumn("{task.completed} entries"),
56
+ console=console,
57
+ ) as progress:
58
+ task = progress.add_task("Restoring...", total=None)
59
+ importer = SnapshotImporter(adapter)
60
+ count = importer.restore(input_path)
61
+ progress.update(task, completed=count, total=count)
62
+ console.print(f"[green]Restored {count} entries from {input_path}[/green]")
@@ -0,0 +1,58 @@
1
+ """amfs stats — display memory statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+
14
+
15
+ def stats_command(
16
+ format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
17
+ remote: bool = typer.Option(False, "--remote", "-r", help="Fetch via HTTP API"),
18
+ ) -> None:
19
+ """Show memory statistics (entries, agents, entities)."""
20
+ if remote:
21
+ from amfs_cli.remote import api_get
22
+
23
+ with console.status("[cyan]Fetching stats...[/cyan]"):
24
+ data = api_get("/api/v1/stats")
25
+ else:
26
+ from amfs import AgentMemory
27
+
28
+ with console.status("[cyan]Computing stats...[/cyan]"):
29
+ mem = AgentMemory(agent_id="cli")
30
+ s = mem.stats()
31
+ data = {
32
+ "total_entries": s.total_entries,
33
+ "total_entities": s.total_entities,
34
+ "total_agents": s.total_agents,
35
+ "avg_confidence": round(s.avg_confidence, 4) if s.avg_confidence else 0,
36
+ "memory_types": s.memory_types if hasattr(s, "memory_types") else {},
37
+ }
38
+
39
+ if format == "json":
40
+ console.print_json(json.dumps(data, default=str))
41
+ return
42
+
43
+ table = Table(show_header=False, box=None, padding=(0, 2))
44
+ table.add_column("Metric", style="bold cyan")
45
+ table.add_column("Value", justify="right", style="green")
46
+
47
+ table.add_row("Total Entries", str(data.get("total_entries", 0)))
48
+ table.add_row("Total Entities", str(data.get("total_entities", 0)))
49
+ table.add_row("Total Agents", str(data.get("total_agents", 0)))
50
+ table.add_row("Avg Confidence", f"{data.get('avg_confidence', 0):.4f}")
51
+
52
+ types = data.get("memory_types", {})
53
+ if types:
54
+ table.add_row("", "")
55
+ for mt, count in types.items():
56
+ table.add_row(f" {mt}", str(count))
57
+
58
+ console.print(Panel(table, title="[bold]Memory Statistics[/bold]", border_style="cyan"))
@@ -0,0 +1,68 @@
1
+ """amfs status — show configuration and connection health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ def status_command() -> None:
17
+ """Show current AMFS configuration, adapter, and connection health."""
18
+ table = Table(show_header=False, box=None, padding=(0, 2))
19
+ table.add_column("Setting", style="bold")
20
+ table.add_column("Value")
21
+
22
+ config_path = Path("amfs.yaml")
23
+ table.add_row("Config file", str(config_path) if config_path.exists() else "[dim]not found[/dim]")
24
+
25
+ http_url = os.environ.get("AMFS_HTTP_URL")
26
+ api_key = os.environ.get("AMFS_API_KEY")
27
+ pg_dsn = os.environ.get("AMFS_POSTGRES_DSN")
28
+ data_dir = os.environ.get("AMFS_DATA_DIR")
29
+
30
+ cred_file = Path.home() / ".config" / "amfs" / "credentials.json"
31
+ if cred_file.exists():
32
+ import json
33
+ creds = json.loads(cred_file.read_text(encoding="utf-8"))
34
+ if not http_url:
35
+ http_url = creds.get("url")
36
+ if not api_key:
37
+ api_key = creds.get("api_key")
38
+
39
+ if http_url:
40
+ table.add_row("Mode", "[cyan]Remote (HTTP API)[/cyan]")
41
+ table.add_row("Server URL", f"[green]{http_url}[/green]")
42
+ table.add_row("API Key", f"[dim]{api_key[:12]}{'•' * 20}[/dim]" if api_key else "[yellow]not set[/yellow]")
43
+ elif pg_dsn:
44
+ table.add_row("Mode", "[cyan]Postgres[/cyan]")
45
+ host = pg_dsn.split("@")[-1].split("/")[0] if "@" in pg_dsn else pg_dsn[:40]
46
+ table.add_row("DSN", f"[dim]{host}[/dim]")
47
+ elif data_dir:
48
+ table.add_row("Mode", "[cyan]Filesystem[/cyan]")
49
+ table.add_row("Data dir", data_dir)
50
+ elif config_path.exists():
51
+ table.add_row("Mode", "[cyan]Config file[/cyan]")
52
+ else:
53
+ table.add_row("Mode", "[yellow]Default (filesystem .amfs/)[/yellow]")
54
+
55
+ if http_url:
56
+ with console.status("[cyan]Checking connection...[/cyan]"):
57
+ try:
58
+ import httpx
59
+ headers = {"X-AMFS-API-Key": api_key} if api_key else {}
60
+ resp = httpx.get(f"{http_url.rstrip('/')}/api/v1/health", headers=headers, timeout=5.0)
61
+ if resp.status_code == 200:
62
+ table.add_row("Connection", "[green]Healthy[/green]")
63
+ else:
64
+ table.add_row("Connection", f"[yellow]HTTP {resp.status_code}[/yellow]")
65
+ except Exception as exc:
66
+ table.add_row("Connection", f"[red]Failed: {exc}[/red]")
67
+
68
+ console.print(Panel(table, title="[bold]AMFS Status[/bold]", border_style="cyan"))
@@ -0,0 +1,74 @@
1
+ """amfs watch — live-stream memory changes via SSE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ def watch_command(
17
+ entity: str | None = typer.Argument(None, help="Filter to entity path (default: all)"),
18
+ limit: int = typer.Option(20, "--limit", "-l", help="Max rows to display"),
19
+ ) -> None:
20
+ """Live-stream memory changes from a remote AMFS server (SSE)."""
21
+ from amfs_cli.remote import get_client
22
+
23
+ client = get_client()
24
+ params = {"entity_path": entity or "*"}
25
+
26
+ events: list[dict] = []
27
+
28
+ def _build_table() -> Table:
29
+ table = Table(title="Live Memory Stream", expand=True)
30
+ table.add_column("Entity", style="cyan", max_width=30)
31
+ table.add_column("Key", style="green", max_width=20)
32
+ table.add_column("Agent", style="magenta", max_width=15)
33
+ table.add_column("Version", justify="right")
34
+ table.add_column("Value", max_width=40)
35
+ for ev in events[-limit:]:
36
+ table.add_row(
37
+ ev.get("entity_path", ""),
38
+ ev.get("key", ""),
39
+ ev.get("agent_id", ""),
40
+ str(ev.get("version", "")),
41
+ str(ev.get("value", ""))[:40],
42
+ )
43
+ return table
44
+
45
+ console.print(f"[cyan]Connecting to SSE stream...[/cyan] (entity: {entity or '*'})")
46
+ console.print("[dim]Press Ctrl-C to stop[/dim]\n")
47
+
48
+ try:
49
+ with Live(_build_table(), console=console, refresh_per_second=2) as live:
50
+ with client.stream(
51
+ "GET", "/api/v1/stream", params=params, timeout=None
52
+ ) as resp:
53
+ resp.raise_for_status()
54
+ buf = ""
55
+ for chunk in resp.iter_text():
56
+ buf += chunk
57
+ while "\n\n" in buf:
58
+ msg, buf = buf.split("\n\n", 1)
59
+ for line in msg.split("\n"):
60
+ if line.startswith("data:"):
61
+ raw = line[5:].strip()
62
+ if raw:
63
+ try:
64
+ events.append(json.loads(raw))
65
+ live.update(_build_table())
66
+ except json.JSONDecodeError:
67
+ pass
68
+ except KeyboardInterrupt:
69
+ console.print(f"\n[dim]Stopped. Received {len(events)} events.[/dim]")
70
+ except httpx.HTTPStatusError as exc:
71
+ console.print(f"[red]Stream error:[/red] {exc.response.status_code}")
72
+ raise typer.Exit(code=1)
73
+ finally:
74
+ client.close()
@@ -0,0 +1,54 @@
1
+ """amfs write — write a memory entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ def write_command(
15
+ entity: str = typer.Argument(..., help="Entity path (e.g. myapp/auth)"),
16
+ key: str = typer.Argument(..., help="Memory key"),
17
+ value: str = typer.Argument(
18
+ None, help="JSON value (reads from stdin if omitted)"
19
+ ),
20
+ agent_id: str = typer.Option("cli", "--agent", "-a", help="Agent ID"),
21
+ confidence: float = typer.Option(1.0, "--confidence", "-c", help="Confidence score"),
22
+ memory_type: str = typer.Option("fact", "--type", "-t", help="Memory type: fact, belief, experience"),
23
+ remote: bool = typer.Option(False, "--remote", "-r", help="Write via HTTP API instead of local adapter"),
24
+ ) -> None:
25
+ """Write a memory entry to AMFS."""
26
+ if value is None:
27
+ if sys.stdin.isatty():
28
+ console.print("[dim]Enter JSON value (Ctrl-D to finish):[/dim]")
29
+ value = sys.stdin.read().strip()
30
+
31
+ try:
32
+ parsed = json.loads(value)
33
+ except json.JSONDecodeError:
34
+ parsed = value
35
+
36
+ if remote:
37
+ from amfs_cli.remote import api_post
38
+
39
+ with console.status("[cyan]Writing entry...[/cyan]"):
40
+ result = api_post("/api/v1/entries", {
41
+ "entity_path": entity,
42
+ "key": key,
43
+ "value": parsed,
44
+ "confidence": confidence,
45
+ "memory_type": memory_type,
46
+ })
47
+ console.print(f"[green]Written[/green] {entity}/{key} v{result.get('version', '?')}")
48
+ else:
49
+ from amfs import AgentMemory
50
+
51
+ with console.status("[cyan]Writing entry...[/cyan]"):
52
+ mem = AgentMemory(agent_id=agent_id)
53
+ entry = mem.write(entity, key, parsed, confidence=confidence, memory_type=memory_type)
54
+ console.print(f"[green]Written[/green] {entity}/{key} v{entry.version}")