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.
- auto_context/__init__.py +3 -0
- auto_context/commands/__init__.py +2 -0
- auto_context/commands/import_cmd.py +144 -0
- auto_context/commands/init_cmd.py +309 -0
- auto_context/commands/mcp.py +304 -0
- auto_context/commands/memory.py +236 -0
- auto_context/commands/share.py +122 -0
- auto_context/config.py +65 -0
- auto_context/main.py +121 -0
- auto_context/security.py +162 -0
- auto_context/templates/AGENTS.template.md +49 -0
- auto_context/templates/GEMINI.template.md +49 -0
- auto_context/templates/context.local.template.md +52 -0
- auto_context/templates/context.template.md +94 -0
- auto_context/templates/maintain_context.template.md +36 -0
- auto_context/templates/mcp_config.template.json +13 -0
- auto_context/templates/profile.template.md +20 -0
- auto_context/templates/rule.template.md +19 -0
- auto_context/templates/rules/code-standards.md +24 -0
- auto_context/templates/rules/security-boundaries.md +21 -0
- auto_context/templates/skill.template.md +39 -0
- auto_context/templates/skills/browser-testing/SKILL.md +59 -0
- auto_context/templates/skills/docker-ops/SKILL.md +79 -0
- auto_context/templates/skills/git-workflow/SKILL.md +59 -0
- auto_context/templates/skills/server-diagnostics/SKILL.md +55 -0
- auto_context_cli-1.0.0.dist-info/METADATA +577 -0
- auto_context_cli-1.0.0.dist-info/RECORD +30 -0
- auto_context_cli-1.0.0.dist-info/WHEEL +4 -0
- auto_context_cli-1.0.0.dist-info/entry_points.txt +2 -0
- auto_context_cli-1.0.0.dist-info/licenses/LICENSE +0 -0
|
@@ -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
|
+
|