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,3 @@
1
+ """auto-context: Autonomous context & instruction manager for AI coding assistants."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,2 @@
1
+ """commands package — all CLI subcommands."""
2
+
@@ -0,0 +1,144 @@
1
+ """
2
+ commands/import_cmd.py — Fetch and install a skill or rule from a Gist or URL.
3
+
4
+ 1. Resolves GitHub Gist HTML URLs to raw content URLs.
5
+ 2. Downloads content (httpx with urllib fallback).
6
+ 3. Runs security audit on downloaded content.
7
+ 4. Detects skill vs rule from frontmatter.
8
+ 5. Handles file collisions with interactive overwrite / rename / cancel prompt.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import tempfile
14
+ import time
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from typing import Annotated, Optional
18
+ from urllib.error import URLError
19
+
20
+ import typer
21
+ from rich.console import Console
22
+
23
+ from auto_context import config, security
24
+ from auto_context.config import AGENTS_SUBDIR
25
+
26
+ console = Console()
27
+
28
+ # Regex: https://gist.github.com/<user>/<id>
29
+ _GIST_HTML_RE = re.compile(r"^https?://gist\.github\.com/([^/]+)/([a-f0-9]+)$")
30
+ # Frontmatter field matchers
31
+ _NAME_RE = re.compile(r"(?m)^name:\s*[\"']?([a-zA-Z0-9_\-]+)")
32
+ _SKILL_RE = re.compile(r"(?m)^triggers\s*:")
33
+
34
+
35
+ def _resolve_url(url: str) -> str:
36
+ """Convert a Gist HTML URL to its raw content URL."""
37
+ m = _GIST_HTML_RE.match(url.strip())
38
+ if m:
39
+ return f"https://gist.githubusercontent.com/{m.group(1)}/{m.group(2)}/raw"
40
+ return url
41
+
42
+
43
+ def _fetch(url: str) -> str:
44
+ """Download URL content as a string. Tries httpx first, falls back to urllib."""
45
+ try:
46
+ import httpx
47
+ resp = httpx.get(url, follow_redirects=True, timeout=30)
48
+ resp.raise_for_status()
49
+ return resp.text
50
+ except (ImportError, Exception):
51
+ pass
52
+
53
+ try:
54
+ with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
55
+ return resp.read().decode("utf-8")
56
+ except URLError as exc:
57
+ raise typer.BadParameter(f"Failed to download content from: {url}\n{exc}") from exc
58
+
59
+
60
+ def _detect_type(content: str) -> str:
61
+ """Return 'skill' or 'rule' based on frontmatter inspection."""
62
+ if _SKILL_RE.search(content) or "# Skill:" in content:
63
+ return "skill"
64
+ return "rule"
65
+
66
+
67
+ def _dest_path(clean_name: str, item_type: str, project_dir: Path, global_: bool) -> Path:
68
+ if global_:
69
+ if item_type == "skill":
70
+ return config.GLOBAL_PLUGIN_DIR / "skills" / clean_name / "SKILL.md"
71
+ return config.GLOBAL_PLUGIN_DIR / "rules" / f"{clean_name}.md"
72
+ else:
73
+ if item_type == "skill":
74
+ return project_dir / AGENTS_SUBDIR / "skills" / clean_name / "SKILL.md"
75
+ return project_dir / AGENTS_SUBDIR / "rules" / f"{clean_name}.md"
76
+
77
+
78
+ def import_item(
79
+ url: Annotated[str, typer.Argument(help="Gist URL or raw content URL to import.")],
80
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
81
+ as_name: Annotated[Optional[str], typer.Option("--as", help="Override imported item name.")] = None,
82
+ global_: Annotated[bool, typer.Option("--global", "-g")] = False,
83
+ force: Annotated[bool, typer.Option("--force", "-f")] = False,
84
+ ) -> None:
85
+ """Audit and import a skill or rule from a GitHub Gist or URL."""
86
+ project_dir = (target or Path.cwd()).resolve()
87
+ raw_url = _resolve_url(url)
88
+
89
+ console.print(f"\n[bold cyan]📥 Importing External Instruction[/]")
90
+ console.print(f" [cyan][*] Fetching:[/] {raw_url}")
91
+ content = _fetch(raw_url)
92
+
93
+ # Write to a temp file so the security scanner can work on it
94
+ with tempfile.NamedTemporaryFile(
95
+ mode="w", suffix=".md", encoding="utf-8", delete=False
96
+ ) as tmp:
97
+ tmp.write(content)
98
+ tmp_path = Path(tmp.name)
99
+
100
+ try:
101
+ console.print(" [cyan][*] Running pre-import security audit...[/]")
102
+ violations = security.audit_path(tmp_path, single_file=True)
103
+ security.abort_if_violations(violations)
104
+
105
+ # Detect metadata
106
+ name_match = _NAME_RE.search(content)
107
+ detected_name = name_match.group(1).strip() if name_match else ""
108
+ item_type = _detect_type(content)
109
+ ts_fallback = f"imported-{int(time.time())}"
110
+ final_name = (as_name or detected_name or ts_fallback).lower().replace(" ", "-").replace("_", "-").removesuffix(".md")
111
+
112
+ scope = "GLOBAL Plugin" if global_ else "LOCAL Project"
113
+ dest = _dest_path(final_name, item_type, project_dir, global_)
114
+
115
+ # Collision guard
116
+ if dest.exists() and not force:
117
+ console.print(f"\n[yellow][!] Collision:[/] '{final_name}' already exists at:\n {dest}")
118
+ choice = typer.prompt("Action? [O]verwrite / [R]ename / [C]ancel", default="C")
119
+
120
+ if choice.strip().upper().startswith("O"):
121
+ console.print(" [yellow]Overwriting...[/]")
122
+ elif choice.strip().upper().startswith("R"):
123
+ new_name = typer.prompt("New name").strip()
124
+ if not new_name:
125
+ console.print("[red][✗] Name cannot be empty. Import cancelled.[/]")
126
+ raise typer.Exit(1)
127
+ final_name = new_name.lower().replace(" ", "-").replace("_", "-").removesuffix(".md")
128
+ dest = _dest_path(final_name, item_type, project_dir, global_)
129
+ else:
130
+ console.print("[cyan][*] Import cancelled.[/]\n")
131
+ raise typer.Exit(0)
132
+
133
+ dest.parent.mkdir(parents=True, exist_ok=True)
134
+ dest.write_text(content, encoding="utf-8")
135
+
136
+ finally:
137
+ tmp_path.unlink(missing_ok=True)
138
+
139
+ console.print(f"\n[bold green]✨ Imported Successfully![/]")
140
+ console.print(f" [yellow]Type: [/] {item_type.upper()}")
141
+ console.print(f" [yellow]Name: [/] {final_name}")
142
+ console.print(f" [cyan]Scope:[/] {scope}")
143
+ console.print(f" [green]File: [/] {dest}\n")
144
+
@@ -0,0 +1,309 @@
1
+ """
2
+ commands/init_cmd.py — Workspace scaffolding, stack detection,
3
+ rule and skill scaffolding commands.
4
+
5
+ Commands exposed:
6
+ init — scaffold the current (or target) project directory
7
+ rule — scaffold a new agent rule file
8
+ skill — scaffold a new agent skill directory
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import shutil
14
+ from datetime import date
15
+ from pathlib import Path
16
+ from typing import Annotated, Optional
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+
22
+ from auto_context import config
23
+ from auto_context.config import AGENTS_SUBDIR, GEMINI_SUBDIR, get_templates_dir
24
+
25
+ import sys
26
+
27
+ console = Console()
28
+
29
+ # ── Template variable substitution ────────────────────────────────────────────
30
+
31
+ def _render(template_path: Path, variables: dict[str, str]) -> str:
32
+ """Read a template file and replace {{KEY}} placeholders."""
33
+ text = template_path.read_text(encoding="utf-8")
34
+ for key, val in variables.items():
35
+ text = text.replace("{{" + key + "}}", val)
36
+ return text
37
+
38
+
39
+ def _write_if_missing(dest: Path, content: str, *, force: bool, dry_run: bool) -> bool:
40
+ """Write *content* to *dest*. Return True if a file was written."""
41
+ if dest.exists() and not force:
42
+ console.print(f" [yellow][*] Preserved:[/] {dest.relative_to(dest.anchor)} (use --force to overwrite)")
43
+ return False
44
+ if dry_run:
45
+ console.print(f" [yellow][!] Dry-run:[/] Would write → {dest}")
46
+ return False
47
+ dest.parent.mkdir(parents=True, exist_ok=True)
48
+ dest.write_text(content, encoding="utf-8")
49
+ console.print(f" [green][✓] Generated:[/] {dest}")
50
+ return True
51
+
52
+
53
+ def _copy_if_missing(src: Path, dest: Path, *, force: bool, dry_run: bool) -> bool:
54
+ """Copy *src* to *dest*. Return True if a file was written."""
55
+ if dest.exists() and not force:
56
+ console.print(f" [yellow][*] Preserved:[/] {dest}")
57
+ return False
58
+ if dry_run:
59
+ console.print(f" [yellow][!] Dry-run:[/] Would copy {src.name} → {dest}")
60
+ return False
61
+ dest.parent.mkdir(parents=True, exist_ok=True)
62
+ shutil.copy2(src, dest)
63
+ console.print(f" [green][✓] Copied:[/] {dest}")
64
+ return True
65
+
66
+
67
+ # ── Stack detection ───────────────────────────────────────────────────────────
68
+
69
+ _JS_FRAMEWORKS = {
70
+ "next": "Next.js", "react": "React", "vue": "Vue.js",
71
+ "express": "Express.js", "fastify": "Fastify", "nest": "NestJS",
72
+ }
73
+ _PY_FRAMEWORKS = {
74
+ "fastapi": "FastAPI", "flask": "Flask", "django": "Django",
75
+ }
76
+
77
+
78
+ def detect_stack(project_dir: Path) -> tuple[str, str]:
79
+ """Return (stack_label, framework_label) for the project directory."""
80
+ for filename, stack, kind in config.STACK_INDICATORS:
81
+ indicator = project_dir / filename
82
+ if not indicator.exists():
83
+ continue
84
+
85
+ if kind == "js":
86
+ content = indicator.read_text(encoding="utf-8", errors="ignore").lower()
87
+ fw = next((fw for key, fw in _JS_FRAMEWORKS.items() if key in content), "Not detected")
88
+ return stack, fw
89
+
90
+ if kind == "py":
91
+ # Check manage.py for Django first
92
+ if (project_dir / "manage.py").exists():
93
+ return stack, "Django"
94
+ # scan all py indicator files
95
+ combined = ""
96
+ for py_file in ("requirements.txt", "pyproject.toml", "Pipfile"):
97
+ p = project_dir / py_file
98
+ if p.exists():
99
+ combined += p.read_text(encoding="utf-8", errors="ignore").lower()
100
+ fw = next((fw for key, fw in _PY_FRAMEWORKS.items() if key in combined), "Not detected")
101
+ return stack, fw
102
+
103
+ return stack, "Standard"
104
+
105
+ return "General / Multi-stack", "Not detected"
106
+
107
+
108
+ # ── .gitignore helper ─────────────────────────────────────────────────────────
109
+
110
+ def _ensure_gitignore_entries(project_dir: Path, entries: list[str]) -> None:
111
+ """Append *entries* to .gitignore if not already present."""
112
+ gitignore = project_dir / ".gitignore"
113
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
114
+ missing = [e for e in entries if e not in existing]
115
+ if missing:
116
+ with gitignore.open("a", encoding="utf-8") as fh:
117
+ fh.write("\n# auto-context: protected files\n")
118
+ fh.write("\n".join(missing) + "\n")
119
+ console.print(f" [green][✓] Updated:[/] .gitignore ({', '.join(missing)})")
120
+
121
+
122
+ # ── init command ──────────────────────────────────────────────────────────────
123
+
124
+ def init(
125
+ target: Annotated[Optional[Path], typer.Argument(help="Target project directory.")] = None,
126
+ force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing files.")] = False,
127
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-d", help="Preview without writing.")] = False,
128
+ ) -> None:
129
+ """Scaffold autonomous context files into a project directory."""
130
+ project_dir = (target or Path.cwd()).resolve()
131
+ project_name = project_dir.name
132
+ stack, framework = detect_stack(project_dir)
133
+ date_now = date.today().isoformat()
134
+ templates = get_templates_dir() # importlib.resources-safe path
135
+
136
+ console.print(Panel(
137
+ f"[bold cyan]🚀 Auto-Context: Initializing Autonomous Context Manager[/]\n"
138
+ f"[yellow] Target:[/] {project_name} ({project_dir})\n"
139
+ f"[cyan] Stack: [/] {stack} ([dim]{framework}[/])",
140
+ border_style="cyan",
141
+ ))
142
+
143
+ if dry_run:
144
+ console.print("[yellow][!] Dry-run mode: No files will be written.\n")
145
+
146
+ variables = {
147
+ "PROJECT_NAME": project_name,
148
+ "PROJECT_DIR": str(project_dir),
149
+ "STACK": stack,
150
+ "FRAMEWORK": framework,
151
+ "DATE_NOW": date_now,
152
+ }
153
+
154
+ # ── Rules ────────────────────────────────────────────────────────────────
155
+ rules_dest = project_dir / AGENTS_SUBDIR / "rules"
156
+ for rule_file in (templates / "rules").glob("*.md"):
157
+ _copy_if_missing(rule_file, rules_dest / rule_file.name, force=force, dry_run=dry_run)
158
+
159
+ maintain_tmpl = templates / "maintain_context.template.md"
160
+ if maintain_tmpl.exists():
161
+ content = _render(maintain_tmpl, variables)
162
+ _write_if_missing(rules_dest / "maintain_context.md", content, force=force, dry_run=dry_run)
163
+
164
+ # ── Skills ───────────────────────────────────────────────────────────────
165
+ skills_dest = project_dir / AGENTS_SUBDIR / "skills"
166
+ for skill_dir in (templates / "skills").iterdir():
167
+ if skill_dir.is_dir():
168
+ skill_md = skill_dir / "SKILL.md"
169
+ if skill_md.exists():
170
+ _copy_if_missing(
171
+ skill_md,
172
+ skills_dest / skill_dir.name / "SKILL.md",
173
+ force=force, dry_run=dry_run,
174
+ )
175
+
176
+ # ── Root context files ────────────────────────────────────────────────────
177
+ for tmpl_name, dest_name in [
178
+ ("context.template.md", "context.md"),
179
+ ("context.local.template.md", "context.local.md"),
180
+ ("GEMINI.template.md", "GEMINI.md"),
181
+ ("AGENTS.template.md", "AGENTS.md"),
182
+ ]:
183
+ tmpl = templates / tmpl_name
184
+ if tmpl.exists():
185
+ content = _render(tmpl, variables)
186
+ _write_if_missing(project_dir / dest_name, content, force=force, dry_run=dry_run)
187
+
188
+ # ── MCP config stub ───────────────────────────────────────────────────────
189
+ mcp_tmpl = templates / "mcp_config.template.json"
190
+ if mcp_tmpl.exists():
191
+ for mcp_dir in [
192
+ project_dir / GEMINI_SUBDIR,
193
+ project_dir / AGENTS_SUBDIR,
194
+ ]:
195
+ _copy_if_missing(
196
+ mcp_tmpl,
197
+ mcp_dir / "mcp_config.json",
198
+ force=force, dry_run=dry_run,
199
+ )
200
+
201
+ # ── .gitignore ────────────────────────────────────────────────────────────
202
+ if not dry_run:
203
+ _ensure_gitignore_entries(project_dir, [
204
+ "context.local.md",
205
+ ".env",
206
+ ".env.local",
207
+ ".env.*.local",
208
+ ])
209
+
210
+ console.print(Panel(
211
+ "[bold green]✨ Project scaffold complete![/]\n"
212
+ "Edit [cyan]context.md[/] and [cyan]GEMINI.md[/] to customise your AI workspace.",
213
+ border_style="green",
214
+ ))
215
+
216
+
217
+ # ── rule command ──────────────────────────────────────────────────────────────
218
+
219
+ def new_rule(
220
+ name: Annotated[str, typer.Argument(help="Rule name (e.g. code-standards).")],
221
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
222
+ global_: Annotated[bool, typer.Option("--global", "-g", help="Install into global plugin.")] = False,
223
+ force: Annotated[bool, typer.Option("--force", "-f")] = False,
224
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-d")] = False,
225
+ ) -> None:
226
+ """Scaffold a new agent rule file."""
227
+ clean_name = name.lower().replace(" ", "-").replace("_", "-").removesuffix(".md")
228
+ title = clean_name.replace("-", " ").title()
229
+
230
+ dest_dir = (
231
+ config.GLOBAL_PLUGIN_DIR / "rules"
232
+ if global_
233
+ else (target or Path.cwd()).resolve() / AGENTS_SUBDIR / "rules"
234
+ )
235
+ dest_file = dest_dir / f"{clean_name}.md"
236
+
237
+ if dest_file.exists() and not force:
238
+ console.print(f"[yellow][!] Rule already exists:[/] {dest_file}\n Use --force to overwrite.")
239
+ raise typer.Exit(1)
240
+
241
+ tmpl = get_templates_dir() / "rule.template.md"
242
+ if tmpl.exists():
243
+ content = _render(tmpl, {"RULE_NAME": clean_name, "RULE_TITLE": title})
244
+ else:
245
+ content = (
246
+ f"---\nname: {clean_name}\ndescription: Guidelines for {title}.\ntrigger: always_on\n---\n\n"
247
+ f"# Rule: {title}\n\n## Objective\n\n## Guidelines & Constraints\n"
248
+ )
249
+
250
+ _write_if_missing(dest_file, content, force=force, dry_run=dry_run)
251
+ if global_ and config.MEMORY_DIR.exists():
252
+ mem_dest = config.MEMORY_DIR / "rules" / f"{clean_name}.md"
253
+ _write_if_missing(mem_dest, content, force=force, dry_run=dry_run)
254
+ scope = "GLOBAL Plugin & Central Memory" if global_ else f"LOCAL ({dest_dir})"
255
+ console.print(Panel(
256
+ f"[bold green]✨ New Rule Created![/]\n"
257
+ f"[yellow] Name:[/] {clean_name}\n"
258
+ f"[cyan] Scope:[/] {scope}\n"
259
+ f"[green] File: [/] {dest_file}",
260
+ border_style="cyan",
261
+ ))
262
+
263
+
264
+ # ── skill command ─────────────────────────────────────────────────────────────
265
+
266
+ def new_skill(
267
+ name: Annotated[str, typer.Argument(help="Skill name (e.g. deploy-docker).")],
268
+ target: Annotated[Optional[Path], typer.Option("--target", "-t")] = None,
269
+ global_: Annotated[bool, typer.Option("--global", "-g")] = False,
270
+ force: Annotated[bool, typer.Option("--force", "-f")] = False,
271
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-d")] = False,
272
+ ) -> None:
273
+ """Scaffold a new agent skill directory."""
274
+ clean_name = name.lower().replace(" ", "-").replace("_", "-")
275
+ title = clean_name.replace("-", " ").title()
276
+
277
+ dest_dir = (
278
+ config.GLOBAL_PLUGIN_DIR / "skills" / clean_name
279
+ if global_
280
+ else (target or Path.cwd()).resolve() / AGENTS_SUBDIR / "skills" / clean_name
281
+ )
282
+ dest_file = dest_dir / "SKILL.md"
283
+
284
+ if dest_file.exists() and not force:
285
+ console.print(f"[yellow][!] Skill already exists:[/] {dest_file}\n Use --force to overwrite.")
286
+ raise typer.Exit(1)
287
+
288
+ tmpl = get_templates_dir() / "skill.template.md"
289
+ if tmpl.exists():
290
+ content = _render(tmpl, {"SKILL_NAME": clean_name, "SKILL_TITLE": title})
291
+ else:
292
+ content = (
293
+ f"---\nname: {clean_name}\nversion: 1.0.0\n"
294
+ f"description: Skill for {title}.\ntriggers: [\"{clean_name}\"]\n---\n\n"
295
+ f"# Skill: {title}\n\n## Overview\n\n## When to Activate\n\n## Workflow\n"
296
+ )
297
+
298
+ _write_if_missing(dest_file, content, force=force, dry_run=dry_run)
299
+ if global_ and config.MEMORY_DIR.exists():
300
+ mem_dest = config.MEMORY_DIR / "skills" / clean_name / "SKILL.md"
301
+ _write_if_missing(mem_dest, content, force=force, dry_run=dry_run)
302
+ scope = "GLOBAL Plugin & Central Memory" if global_ else f"LOCAL ({dest_dir})"
303
+ console.print(Panel(
304
+ f"[bold green]🎯 New Skill Created![/]\n"
305
+ f"[yellow] Name:[/] {clean_name}\n"
306
+ f"[cyan] Scope:[/] {scope}\n"
307
+ f"[green] File: [/] {dest_file}",
308
+ border_style="cyan",
309
+ ))