ivon-cli 0.1.0__tar.gz

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 @@
1
+ recursive-include src/ivon_cli/data *.md
@@ -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,51 @@
1
+ # ivon CLI
2
+
3
+ Command-line access to the ivon Meta Ads connector — the same hosted MCP
4
+ server that powers ivon's agents, wrapped in human-friendly commands.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install -e cli/ # from the repo root (or publish to PyPI later)
10
+ ```
11
+
12
+ ## Login
13
+
14
+ ```bash
15
+ ivon auth login # opens browser → Google sign-in on ivon.ai → done
16
+ ivon auth status # who am I + Facebook Ads connection state
17
+ ivon auth logout
18
+ ```
19
+
20
+ Credentials are stored in `~/.ivon/credentials` (0600). The token is your
21
+ long-lived ivon API token — regenerate it on https://ivon.ai/profile to
22
+ revoke all CLI / MCP access.
23
+
24
+ ## Meta Ads commands
25
+
26
+ ```bash
27
+ ivon meta tools # list every MCP tool
28
+ ivon meta accounts # your ad accounts
29
+ ivon meta campaigns -a act_123 --status ACTIVE
30
+ ivon meta adsets -a act_123 -c <campaign_id>
31
+ ivon meta ads -a act_123
32
+ ivon meta insights act_123 -t last_7d --breakdown age
33
+ ivon meta call create_campaign -p account_id=act_123 -p name="Spring Sale" \
34
+ -p objective=OUTCOME_TRAFFIC -p status=PAUSED
35
+ ```
36
+
37
+ `ivon meta call <tool> -p key=value` reaches any tool on the server —
38
+ values parse as JSON when possible (`-p limit=25`, `-p targeting='{"geo_locations":{"countries":["US"]}}'`).
39
+
40
+ ## MCP client setup
41
+
42
+ ```bash
43
+ ivon mcp config # prints Claude Code / Claude Desktop config
44
+ ivon mcp config --show-token # same, with your real token embedded
45
+ ```
46
+
47
+ ## Environment overrides
48
+
49
+ - `IVON_API_URL` — point at a different backend (loopback allowed for dev;
50
+ other hosts need `IVON_ALLOW_CUSTOM_API=1`).
51
+ - `IVON_CONFIG_DIR` — credentials directory (default `~/.ivon`).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ivon-cli"
7
+ dynamic = ["version"]
8
+ description = "ivon CLI — Meta Ads connector commands + MCP access for the ivon autonomous marketing team"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ keywords = ["ivon", "meta", "facebook", "ads", "mcp", "cli"]
12
+ license = {text = "Proprietary"}
13
+ authors = [{name = "ivon (LemonTree Media)"}]
14
+ # Strict upper bounds so a silent major bump can't change bearer-token
15
+ # handling out from under us (same policy as the rgba CLI).
16
+ dependencies = [
17
+ "typer>=0.12,<1.0",
18
+ "httpx>=0.27,<1.0",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://ivon.ai"
23
+ Documentation = "https://ivon.ai/profile"
24
+
25
+ [project.scripts]
26
+ ivon = "ivon_cli.main:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.setuptools.package-dir]
32
+ "" = "src"
33
+
34
+ [tool.setuptools.package-data]
35
+ ivon_cli = ["data/*.md"]
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = {attr = "ivon_cli.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """ivon CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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)
@@ -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.")
@@ -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.")
@@ -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))
@@ -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).
@@ -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
@@ -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,20 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ src/ivon_cli/__init__.py
5
+ src/ivon_cli/api.py
6
+ src/ivon_cli/config.py
7
+ src/ivon_cli/exit_codes.py
8
+ src/ivon_cli/main.py
9
+ src/ivon_cli.egg-info/PKG-INFO
10
+ src/ivon_cli.egg-info/SOURCES.txt
11
+ src/ivon_cli.egg-info/dependency_links.txt
12
+ src/ivon_cli.egg-info/entry_points.txt
13
+ src/ivon_cli.egg-info/requires.txt
14
+ src/ivon_cli.egg-info/top_level.txt
15
+ src/ivon_cli/cmds/__init__.py
16
+ src/ivon_cli/cmds/auth.py
17
+ src/ivon_cli/cmds/install.py
18
+ src/ivon_cli/cmds/mcp.py
19
+ src/ivon_cli/cmds/meta.py
20
+ src/ivon_cli/data/ivon_meta_ads_skill.md
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ivon = ivon_cli.main:main
@@ -0,0 +1,2 @@
1
+ typer<1.0,>=0.12
2
+ httpx<1.0,>=0.27
@@ -0,0 +1 @@
1
+ ivon_cli