autoplay-setup 0.1.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,10 @@
1
+ """autoplay_setup — the pipx-installable wrapper CLI (PROJECT.md §5.1).
2
+
3
+ Pure scripting, no AI logic: detects the project root, downloads the setup
4
+ bundle, collects and validates PostHog credentials, then launches Claude Code
5
+ from the project root to perform the actual integration.
6
+ """
7
+
8
+ from autoplay_setup.__version__ import __version__
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Single source of version truth for the CLI (PROJECT.md §7 Versioning).
2
+
3
+ This value is surfaced in the rendered CLAUDE.md and recorded in
4
+ .autoplay/manifest.json so re-runs and support cases can pin exactly what
5
+ shipped.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ """Detects, installs, and launches Claude Code (PROJECT.md §5.1.4, §5.1.8).
2
+
3
+ Checks for `claude` on PATH (offering an npm install if missing) and launches it
4
+ from the project root with our instructions via --append-system-prompt. Claude
5
+ Code's output streams straight to the user's terminal (never captured).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ _NPM_PACKAGE = "@anthropic-ai/claude-code"
15
+
16
+ # Pinned to a non-1M-context model: the default Opus 1M variant 429s on
17
+ # plans without long-context credits ("usage credits required"). Sonnet
18
+ # handles the setup procedure reliably. Overridable via the --model flag.
19
+ DEFAULT_MODEL = "sonnet"
20
+
21
+ # First user turn. `--append-system-prompt` alone gives the agent context
22
+ # but no message to act on, so it sits idle. Passing this as a positional
23
+ # prompt keeps the session interactive (only -p/--print is headless) while
24
+ # auto-starting the skill — the confirmation prompts still work.
25
+ _KICKOFF_PROMPT = (
26
+ "Begin the Autoplay setup now: read ./.autoplay/task.md, then load and "
27
+ "follow ./.autoplay/skills/posthog-setup/SKILL.md exactly. Confirm with me "
28
+ "before every external write."
29
+ )
30
+
31
+
32
+ def check_installed() -> bool:
33
+ """Return True if the `claude` CLI is on PATH."""
34
+ return shutil.which("claude") is not None
35
+
36
+
37
+ def install_via_npm() -> bool:
38
+ """Install Claude Code globally via npm.
39
+
40
+ Returns:
41
+ True if installation succeeded, False otherwise (incl. missing npm).
42
+ """
43
+ if shutil.which("npm") is None:
44
+ return False
45
+ result = subprocess.run(
46
+ ["npm", "install", "-g", _NPM_PACKAGE],
47
+ check=False,
48
+ )
49
+ return result.returncode == 0
50
+
51
+
52
+ def launch(
53
+ project_root: Path,
54
+ claude_md_path: Path,
55
+ *,
56
+ model: str = DEFAULT_MODEL,
57
+ kickoff: str = _KICKOFF_PROMPT,
58
+ ) -> int:
59
+ """Launch Claude Code from the project root with our instructions.
60
+
61
+ Runs from the customer's project root (NOT .autoplay/) so the agent can
62
+ edit real source while following our appended system prompt. Output is not
63
+ captured — it streams to the terminal.
64
+
65
+ Args:
66
+ project_root: The customer's project root (used as cwd).
67
+ claude_md_path: Path to the rendered CLAUDE.md to append.
68
+ model: Model alias/name to pin (avoids the 1M-context credit gate).
69
+ kickoff: Positional first-turn prompt that auto-starts the skill.
70
+
71
+ Returns:
72
+ Claude Code's process exit code.
73
+ """
74
+ claude_md = claude_md_path.read_text(encoding="utf-8")
75
+ # No --dangerously-skip-permissions: Claude Code prompts the user to approve
76
+ # each command/edit. The customer stays in control of every action.
77
+ result = subprocess.run(
78
+ [
79
+ "claude",
80
+ "--model",
81
+ model,
82
+ "--append-system-prompt",
83
+ claude_md,
84
+ kickoff,
85
+ ],
86
+ cwd=str(project_root),
87
+ check=False,
88
+ )
89
+ return result.returncode
@@ -0,0 +1,209 @@
1
+ """Interactive credential collection (PROJECT.md §5.1.5).
2
+
3
+ Walks the customer through host selection, the three PostHog keys/IDs, and the
4
+ inline validation loop, re-prompting only the fields relevant to each failure
5
+ so a bad input never kills the CLI. Secrets are never logged or shown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.prompt import Prompt
17
+
18
+ from autoplay_setup.env_detector import detect_posthog_host
19
+ from autoplay_setup.errors import PostHogValidationError, UserAbortedError
20
+ from autoplay_setup.host import parse_host
21
+ from autoplay_setup.posthog_validator import ValidationResult, validate_credentials
22
+
23
+ Validator = Callable[[str, str, str], Awaitable[ValidationResult]]
24
+
25
+
26
+ @dataclass
27
+ class Credentials:
28
+ """Validated PostHog credentials.
29
+
30
+ Attributes:
31
+ host: Normalized PostHog host URL.
32
+ project_id: PostHog project id.
33
+ project_api_key: Project API key (phc_...); excluded from repr.
34
+ personal_api_key: Personal API key (phx_...); excluded from repr.
35
+ """
36
+
37
+ host: str
38
+ project_id: str
39
+ project_api_key: str = field(repr=False)
40
+ personal_api_key: str = field(repr=False)
41
+ project_name: str = ""
42
+
43
+
44
+ def _prompt_host(detected: str | None, console: Console) -> str:
45
+ """Prompt for the host (with detected default) until it parses."""
46
+ if detected:
47
+ console.print(
48
+ f" [dim]Found [bold]{detected}[/bold] in your code — press "
49
+ f"Enter to use it.[/dim]"
50
+ )
51
+ else:
52
+ console.print(
53
+ " [dim]The API host your app sends events to. Just type "
54
+ "[bold]us[/bold] or [bold]eu[/bold] for PostHog Cloud "
55
+ "([bold]https://us.posthog.com[/bold] / "
56
+ "[bold]https://eu.posthog.com[/bold]), or your own URL if "
57
+ "self-hosted.[/dim]"
58
+ )
59
+ while True:
60
+ suffix = f" [detected: {detected}]" if detected else ""
61
+ raw = Prompt.ask(f"PostHog host{suffix}", default=detected or None)
62
+ try:
63
+ return parse_host(raw or "")
64
+ except ValueError as exc:
65
+ console.print(Panel(str(exc), title="Invalid host", style="red"))
66
+
67
+
68
+ def _settings_host(host: str) -> str:
69
+ """Map a PostHog API host to the app host that serves the settings UI.
70
+
71
+ Cloud uses the `*.i.posthog.com` ingestion host for API/events but serves
72
+ settings (where personal API keys are created) on `*.posthog.com`. For
73
+ self-hosted, the API and app host are the same.
74
+ """
75
+ return host.replace("us.i.posthog.com", "us.posthog.com").replace(
76
+ "eu.i.posthog.com", "eu.posthog.com"
77
+ )
78
+
79
+
80
+ def _print_project_id_help(host: str, console: Console) -> None:
81
+ app = _settings_host(host)
82
+ console.print(
83
+ f" [dim]Your numeric Project ID. Find it under "
84
+ f"[bold]{app}/settings/project[/bold] ('Project ID'), or read it "
85
+ f"straight from your PostHog URL — the number in "
86
+ f"[bold]{app}/project/<ID>[/bold].[/dim]"
87
+ )
88
+
89
+
90
+ def _print_project_key_help(host: str, console: Console) -> None:
91
+ app = _settings_host(host)
92
+ console.print(
93
+ f" [dim]The public [bold]Project API Key[/bold] (starts "
94
+ f"[bold]phc_[/bold]) from [bold]{app}/settings/project[/bold] — the "
95
+ f"same token already in your posthog.init(...) snippet. Safe to expose "
96
+ f"in client code.[/dim]"
97
+ )
98
+
99
+
100
+ def _print_personal_key_help(host: str, console: Console) -> None:
101
+ console.print(
102
+ f" [dim]A private key (starts [bold]phx_[/bold]) that lets us read "
103
+ f"your project and create the Autoplay destination. Create one at "
104
+ f"[bold]{_settings_host(host)}/settings/user-api-keys[/bold] → "
105
+ f"'New personal API key'. Easiest is scope [bold]All access[/bold]; if "
106
+ f"you'd rather narrow it, include [bold]project:read[/bold] and "
107
+ f"[bold]hog_function:write[/bold], scoped to this project.[/dim]"
108
+ )
109
+
110
+
111
+ def _render_error(result: ValidationResult, console: Console) -> None:
112
+ messages = {
113
+ "host_unreachable": "Can't reach that host. Check the URL and your "
114
+ "network; if self-hosted, confirm the server is up.",
115
+ "project_not_found": "Got a 404 for that project. Either the Project "
116
+ "ID is wrong, or your Personal API Key can't see this project — make "
117
+ "sure the key has the [bold]project:read[/bold] scope and is scoped "
118
+ "to this project (or all projects). Confirm the ID at "
119
+ "Settings → Project.",
120
+ "auth_failed": "That personal API key was rejected. Make sure you "
121
+ "used a Personal API Key (phx_...), not the Project API Key (phc_...).",
122
+ }
123
+ detail = messages.get(result.status, "Validation failed.")
124
+ console.print(Panel(detail, title="Validation failed", style="red"))
125
+
126
+
127
+ async def collect_and_validate(
128
+ project_root: Path,
129
+ console: Console | None = None,
130
+ validator: Validator = validate_credentials,
131
+ ) -> Credentials:
132
+ """Collect and live-validate PostHog credentials (PROJECT.md §5.1.5).
133
+
134
+ Args:
135
+ project_root: The customer's project root (used for host detection).
136
+ console: Optional Rich console.
137
+ validator: Async validation function (injectable for tests).
138
+
139
+ Returns:
140
+ Validated Credentials.
141
+
142
+ Raises:
143
+ UserAbortedError: If the user presses Ctrl-C.
144
+ PostHogValidationError: On an unrecoverable (unknown) validation error.
145
+ """
146
+ console = console or Console()
147
+ detected = detect_posthog_host(project_root)
148
+ console.print(
149
+ " I need 4 things from PostHog: your [bold]host[/bold], "
150
+ "[bold]Project ID[/bold], [bold]Project API Key[/bold], and a "
151
+ "[bold]Personal API Key[/bold]. I'll point you to each one."
152
+ )
153
+
154
+ host: str | None = None
155
+ project_id: str | None = None
156
+ project_api_key: str | None = None
157
+ personal_api_key: str | None = None
158
+
159
+ try:
160
+ while True:
161
+ if host is None:
162
+ host = _prompt_host(detected, console)
163
+ if project_id is None:
164
+ _print_project_id_help(host, console)
165
+ project_id = Prompt.ask("PostHog Project ID")
166
+ if project_api_key is None:
167
+ _print_project_key_help(host, console)
168
+ while not project_api_key:
169
+ project_api_key = Prompt.ask(
170
+ "PostHog Project API Key (phc_...)", password=True
171
+ )
172
+ if not project_api_key:
173
+ console.print(
174
+ "[yellow]Project API Key is required.[/yellow]"
175
+ )
176
+ if personal_api_key is None:
177
+ _print_personal_key_help(host, console)
178
+ personal_api_key = Prompt.ask(
179
+ "PostHog Personal API Key (phx_...)", password=True
180
+ )
181
+
182
+ result = await validator(host, project_id, personal_api_key)
183
+ if result.status == "success":
184
+ return Credentials(
185
+ host=host,
186
+ project_id=project_id,
187
+ project_api_key=project_api_key,
188
+ personal_api_key=personal_api_key,
189
+ project_name=getattr(result, "project_name", ""),
190
+ )
191
+
192
+ _render_error(result, console)
193
+ if result.status == "host_unreachable":
194
+ host = None
195
+ elif result.status == "project_not_found":
196
+ # The host responded (not unreachable), so keep it. A 404 means
197
+ # either the project id is wrong or the key can't see it —
198
+ # re-prompt both.
199
+ project_id = None
200
+ personal_api_key = None
201
+ elif result.status == "auth_failed":
202
+ personal_api_key = None
203
+ else:
204
+ raise PostHogValidationError(
205
+ "unknown", f"HTTP {result.status_code}"
206
+ )
207
+ except KeyboardInterrupt as exc:
208
+ message = "Setup cancelled during credential entry."
209
+ raise UserAbortedError(message) from exc
@@ -0,0 +1,108 @@
1
+ """Bundle download from the token server (PROJECT.md §5.1.3).
2
+
3
+ Fetches the zip from the token server's one-time download endpoint, validates
4
+ its contents, and unpacks it into ./.autoplay/ (prompting before overwriting an
5
+ existing directory).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import shutil
12
+ import zipfile
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+ from rich.console import Console
17
+ from rich.prompt import Confirm
18
+
19
+ from autoplay_setup.errors import (
20
+ AutoplaySetupError,
21
+ TokenNotFoundError,
22
+ UserAbortedError,
23
+ )
24
+
25
+ _TIMEOUT_SECONDS = 30.0
26
+
27
+ # Files every valid bundle must contain (PROJECT.md §6.5 / §5.3).
28
+ _REQUIRED_FILES = {"CLAUDE.md", "task.md", "manifest.json", ".env.example"}
29
+ _REQUIRED_PREFIX = "skills/posthog-setup/"
30
+
31
+
32
+ def _corrupt_download() -> AutoplaySetupError:
33
+ return AutoplaySetupError(
34
+ message="The downloaded setup bundle looks corrupt or incomplete.",
35
+ exit_code=2,
36
+ remediation=(
37
+ "Generate a fresh setup token and try again. If it keeps "
38
+ "happening, contact support with your setup session id."
39
+ ),
40
+ )
41
+
42
+
43
+ def _validate_members(names: list[str]) -> None:
44
+ """Reject unsafe paths and ensure the bundle has the required files."""
45
+ for name in names:
46
+ if name.startswith("/") or ".." in Path(name).parts:
47
+ raise _corrupt_download()
48
+ present = set(names)
49
+ if not _REQUIRED_FILES.issubset(present):
50
+ raise _corrupt_download()
51
+ if not any(name.startswith(_REQUIRED_PREFIX) for name in names):
52
+ raise _corrupt_download()
53
+
54
+
55
+ async def download_setup_package(
56
+ server_url: str,
57
+ token: str,
58
+ target_dir: Path,
59
+ console: Console | None = None,
60
+ client: httpx.AsyncClient | None = None,
61
+ ) -> None:
62
+ """Download, validate, and unpack the setup bundle into target_dir.
63
+
64
+ Args:
65
+ server_url: Base URL of the token server.
66
+ token: One-time setup token.
67
+ target_dir: Directory to unpack into (e.g. project/.autoplay).
68
+ console: Optional Rich console.
69
+ client: Optional injected httpx client (for tests).
70
+
71
+ Raises:
72
+ TokenNotFoundError: If the server returns 404.
73
+ UserAbortedError: If the user declines to overwrite an existing dir.
74
+ AutoplaySetupError: If the download fails or the bundle is corrupt.
75
+ """
76
+ console = console or Console()
77
+
78
+ if target_dir.exists() and any(target_dir.iterdir()):
79
+ if not Confirm.ask(
80
+ f"{target_dir} already exists. Overwrite it?", default=False
81
+ ):
82
+ raise UserAbortedError(f"Left existing {target_dir} untouched.")
83
+
84
+ url = f"{server_url.rstrip('/')}/setup/{token}/download"
85
+ owns_client = client is None
86
+ client = client or httpx.AsyncClient(timeout=_TIMEOUT_SECONDS)
87
+ try:
88
+ response = await client.get(url)
89
+ finally:
90
+ if owns_client:
91
+ await client.aclose()
92
+
93
+ if response.status_code == 404:
94
+ raise TokenNotFoundError()
95
+ if response.status_code != 200:
96
+ raise _corrupt_download()
97
+
98
+ try:
99
+ archive = zipfile.ZipFile(io.BytesIO(response.content))
100
+ except zipfile.BadZipFile as exc:
101
+ raise _corrupt_download() from exc
102
+
103
+ _validate_members(archive.namelist())
104
+
105
+ if target_dir.exists():
106
+ shutil.rmtree(target_dir)
107
+ target_dir.mkdir(parents=True, exist_ok=True)
108
+ archive.extractall(target_dir)
@@ -0,0 +1,78 @@
1
+ """Detects existing PostHog config in the customer's project (§5.1.5a).
2
+
3
+ Best-effort scan of .env files and framework configs for an existing PostHog
4
+ host (or a posthog.init api_host) to offer as the suggested default. Never
5
+ raises — detection is convenience, not authority.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ _ENV_FILES = (".env", ".env.local", ".env.example")
14
+ _ENV_KEYS = ("POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_HOST", "VITE_POSTHOG_HOST")
15
+ _JS_CONFIGS = ("next.config.js", "vite.config.ts", "nuxt.config.ts")
16
+
17
+ # KEY=value (optionally quoted) on a single env line.
18
+ _ENV_LINE = re.compile(r"^\s*(?P<key>[A-Z0-9_]+)\s*=\s*(?P<val>.+?)\s*$")
19
+ # api_host: "https://..." inside a posthog.init(...) style config.
20
+ _API_HOST = re.compile(r"""api_host\s*:\s*['"](?P<val>[^'"]+)['"]""")
21
+
22
+
23
+ def _strip_quotes(value: str) -> str:
24
+ value = value.strip()
25
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
26
+ return value[1:-1]
27
+ return value
28
+
29
+
30
+ def _scan_env_file(path: Path) -> str | None:
31
+ try:
32
+ text = path.read_text(encoding="utf-8")
33
+ except OSError:
34
+ return None
35
+ values: dict[str, str] = {}
36
+ for line in text.splitlines():
37
+ if line.lstrip().startswith("#"):
38
+ continue
39
+ match = _ENV_LINE.match(line)
40
+ if match:
41
+ values[match.group("key")] = _strip_quotes(match.group("val"))
42
+ for key in _ENV_KEYS:
43
+ if key in values and values[key]:
44
+ return values[key]
45
+ return None
46
+
47
+
48
+ def _scan_js_config(path: Path) -> str | None:
49
+ try:
50
+ text = path.read_text(encoding="utf-8")
51
+ except OSError:
52
+ return None
53
+ match = _API_HOST.search(text)
54
+ return match.group("val") if match else None
55
+
56
+
57
+ def detect_posthog_host(project_root: Path) -> str | None:
58
+ """Find a likely PostHog host already configured in the project.
59
+
60
+ Args:
61
+ project_root: The customer's project root directory.
62
+
63
+ Returns:
64
+ The first host value found, or None. Env files are checked before
65
+ framework configs; never raises.
66
+ """
67
+ try:
68
+ for name in _ENV_FILES:
69
+ found = _scan_env_file(project_root / name)
70
+ if found:
71
+ return found
72
+ for name in _JS_CONFIGS:
73
+ found = _scan_js_config(project_root / name)
74
+ if found:
75
+ return found
76
+ except Exception:
77
+ return None
78
+ return None
@@ -0,0 +1,65 @@
1
+ """Atomic .env writer with 0600 permissions (PROJECT.md §5.1.5e, §6.2, §7).
2
+
3
+ Writes ./.autoplay/.env atomically (temp file + rename) with owner-only
4
+ permissions, splitting the user-provided values from the Autoplay values Claude
5
+ Code fills in later. Secrets are never logged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ # Autoplay values Claude Code fills in during the run (PROJECT.md §6.2).
15
+ _AUTOPLAY_PLACEHOLDERS = (
16
+ "AUTOPLAY_CONNECTOR_URL",
17
+ "AUTOPLAY_STREAM_URL",
18
+ "AUTOPLAY_WEBHOOK_SECRET",
19
+ "AUTOPLAY_UNKEY_TOKEN",
20
+ )
21
+
22
+
23
+ def _quote(value: str) -> str:
24
+ """Quote a value for a .env line, escaping backslashes and quotes."""
25
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
26
+ return f'"{escaped}"'
27
+
28
+
29
+ def _render(env_vars: dict[str, str]) -> str:
30
+ """Render the full .env content with both sections."""
31
+ lines = ["# Provided by user (CLI)"]
32
+ lines += [f"{key}={_quote(val)}" for key, val in env_vars.items()]
33
+ lines.append("")
34
+ lines.append("# Filled in by Claude Code during the run")
35
+ # AUTOPLAY_PRODUCT_ID always mirrors POSTHOG_PROJECT_ID (PROJECT.md §6.2).
36
+ project_id = env_vars.get("POSTHOG_PROJECT_ID", "")
37
+ lines.append(f"AUTOPLAY_PRODUCT_ID={_quote(project_id)}")
38
+ lines += [f"{key}={_quote('')}" for key in _AUTOPLAY_PLACEHOLDERS]
39
+ return "\n".join(lines) + "\n"
40
+
41
+
42
+ def write_env_file(path: Path, env_vars: dict[str, str]) -> None:
43
+ """Atomically write a 0600 .env file with the given user values.
44
+
45
+ Args:
46
+ path: Destination path (e.g. .autoplay/.env).
47
+ env_vars: User-provided values (POSTHOG_*). The Autoplay section is
48
+ scaffolded with empty placeholders for Claude Code to fill in.
49
+ """
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ content = _render(env_vars)
52
+
53
+ fd, tmp_name = tempfile.mkstemp(
54
+ dir=str(path.parent), prefix=".env.", suffix=".tmp"
55
+ )
56
+ tmp = Path(tmp_name)
57
+ try:
58
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
59
+ handle.write(content)
60
+ os.chmod(tmp, 0o600)
61
+ os.replace(tmp, path)
62
+ except BaseException:
63
+ if tmp.exists():
64
+ tmp.unlink()
65
+ raise