ivon-cli 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.
- ivon_cli/__init__.py +3 -0
- ivon_cli/api.py +266 -0
- ivon_cli/cmds/__init__.py +0 -0
- ivon_cli/cmds/auth.py +116 -0
- ivon_cli/cmds/install.py +72 -0
- ivon_cli/cmds/mcp.py +52 -0
- ivon_cli/cmds/meta.py +135 -0
- ivon_cli/config.py +127 -0
- ivon_cli/data/ivon_meta_ads_skill.md +125 -0
- ivon_cli/exit_codes.py +16 -0
- ivon_cli/main.py +67 -0
- ivon_cli-0.1.0.dist-info/METADATA +65 -0
- ivon_cli-0.1.0.dist-info/RECORD +16 -0
- ivon_cli-0.1.0.dist-info/WHEEL +5 -0
- ivon_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ivon_cli-0.1.0.dist-info/top_level.txt +1 -0
ivon_cli/__init__.py
ADDED
ivon_cli/api.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Thin HTTPS client for the ivon Meta Ads connector API.
|
|
2
|
+
|
|
3
|
+
Layers:
|
|
4
|
+
|
|
5
|
+
* ``ApiError`` / ``NotLoggedIn`` / ``TransportError`` — exception types
|
|
6
|
+
the command layer maps to exit codes.
|
|
7
|
+
* device-flow helpers (no auth) — start + poll the CLI login.
|
|
8
|
+
* ``McpClient`` — JSON-RPC 2.0 client for the hosted MCP server's
|
|
9
|
+
streamable-http endpoint (/mcp). The ivon API token rides in the
|
|
10
|
+
Authorization header; the server resolves it to the user's connected
|
|
11
|
+
Facebook Ads token per request.
|
|
12
|
+
|
|
13
|
+
The CLI is synchronous by design.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Iterator, Optional
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
from .config import Credentials, api_url, load_credentials
|
|
29
|
+
|
|
30
|
+
DEFAULT_TIMEOUT = httpx.Timeout(300.0, connect=15.0)
|
|
31
|
+
USER_AGENT = f"ivon-cli/{__version__}"
|
|
32
|
+
DEFAULT_HEADERS = {"User-Agent": USER_AGENT}
|
|
33
|
+
|
|
34
|
+
# Strip C0 + DEL + select C1 control chars from server-supplied error strings
|
|
35
|
+
# before echoing, so a hostile endpoint can't inject ANSI escape sequences.
|
|
36
|
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scrub(s: str) -> str:
|
|
40
|
+
return _CONTROL_CHAR_RE.sub("?", s)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiError(Exception):
|
|
44
|
+
"""HTTP-shaped error from the ivon API."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, status: int, body: Any):
|
|
47
|
+
self.status = status
|
|
48
|
+
self.body = body
|
|
49
|
+
super().__init__(self._human(body))
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _human(body: Any) -> str:
|
|
53
|
+
raw = ""
|
|
54
|
+
if isinstance(body, dict):
|
|
55
|
+
raw = str(
|
|
56
|
+
body.get("error_description")
|
|
57
|
+
or body.get("message")
|
|
58
|
+
or body.get("error")
|
|
59
|
+
or body
|
|
60
|
+
)
|
|
61
|
+
if not raw:
|
|
62
|
+
raw = str(body)
|
|
63
|
+
return _scrub(raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotLoggedIn(Exception):
|
|
67
|
+
"""No usable credentials on disk — caller should run `ivon auth login`."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TransportError(Exception):
|
|
71
|
+
"""Network-level failure (DNS, refused connection, read timeout)."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def raw_client(base: Optional[str] = None) -> Iterator[httpx.Client]:
|
|
76
|
+
base_url = (base or api_url()).rstrip("/")
|
|
77
|
+
with httpx.Client(
|
|
78
|
+
base_url=base_url,
|
|
79
|
+
timeout=DEFAULT_TIMEOUT,
|
|
80
|
+
headers=DEFAULT_HEADERS,
|
|
81
|
+
) as cx:
|
|
82
|
+
yield cx
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode(resp: httpx.Response) -> Any:
|
|
86
|
+
if not resp.content:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return resp.json()
|
|
90
|
+
except ValueError:
|
|
91
|
+
return resp.text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check(resp: httpx.Response) -> Any:
|
|
95
|
+
if resp.status_code >= 400:
|
|
96
|
+
raise ApiError(resp.status_code, _decode(resp))
|
|
97
|
+
return _decode(resp)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Device-flow helpers (no auth required)
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class DeviceStart:
|
|
107
|
+
device_code: str
|
|
108
|
+
user_code: str
|
|
109
|
+
verification_uri: str
|
|
110
|
+
verification_uri_complete: str
|
|
111
|
+
interval: int
|
|
112
|
+
expires_in: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def device_flow_start() -> DeviceStart:
|
|
116
|
+
try:
|
|
117
|
+
with raw_client() as cx:
|
|
118
|
+
data = _check(cx.post("/cli/auth/device/start"))
|
|
119
|
+
except httpx.RequestError as exc:
|
|
120
|
+
raise TransportError(f"cannot reach {api_url()}: {type(exc).__name__}: {exc}") from exc
|
|
121
|
+
return DeviceStart(**{k: data[k] for k in DeviceStart.__dataclass_fields__})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def device_flow_poll_once(device_code: str) -> Optional[dict]:
|
|
125
|
+
"""Single poll. Returns credentials dict on success, None if pending."""
|
|
126
|
+
try:
|
|
127
|
+
with raw_client() as cx:
|
|
128
|
+
resp = cx.get("/cli/auth/device/poll", params={"device_code": device_code})
|
|
129
|
+
except httpx.RequestError as exc:
|
|
130
|
+
raise TransportError(f"cannot reach {api_url()}: {type(exc).__name__}: {exc}") from exc
|
|
131
|
+
if resp.status_code == 200:
|
|
132
|
+
return resp.json()
|
|
133
|
+
body = _decode(resp)
|
|
134
|
+
if isinstance(body, dict) and body.get("error") == "authorization_pending":
|
|
135
|
+
return None
|
|
136
|
+
raise ApiError(resp.status_code, body)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def device_flow_wait(device_code: str, interval: int, deadline_s: int = 600) -> dict:
|
|
140
|
+
"""Block until the API token is released or the device code expires."""
|
|
141
|
+
interval = max(1, int(interval))
|
|
142
|
+
start = time.time()
|
|
143
|
+
while True:
|
|
144
|
+
result = device_flow_poll_once(device_code)
|
|
145
|
+
if result is not None:
|
|
146
|
+
return result
|
|
147
|
+
if time.time() - start > deadline_s:
|
|
148
|
+
raise ApiError(408, {"error": "expired", "error_description": "Polling deadline reached"})
|
|
149
|
+
time.sleep(interval)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# MCP client (JSON-RPC 2.0 over streamable HTTP)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_effective_target(creds: Credentials) -> Credentials:
|
|
158
|
+
"""Refuse to send the saved token to a host it wasn't issued against."""
|
|
159
|
+
import os as _os
|
|
160
|
+
import sys as _sys
|
|
161
|
+
from urllib.parse import urlparse as _urlparse
|
|
162
|
+
|
|
163
|
+
env_url = (_os.environ.get("IVON_API_URL") or "").rstrip("/")
|
|
164
|
+
if not env_url or env_url == creds.api_url.rstrip("/"):
|
|
165
|
+
return creds
|
|
166
|
+
|
|
167
|
+
host = (_urlparse(env_url).hostname or "").lower()
|
|
168
|
+
loopback = host in ("localhost", "127.0.0.1", "::1") or host.endswith(".localhost")
|
|
169
|
+
allowed = _os.environ.get("IVON_ALLOW_CUSTOM_API", "") == "1"
|
|
170
|
+
if not (loopback or allowed):
|
|
171
|
+
raise ApiError(
|
|
172
|
+
0,
|
|
173
|
+
f"IVON_API_URL={env_url} differs from the host you are logged into "
|
|
174
|
+
f"({creds.api_url}). Refusing to send your saved token to a different "
|
|
175
|
+
"host. Re-run `ivon auth login` against it, unset IVON_API_URL, or "
|
|
176
|
+
"set IVON_ALLOW_CUSTOM_API=1 if intentional.",
|
|
177
|
+
)
|
|
178
|
+
print(
|
|
179
|
+
f"ivon: targeting {env_url} (IVON_API_URL override; logged into {creds.api_url})",
|
|
180
|
+
file=_sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
return Credentials(api_token=creds.api_token, email=creds.email, api_url=env_url)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class McpClient:
|
|
186
|
+
"""JSON-RPC 2.0 client for the hosted ivon Meta Ads MCP server."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, creds: Credentials, timeout_seconds: Optional[float] = None):
|
|
189
|
+
self.creds = creds
|
|
190
|
+
timeout = (
|
|
191
|
+
httpx.Timeout(float(timeout_seconds), connect=15.0)
|
|
192
|
+
if timeout_seconds is not None
|
|
193
|
+
else DEFAULT_TIMEOUT
|
|
194
|
+
)
|
|
195
|
+
self._cx = httpx.Client(
|
|
196
|
+
base_url=creds.api_url.rstrip("/"),
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
headers={
|
|
199
|
+
**DEFAULT_HEADERS,
|
|
200
|
+
"Authorization": f"Bearer {creds.api_token}",
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
"Accept": "application/json, text/event-stream",
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
self._next_id = 1
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
self._cx.close()
|
|
209
|
+
|
|
210
|
+
def __enter__(self) -> "McpClient":
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def __exit__(self, *exc) -> None:
|
|
214
|
+
self.close()
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_disk(cls, timeout_seconds: Optional[float] = None) -> "McpClient":
|
|
218
|
+
creds = load_credentials()
|
|
219
|
+
if not creds:
|
|
220
|
+
raise NotLoggedIn("No credentials found — run `ivon auth login` first.")
|
|
221
|
+
creds = _resolve_effective_target(creds)
|
|
222
|
+
return cls(creds, timeout_seconds=timeout_seconds)
|
|
223
|
+
|
|
224
|
+
def rpc(self, method: str, params: Optional[dict] = None) -> Any:
|
|
225
|
+
body = {"jsonrpc": "2.0", "method": method, "id": self._next_id}
|
|
226
|
+
if params is not None:
|
|
227
|
+
body["params"] = params
|
|
228
|
+
self._next_id += 1
|
|
229
|
+
try:
|
|
230
|
+
resp = self._cx.post("/mcp", json=body)
|
|
231
|
+
except httpx.RequestError as exc:
|
|
232
|
+
raise TransportError(
|
|
233
|
+
f"cannot reach {self._cx.base_url}: {type(exc).__name__}: {exc}"
|
|
234
|
+
) from exc
|
|
235
|
+
if resp.status_code == 401:
|
|
236
|
+
raise NotLoggedIn(
|
|
237
|
+
"The server rejected your ivon API token — it may have been "
|
|
238
|
+
"regenerated. Run `ivon auth login` again."
|
|
239
|
+
)
|
|
240
|
+
data = _check(resp)
|
|
241
|
+
if isinstance(data, dict) and data.get("error"):
|
|
242
|
+
raise ApiError(resp.status_code, data["error"])
|
|
243
|
+
return data.get("result") if isinstance(data, dict) else data
|
|
244
|
+
|
|
245
|
+
# Higher-level wrappers ---------------------------------------------
|
|
246
|
+
|
|
247
|
+
def tools_list(self) -> list:
|
|
248
|
+
result = self.rpc("tools/list")
|
|
249
|
+
return result.get("tools", []) if isinstance(result, dict) else []
|
|
250
|
+
|
|
251
|
+
def tools_call(self, name: str, arguments: Optional[dict] = None) -> Any:
|
|
252
|
+
result = self.rpc("tools/call", {"name": name, "arguments": arguments or {}})
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
def tool_text(self, name: str, arguments: Optional[dict] = None) -> Any:
|
|
256
|
+
"""Call a tool and return its text payload, JSON-decoded when possible."""
|
|
257
|
+
result = self.tools_call(name, arguments)
|
|
258
|
+
if not isinstance(result, dict):
|
|
259
|
+
return result
|
|
260
|
+
content = result.get("content") or []
|
|
261
|
+
texts = [c.get("text", "") for c in content if c.get("type") == "text"]
|
|
262
|
+
joined = "\n".join(t for t in texts if t)
|
|
263
|
+
try:
|
|
264
|
+
return json.loads(joined)
|
|
265
|
+
except (ValueError, TypeError):
|
|
266
|
+
return joined or result
|
|
File without changes
|
ivon_cli/cmds/auth.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`ivon auth` — login / logout / status via the device-code flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..api import McpClient, NotLoggedIn, device_flow_start, device_flow_wait
|
|
11
|
+
from ..config import (
|
|
12
|
+
Credentials,
|
|
13
|
+
api_url,
|
|
14
|
+
clear_credentials,
|
|
15
|
+
credentials_path,
|
|
16
|
+
is_default_api_url,
|
|
17
|
+
is_loopback_url,
|
|
18
|
+
is_safe_custom_api_url,
|
|
19
|
+
load_credentials,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Authenticate the ivon CLI.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def login(
|
|
27
|
+
allow_custom_api: bool = typer.Option(
|
|
28
|
+
False,
|
|
29
|
+
"--allow-custom-api",
|
|
30
|
+
help="Permit logging in against a non-default IVON_API_URL endpoint.",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Log in via your browser (Google sign-in on ivon.ai)."""
|
|
34
|
+
target = api_url()
|
|
35
|
+
if not is_default_api_url():
|
|
36
|
+
# Loopback is the documented local-dev flow — allow with a notice.
|
|
37
|
+
# Anything else is the endpoint-phishing vector: require https plus
|
|
38
|
+
# an explicit opt-in before sending a fresh login there.
|
|
39
|
+
if is_loopback_url(target):
|
|
40
|
+
typer.secho(f"ivon: logging in against local endpoint {target}", err=True)
|
|
41
|
+
else:
|
|
42
|
+
allowed = allow_custom_api or os.environ.get("IVON_ALLOW_CUSTOM_API", "") == "1"
|
|
43
|
+
if not is_safe_custom_api_url(target) or not allowed:
|
|
44
|
+
typer.secho(
|
|
45
|
+
f"ivon: refusing to authenticate against custom endpoint {target}.\n"
|
|
46
|
+
"Pass --allow-custom-api (or set IVON_ALLOW_CUSTOM_API=1) if this "
|
|
47
|
+
"is intentional.",
|
|
48
|
+
fg=typer.colors.RED,
|
|
49
|
+
err=True,
|
|
50
|
+
)
|
|
51
|
+
raise typer.Exit(code=4)
|
|
52
|
+
typer.secho(f"ivon: logging in against custom endpoint {target}", err=True)
|
|
53
|
+
|
|
54
|
+
start = device_flow_start()
|
|
55
|
+
typer.echo("To sign in, open this URL in your browser:")
|
|
56
|
+
typer.secho(f" {start.verification_uri_complete}", fg=typer.colors.CYAN)
|
|
57
|
+
typer.echo("and confirm this one-time code:")
|
|
58
|
+
typer.secho(f" {start.user_code}", fg=typer.colors.GREEN, bold=True)
|
|
59
|
+
try:
|
|
60
|
+
webbrowser.open(start.verification_uri_complete)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass # best-effort; the URL is printed above
|
|
63
|
+
|
|
64
|
+
typer.echo("Waiting for approval…")
|
|
65
|
+
result = device_flow_wait(start.device_code, interval=start.interval, deadline_s=start.expires_in)
|
|
66
|
+
|
|
67
|
+
creds = Credentials(
|
|
68
|
+
api_token=result["api_token"],
|
|
69
|
+
email=result.get("email", ""),
|
|
70
|
+
api_url=target,
|
|
71
|
+
)
|
|
72
|
+
from ..config import save_credentials
|
|
73
|
+
|
|
74
|
+
save_credentials(creds)
|
|
75
|
+
typer.secho(f"Logged in as {creds.email or '(unknown)'}", fg=typer.colors.GREEN)
|
|
76
|
+
typer.echo(f"Credentials saved to {credentials_path()}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def logout() -> None:
|
|
81
|
+
"""Remove the saved credentials from this machine."""
|
|
82
|
+
if clear_credentials():
|
|
83
|
+
typer.echo("Logged out — credentials removed.")
|
|
84
|
+
else:
|
|
85
|
+
typer.echo("No credentials were stored.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def status() -> None:
|
|
90
|
+
"""Show who you're logged in as and whether Facebook Ads is connected."""
|
|
91
|
+
creds = load_credentials()
|
|
92
|
+
if not creds:
|
|
93
|
+
typer.echo("Not logged in. Run `ivon auth login`.")
|
|
94
|
+
raise typer.Exit(code=4)
|
|
95
|
+
typer.echo(f"Logged in as: {creds.email or '(unknown)'}")
|
|
96
|
+
typer.echo(f"API endpoint: {creds.api_url}")
|
|
97
|
+
typer.echo(f"API token: {creds.api_token[:12]}…")
|
|
98
|
+
try:
|
|
99
|
+
with McpClient.from_disk(timeout_seconds=30) as client:
|
|
100
|
+
info = client.tool_text("get_login_link")
|
|
101
|
+
if isinstance(info, dict):
|
|
102
|
+
method = info.get("authentication_method", "")
|
|
103
|
+
if "Already" in str(info.get("message", "")):
|
|
104
|
+
fb = info.get("facebook_user")
|
|
105
|
+
typer.secho(
|
|
106
|
+
f"Facebook Ads: connected{f' ({fb})' if fb else ''}",
|
|
107
|
+
fg=typer.colors.GREEN,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
url = info.get("login_url") or info.get("sign_in_url") or ""
|
|
111
|
+
typer.secho("Facebook Ads: not connected", fg=typer.colors.YELLOW)
|
|
112
|
+
if url:
|
|
113
|
+
typer.echo(f"Connect at: {url}")
|
|
114
|
+
except NotLoggedIn as exc:
|
|
115
|
+
typer.secho(f"Token check failed: {exc}", fg=typer.colors.RED, err=True)
|
|
116
|
+
raise typer.Exit(code=4)
|
ivon_cli/cmds/install.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`ivon install skill` — drop the ivon Meta Ads agent guide into a project.
|
|
2
|
+
|
|
3
|
+
The usage guide bundled in the wheel (data/ivon_meta_ads_skill.md) is
|
|
4
|
+
written where each agent runner natively discovers it:
|
|
5
|
+
|
|
6
|
+
.claude/skills/ivon-meta-ads/SKILL.md Claude Code skill (frontmatter kept)
|
|
7
|
+
.codex/prompts/ivon-meta-ads.md Codex prompt → /ivon-meta-ads
|
|
8
|
+
.agents/ivon-meta-ads.md generic agent guide (plain markdown)
|
|
9
|
+
|
|
10
|
+
Default target is the current directory; pass --path to install elsewhere
|
|
11
|
+
(e.g. `ivon install skill --path ~` for a user-global install).
|
|
12
|
+
|
|
13
|
+
The bundled copy is regenerated from .claude/skills/ivon-meta-ads/SKILL.md
|
|
14
|
+
in the ivon repo (the canonical source) at release time.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from importlib import resources
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Install ivon agent assets locally.", no_args_is_help=True)
|
|
26
|
+
|
|
27
|
+
# (label, relative target path, keep YAML frontmatter?)
|
|
28
|
+
_TARGETS: List[Tuple[str, str, bool]] = [
|
|
29
|
+
("Claude Code", ".claude/skills/ivon-meta-ads/SKILL.md", True),
|
|
30
|
+
("Codex", ".codex/prompts/ivon-meta-ads.md", False),
|
|
31
|
+
("Agents", ".agents/ivon-meta-ads.md", False),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bundled_skill() -> str:
|
|
36
|
+
return (
|
|
37
|
+
resources.files("ivon_cli")
|
|
38
|
+
.joinpath("data/ivon_meta_ads_skill.md")
|
|
39
|
+
.read_text(encoding="utf-8")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _strip_frontmatter(text: str) -> str:
|
|
44
|
+
"""Drop a leading ----delimited YAML block (only Claude Code reads it)."""
|
|
45
|
+
if not text.startswith("---"):
|
|
46
|
+
return text
|
|
47
|
+
end = text.find("\n---", 3)
|
|
48
|
+
if end == -1:
|
|
49
|
+
return text
|
|
50
|
+
return text[end + 4 :].lstrip("\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def skill(
|
|
55
|
+
path: Optional[str] = typer.Option(
|
|
56
|
+
None, "--path", help="Directory to install into (default: current directory)."
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Install the ivon Meta Ads usage guide for Claude Code, Codex, and agents."""
|
|
60
|
+
root = Path(path).expanduser() if path else Path.cwd()
|
|
61
|
+
content = _bundled_skill()
|
|
62
|
+
|
|
63
|
+
for label, rel_path, keep_frontmatter in _TARGETS:
|
|
64
|
+
target = root / rel_path
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target.write_text(
|
|
67
|
+
content if keep_frontmatter else _strip_frontmatter(content),
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
typer.secho(f" {label:12} → {target}", fg=typer.colors.GREEN)
|
|
71
|
+
|
|
72
|
+
typer.echo("\nInstalled. Claude Code picks it up automatically; restart the session if open.")
|
ivon_cli/cmds/mcp.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""`ivon mcp` — helpers for wiring the hosted MCP server into MCP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..api import NotLoggedIn
|
|
10
|
+
from ..config import load_credentials
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="MCP client configuration helpers.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def config(
|
|
17
|
+
show_token: bool = typer.Option(
|
|
18
|
+
False,
|
|
19
|
+
"--show-token",
|
|
20
|
+
help="Embed your real API token (otherwise a placeholder is printed).",
|
|
21
|
+
),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Print MCP client config for the hosted ivon Meta Ads server."""
|
|
24
|
+
creds = load_credentials()
|
|
25
|
+
if not creds:
|
|
26
|
+
raise NotLoggedIn("No credentials found — run `ivon auth login` first.")
|
|
27
|
+
|
|
28
|
+
token = creds.api_token if show_token else "<your ivon API token>"
|
|
29
|
+
url = f"{creds.api_url}/mcp"
|
|
30
|
+
|
|
31
|
+
typer.secho("# Claude Code", bold=True)
|
|
32
|
+
typer.echo(
|
|
33
|
+
f'claude mcp add --transport http ivon-meta-ads "{url}" '
|
|
34
|
+
f'--header "Authorization: Bearer {token}"'
|
|
35
|
+
)
|
|
36
|
+
typer.echo("")
|
|
37
|
+
typer.secho("# Claude Desktop / generic MCP client (mcpServers entry)", bold=True)
|
|
38
|
+
typer.echo(
|
|
39
|
+
json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"ivon-meta-ads": {
|
|
42
|
+
"type": "http",
|
|
43
|
+
"url": url,
|
|
44
|
+
"headers": {"Authorization": f"Bearer {token}"},
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
indent=2,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
if not show_token:
|
|
51
|
+
typer.echo("")
|
|
52
|
+
typer.echo("Your token is on https://ivon.ai/profile — or rerun with --show-token.")
|
ivon_cli/cmds/meta.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""`ivon meta` — Meta Ads commands wrapping the hosted MCP server's tools.
|
|
2
|
+
|
|
3
|
+
Every subcommand is a thin shell over `tools/call` on the ivon Meta Ads MCP
|
|
4
|
+
server. `ivon meta tools` lists the full surface; `ivon meta call` invokes
|
|
5
|
+
any tool generically with `-p key=value` params, so the CLI never lags the
|
|
6
|
+
MCP server's capabilities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, List, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from ..api import McpClient
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Meta (Facebook) Ads commands.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_params(params: List[str]) -> dict:
|
|
22
|
+
"""Parse repeated -p key=value flags. Values parse as JSON when possible
|
|
23
|
+
(numbers, booleans, objects, arrays), else as strings."""
|
|
24
|
+
out: dict = {}
|
|
25
|
+
for raw in params:
|
|
26
|
+
if "=" not in raw:
|
|
27
|
+
raise typer.BadParameter(f"expected key=value, got {raw!r}")
|
|
28
|
+
key, value = raw.split("=", 1)
|
|
29
|
+
try:
|
|
30
|
+
out[key] = json.loads(value)
|
|
31
|
+
except ValueError:
|
|
32
|
+
out[key] = value
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit(result: Any) -> None:
|
|
37
|
+
if isinstance(result, (dict, list)):
|
|
38
|
+
typer.echo(json.dumps(result, indent=2))
|
|
39
|
+
else:
|
|
40
|
+
typer.echo(str(result))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def tools() -> None:
|
|
45
|
+
"""List all tools exposed by the ivon Meta Ads MCP server."""
|
|
46
|
+
with McpClient.from_disk(timeout_seconds=60) as client:
|
|
47
|
+
for tool in client.tools_list():
|
|
48
|
+
desc = (tool.get("description") or "").strip().splitlines()
|
|
49
|
+
first = desc[0].strip() if desc else ""
|
|
50
|
+
typer.echo(f"{tool['name']:32} {first}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def call(
|
|
55
|
+
tool: str = typer.Argument(..., help="Tool name (see `ivon meta tools`)."),
|
|
56
|
+
params: List[str] = typer.Option(
|
|
57
|
+
[], "--param", "-p", help="Tool argument as key=value (value parsed as JSON when possible). Repeatable."
|
|
58
|
+
),
|
|
59
|
+
timeout: float = typer.Option(300.0, "--timeout", help="Request timeout in seconds."),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Call any MCP tool generically."""
|
|
62
|
+
arguments = _parse_params(params)
|
|
63
|
+
with McpClient.from_disk(timeout_seconds=timeout) as client:
|
|
64
|
+
_emit(client.tool_text(tool, arguments))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def accounts(
|
|
69
|
+
limit: int = typer.Option(10, help="Maximum number of accounts to return."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""List the ad accounts your connected Facebook user can access."""
|
|
72
|
+
with McpClient.from_disk() as client:
|
|
73
|
+
_emit(client.tool_text("get_ad_accounts", {"limit": limit}))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def campaigns(
|
|
78
|
+
account: str = typer.Option(..., "--account", "-a", help="Ad account id (act_… prefix optional)."),
|
|
79
|
+
status: Optional[str] = typer.Option(None, help="Filter: ACTIVE, PAUSED, ARCHIVED…"),
|
|
80
|
+
limit: int = typer.Option(10, help="Maximum number of campaigns."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""List campaigns in an ad account."""
|
|
83
|
+
args: dict = {"account_id": account, "limit": limit}
|
|
84
|
+
if status:
|
|
85
|
+
args["status_filter"] = status
|
|
86
|
+
with McpClient.from_disk() as client:
|
|
87
|
+
_emit(client.tool_text("get_campaigns", args))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def adsets(
|
|
92
|
+
account: str = typer.Option(..., "--account", "-a", help="Ad account id."),
|
|
93
|
+
campaign: Optional[str] = typer.Option(None, "--campaign", "-c", help="Campaign id to filter by."),
|
|
94
|
+
limit: int = typer.Option(10, help="Maximum number of ad sets."),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""List ad sets in an account (optionally for one campaign)."""
|
|
97
|
+
args: dict = {"account_id": account, "limit": limit}
|
|
98
|
+
if campaign:
|
|
99
|
+
args["campaign_id"] = campaign
|
|
100
|
+
with McpClient.from_disk() as client:
|
|
101
|
+
_emit(client.tool_text("get_adsets", args))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def ads(
|
|
106
|
+
account: str = typer.Option(..., "--account", "-a", help="Ad account id."),
|
|
107
|
+
campaign: Optional[str] = typer.Option(None, "--campaign", "-c", help="Campaign id to filter by."),
|
|
108
|
+
adset: Optional[str] = typer.Option(None, "--adset", help="Ad set id to filter by."),
|
|
109
|
+
limit: int = typer.Option(10, help="Maximum number of ads."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""List ads in an account (optionally for one campaign / ad set)."""
|
|
112
|
+
args: dict = {"account_id": account, "limit": limit}
|
|
113
|
+
if campaign:
|
|
114
|
+
args["campaign_id"] = campaign
|
|
115
|
+
if adset:
|
|
116
|
+
args["adset_id"] = adset
|
|
117
|
+
with McpClient.from_disk() as client:
|
|
118
|
+
_emit(client.tool_text("get_ads", args))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def insights(
|
|
123
|
+
object_id: str = typer.Argument(..., help="Account (act_…), campaign, ad set, or ad id."),
|
|
124
|
+
time_range: str = typer.Option("last_30d", "--time-range", "-t", help="Preset: today, yesterday, last_7d, last_30d, …"),
|
|
125
|
+
breakdown: Optional[str] = typer.Option(None, help="Optional breakdown, e.g. age, gender, country."),
|
|
126
|
+
level: Optional[str] = typer.Option(None, help="Aggregation level: ad, adset, campaign, account."),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Get performance insights for an account, campaign, ad set, or ad."""
|
|
129
|
+
args: dict = {"object_id": object_id, "time_range": time_range}
|
|
130
|
+
if breakdown:
|
|
131
|
+
args["breakdown"] = breakdown
|
|
132
|
+
if level:
|
|
133
|
+
args["level"] = level
|
|
134
|
+
with McpClient.from_disk() as client:
|
|
135
|
+
_emit(client.tool_text("get_insights", args))
|
ivon_cli/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""ivon CLI — runtime config + on-disk credentials.
|
|
2
|
+
|
|
3
|
+
Two responsibilities:
|
|
4
|
+
|
|
5
|
+
1. Resolve the API base URL (env override → Modal prod default).
|
|
6
|
+
2. Persist the ivon API token in `~/.ivon/credentials` with strict 0600
|
|
7
|
+
permissions, written atomically (tempfile + rename).
|
|
8
|
+
|
|
9
|
+
**Security note** (mirrors the rgba CLI's V1/V4 review): ``IVON_API_URL``
|
|
10
|
+
and ``IVON_CONFIG_DIR`` are honored as runtime overrides, but a hostile env
|
|
11
|
+
could use either to phish a fresh login or redirect token storage. The
|
|
12
|
+
login flow refuses non-default, non-loopback endpoints unless
|
|
13
|
+
``IVON_ALLOW_CUSTOM_API=1`` is set, and authed calls refuse to send the
|
|
14
|
+
saved token to a host other than the one it was issued against.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import asdict, dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
from urllib.parse import urlparse
|
|
27
|
+
|
|
28
|
+
# Default points at the deployed Modal app. Override at runtime with
|
|
29
|
+
# IVON_API_URL=http://127.0.0.1:8765 for local development.
|
|
30
|
+
DEFAULT_API_URL = "https://lemontree-media-llc--ivon-meta-ads-mcp-app.modal.run"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def api_url() -> str:
|
|
34
|
+
return os.environ.get("IVON_API_URL", DEFAULT_API_URL).rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_default_api_url() -> bool:
|
|
38
|
+
return api_url() == DEFAULT_API_URL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_loopback_url(url: str) -> bool:
|
|
42
|
+
"""True for localhost/127.0.0.1/::1 endpoints — the local-dev flow."""
|
|
43
|
+
host = (urlparse(url).hostname or "").lower()
|
|
44
|
+
return host in ("localhost", "127.0.0.1", "::1") or host.endswith(".localhost")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_safe_custom_api_url(url: str) -> bool:
|
|
48
|
+
"""Accept an explicit override only if it's https *or* loopback."""
|
|
49
|
+
parsed = urlparse(url)
|
|
50
|
+
if parsed.scheme not in ("http", "https"):
|
|
51
|
+
return False
|
|
52
|
+
if parsed.scheme == "https":
|
|
53
|
+
return True
|
|
54
|
+
return is_loopback_url(url)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def config_dir() -> Path:
|
|
58
|
+
override = os.environ.get("IVON_CONFIG_DIR")
|
|
59
|
+
if override:
|
|
60
|
+
return Path(override)
|
|
61
|
+
return Path.home() / ".ivon"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def credentials_path() -> Path:
|
|
65
|
+
return config_dir() / "credentials"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Credentials:
|
|
70
|
+
api_token: str
|
|
71
|
+
email: str
|
|
72
|
+
api_url: str
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return asdict(self)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> "Credentials":
|
|
79
|
+
return cls(
|
|
80
|
+
api_token=data["api_token"],
|
|
81
|
+
email=data.get("email", ""),
|
|
82
|
+
api_url=data.get("api_url", DEFAULT_API_URL),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_credentials() -> Optional[Credentials]:
|
|
87
|
+
path = credentials_path()
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
with path.open("r") as f:
|
|
92
|
+
return Credentials.from_dict(json.load(f))
|
|
93
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
94
|
+
# Corrupt or unreadable — treat as logged-out.
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_credentials(creds: Credentials) -> None:
|
|
99
|
+
"""Atomic 0600 write into ~/.ivon/credentials."""
|
|
100
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
101
|
+
target = credentials_path()
|
|
102
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
103
|
+
prefix=".credentials.", dir=str(config_dir()), text=True
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(fd, "w") as f:
|
|
107
|
+
json.dump(creds.to_dict(), f, indent=2)
|
|
108
|
+
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
109
|
+
os.replace(tmp_path, target)
|
|
110
|
+
except Exception:
|
|
111
|
+
try:
|
|
112
|
+
os.unlink(tmp_path)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clear_credentials() -> bool:
|
|
119
|
+
"""Delete ~/.ivon/credentials. Returns True if a file was removed."""
|
|
120
|
+
path = credentials_path()
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return False
|
|
123
|
+
try:
|
|
124
|
+
path.unlink()
|
|
125
|
+
return True
|
|
126
|
+
except OSError:
|
|
127
|
+
return False
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ivon-meta-ads
|
|
3
|
+
description: ivon Meta Ads connector — manage Facebook/Instagram ads via the `ivon` CLI or the hosted MCP server (37 tools; campaigns, ad sets, creatives, insights, targeting). Triggers: "facebook ads", "meta ads", "instagram ads", "launch a campaign", "ad performance / insights", "audience targeting", "use the ivon CLI".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ivon Meta Ads connector
|
|
7
|
+
|
|
8
|
+
One hosted MCP server (Modal) exposes the user's Facebook Ads account as 37
|
|
9
|
+
tools. Two ways in: the `ivon` CLI (shell) or MCP (`/mcp` endpoint, Bearer
|
|
10
|
+
auth). Both use the same long-lived ivon API token; the server resolves it
|
|
11
|
+
to the user's connected Facebook token per request — you never handle
|
|
12
|
+
Facebook credentials.
|
|
13
|
+
|
|
14
|
+
## Install + auth
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install ivon-cli # or: pip install -e cli/ (from the ivon repo)
|
|
18
|
+
|
|
19
|
+
ivon auth login # device flow → browser → Google sign-in on ivon.ai
|
|
20
|
+
ivon auth status # whoami + Facebook Ads connection state
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If status says Facebook Ads is **not connected**, stop and send the user to
|
|
24
|
+
https://ivon.ai/profile?tab=connectors — only they can grant ads access.
|
|
25
|
+
Tool calls in that state return `{"error": {"type": "not_connected", ...}}`
|
|
26
|
+
with the same URL; relay it, don't retry.
|
|
27
|
+
|
|
28
|
+
## MCP client setup (alternative to the CLI)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ivon mcp config # prints ready-to-paste Claude Code / Desktop config
|
|
32
|
+
# essentially:
|
|
33
|
+
claude mcp add --transport http ivon-meta-ads \
|
|
34
|
+
"https://lemontree-media-llc--ivon-meta-ads-mcp-app.modal.run/mcp" \
|
|
35
|
+
--header "Authorization: Bearer <ivon API token>"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Discover + call any tool
|
|
39
|
+
|
|
40
|
+
The CLI is a thin wrapper over the MCP tools — these two commands reach the
|
|
41
|
+
entire surface, so prefer them over guessing:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
ivon meta tools # list all 37 tools (one line each)
|
|
45
|
+
ivon meta call <tool> -p key=value … # call any tool; values parse as JSON
|
|
46
|
+
ivon meta call create_adset -p account_id=act_1 \
|
|
47
|
+
-p targeting='{"geo_locations":{"countries":["US"]}}' …
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Convenience verbs for the common reads:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ivon meta accounts # ad accounts
|
|
54
|
+
ivon meta campaigns -a act_123 [--status ACTIVE]
|
|
55
|
+
ivon meta adsets -a act_123 [-c <campaign_id>]
|
|
56
|
+
ivon meta ads -a act_123 [--adset <adset_id>]
|
|
57
|
+
ivon meta insights act_123 -t last_7d [--breakdown age] [--level campaign]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Tool map (by intent)
|
|
61
|
+
|
|
62
|
+
| Intent | Tools |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Orient | `get_ad_accounts`, `get_account_info`, `get_account_pages` |
|
|
65
|
+
| Read structure | `get_campaigns`, `get_adsets`, `get_ads`, `get_*_details` |
|
|
66
|
+
| Performance | `get_insights` (presets: today, yesterday, last_7d, last_30d, last_90d, maximum…; breakdowns: age, gender, country, publisher_platform…) |
|
|
67
|
+
| Research audience | `search_interests`, `get_interest_suggestions`, `search_behaviors`, `search_demographics`, `search_geo_locations`, `estimate_audience_size` |
|
|
68
|
+
| Build | `upload_ad_image`, `compute_image_crops`, `create_campaign`, `create_adset`, `create_ad_creative`, `create_ad` |
|
|
69
|
+
| Operate | `update_campaign`, `update_adset`, `update_ad`, `update_ad_creative`, `create_budget_schedule` |
|
|
70
|
+
| Inspect creative | `get_ad_creatives`, `get_creative_details`, `get_ad_image`, `get_image_by_hash`, `get_ad_video` |
|
|
71
|
+
| Competitors | `search_ads_archive` (public Ad Library) |
|
|
72
|
+
| Connection | `get_login_link` (status + connect URL) |
|
|
73
|
+
|
|
74
|
+
## Launch workflow (the full chain)
|
|
75
|
+
|
|
76
|
+
Order matters — each step needs an id from the previous one:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# 1. account + page identity
|
|
80
|
+
ivon meta accounts # → act_<id>
|
|
81
|
+
ivon meta call get_account_pages -p account_id=act_X # → page_id (ads run under a Page)
|
|
82
|
+
|
|
83
|
+
# 2. campaign (objective: OUTCOME_TRAFFIC | OUTCOME_SALES | OUTCOME_LEADS |
|
|
84
|
+
# OUTCOME_AWARENESS | OUTCOME_ENGAGEMENT | OUTCOME_APP_PROMOTION)
|
|
85
|
+
ivon meta call create_campaign -p account_id=act_X -p name="Spring Sale" \
|
|
86
|
+
-p objective=OUTCOME_TRAFFIC # → campaign_id
|
|
87
|
+
|
|
88
|
+
# 3. ad set — budget lives here unless the campaign got one (CBO)
|
|
89
|
+
ivon meta call create_adset -p account_id=act_X -p campaign_id=<id> \
|
|
90
|
+
-p name="US broad" -p optimization_goal=LINK_CLICKS -p billing_event=IMPRESSIONS \
|
|
91
|
+
-p daily_budget=2000 -p targeting='{"geo_locations":{"countries":["US"]},"age_min":18}'
|
|
92
|
+
|
|
93
|
+
# 4. creative — upload image first, then build under the Page
|
|
94
|
+
ivon meta call upload_ad_image -p account_id=act_X -p image_url=https://… # → hash
|
|
95
|
+
ivon meta call create_ad_creative -p account_id=act_X -p page_id=<page_id> \
|
|
96
|
+
-p image_hash=<hash> -p message="Primary text" -p headline="Headline" \
|
|
97
|
+
-p link_url=https://example.com -p call_to_action_type=SHOP_NOW # → creative_id
|
|
98
|
+
|
|
99
|
+
# 5. ad
|
|
100
|
+
ivon meta call create_ad -p account_id=act_X -p adset_id=<id> \
|
|
101
|
+
-p creative_id=<id> -p name="Spring Sale — img A"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Hard rules
|
|
105
|
+
|
|
106
|
+
- **Money is minor units**: `daily_budget=2000` = $20.00/day. Say the
|
|
107
|
+
dollar amount back to the user before creating anything with a budget.
|
|
108
|
+
- **Everything is created PAUSED** by design. Activating spend
|
|
109
|
+
(`-p status=ACTIVE`, or `update_*` to ACTIVE) requires explicit user
|
|
110
|
+
confirmation — never activate on your own initiative.
|
|
111
|
+
- **Destructive/spend changes** (status flips, budget changes, deletes):
|
|
112
|
+
confirm with the user first; read back what will change.
|
|
113
|
+
- `special_ad_categories` is legally required for housing / credit /
|
|
114
|
+
employment / political ads — ask if the business could fall in one.
|
|
115
|
+
- Creative content is mostly immutable once delivering: to change a live
|
|
116
|
+
ad's content, create a new creative and swap via
|
|
117
|
+
`update_ad -p creative_id=…`.
|
|
118
|
+
|
|
119
|
+
## Errors you'll see
|
|
120
|
+
|
|
121
|
+
- `not_connected` → user must connect at https://ivon.ai/profile?tab=connectors.
|
|
122
|
+
- `code: 190` → Facebook session expired; same reconnect URL.
|
|
123
|
+
- `code: 4/17/32` (rate limit) → wait a few minutes; don't hammer retries.
|
|
124
|
+
- HTTP 401 from the server → bad/rotated ivon token: `ivon auth login` again
|
|
125
|
+
(tokens rotate when the user clicks Regenerate on ivon.ai/profile).
|
ivon_cli/exit_codes.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Exit codes for the ivon CLI.
|
|
2
|
+
|
|
3
|
+
0 success
|
|
4
|
+
1 API error (server returned an error body)
|
|
5
|
+
2 usage error (Typer/Click handles this)
|
|
6
|
+
3 transport error (DNS, refused connection, timeout)
|
|
7
|
+
4 not logged in
|
|
8
|
+
130 interrupted (Ctrl-C)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
OK = 0
|
|
12
|
+
API_ERROR = 1
|
|
13
|
+
USAGE = 2
|
|
14
|
+
TRANSPORT = 3
|
|
15
|
+
NOT_LOGGED_IN = 4
|
|
16
|
+
INTERRUPTED = 130
|
ivon_cli/main.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""ivon CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .api import ApiError, NotLoggedIn, TransportError
|
|
11
|
+
from .cmds import auth as auth_cmds
|
|
12
|
+
from .cmds import install as install_cmds
|
|
13
|
+
from .cmds import mcp as mcp_cmds
|
|
14
|
+
from .cmds import meta as meta_cmds
|
|
15
|
+
from . import exit_codes
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="ivon",
|
|
19
|
+
help="ivon — autonomous marketing team CLI. Meta Ads connector commands + MCP access.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app.add_typer(auth_cmds.app, name="auth")
|
|
24
|
+
app.add_typer(meta_cmds.app, name="meta")
|
|
25
|
+
app.add_typer(mcp_cmds.app, name="mcp")
|
|
26
|
+
app.add_typer(install_cmds.app, name="install")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def me() -> None:
|
|
31
|
+
"""Alias for `ivon auth status`."""
|
|
32
|
+
auth_cmds.status()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def version() -> None:
|
|
37
|
+
"""Print the CLI version."""
|
|
38
|
+
typer.echo(f"ivon-cli {__version__}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> int:
|
|
42
|
+
try:
|
|
43
|
+
# With standalone_mode=False Click *returns* a command's Exit code
|
|
44
|
+
# instead of sys.exit()ing — propagate it.
|
|
45
|
+
result = app(standalone_mode=False)
|
|
46
|
+
return int(result) if isinstance(result, int) else exit_codes.OK
|
|
47
|
+
except typer.Exit as exc:
|
|
48
|
+
return int(exc.exit_code or 0)
|
|
49
|
+
except KeyboardInterrupt:
|
|
50
|
+
print("\nivon: interrupted", file=sys.stderr)
|
|
51
|
+
return exit_codes.INTERRUPTED
|
|
52
|
+
except NotLoggedIn as exc:
|
|
53
|
+
print(f"ivon: {exc}", file=sys.stderr)
|
|
54
|
+
return exit_codes.NOT_LOGGED_IN
|
|
55
|
+
except TransportError as exc:
|
|
56
|
+
print(f"ivon: {exc}", file=sys.stderr)
|
|
57
|
+
return exit_codes.TRANSPORT
|
|
58
|
+
except ApiError as exc:
|
|
59
|
+
print(f"ivon: {exc}", file=sys.stderr)
|
|
60
|
+
return exit_codes.API_ERROR
|
|
61
|
+
except typer.Abort:
|
|
62
|
+
print("\nivon: aborted", file=sys.stderr)
|
|
63
|
+
return exit_codes.INTERRUPTED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ivon-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ivon CLI — Meta Ads connector commands + MCP access for the ivon autonomous marketing team
|
|
5
|
+
Author: ivon (LemonTree Media)
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://ivon.ai
|
|
8
|
+
Project-URL: Documentation, https://ivon.ai/profile
|
|
9
|
+
Keywords: ivon,meta,facebook,ads,mcp,cli
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
13
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
14
|
+
|
|
15
|
+
# ivon CLI
|
|
16
|
+
|
|
17
|
+
Command-line access to the ivon Meta Ads connector — the same hosted MCP
|
|
18
|
+
server that powers ivon's agents, wrapped in human-friendly commands.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e cli/ # from the repo root (or publish to PyPI later)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Login
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ivon auth login # opens browser → Google sign-in on ivon.ai → done
|
|
30
|
+
ivon auth status # who am I + Facebook Ads connection state
|
|
31
|
+
ivon auth logout
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Credentials are stored in `~/.ivon/credentials` (0600). The token is your
|
|
35
|
+
long-lived ivon API token — regenerate it on https://ivon.ai/profile to
|
|
36
|
+
revoke all CLI / MCP access.
|
|
37
|
+
|
|
38
|
+
## Meta Ads commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ivon meta tools # list every MCP tool
|
|
42
|
+
ivon meta accounts # your ad accounts
|
|
43
|
+
ivon meta campaigns -a act_123 --status ACTIVE
|
|
44
|
+
ivon meta adsets -a act_123 -c <campaign_id>
|
|
45
|
+
ivon meta ads -a act_123
|
|
46
|
+
ivon meta insights act_123 -t last_7d --breakdown age
|
|
47
|
+
ivon meta call create_campaign -p account_id=act_123 -p name="Spring Sale" \
|
|
48
|
+
-p objective=OUTCOME_TRAFFIC -p status=PAUSED
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`ivon meta call <tool> -p key=value` reaches any tool on the server —
|
|
52
|
+
values parse as JSON when possible (`-p limit=25`, `-p targeting='{"geo_locations":{"countries":["US"]}}'`).
|
|
53
|
+
|
|
54
|
+
## MCP client setup
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
ivon mcp config # prints Claude Code / Claude Desktop config
|
|
58
|
+
ivon mcp config --show-token # same, with your real token embedded
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Environment overrides
|
|
62
|
+
|
|
63
|
+
- `IVON_API_URL` — point at a different backend (loopback allowed for dev;
|
|
64
|
+
other hosts need `IVON_ALLOW_CUSTOM_API=1`).
|
|
65
|
+
- `IVON_CONFIG_DIR` — credentials directory (default `~/.ivon`).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ivon_cli/__init__.py,sha256=uy6i4gNKlDS_vsyl0DoMm1siKnfKAf3YxsNFc6bzy2Q,39
|
|
2
|
+
ivon_cli/api.py,sha256=bULbRr9JrwZ6HPlDHDeVsAUjUGoIS42VrQxMeSElyTM,9179
|
|
3
|
+
ivon_cli/config.py,sha256=U_kddUZMNcTOjY4ctN66qvZb3mMmCHgzD4vUR0eYKsY,3722
|
|
4
|
+
ivon_cli/exit_codes.py,sha256=1v9ZXtg9M3ftAZs1VJxm5RmadJeq25rvXmWfXm6efT0,313
|
|
5
|
+
ivon_cli/main.py,sha256=lDc_laSUaY0G6D6zpAbCvphtf2jeaFnAJRDMxDurPeY,1864
|
|
6
|
+
ivon_cli/cmds/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ivon_cli/cmds/auth.py,sha256=Ut0GapTPZgOQLyfqaqw6TiBXgY2gAXzJCAEexU0dvp8,4288
|
|
8
|
+
ivon_cli/cmds/install.py,sha256=qt37iNp7r6OwEpKM3qYpuLkjOXagO7tMDB-gMADAq_E,2491
|
|
9
|
+
ivon_cli/cmds/mcp.py,sha256=qU4dWKrGoDWOnJOQAZ9QwZCvCRzNgUDTg5wI6IYv4lY,1509
|
|
10
|
+
ivon_cli/cmds/meta.py,sha256=lc0zs4mzdcJBYOMjfQ-OXzbwh9tzwDyfanVLgal_cHM,5081
|
|
11
|
+
ivon_cli/data/ivon_meta_ads_skill.md,sha256=sz25XYhxOJRWWLhsDPyWyA62eYD7kaqJ5qq54Jyi1Vo,5950
|
|
12
|
+
ivon_cli-0.1.0.dist-info/METADATA,sha256=sRvf5u5emXFFE629rLP7FOPIxBuCSr2xpZnzFAhyGOo,2148
|
|
13
|
+
ivon_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
ivon_cli-0.1.0.dist-info/entry_points.txt,sha256=D-6c0nBfOLJtIiV6VAMRQJm6dsI2uePhZF9wWhp_J30,44
|
|
15
|
+
ivon_cli-0.1.0.dist-info/top_level.txt,sha256=ORVlPfIpN0cY9g946Mv-qtiaka1W8eot8la3yzK_KGs,9
|
|
16
|
+
ivon_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ivon_cli
|