publinker-cli 0.6.2__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,46 @@
1
+ """`publinker skill` — install the agent skill that teaches AI assistants the CLI.
2
+
3
+ The skill is a plain SKILL.md packaged with the CLI, so it always matches the
4
+ installed version. `install` copies it to the user-level skill folders that
5
+ Claude Code and Codex read; `path` prints it for any other assistant.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ app = typer.Typer(
16
+ name="skill",
17
+ help="Install the Publinker skill for Claude Code and Codex.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ SKILL_NAME = "publinker"
22
+ # User-level skill folders: Claude Code reads ~/.claude/skills, Codex reads
23
+ # ~/.agents/skills. Both take <name>/SKILL.md.
24
+ TARGETS = (Path(".claude/skills"), Path(".agents/skills"))
25
+
26
+
27
+ def packaged_skill() -> Path:
28
+ return Path(str(resources.files("publinker_cli") / "skill" / "SKILL.md"))
29
+
30
+
31
+ @app.command(
32
+ "install", help="Copy the skill into ~/.claude/skills and ~/.agents/skills."
33
+ )
34
+ def install_cmd() -> None:
35
+ text = packaged_skill().read_text(encoding="utf-8")
36
+ for target in TARGETS:
37
+ dest = Path.home() / target / SKILL_NAME / "SKILL.md"
38
+ dest.parent.mkdir(parents=True, exist_ok=True)
39
+ dest.write_text(text, encoding="utf-8")
40
+ typer.echo(f"installed {dest}")
41
+ typer.echo("Restart Claude Code / Codex to pick it up.")
42
+
43
+
44
+ @app.command("path", help="Print the packaged SKILL.md path (for other assistants).")
45
+ def path_cmd() -> None:
46
+ typer.echo(packaged_skill())
@@ -0,0 +1,97 @@
1
+ """`publinker token` — print the live access/id token for scripting and debugging.
2
+
3
+ Exists so nobody has to dig a session token out of DevTools → Application →
4
+ Local Storage to authenticate a curl or a preview host (the workflow this
5
+ replaces; see DEV-1361's per-PR preview environments).
6
+
7
+ Contract, deliberately Unix-ish:
8
+ - **stdout carries the token and nothing else** — no banner, no color, no
9
+ spinner — so `$(publinker token)` is safe to embed in a command.
10
+ - **All human messaging goes to stderr**, so it stays visible in a terminal but
11
+ never contaminates a pipe.
12
+ - The token is never written to a file, a log, or the shell history by this
13
+ command; printing it to stdout is the caller's decision to make.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+
21
+ import typer
22
+ from rich.console import Console
23
+
24
+ from publinker_cli import keyring_store
25
+ from publinker_cli.client import NotAuthenticatedError, PublinkerClient
26
+ from publinker_cli.config import AUTH0_AUDIENCE, AUTH0_SCOPES
27
+
28
+ # stderr so it can never pollute `$(publinker token)`.
29
+ err_console = Console(stderr=True)
30
+
31
+
32
+ def token_command(
33
+ id_token: bool = typer.Option(
34
+ False,
35
+ "--id",
36
+ help="Print the id_token (identity claims) instead of the access token.",
37
+ ),
38
+ as_json: bool = typer.Option(
39
+ False,
40
+ "--json",
41
+ help=(
42
+ "Print the full bundle as JSON: access_token, id_token, token_type, "
43
+ "scope, audience, expires_at, expires_in."
44
+ ),
45
+ ),
46
+ ) -> None:
47
+ """Print a live Auth0 token for the logged-in user.
48
+
49
+ Refreshes transparently when the cached token has expired, so the value
50
+ printed is always usable right now.
51
+ """
52
+ if id_token and as_json:
53
+ err_console.print("[red]--id and --json are mutually exclusive.[/red]")
54
+ raise typer.Exit(code=2)
55
+
56
+ try:
57
+ # Reuse the client's refresh-and-persist path rather than duplicating it:
58
+ # current_bundle() returns a non-expired bundle, refreshing (and
59
+ # re-saving) when needed, and raises when the session is truly gone.
60
+ bundle = PublinkerClient().current_bundle()
61
+ except NotAuthenticatedError as exc:
62
+ err_console.print(f"[red]{exc}[/red]")
63
+ raise typer.Exit(code=1)
64
+
65
+ if as_json:
66
+ typer.echo(json.dumps(_bundle_as_dict(bundle), indent=2))
67
+ return
68
+
69
+ value = bundle.id_token if id_token else bundle.access_token
70
+ if not value:
71
+ which = "id_token" if id_token else "access_token"
72
+ err_console.print(
73
+ f"[red]No {which} in the stored session — run `publinker login`.[/red]"
74
+ )
75
+ raise typer.Exit(code=1)
76
+
77
+ typer.echo(value)
78
+
79
+
80
+ def _bundle_as_dict(bundle: keyring_store.TokenBundle) -> dict:
81
+ """Shape the bundle like Auth0's own /oauth/token response.
82
+
83
+ Mirroring that shape means the JSON can be dropped straight into tooling
84
+ that already understands an Auth0 token response. `expires_in` is recomputed
85
+ from the stored absolute expiry so it is accurate at print time, not at
86
+ login time.
87
+ """
88
+ now = int(time.time())
89
+ return {
90
+ "access_token": bundle.access_token,
91
+ "id_token": bundle.id_token,
92
+ "token_type": "Bearer",
93
+ "scope": AUTH0_SCOPES,
94
+ "audience": AUTH0_AUDIENCE,
95
+ "expires_at": bundle.expires_at,
96
+ "expires_in": max(0, bundle.expires_at - now),
97
+ }
@@ -0,0 +1,207 @@
1
+ """`publinker update` — check for and install a newer publinker-cli (DEV-1973).
2
+
3
+ The release source is the git spec pipx recorded when this installation was
4
+ made (`install-cli.sh` records a `git+https://...@<ref>#subdirectory=cli` spec).
5
+ The latest version is the `__version__` in `publinker_cli/__init__.py` at that
6
+ ref, read with the same git credentials pipx installs with — no Publinker
7
+ login, no AWS credentials, no write gate.
8
+
9
+ Only the running pipx installation is ever modified, and only from the source
10
+ it was installed from. Editable/local-path/wheel installs and customer builds
11
+ get instructions instead: a customer wheel must never be replaced by the
12
+ internal build that lives in the repository.
13
+
14
+ Exit codes:
15
+ - 0 up to date, or updated and verified
16
+ - 10 `--check` only: a newer version is available
17
+ - 1 anything else (unsupported install, metadata/network/access/installer
18
+ failure); nothing was installed or the install could not be verified
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ import typer
35
+
36
+ from publinker_cli import __version__, audience
37
+ from publinker_cli.version import is_newer, parse_version
38
+
39
+ PACKAGE = "publinker-cli"
40
+ EXIT_UPDATE_AVAILABLE = 10
41
+ INSTALLER_HINT = (
42
+ "Install with ./cli/scripts/install-cli.sh from a publinker-api checkout "
43
+ "(a pipx install from git), then `publinker update` can manage it."
44
+ )
45
+ _GIT_SPEC_RE = re.compile(
46
+ r"^git\+(?P<url>[^@#]+?)(?:@(?P<ref>[^#]+))?#subdirectory=cli$"
47
+ )
48
+ _INIT_VERSION_RE = re.compile(r'^__version__ = "([^"]+)"', re.M)
49
+
50
+
51
+ class UpdateError(Exception):
52
+ """A failure the user must act on. The message says how."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Installation:
57
+ pipx: str
58
+ spec: str
59
+ url: str
60
+ ref: Optional[str]
61
+
62
+
63
+ def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
64
+ # GIT_TERMINAL_PROMPT=0: a missing credential fails instead of hanging.
65
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
66
+ return subprocess.run(cmd, text=True, env=env, **kwargs)
67
+
68
+
69
+ def find_installation() -> Installation:
70
+ """The pipx install that is running this process, or UpdateError."""
71
+ if audience.is_customer_build():
72
+ raise UpdateError(
73
+ "This is a customer build of publinker-cli; update it with "
74
+ "`pipx upgrade publinker-cli`."
75
+ )
76
+ pipx = shutil.which("pipx")
77
+ if not pipx:
78
+ raise UpdateError(f"pipx not found on PATH. {INSTALLER_HINT}")
79
+ venvs = _run(
80
+ [pipx, "environment", "--value", "PIPX_LOCAL_VENVS"], capture_output=True
81
+ )
82
+ if (
83
+ venvs.returncode != 0
84
+ or Path(sys.prefix).resolve()
85
+ != (Path(venvs.stdout.strip()) / PACKAGE).resolve()
86
+ ):
87
+ raise UpdateError(
88
+ f"This publinker ({sys.prefix}) is not a pipx installation; "
89
+ f"update it where it was installed (e.g. `git pull` for an editable "
90
+ f"install). {INSTALLER_HINT}"
91
+ )
92
+ listing = _run([pipx, "list", "--json"], capture_output=True)
93
+ try:
94
+ spec = json.loads(listing.stdout)["venvs"][PACKAGE]["metadata"]["main_package"][
95
+ "package_or_url"
96
+ ]
97
+ except (ValueError, KeyError, TypeError) as exc:
98
+ raise UpdateError(f"could not read the pipx install record: {exc}") from exc
99
+ match = _GIT_SPEC_RE.match(spec)
100
+ if not match:
101
+ raise UpdateError(
102
+ f"publinker-cli was installed from {spec}, which has no release "
103
+ f"source to check. {INSTALLER_HINT}"
104
+ )
105
+ return Installation(pipx, spec, match["url"], match["ref"])
106
+
107
+
108
+ def latest_version(inst: Installation) -> str:
109
+ """`__version__` of cli/ at the installation's git ref."""
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ clone = [
112
+ "git",
113
+ "clone",
114
+ "--quiet",
115
+ "--depth",
116
+ "1",
117
+ "--filter=blob:none",
118
+ "--no-checkout",
119
+ ]
120
+ if inst.ref:
121
+ clone += ["--branch", inst.ref]
122
+ cloned = _run(clone + [inst.url, tmp], capture_output=True)
123
+ if cloned.returncode != 0:
124
+ raise UpdateError(
125
+ f"could not read {inst.url}@{inst.ref or 'HEAD'}: "
126
+ f"{cloned.stderr.strip() or 'git clone failed'}"
127
+ )
128
+ shown = _run(
129
+ ["git", "-C", tmp, "show", "HEAD:cli/publinker_cli/__init__.py"],
130
+ capture_output=True,
131
+ )
132
+ found = _INIT_VERSION_RE.search(shown.stdout) if shown.returncode == 0 else None
133
+ if not found:
134
+ raise UpdateError(
135
+ f"no publinker-cli version found at {inst.url}@{inst.ref or 'HEAD'}"
136
+ )
137
+ try:
138
+ parse_version(found[1])
139
+ except ValueError as exc:
140
+ raise UpdateError(f"release source has a malformed version: {exc}") from exc
141
+ return found[1]
142
+
143
+
144
+ def installed_version() -> str:
145
+ """Version reported by this installation's own binary, after a reinstall."""
146
+ binary = Path(sys.prefix) / "bin" / "publinker"
147
+ out = _run([str(binary), "--version"], capture_output=True)
148
+ if out.returncode != 0:
149
+ raise UpdateError(f"{binary} --version failed: {out.stderr.strip()}")
150
+ reported = out.stdout.strip().rsplit(" ", 1)[-1]
151
+ try:
152
+ parse_version(reported)
153
+ except ValueError as exc:
154
+ raise UpdateError(f"could not verify the installed version: {exc}") from exc
155
+ return reported
156
+
157
+
158
+ def update_command(
159
+ check: bool = typer.Option(
160
+ False,
161
+ "--check",
162
+ help="Only report installed/latest versions as JSON; never install.",
163
+ ),
164
+ ) -> None:
165
+ """Check for a newer publinker-cli and install it into this pipx install.
166
+
167
+ Needs no Publinker login. Exit codes: 0 current/updated, 10 update
168
+ available (--check), 1 failure.
169
+ """
170
+ try:
171
+ inst = find_installation()
172
+ latest = latest_version(inst)
173
+ available = is_newer(latest)
174
+ if check:
175
+ typer.echo(
176
+ json.dumps(
177
+ {
178
+ "installed": __version__,
179
+ "latest": latest,
180
+ "update_available": available,
181
+ "source": inst.spec,
182
+ }
183
+ )
184
+ )
185
+ raise typer.Exit(EXIT_UPDATE_AVAILABLE if available else 0)
186
+ if not available:
187
+ typer.echo(f"publinker-cli {__version__} is up to date ({inst.spec}).")
188
+ return
189
+ typer.echo(f"Updating publinker-cli {__version__} -> {latest} ...", err=True)
190
+ # Installer logs go to stderr; stdout stays the one-line result.
191
+ reinstall = _run(
192
+ [inst.pipx, "reinstall", PACKAGE], stdout=sys.stderr, stderr=sys.stderr
193
+ )
194
+ if reinstall.returncode != 0:
195
+ raise UpdateError(
196
+ f"pipx reinstall failed (exit {reinstall.returncode}); "
197
+ f"publinker-cli may need `pipx install --force '{inst.spec}'`."
198
+ )
199
+ now = installed_version()
200
+ if parse_version(now) < parse_version(latest):
201
+ raise UpdateError(
202
+ f"installed version is {now}, expected {latest}; update not verified."
203
+ )
204
+ typer.echo(f"publinker-cli updated {__version__} -> {now}.")
205
+ except UpdateError as exc:
206
+ typer.echo(f"publinker update: {exc}", err=True)
207
+ raise typer.Exit(1)
@@ -0,0 +1,100 @@
1
+ """`publinker use <site>` — set the active site for subsequent commands.
2
+
3
+ Persists to ~/.config/publinker/state.json. Validates the site is in the
4
+ user's permission set against `GET /site` so typos surface immediately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from publinker_cli import state
15
+ from publinker_cli.config import available_stages
16
+ from publinker_cli.client import APIError, NotAuthenticatedError, PublinkerClient
17
+
18
+ console = Console()
19
+
20
+
21
+ def use_command(
22
+ site: Optional[str] = typer.Argument(
23
+ None,
24
+ help=(
25
+ "Site domain to make active. Subsequent site-scoped commands "
26
+ "default to this site. Use --clear to unset."
27
+ ),
28
+ ),
29
+ clear: bool = typer.Option(
30
+ False,
31
+ "--clear",
32
+ "-c",
33
+ help="Clear the active site instead of setting one.",
34
+ ),
35
+ no_verify: bool = typer.Option(
36
+ False,
37
+ "--no-verify",
38
+ help=(
39
+ "Skip the API permission check. Useful when your permissions "
40
+ "just changed and the cached token hasn't refreshed."
41
+ ),
42
+ ),
43
+ stage: str = typer.Option(
44
+ "prod",
45
+ "--stage",
46
+ "-s",
47
+ help=f"API stage to verify against: {' | '.join(available_stages())}",
48
+ ),
49
+ ) -> None:
50
+ """Set or clear the active publinker site (persisted across sessions)."""
51
+ if clear:
52
+ if site:
53
+ console.print(
54
+ "[red]error:[/red] --clear takes no argument. Use either "
55
+ "`publinker use <site>` OR `publinker use --clear`."
56
+ )
57
+ raise typer.Exit(code=2)
58
+ state.clear()
59
+ console.print("[green]✓[/green] Active site cleared.")
60
+ return
61
+
62
+ if not site:
63
+ current = state.active_site()
64
+ if current:
65
+ console.print(f"Active site: [cyan]{current}[/cyan]")
66
+ else:
67
+ console.print(
68
+ "[yellow]No active site set.[/yellow] "
69
+ "Run `publinker use <domain>` to set one."
70
+ )
71
+ return
72
+
73
+ if not no_verify:
74
+ try:
75
+ sites = PublinkerClient(stage=stage).list_sites()
76
+ except NotAuthenticatedError as exc:
77
+ console.print(f"[red]{exc}[/red]")
78
+ raise typer.Exit(code=1)
79
+ except (APIError, ValueError) as exc:
80
+ console.print(f"[red]{exc}[/red]")
81
+ raise typer.Exit(code=1)
82
+ if site not in sites:
83
+ console.print(
84
+ f"[red]error:[/red] [cyan]{site}[/cyan] is not in your permission "
85
+ f"set on stage [cyan]{stage}[/cyan]."
86
+ )
87
+ if sites:
88
+ console.print(
89
+ "\nYou have access to "
90
+ f"[cyan]{len(sites)}[/cyan] site(s). "
91
+ "Run [bold]publinker site list[/bold] to see them."
92
+ )
93
+ console.print(
94
+ "\nIf your permissions just changed and the cached token hasn't "
95
+ "refreshed, pass [bold]--no-verify[/bold] to skip the check."
96
+ )
97
+ raise typer.Exit(code=1)
98
+
99
+ state.set_active_site(site)
100
+ console.print(f"[green]✓[/green] Active site set to [cyan]{site}[/cyan].")
@@ -0,0 +1,72 @@
1
+ """`publinker write` diagnostics for the process-scoped mutation gate.
2
+
3
+ File-backed allow windows are retired. A reviewed mutation carries
4
+ ``PUBLINKER_ALLOW_WRITES=1`` on that one command; nothing stays armed afterward.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from publinker_cli import write_gate
15
+ from publinker_cli.config import available_stages
16
+
17
+ app = typer.Typer(
18
+ name="write",
19
+ help="Inspect the process-scoped mutation gate and clear retired state.",
20
+ no_args_is_help=True,
21
+ )
22
+ console = Console()
23
+
24
+
25
+ @app.command("status", help="Show whether this process is authorized to mutate.")
26
+ def status_cmd() -> None:
27
+ gate = write_gate.status()
28
+ if gate.allowed:
29
+ console.print(
30
+ f"[green]Writes ALLOWED[/green] — {gate.reason}; this process only"
31
+ )
32
+ return
33
+
34
+ console.print(f"[red]Writes BLOCKED[/red] — {gate.reason}.")
35
+ console.print("Authorize one reviewed command inline:")
36
+ stage = " --stage <stage>" if len(available_stages()) > 1 else ""
37
+ console.print(
38
+ f" [cyan]{write_gate.ENV_VAR}=1 publinker <mutation>{stage} ...[/cyan]"
39
+ )
40
+
41
+
42
+ @app.command("allow", help="Explain the one-process write authorization pattern.")
43
+ def allow_cmd(
44
+ minutes: Optional[int] = typer.Option(
45
+ None,
46
+ "--minutes",
47
+ "-m",
48
+ help="Retired compatibility option; persistent windows are disabled.",
49
+ ),
50
+ persistent: bool = typer.Option(
51
+ False,
52
+ "--persistent",
53
+ help="Retired compatibility option; persistent authorization is disabled.",
54
+ ),
55
+ ) -> None:
56
+ del minutes, persistent
57
+ console.print(
58
+ "[red]Persistent write windows are disabled.[/red] Prefix one reviewed "
59
+ f"mutation with [cyan]{write_gate.ENV_VAR}=1[/cyan] instead."
60
+ )
61
+ raise typer.Exit(code=2)
62
+
63
+
64
+ @app.command("block", help="Clear retired file-backed write authorization.")
65
+ def block_cmd() -> None:
66
+ write_gate.clear_legacy_state()
67
+ console.print("[yellow]Retired file-backed write state cleared.[/yellow]")
68
+ if write_gate.status().allowed:
69
+ console.print(
70
+ f"[yellow]{write_gate.ENV_VAR} is enabled in this process; "
71
+ "don't export it in a shared shell.[/yellow]"
72
+ )
@@ -0,0 +1,64 @@
1
+ """Static Auth0 + API config for publinker-cli.
2
+
3
+ These values are non-secret per OAuth spec (the audience and client_id of a public
4
+ Native client are public), so they live in source.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ AUTH0_DOMAIN = "dev-hqiwr-re.us.auth0.com"
12
+ # Internal by default; the customer wheel replaces this and the vault namespace.
13
+ AUTH0_CLIENT_ID = "t1NcBeA9ULAC34DYz3Ms5rwIlrFkjW7Z"
14
+ AUTH0_AUDIENCE = "https://backend.publinker.com"
15
+ AUTH0_SCOPES = "openid profile email offline_access"
16
+
17
+ # Per-stage API base URLs.
18
+ STAGE_URLS: dict[str, str] = {
19
+ "prod": "https://hlkl4tw3vd.execute-api.us-east-1.amazonaws.com/prod",
20
+ }
21
+
22
+ DEFAULT_STAGE = "prod"
23
+
24
+ # Keyring service name — namespaces our tokens away from other apps.
25
+ KEYRING_SERVICE = "publinker-cli-customer"
26
+ KEYRING_ACCOUNT = "default"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Auth0Config:
31
+ domain: str
32
+ client_id: str
33
+ audience: str
34
+ scopes: str
35
+
36
+ @property
37
+ def device_code_url(self) -> str:
38
+ return f"https://{self.domain}/oauth/device/code"
39
+
40
+ @property
41
+ def token_url(self) -> str:
42
+ return f"https://{self.domain}/oauth/token"
43
+
44
+
45
+ def auth0_config() -> Auth0Config:
46
+ return Auth0Config(
47
+ domain=AUTH0_DOMAIN,
48
+ client_id=AUTH0_CLIENT_ID,
49
+ audience=AUTH0_AUDIENCE,
50
+ scopes=AUTH0_SCOPES,
51
+ )
52
+
53
+
54
+ def available_stages() -> list[str]:
55
+ """Stages this build may call. Customer builds get prod only (DEV-1566)."""
56
+ from publinker_cli.audience import is_customer_build
57
+
58
+ return [DEFAULT_STAGE] if is_customer_build() else sorted(STAGE_URLS)
59
+
60
+
61
+ def api_base_url(stage: str) -> str:
62
+ if stage not in available_stages():
63
+ raise ValueError(f"unknown stage {stage!r}; valid: {available_stages()}")
64
+ return STAGE_URLS[stage]