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,61 @@
1
+ ---
2
+ name: publinker
3
+ description: Read and manage Publinker data (sites, ad revenue reports, pricing floors, site config) with the `publinker` CLI. Use when the user asks about their Publinker sites, revenue, reports, floors or config, or mentions publinker.
4
+ ---
5
+
6
+ # Publinker CLI
7
+
8
+ The `publinker` command talks to the Publinker API as the logged-in user. It
9
+ only sees the sites that user can see on publinker.com.
10
+
11
+ ## Before anything
12
+
13
+ 1. `publinker whoami`. If it says you are not logged in, ask the user to run
14
+ `publinker login` themselves (it opens a browser to confirm a code). Never
15
+ run `publinker login` for them, and never print or store tokens.
16
+ 2. `publinker site list` to see the sites, then `publinker use <site>` to set
17
+ the active one, or pass `--site <site>` on a single command.
18
+
19
+ ## Find the right command
20
+
21
+ Every group and command has `--help`; read it instead of guessing flags.
22
+
23
+ ```bash
24
+ publinker --help
25
+ publinker site --help
26
+ publinker site report get --help
27
+ publinker site report list-options # valid --platform and --dimensions-keys values
28
+ ```
29
+
30
+ Add `--json` to any read command when you need to process the output.
31
+
32
+ ## Common reads
33
+
34
+ ```bash
35
+ publinker site report get --platform google_admanager --account-id <network code> \
36
+ --dimensions-keys date --date-start 2026-01-01 --date-stop 2026-01-07 --json
37
+ publinker site pricing get --json
38
+ publinker site config get --json
39
+ ```
40
+
41
+ ## Changes (writes)
42
+
43
+ Commands that create, change or delete data are blocked by default, and
44
+ each one is unlocked on its own. Before a write:
45
+
46
+ 1. Read the current value first, then tell the user exactly what will change
47
+ and get a clear yes for that exact command.
48
+ 2. Run that one command with `PUBLINKER_ALLOW_WRITES=1` in front of it and an
49
+ explicit `--site <site>` (the active site from `publinker use` is not
50
+ enough for a write):
51
+ `PUBLINKER_ALLOW_WRITES=1 publinker site pricing adjust --site <site> ...`
52
+ 3. Never `export PUBLINKER_ALLOW_WRITES`; nothing stays unlocked afterwards.
53
+ `publinker write allow` no longer opens a window.
54
+ 4. Read the value again and show the user the result. If a write times out,
55
+ read before retrying; it may have gone through.
56
+
57
+ ## Errors
58
+
59
+ - `401` / "not logged in": the user runs `publinker login`.
60
+ - `403`: the user has no access to that site. Do not retry with other sites.
61
+ - A newer version is announced: tell the user to run `pipx upgrade publinker-cli`.
publinker_cli/state.py ADDED
@@ -0,0 +1,138 @@
1
+ """User-state persistence for publinker-cli.
2
+
3
+ The state file at ~/.config/publinker/state.json tracks the active site
4
+ (selected via `publinker use <site>`) so that site-scoped commands can be
5
+ called without `--site` every time.
6
+
7
+ The active site is NOT a secret (it's a domain you own). Plain file storage
8
+ beats keyring here: one less keyring read per command + easy inspection
9
+ (`jq .active_site ~/.config/publinker/state.json`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import asdict, dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from publinker_cli import __version__
22
+
23
+ DEFAULT_STATE_DIR = Path.home() / ".config" / "publinker"
24
+ DEFAULT_STATE_FILE = DEFAULT_STATE_DIR / "state.json"
25
+
26
+
27
+ def _state_path() -> Path:
28
+ """Allow tests to override via PUBLINKER_STATE_FILE."""
29
+ override = os.environ.get("PUBLINKER_STATE_FILE")
30
+ return Path(override) if override else DEFAULT_STATE_FILE
31
+
32
+
33
+ @dataclass
34
+ class State:
35
+ active_site: Optional[str] = None
36
+ set_at: Optional[str] = None # ISO 8601 UTC
37
+ set_by_cli_version: Optional[str] = None
38
+ # Retired file-backed write fields are parsed only so `publinker write block`
39
+ # can remove them during migration. Runtime authorization never reads them.
40
+ writes_allowed_until: Optional[str] = None
41
+ writes_persistent: Optional[bool] = None
42
+
43
+ def to_json(self) -> str:
44
+ # Only emit fields that are set, so the file stays clean.
45
+ return json.dumps(
46
+ {k: v for k, v in asdict(self).items() if v is not None}, indent=2
47
+ )
48
+
49
+
50
+ def load() -> State:
51
+ """Read state from disk. Returns an empty State if the file is missing or unreadable."""
52
+ path = _state_path()
53
+ if not path.exists():
54
+ return State()
55
+ try:
56
+ data = json.loads(path.read_text())
57
+ except (json.JSONDecodeError, OSError):
58
+ # Corrupted file → treat as no state. Next save will overwrite cleanly.
59
+ return State()
60
+ return State(
61
+ active_site=data.get("active_site"),
62
+ set_at=data.get("set_at"),
63
+ set_by_cli_version=data.get("set_by_cli_version"),
64
+ writes_allowed_until=data.get("writes_allowed_until"),
65
+ writes_persistent=data.get("writes_persistent"),
66
+ )
67
+
68
+
69
+ def save(state: State) -> None:
70
+ """Write state to disk, creating the parent directory if needed."""
71
+ path = _state_path()
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(state.to_json())
74
+ # 0644 — readable, not group-writable. Active site isn't sensitive.
75
+ os.chmod(path, 0o644)
76
+
77
+
78
+ def set_active_site(site: str) -> State:
79
+ """Set (or replace) the active site and clear retired write-gate fields."""
80
+ state = State(
81
+ active_site=site,
82
+ set_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
83
+ set_by_cli_version=__version__,
84
+ )
85
+ save(state)
86
+ return state
87
+
88
+
89
+ def update_write_gate(*, until: Optional[str], persistent: Optional[bool]) -> State:
90
+ """Rewrite retired gate fields while preserving the active site.
91
+
92
+ Runtime authorization ignores these fields. Passing both values as ``None``
93
+ removes stale armed state written by older CLI versions.
94
+ """
95
+ existing = load()
96
+ state = State(
97
+ active_site=existing.active_site,
98
+ set_at=existing.set_at,
99
+ set_by_cli_version=existing.set_by_cli_version,
100
+ writes_allowed_until=until,
101
+ writes_persistent=persistent,
102
+ )
103
+ save(state)
104
+ return state
105
+
106
+
107
+ def clear() -> None:
108
+ """Remove the state file entirely. Idempotent."""
109
+ path = _state_path()
110
+ if path.exists():
111
+ path.unlink()
112
+
113
+
114
+ def active_site() -> Optional[str]:
115
+ """Return the currently selected site, or None."""
116
+ return load().active_site
117
+
118
+
119
+ def resolve_site(explicit: Optional[str]) -> str:
120
+ """Resolve the site to use for a command.
121
+
122
+ Precedence:
123
+ 1. The explicit `--site` flag (passed in)
124
+ 2. The active site from state.json
125
+ 3. Raise NoSiteSelectedError so the caller can render a friendly message
126
+ """
127
+ if explicit:
128
+ return explicit
129
+ selected = active_site()
130
+ if selected:
131
+ return selected
132
+ raise NoSiteSelectedError(
133
+ "no site selected. Run `publinker use <domain>` or pass --site <domain>."
134
+ )
135
+
136
+
137
+ class NoSiteSelectedError(Exception):
138
+ """Raised when a site-scoped command runs without a resolvable site."""
@@ -0,0 +1,58 @@
1
+ """CLI version comparison and the passive update notice (DEV-1973).
2
+
3
+ Every API request sends `User-Agent: publinker-cli/<version>`; publinker-api
4
+ answers with `X-Publinker-Cli-Latest`. `observe()` reads that header off a
5
+ response the command was making anyway — no extra request — and prints one
6
+ stderr line per process when a newer CLI exists. stdout and the exit status are
7
+ never touched, and a missing or malformed header is silently ignored.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import sys
14
+ from typing import Mapping, Optional
15
+
16
+ from publinker_cli import __version__, audience
17
+
18
+ USER_AGENT = f"publinker-cli/{__version__}"
19
+ LATEST_HEADER = "X-Publinker-Cli-Latest"
20
+
21
+ _VERSION_RE = re.compile(r"^\d+(\.\d+)*$")
22
+ _notified = False
23
+
24
+
25
+ def parse_version(value: str) -> tuple[int, ...]:
26
+ """`"0.10.0"` -> `(0, 10, 0)`. Raises ValueError for anything else."""
27
+ value = value.strip()
28
+ if not _VERSION_RE.match(value):
29
+ raise ValueError(f"not a release version: {value!r}")
30
+ parts = [int(p) for p in value.split(".")]
31
+ # Pad so "0.3" and "0.3.0" compare equal.
32
+ return tuple(parts + [0] * (3 - len(parts)))
33
+
34
+
35
+ def is_newer(candidate: str, installed: str = __version__) -> bool:
36
+ """True when `candidate` is strictly newer than `installed` (numeric order)."""
37
+ return parse_version(candidate) > parse_version(installed)
38
+
39
+
40
+ def observe(headers: Mapping[str, str]) -> None:
41
+ """Print the update notice once if the response advertises a newer CLI."""
42
+ global _notified
43
+ # A customer build has no self-update source (see commands/update.py),
44
+ # so pointing it at `publinker update` would only produce a refusal.
45
+ if _notified or audience.is_customer_build():
46
+ return
47
+ latest: Optional[str] = headers.get(LATEST_HEADER)
48
+ try:
49
+ if not latest or not is_newer(latest):
50
+ return
51
+ except ValueError:
52
+ return
53
+ _notified = True
54
+ print(
55
+ f"publinker-cli {latest} is available (installed {__version__}). "
56
+ "Run: publinker update",
57
+ file=sys.stderr,
58
+ )
@@ -0,0 +1,51 @@
1
+ """Process-scoped write-gate policy for publinker-cli.
2
+
3
+ Mutations are blocked unless the current process receives
4
+ ``PUBLINKER_ALLOW_WRITES=1`` (or another documented truthy value). Durable state
5
+ must never arm writes: a persisted allow window can outlive the reviewed command,
6
+ the authenticated user, or the intended API stage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+ from publinker_cli import state
15
+
16
+ ENV_VAR = "PUBLINKER_ALLOW_WRITES"
17
+ _TRUTHY = {"1", "true", "yes", "on"}
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class GateStatus:
22
+ """Snapshot of the process-scoped write-gate decision."""
23
+
24
+ allowed: bool
25
+ reason: str
26
+
27
+
28
+ def status() -> GateStatus:
29
+ """Return the current process gate without reading mutable state."""
30
+ value = os.environ.get(ENV_VAR, "").strip().lower()
31
+ if value in _TRUTHY:
32
+ return GateStatus(allowed=True, reason=f"process env {ENV_VAR}=1")
33
+ if value:
34
+ return GateStatus(allowed=False, reason=f"process env {ENV_VAR} is not enabled")
35
+ return GateStatus(allowed=False, reason="default (process gate absent)")
36
+
37
+
38
+ def is_allowed() -> bool:
39
+ """Return whether a mutation may run in this process."""
40
+ return status().allowed
41
+
42
+
43
+ def clear_legacy_state() -> GateStatus:
44
+ """Delete retired file-backed gate fields without changing the active site."""
45
+ snapshot = state.load()
46
+ if (
47
+ snapshot.writes_allowed_until is not None
48
+ or snapshot.writes_persistent is not None
49
+ ):
50
+ state.update_write_gate(until=None, persistent=None)
51
+ return GateStatus(allowed=False, reason="legacy file-backed gate cleared")
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: publinker-cli
3
+ Version: 0.6.2
4
+ Summary: Command line for Publinker: sites, reports, floors and config.
5
+ Project-URL: Homepage, https://publinker.com
6
+ Author: Martech-DM
7
+ License: Proprietary
8
+ Keywords: auth0,cli,martech,publinker
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: Other/Proprietary License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: keyring>=24.0.0
20
+ Requires-Dist: pyyaml>=6.0
21
+ Requires-Dist: requests>=2.31.0
22
+ Requires-Dist: typer[all]>=0.12.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: black==25.9.0; extra == 'dev'
25
+ Requires-Dist: flake8>=7.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: responses>=0.25; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # publinker-cli
33
+
34
+ Command line for [Publinker](https://publinker.com): your sites, ad revenue
35
+ reports, pricing floors and site config, from the terminal and from AI
36
+ assistants like Claude Code and Codex.
37
+
38
+ You need a Publinker account. The CLI only sees the sites you can see on
39
+ publinker.com.
40
+
41
+ ## Install
42
+
43
+ Needs Python 3.10+ and [pipx](https://pipx.pypa.io/stable/installation/).
44
+
45
+ ```bash
46
+ pipx install publinker-cli
47
+ publinker skill install # teaches Claude Code and Codex to use publinker
48
+ publinker login # opens a browser to confirm a one-time code
49
+ ```
50
+
51
+ Update with `pipx upgrade publinker-cli`.
52
+
53
+ ## Use
54
+
55
+ ```bash
56
+ publinker site list # sites you can access
57
+ publinker use example.com # set your active site
58
+ publinker site report get --help # every command has --help
59
+ publinker site pricing get
60
+ ```
61
+
62
+ Commands that change data are blocked by default. Allow one command at a time
63
+ by prefixing it with `PUBLINKER_ALLOW_WRITES=1` and naming the site explicitly:
64
+
65
+ ```bash
66
+ PUBLINKER_ALLOW_WRITES=1 publinker site pricing adjust --site example.com ...
67
+ ```
68
+
69
+ Nothing stays unlocked afterwards. Don't `export` the variable.
70
+
71
+ ## Servers and CI
72
+
73
+ On a machine where you are logged in, run `publinker token`, then on the
74
+ server set `PUBLINKER_TOKEN` to that value. Treat it like a password; mint a
75
+ new one when it expires.
76
+
77
+ ## AI assistants
78
+
79
+ `publinker skill install` copies a skill to `~/.claude/skills/publinker` and
80
+ `~/.agents/skills/publinker`. For any other assistant, point it at the file
81
+ that `publinker skill path` prints.
@@ -0,0 +1,26 @@
1
+ publinker_cli/__init__.py,sha256=lNxLRBBaSEJQvjLBcIMSzm_K43eNXI6tppU9FSwvQ9Y,526
2
+ publinker_cli/__main__.py,sha256=VVzUKqRBm-Gy-Rbcx5cRKslUQLZCrDs8FKRG1mP-RDs,2604
3
+ publinker_cli/audience.py,sha256=fY9PRciHKqdLsP_p9JGPjD4cCTiYFoEg5NGYDnwbcQ4,2611
4
+ publinker_cli/auth.py,sha256=rAqzsVp6UBhJsBg3ihUwV4OLpo9vpl5VNFn9x6mU83Y,5070
5
+ publinker_cli/client.py,sha256=adv89_23T3nDg4rZR_sLG43zkdd9wZL29qXEMk9qs7Q,6941
6
+ publinker_cli/config.py,sha256=Bs-DWTwpMpdESqQcMtOBRBYUb8soD4KQzRKlRDx_Bxw,1799
7
+ publinker_cli/dispatch_cli.py,sha256=1_k-6o93GLGlLAqkiYN4nhi-kPheJeA8dNG9t0q1pk8,14206
8
+ publinker_cli/keyring_store.py,sha256=9JQbJVHk_eG_1YphEJFSkAOnQ3TQWrBM58S_1GLF-rw,3456
9
+ publinker_cli/manifest.py,sha256=kA9pEMAyL_3op9UXEs-3uy-jbzsje-dHjOjzHMB_Xec,15151
10
+ publinker_cli/routes.yaml,sha256=TsAy1zubWAufd0zv3F5uHkXHO1LIHZLwo9N64jeesY8,38345
11
+ publinker_cli/state.py,sha256=_N9bA4kcCfb6LEzQopNKFsSV1gMtBtTKs-28hxWSCuY,4420
12
+ publinker_cli/version.py,sha256=8kSbaUgMEqYvf_VBAuFpBs-RZihEKZCY6kvAJ5U4TSw,2060
13
+ publinker_cli/write_gate.py,sha256=F6a-SWrfDBwsEIzAvSwFmWc0Yd9ATEzcvqpXuP5FKnY,1625
14
+ publinker_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ publinker_cli/commands/auth_cmds.py,sha256=i4SNh16FeAKdLp4wAHsjwWFtAK3pbCpePPNTfcjFr4Y,2216
16
+ publinker_cli/commands/report_options.py,sha256=pQf-llhG4J1t22zA6giyv27_d2-6G1UtGmgj5cxie_Q,3146
17
+ publinker_cli/commands/skill.py,sha256=XEcHag0d8uJYtHuo83IK3jH3NwLaiXFYteGe2h-Qt4s,1493
18
+ publinker_cli/commands/token_cmd.py,sha256=jJgvDEvKwFWnkqJMHmyeQunYUxnXnDD7YizLp6hmXmU,3398
19
+ publinker_cli/commands/update.py,sha256=ToPDP7EEvmmPtHhP1husYj7WeG85rTONAqMUZ3tFhJI,7456
20
+ publinker_cli/commands/use.py,sha256=I44yJsswDuUoRzBit3xoeIw18f_J3LNGiwqh58ZUYCA,3262
21
+ publinker_cli/commands/write.py,sha256=zVZ8HV_vfDGu8TITyN2R-2Xbly3JoOw1iEbTsYSkwA0,2292
22
+ publinker_cli/skill/SKILL.md,sha256=Gh7AgNE5EwrDkq3LB2TpvphSW1lm2cWxN1nuM-8tjsU,2411
23
+ publinker_cli-0.6.2.dist-info/METADATA,sha256=jYPPS1TMh1kbhI6j98RQ6A0tGFWkGuC2_q8aabN4H6U,2715
24
+ publinker_cli-0.6.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
25
+ publinker_cli-0.6.2.dist-info/entry_points.txt,sha256=N6mAgefPEmix3dV1y3cKNs815txQVjas1NVXIO5Hmn4,57
26
+ publinker_cli-0.6.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ publinker = publinker_cli.__main__:app