auto-context-cli 1.0.0__py3-none-any.whl

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,304 @@
1
+ """
2
+ commands/mcp.py — MCP tool registry management.
3
+
4
+ Subcommands: add, remove, list
5
+ Uses native json.load / json.dump — no shell JSON manipulation.
6
+
7
+ Presets: sqlite, duckdb, git, fetch, time, postgres, github, filesystem, brave
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import stat
14
+ from pathlib import Path
15
+ from typing import Annotated, Optional
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from auto_context import config
22
+ from auto_context.config import AGENTS_SUBDIR, GEMINI_SUBDIR
23
+
24
+ app = typer.Typer(help="MCP tool registry management.")
25
+ console = Console()
26
+
27
+ # ── Preset definitions ────────────────────────────────────────────────────────
28
+ # Each preset is a callable that receives (runtime, args, env_var) and returns
29
+ # a dict that becomes the server config value in mcp_config.json.
30
+
31
+ _PRESETS: dict[str, dict] = {
32
+ "sqlite": {
33
+ "uvx": lambda db: {"command": "uvx", "args": ["mcp-server-sqlite", "--db-path", db]},
34
+ "npx": lambda db: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-sqlite", db]},
35
+ "default_args": "database.db",
36
+ "env_var": None,
37
+ },
38
+ "duckdb": {
39
+ "uvx": lambda db: {"command": "uvx", "args": ["duckdb-mcp", "--db-path", db]},
40
+ "npx": lambda db: {"command": "uvx", "args": ["duckdb-mcp", "--db-path", db]}, # always uvx
41
+ "default_args": "database.duckdb",
42
+ "env_var": None,
43
+ "force_uvx": True,
44
+ },
45
+ "git": {
46
+ "uvx": lambda repo: {"command": "uvx", "args": ["mcp-server-git", "--repository", repo]},
47
+ "npx": lambda repo: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-git", repo]},
48
+ "default_args": ".",
49
+ "env_var": None,
50
+ },
51
+ "fetch": {
52
+ "uvx": lambda _: {"command": "uvx", "args": ["mcp-server-fetch"]},
53
+ "npx": lambda _: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"]},
54
+ "env_var": None,
55
+ },
56
+ "time": {
57
+ "uvx": lambda _: {"command": "uvx", "args": ["mcp-server-time"]},
58
+ "npx": lambda _: {"command": "uvx", "args": ["mcp-server-time"]}, # always uvx
59
+ "env_var": None,
60
+ "force_uvx": True,
61
+ },
62
+ "postgres": {
63
+ "uvx": lambda conn: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres", conn]},
64
+ "npx": lambda conn: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres", conn]},
65
+ "default_env_var": "DATABASE_URL",
66
+ "env_placeholder": "postgresql://username:password@localhost:5432/mydb",
67
+ },
68
+ "github": {
69
+ "uvx": lambda _: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
70
+ "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"}},
71
+ "npx": lambda _: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
72
+ "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"}},
73
+ "default_env_var": "GITHUB_PERSONAL_ACCESS_TOKEN",
74
+ "env_placeholder": "ghp_yourPersonalAccessTokenHere",
75
+ },
76
+ "filesystem": {
77
+ "uvx": lambda d: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", d]},
78
+ "npx": lambda d: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", d]},
79
+ "default_args": ".",
80
+ "env_var": None,
81
+ },
82
+ "brave": {
83
+ "uvx": lambda _: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"],
84
+ "env": {"BRAVE_API_KEY": "${BRAVE_API_KEY}"}},
85
+ "npx": lambda _: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"],
86
+ "env": {"BRAVE_API_KEY": "${BRAVE_API_KEY}"}},
87
+ "default_env_var": "BRAVE_API_KEY",
88
+ "env_placeholder": "your_brave_api_key_here",
89
+ },
90
+ }
91
+
92
+
93
+ # ── .env helpers ──────────────────────────────────────────────────────────────
94
+
95
+ def _write_env_placeholder(project_dir: Path, var_name: str, placeholder: str) -> None:
96
+ """Write a placeholder to .env.example and protect .env in .gitignore."""
97
+ example = project_dir / ".env.example"
98
+ existing = example.read_text(encoding="utf-8") if example.exists() else ""
99
+ if f"{var_name}=" not in existing:
100
+ with example.open("a", encoding="utf-8") as fh:
101
+ fh.write(f"{var_name}={placeholder}\n")
102
+ console.print(f" [green][✓] Added placeholder:[/] .env.example ({var_name})")
103
+
104
+ # Secure .env with chmod 600 equivalent if it exists
105
+ dot_env = project_dir / ".env"
106
+ if dot_env.exists():
107
+ try:
108
+ dot_env.chmod(stat.S_IRUSR | stat.S_IWUSR)
109
+ except OSError:
110
+ pass # Windows may not support this; best-effort
111
+
112
+
113
+ def _detect_runtime(project_dir: Path) -> str:
114
+ """Auto-detect uvx vs npx based on project indicators."""
115
+ import shutil
116
+ py_indicators = ("pyproject.toml", "requirements.txt", "Pipfile")
117
+ has_python = any((project_dir / f).exists() for f in py_indicators)
118
+ if has_python and shutil.which("uvx"):
119
+ return "uvx"
120
+ return "npx"
121
+
122
+
123
+ # ── JSON config helpers ───────────────────────────────────────────────────────
124
+
125
+ def _load_mcp_config(path: Path) -> dict:
126
+ """Load mcp_config.json, returning an empty mcpServers dict on failure."""
127
+ if path.exists():
128
+ try:
129
+ data = json.loads(path.read_text(encoding="utf-8"))
130
+ if isinstance(data, dict):
131
+ return data
132
+ except json.JSONDecodeError:
133
+ pass
134
+ return {"mcpServers": {}}
135
+
136
+
137
+ def _save_mcp_config(path: Path, data: dict) -> None:
138
+ """Write mcp_config.json with 2-space indent."""
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
141
+
142
+
143
+ def _mcp_paths(project_dir: Path, global_: bool) -> list[Path]:
144
+ """Return the list of mcp_config.json paths to write."""
145
+ if global_:
146
+ return [
147
+ config.GLOBAL_GEMINI_DIR / "mcp_config.json",
148
+ config.GLOBAL_PLUGIN_DIR / "mcp_config.json",
149
+ ]
150
+ return [
151
+ project_dir / GEMINI_SUBDIR / "mcp_config.json",
152
+ project_dir / AGENTS_SUBDIR / "mcp_config.json",
153
+ ]
154
+
155
+
156
+ # ── add subcommand ────────────────────────────────────────────────────────────
157
+
158
+ @app.command("add")
159
+ def add_mcp(
160
+ name: Annotated[str, typer.Argument(help="Server name or preset (sqlite, github, …).")],
161
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
162
+ global_: Annotated[bool, typer.Option("--global", "-g")] = False,
163
+ preset: Annotated[Optional[str], typer.Option("--preset", "-p", help="Preset name.")] = None,
164
+ runtime: Annotated[Optional[str], typer.Option("--runtime", "--runner")] = None,
165
+ command: Annotated[Optional[str], typer.Option("--command", "-c")] = None,
166
+ args: Annotated[Optional[str], typer.Option("--args", "-a")] = None,
167
+ url: Annotated[Optional[str], typer.Option("--url")] = None,
168
+ env_var: Annotated[Optional[str], typer.Option("--env-var")] = None,
169
+ env: Annotated[Optional[str], typer.Option("--env", help="KEY=val,KEY2=val2")] = None,
170
+ ) -> None:
171
+ """Add or update an MCP server configuration."""
172
+ project_dir = (target or Path.cwd()).resolve()
173
+ server_name = name.lower()
174
+ chosen_preset = preset or (name.lower() if name.lower() in _PRESETS else None)
175
+ runner = runtime or _detect_runtime(project_dir)
176
+
177
+ server_cfg: dict = {}
178
+
179
+ if url:
180
+ server_cfg = {"url": url}
181
+
182
+ elif chosen_preset and chosen_preset in _PRESETS:
183
+ p = _PRESETS[chosen_preset]
184
+ force_uvx = p.get("force_uvx", False)
185
+ r = "uvx" if force_uvx else runner
186
+ arg_val = args or p.get("default_args", ".")
187
+ fn = p[r] if r in p else p["npx"]
188
+ server_cfg = fn(arg_val)
189
+
190
+ # Handle secret env vars
191
+ default_ev = p.get("default_env_var")
192
+ if default_ev:
193
+ ev = env_var or default_ev
194
+ ph = p.get("env_placeholder", f"your_{ev}_here")
195
+ if not global_:
196
+ _write_env_placeholder(project_dir, ev, ph)
197
+ console.print(f" [bold green][🔒] Secret safety:[/] {ev} referenced via env var (not hardcoded).")
198
+
199
+ elif command:
200
+ server_cfg = {"command": command}
201
+ if args:
202
+ server_cfg["args"] = args.split()
203
+ if env_var:
204
+ server_cfg.setdefault("env", {})[env_var] = f"${{{env_var}}}"
205
+ if not global_:
206
+ _write_env_placeholder(project_dir, env_var, f"your_{env_var}_here")
207
+
208
+ else:
209
+ # Generic: derive package name from server_name
210
+ if runner == "uvx":
211
+ server_cfg = {"command": "uvx", "args": [f"mcp-server-{server_name}"]}
212
+ else:
213
+ server_cfg = {"command": "npx", "args": ["-y", f"@modelcontextprotocol/server-{server_name}"]}
214
+
215
+ # Merge explicit --env pairs
216
+ if env:
217
+ extra = {}
218
+ for item in env.split(","):
219
+ if "=" in item:
220
+ k, v = item.split("=", 1)
221
+ extra[k.strip()] = v.strip()
222
+ server_cfg.setdefault("env", {}).update(extra)
223
+
224
+ # Write to all target config files
225
+ for cfg_path in _mcp_paths(project_dir, global_):
226
+ data = _load_mcp_config(cfg_path)
227
+ data["mcpServers"][server_name] = server_cfg
228
+ _save_mcp_config(cfg_path, data)
229
+
230
+ scope = f"GLOBAL (~/.gemini/config/mcp_config.json)" if global_ else f"LOCAL ({project_dir}/.gemini/mcp_config.json)"
231
+ transport = f"HTTP ({url})" if url else f"stdio ({server_cfg.get('command', '?')})"
232
+ console.print(f"\n[bold cyan]🔌 MCP Server Configured![/]")
233
+ console.print(f" [yellow]Server:[/] {server_name}")
234
+ console.print(f" [cyan]Scope:[/] {scope}")
235
+ console.print(f" [green]Transport:[/] {transport}")
236
+ console.print(" [dim][i] Restart your AI client to activate new MCP tools.[/]\n")
237
+
238
+
239
+ # ── remove subcommand ─────────────────────────────────────────────────────────
240
+
241
+ @app.command("remove")
242
+ def remove_mcp(
243
+ name: Annotated[str, typer.Argument(help="Server name to remove.")],
244
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
245
+ global_: Annotated[bool, typer.Option("--global", "-g")] = False,
246
+ ) -> None:
247
+ """Remove an MCP server from configuration."""
248
+ project_dir = (target or Path.cwd()).resolve()
249
+ removed_from: list[Path] = []
250
+
251
+ for cfg_path in _mcp_paths(project_dir, global_):
252
+ if not cfg_path.exists():
253
+ continue
254
+ data = _load_mcp_config(cfg_path)
255
+ if name in data.get("mcpServers", {}):
256
+ del data["mcpServers"][name]
257
+ _save_mcp_config(cfg_path, data)
258
+ removed_from.append(cfg_path)
259
+
260
+ if removed_from:
261
+ console.print(f"[green][✓] Removed MCP server:[/] {name}")
262
+ for p in removed_from:
263
+ console.print(f" [dim]{p}[/]")
264
+ else:
265
+ console.print(f"[yellow][!] Server '{name}' not found in any MCP config.[/]")
266
+
267
+
268
+ # ── list subcommand ───────────────────────────────────────────────────────────
269
+
270
+ @app.command("list")
271
+ def list_mcp(
272
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
273
+ global_: Annotated[bool, typer.Option("--global", "-g")] = False,
274
+ ) -> None:
275
+ """List all registered MCP servers."""
276
+ project_dir = (target or Path.cwd()).resolve()
277
+
278
+ for cfg_path in _mcp_paths(project_dir, global_):
279
+ if not cfg_path.exists():
280
+ continue
281
+ data = _load_mcp_config(cfg_path)
282
+ servers = data.get("mcpServers", {})
283
+ if not servers:
284
+ continue
285
+
286
+ table = Table(title=f"MCP Servers — {cfg_path}", border_style="cyan")
287
+ table.add_column("Name", style="yellow", no_wrap=True)
288
+ table.add_column("Type", style="magenta", width=6)
289
+ table.add_column("Endpoint / Command", style="white")
290
+
291
+ for sname, cfg in servers.items():
292
+ if "url" in cfg:
293
+ table.add_row(sname, "http", cfg["url"])
294
+ else:
295
+ cmd_str = cfg.get("command", "?") + " " + " ".join(cfg.get("args", []))
296
+ if len(cmd_str) > 60:
297
+ cmd_str = cmd_str[:57] + "..."
298
+ table.add_row(sname, "stdio", cmd_str)
299
+
300
+ console.print(table)
301
+ return
302
+
303
+ console.print("[yellow][!] No MCP configuration found. Run:[/] init-context mcp add <name>")
304
+
@@ -0,0 +1,236 @@
1
+ """
2
+ commands/memory.py — Central memory brain management.
3
+
4
+ Wraps git CLI via subprocess.run. Runs security.audit_path()
5
+ before every commit or push — zero-bypass hard-block.
6
+
7
+ Subcommands: init, push, pull, status
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Annotated, Optional
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ from auto_context import config, security
22
+
23
+ app = typer.Typer(help="Central memory brain management.")
24
+ console = Console()
25
+
26
+ MEMORY_DIR = config.MEMORY_DIR
27
+
28
+ _GITIGNORE_ENTRIES = """\
29
+ # Security: Prohibit credentials and environment files from central memory
30
+ .env
31
+ .env.*
32
+ *.local.md
33
+ context.local.md
34
+ *.key
35
+ *.pem
36
+ *.p12
37
+ *.pfx
38
+ *.kdbx
39
+ id_rsa*
40
+ id_ed25519*
41
+ id_ecdsa*
42
+ id_dsa*
43
+ authorized_keys
44
+ known_hosts
45
+ """
46
+
47
+
48
+ def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
49
+ """Run a git command in *cwd*, capturing output."""
50
+ return subprocess.run(
51
+ ["git", "-C", str(cwd), *args],
52
+ capture_output=True,
53
+ text=True,
54
+ check=check,
55
+ )
56
+
57
+
58
+ def _has_gh() -> bool:
59
+ return shutil.which("gh") is not None
60
+
61
+
62
+ # ── init ──────────────────────────────────────────────────────────────────────
63
+
64
+ @app.command("init")
65
+ def memory_init(
66
+ repo_name: Annotated[str, typer.Argument(help="GitHub repo name.")] = "auto-context-memory",
67
+ ) -> None:
68
+ """Initialize a private central memory brain repository."""
69
+ console.print(Panel(
70
+ f"[bold green]🧠 Initializing Private Central Memory Brain[/]\n"
71
+ f"[yellow] Directory:[/] {MEMORY_DIR}\n"
72
+ f"[cyan] Repo: [/] {repo_name} (Strictly Private)",
73
+ border_style="cyan",
74
+ ))
75
+
76
+ (MEMORY_DIR / "rules").mkdir(parents=True, exist_ok=True)
77
+ (MEMORY_DIR / "skills").mkdir(parents=True, exist_ok=True)
78
+
79
+ # Init git repo
80
+ if not (MEMORY_DIR / ".git").exists():
81
+ _git(MEMORY_DIR, "init")
82
+ console.print(" [green][✓] Initialized Git repository[/]")
83
+
84
+ # Hardened .gitignore
85
+ (MEMORY_DIR / ".gitignore").write_text(_GITIGNORE_ENTRIES, encoding="utf-8")
86
+ console.print(" [green][✓] Hardened .gitignore written[/]")
87
+
88
+ # profile.md
89
+ _write_profile(repo_name)
90
+
91
+ # Security audit before first commit
92
+ console.print(" [cyan][*] Running pre-commit security audit...[/]")
93
+ violations = security.audit_path(MEMORY_DIR)
94
+ security.abort_if_violations(violations)
95
+
96
+ _git(MEMORY_DIR, "add", "-A", check=False)
97
+ _git(MEMORY_DIR, "commit", "-m", config.GIT_COMMIT_INIT, check=False)
98
+ _git(MEMORY_DIR, "branch", "-M", "main", check=False)
99
+
100
+ if _has_gh():
101
+ console.print(" [cyan][*] Creating strictly private GitHub repository via gh...[/]")
102
+ result = subprocess.run(
103
+ ["gh", "repo", "create", repo_name,
104
+ "--private", f"--source={MEMORY_DIR}", "--remote=origin", "--push"],
105
+ capture_output=True, text=True,
106
+ )
107
+ if result.returncode == 0:
108
+ console.print(" [green][✓] Private GitHub memory repository created and pushed![/]")
109
+ else:
110
+ _print_manual_push(repo_name)
111
+ else:
112
+ console.print(" [yellow][!] GitHub CLI ('gh') not found. Install it or connect manually:[/]")
113
+ _print_manual_push(repo_name)
114
+
115
+ console.print(f"\n[bold green]🎉 Central Memory Initialized![/]")
116
+ console.print(f" [yellow]Local Brain:[/] {MEMORY_DIR}")
117
+ console.print(f" [green]Sync Command:[/] init-context memory push\n")
118
+
119
+
120
+ def _print_manual_push(repo_name: str) -> None:
121
+ console.print(f" [dim]git -C \"{MEMORY_DIR}\" remote add origin git@github.com:<username>/{repo_name}.git[/]")
122
+ console.print(f" [dim]git -C \"{MEMORY_DIR}\" push -u origin main[/]")
123
+
124
+
125
+ def _write_profile(repo_name: str) -> None:
126
+ profile = MEMORY_DIR / "profile.md"
127
+ if profile.exists():
128
+ return
129
+ import getpass
130
+ username = getpass.getuser()
131
+ date_now = datetime.now().strftime("%Y-%m-%d")
132
+ profile.write_text(
133
+ f"# Agent Memory & Developer Profile\n\n"
134
+ f"> **Owner:** {username} \n"
135
+ f"> **Initialized:** {date_now} \n"
136
+ f"> **Repository:** {repo_name} \n"
137
+ f"> **Visibility:** Private Central Agent Brain \n\n---\n\n"
138
+ f"## 1. Developer Preferences\n- Edit this file to personalise agent behaviour.\n\n"
139
+ f"## 2. Central Knowledge Base Index\n"
140
+ f"- **Global Rules:** `rules/`\n"
141
+ f"- **Reusable Skills:** `skills/`\n",
142
+ encoding="utf-8",
143
+ )
144
+ console.print(" [green][✓] Generated Developer Profile: profile.md[/]")
145
+
146
+
147
+ # ── push ──────────────────────────────────────────────────────────────────────
148
+
149
+ @app.command("push")
150
+ def memory_push() -> None:
151
+ """Audit secrets and push memory brain to private GitHub repo."""
152
+ console.print(Panel(
153
+ "[bold green]🧠 Synchronizing Central Memory Brain[/]\n"
154
+ f"[yellow] Location:[/] {MEMORY_DIR}",
155
+ border_style="cyan",
156
+ ))
157
+
158
+ if not MEMORY_DIR.exists():
159
+ console.print(f"[red][✗] Memory directory not found:[/] {MEMORY_DIR}")
160
+ console.print(" Run: [cyan]init-context memory init[/]")
161
+ raise typer.Exit(1)
162
+
163
+ console.print(" [cyan][*] Running Zero-Bypass Security Auditor...[/]")
164
+ violations = security.audit_path(MEMORY_DIR)
165
+ security.abort_if_violations(violations)
166
+
167
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
168
+ _git(MEMORY_DIR, "add", "-A", check=False)
169
+ _git(MEMORY_DIR, "commit", "-m", f"{config.GIT_COMMIT_SYNC} [{timestamp}]", check=False)
170
+
171
+ remote = _git(MEMORY_DIR, "remote", "get-url", "origin", check=False).stdout.strip()
172
+ if remote:
173
+ console.print(" [cyan][*] Pushing to private remote repository...[/]")
174
+ result = _git(MEMORY_DIR, "push", "origin", "main", check=False)
175
+ if result.returncode == 0:
176
+ console.print(" [green][✓] Memory brain pushed successfully![/]")
177
+ else:
178
+ console.print(f" [red][✗] Push failed:[/] {result.stderr.strip()}")
179
+ else:
180
+ console.print(" [yellow][!] No remote 'origin' configured. Run memory init first.[/]")
181
+
182
+ console.print("\n[bold green]✨ Memory synchronization complete![/]\n")
183
+
184
+
185
+ # ── pull ──────────────────────────────────────────────────────────────────────
186
+
187
+ @app.command("pull")
188
+ def memory_pull(
189
+ repo_url: Annotated[Optional[str], typer.Argument(help="Remote repo URL (required for first pull).")] = None,
190
+ ) -> None:
191
+ """Pull and sync the central memory brain from remote."""
192
+ console.print(Panel(
193
+ "[bold green]🧠 Pulling Central Memory Brain[/]",
194
+ border_style="cyan",
195
+ ))
196
+
197
+ if not MEMORY_DIR.exists():
198
+ if not repo_url:
199
+ console.print("[red][✗] Memory directory not found. Provide a repo URL:[/]")
200
+ console.print(" [cyan]init-context memory pull <url>[/]")
201
+ raise typer.Exit(1)
202
+ subprocess.run(["git", "clone", repo_url, str(MEMORY_DIR)], check=True)
203
+ else:
204
+ _git(MEMORY_DIR, "pull", "origin", "main")
205
+
206
+ console.print(" [cyan][*] Running post-pull security audit...[/]")
207
+ violations = security.audit_path(MEMORY_DIR)
208
+ security.abort_if_violations(violations)
209
+ console.print("\n[bold green]✨ Central memory updated successfully![/]\n")
210
+
211
+
212
+ # ── status ────────────────────────────────────────────────────────────────────
213
+
214
+ @app.command("status")
215
+ def memory_status() -> None:
216
+ """Show status of the central memory brain."""
217
+ console.print(Panel(
218
+ "[bold green]🧠 Central Memory Status[/]",
219
+ border_style="cyan",
220
+ ))
221
+
222
+ if not MEMORY_DIR.exists():
223
+ console.print(" [yellow][!] Central memory not initialized.[/]")
224
+ console.print(" Run: [cyan]init-context memory init[/]")
225
+ return
226
+
227
+ remote = _git(MEMORY_DIR, "remote", "get-url", "origin", check=False).stdout.strip()
228
+ rules_count = len(list((MEMORY_DIR / "rules").glob("*.md"))) if (MEMORY_DIR / "rules").exists() else 0
229
+ skills_count = len([d for d in (MEMORY_DIR / "skills").iterdir() if d.is_dir()]) if (MEMORY_DIR / "skills").exists() else 0
230
+
231
+ console.print(f" [yellow]Location:[/] {MEMORY_DIR}")
232
+ console.print(f" [cyan]Remote: [/] {remote or '(none)'}")
233
+ console.print(f" [green]Rules: [/] {rules_count} registered")
234
+ console.print(f" [green]Skills: [/] {skills_count} registered")
235
+ console.print()
236
+
@@ -0,0 +1,122 @@
1
+ """
2
+ commands/share.py — Publish a skill or rule via GitHub Gist.
3
+
4
+ Primary: gh gist create (if gh CLI available and authenticated)
5
+ Fallback: httpx + GITHUB_TOKEN env var → POST /gists
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Annotated, Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from auto_context import config, security
19
+
20
+ console = Console()
21
+
22
+
23
+ def _find_item(name: str, project_dir: Path) -> Optional[Path]:
24
+ """Search local, memory, and global scopes for a named skill or rule."""
25
+ clean = name.lower().replace(" ", "-").replace("_", "-").removesuffix(".md")
26
+ candidates = [
27
+ project_dir / ".agents" / "skills" / clean / "SKILL.md",
28
+ project_dir / ".agents" / "rules" / f"{clean}.md",
29
+ config.MEMORY_DIR / "skills" / clean / "SKILL.md",
30
+ config.MEMORY_DIR / "rules" / f"{clean}.md",
31
+ config.GLOBAL_PLUGIN_DIR / "skills" / clean / "SKILL.md",
32
+ config.GLOBAL_PLUGIN_DIR / "rules" / f"{clean}.md",
33
+ ]
34
+ return next((p for p in candidates if p.exists()), None)
35
+
36
+
37
+ def _gh_gist_create(file_path: Path, public: bool, description: str) -> Optional[str]:
38
+ """Create a Gist via gh CLI. Returns the Gist URL or None on failure."""
39
+ if not shutil.which("gh"):
40
+ return None
41
+ cmd = ["gh", "gist", "create", str(file_path), "--desc", description]
42
+ if public:
43
+ cmd.append("--public")
44
+ result = subprocess.run(
45
+ cmd,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ if result.returncode == 0:
50
+ for line in result.stdout.splitlines():
51
+ clean_line = line.strip()
52
+ if "gist.github.com" in clean_line:
53
+ return clean_line
54
+ return result.stdout.strip()
55
+ return None
56
+
57
+
58
+ def _httpx_gist_create(file_path: Path, public: bool, description: str) -> Optional[str]:
59
+ """Fallback: create a Gist via GitHub REST API using GITHUB_TOKEN."""
60
+ token = os.environ.get("GITHUB_TOKEN")
61
+ if not token:
62
+ return None
63
+ try:
64
+ import httpx
65
+ except ImportError:
66
+ return None
67
+
68
+ content = file_path.read_text(encoding="utf-8")
69
+ payload = {
70
+ "description": description,
71
+ "public": public,
72
+ "files": {file_path.name: {"content": content}},
73
+ }
74
+ resp = httpx.post(
75
+ "https://api.github.com/gists",
76
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
77
+ json=payload,
78
+ timeout=30,
79
+ )
80
+ if resp.status_code == 201:
81
+ return resp.json().get("html_url")
82
+ return None
83
+
84
+
85
+ def share(
86
+ name: Annotated[str, typer.Argument(help="Skill or rule name to share.")],
87
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
88
+ public: Annotated[bool, typer.Option("--public", help="Publish as public Gist (default: secret).")] = False,
89
+ ) -> None:
90
+ """Audit and publish a skill or rule via GitHub Gist."""
91
+ project_dir = (target or Path.cwd()).resolve()
92
+ target_file = _find_item(name, project_dir)
93
+
94
+ if not target_file:
95
+ console.print(f"[red][✗] Item '{name}' not found in local, memory, or global scopes.[/]")
96
+ raise typer.Exit(1)
97
+
98
+ clean_name = name.lower().replace(" ", "-").replace("_", "-").removesuffix(".md")
99
+ console.print(f"\n[bold cyan]📤 Publishing:[/] {clean_name}\n [dim]{target_file}[/]")
100
+
101
+ console.print(" [cyan][*] Running pre-share security audit...[/]")
102
+ violations = security.audit_path(target_file, single_file=True)
103
+ security.abort_if_violations(violations)
104
+
105
+ description = f"auto-context: {clean_name}"
106
+ gist_url = _gh_gist_create(target_file, public, description)
107
+ if not gist_url:
108
+ gist_url = _httpx_gist_create(target_file, public, description)
109
+
110
+ if not gist_url:
111
+ console.print(
112
+ "[red][✗] Could not publish Gist.[/]\n"
113
+ " Install [cyan]gh[/] CLI and run [cyan]gh auth login[/], "
114
+ "or set the [cyan]GITHUB_TOKEN[/] environment variable."
115
+ )
116
+ raise typer.Exit(1)
117
+
118
+ console.print(f"\n[bold green]✨ Published successfully![/]")
119
+ console.print(f" [cyan]Gist URL:[/] {gist_url}")
120
+ console.print(f"\n[yellow]💡 Anyone can import this with:[/]")
121
+ console.print(f" [green]init-context import {gist_url}[/]\n")
122
+