venn-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: venn-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI for the Venn ToolIQ API
5
5
  Project-URL: Homepage, https://github.com/moda-labs/venn-cli
6
6
  Project-URL: Repository, https://github.com/moda-labs/venn-cli
@@ -21,6 +21,7 @@ Requires-Python: >=3.11
21
21
  Requires-Dist: click>=8.1
22
22
  Requires-Dist: httpx>=0.27
23
23
  Requires-Dist: python-dotenv>=1.0
24
+ Requires-Dist: pyyaml>=6.0
24
25
  Requires-Dist: rich>=13.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "venn-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "CLI for the Venn ToolIQ API"
5
5
  requires-python = ">=3.11"
6
6
  license = "MIT"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  dependencies = [
22
22
  "click>=8.1",
23
23
  "httpx>=0.27",
24
+ "pyyaml>=6.0",
24
25
  "rich>=13.0",
25
26
  "python-dotenv>=1.0",
26
27
  ]
@@ -0,0 +1,461 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from dotenv import load_dotenv
10
+
11
+ from venn_cli.client import VennClient
12
+ from venn_cli import output
13
+
14
+
15
+ def _load_env() -> None:
16
+ for p in [Path.cwd() / ".env", Path(__file__).resolve().parents[2] / ".env"]:
17
+ if p.exists():
18
+ load_dotenv(p)
19
+ return
20
+ load_dotenv()
21
+
22
+
23
+ def _client(ctx: click.Context) -> VennClient:
24
+ return ctx.obj["client"]
25
+
26
+
27
+ def _mode(ctx: click.Context) -> str:
28
+ return ctx.obj["mode"]
29
+
30
+
31
+ def _out(ctx: click.Context, *, yaml_fn, table_fn, data):
32
+ mode = _mode(ctx)
33
+ if mode == "json":
34
+ output.print_json(data)
35
+ elif mode == "table":
36
+ table_fn()
37
+ else:
38
+ yaml_fn()
39
+
40
+
41
+ @click.group()
42
+ @click.option("--api-key", envvar="VENN_API_KEY", help="Venn API key (or set VENN_API_KEY)")
43
+ @click.option("--base-url", envvar="VENN_BASE_URL", default=None, help="Override API base URL")
44
+ @click.option("--json", "use_json", is_flag=True, help="Output raw JSON")
45
+ @click.option("--table", "use_table", is_flag=True, help="Output rich tables (for terminals)")
46
+ @click.version_option(package_name="venn-cli")
47
+ @click.pass_context
48
+ def main(ctx: click.Context, api_key: str | None, base_url: str | None, use_json: bool, use_table: bool) -> None:
49
+ """Venn ToolIQ CLI — discover, inspect, and execute tools."""
50
+ _load_env()
51
+ ctx.ensure_object(dict)
52
+ if use_json:
53
+ ctx.obj["mode"] = "json"
54
+ elif use_table:
55
+ ctx.obj["mode"] = "table"
56
+ else:
57
+ ctx.obj["mode"] = "text"
58
+ api_key = api_key or os.environ.get("VENN_API_KEY")
59
+ if ctx.invoked_subcommand == "docs":
60
+ return
61
+ if not api_key:
62
+ click.echo("Error: VENN_API_KEY not set. Pass --api-key or add it to .env", err=True)
63
+ raise SystemExit(1)
64
+ kwargs = {"api_key": api_key}
65
+ if base_url:
66
+ kwargs["base_url"] = base_url
67
+ ctx.obj["client"] = VennClient(**kwargs)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # venn docs
72
+ # ---------------------------------------------------------------------------
73
+
74
+ DOCS = """\
75
+ # Venn CLI
76
+
77
+ Tool discovery and execution across connected MCP servers.
78
+ Default output is compact text optimized for LLM context windows.
79
+ Use `--json` for raw JSON or `--table` for rich terminal tables.
80
+
81
+ ## Setup
82
+
83
+ Set VENN_API_KEY as an environment variable or in a .env file.
84
+
85
+ ## Typical workflow
86
+
87
+ 1. `venn help list_servers` — see connected servers and their IDs
88
+ 2. `venn tools search "<intent>"` — find tools by natural language
89
+ 3. `venn tools describe -s <server_id> -t <tool>` — get full schema and params
90
+ 4. `venn tools execute -s <server_id> -t <tool> -a '<json args>'` — run it
91
+ For write operations, add `--confirm` to auto-obtain a confirmation token.
92
+
93
+ ## Commands
94
+
95
+ ### venn tools search <query>
96
+ Search for tools across all connected servers using semantic similarity.
97
+ Returns ranked results — tools and skills.
98
+ -n, --limit INT Max results (default: 10)
99
+ --offset INT Skip N results (default: 0)
100
+ --min-score FLOAT Minimum similarity (0-1, default: 0.3)
101
+ --refresh Bypass cache
102
+ Example: venn tools search "create calendar event" -n 5
103
+
104
+ ### venn tools list -s <slug>
105
+ List all tools for a specific server/toolset.
106
+ -s, --slug TEXT Toolset slug (e.g. "gmail", "slack")
107
+ -d, --directory-id TEXT Server directory UUID
108
+ -i, --instance-id TEXT Server instance UUID
109
+ At least one identifier is required.
110
+ Example: venn tools list -s gmail
111
+
112
+ ### venn tools describe -s <server_id> -t <tool_name>
113
+ Get detailed schema for one or more tools — description, params, types.
114
+ -s, --server-id TEXT Server identifier [required]
115
+ -t, --tool-name TEXT Tool name, repeatable for multiple tools [required]
116
+ --refresh Bypass cache
117
+ Example: venn tools describe -s personal-gmail -t send_email -t create_draft
118
+
119
+ ### venn tools execute -s <server_id> -t <tool_name>
120
+ Execute a tool on a connected server.
121
+ -s, --server-id TEXT Server identifier [required]
122
+ -t, --tool-name TEXT Tool name [required]
123
+ -a, --args TEXT Tool arguments as a JSON string
124
+ --confirm Auto-obtain confirmation token for write operations
125
+ Example (read): venn tools execute -s personal-gmail -t list_emails -a '{"maxResults": 5}'
126
+ Example (write): venn tools execute -s personal-gmail -t send_email --confirm -a '{"sender":"me","to":"bob@example.com","subject":"hi","body":"hello"}'
127
+
128
+ ### venn tools confirm
129
+ Get a standalone confirmation token for write operations.
130
+ Returns a single-use token and its expiry. Pass it to execute via --confirm
131
+ or manually via the API.
132
+
133
+ ### venn workflow run
134
+ Execute Python code in a sandboxed environment. Two async functions are
135
+ injected automatically (no imports needed):
136
+
137
+ async_call_tool(server_id, tool_name, **kwargs) -> dict
138
+ async_call_skill(skill_name, **kwargs) -> dict
139
+
140
+ Both must be awaited. Return results with `return`.
141
+
142
+ Options:
143
+ -c, --code TEXT Inline Python code
144
+ -f, --file PATH Read code from a file
145
+ --timeout INT Max execution seconds (1-360, default: 180)
146
+ --confirm Obtain confirmation for write operations
147
+
148
+ Sandbox builtins: len, sum, max, min, filter, map, sorted, range,
149
+ enumerate, zip, dict, set, list, isinstance, type, asyncio
150
+ Not available: network (requests/httpx/urllib), filesystem (open),
151
+ subprocess, os, sys. Use async_call_tool for all external access.
152
+ Do NOT use asyncio.run() or loop.run_until_complete() — use await and
153
+ asyncio.gather() only.
154
+
155
+ Examples:
156
+
157
+ Single tool call:
158
+ venn workflow run -c "
159
+ result = await async_call_tool('personal-gmail', 'list_emails', userId='me', maxResults=5)
160
+ return result
161
+ "
162
+
163
+ Chain tool calls:
164
+ venn workflow run -c "
165
+ emails = await async_call_tool('personal-gmail', 'list_emails', userId='me', maxResults=1)
166
+ msg_id = emails['messages'][0]['id']
167
+ detail = await async_call_tool('personal-gmail', 'find_email', userId='me', id=msg_id)
168
+ return {'subject': detail.get('subject'), 'snippet': detail.get('snippet')}
169
+ "
170
+
171
+ Parallel execution:
172
+ venn workflow run -c "
173
+ import asyncio
174
+ results = await asyncio.gather(
175
+ async_call_tool('personal-gmail', 'list_emails', userId='me', maxResults=3),
176
+ async_call_tool('work-gmail', 'list_emails', userId='me', maxResults=3),
177
+ )
178
+ return {'personal': len(results[0].get('messages',[])), 'work': len(results[1].get('messages',[]))}
179
+ "
180
+
181
+ Write operation (requires --confirm):
182
+ venn workflow run --confirm -c "
183
+ result = await async_call_tool('personal-gmail', 'send_email', sender='me', to='bob@example.com', subject='hi', body='hello')
184
+ return result
185
+ "
186
+
187
+ ### venn help [action]
188
+ Get help info, server status, and auth URLs.
189
+ Actions: getting_started, connector_help, auth_helper, list_servers
190
+ -s, --server-id TEXT Required for auth_helper
191
+ --refresh Bypass cache
192
+ Example: venn help list_servers
193
+
194
+ ### venn skills upsert -f <file.yaml>
195
+ Create or update an executable skill from a YAML file.
196
+ -f, --file PATH Skill YAML file [required]
197
+
198
+ ### venn history toolset <key>
199
+ Show revision history for a 1st-party toolset.
200
+ -n, --limit INT Max revisions (default: 50)
201
+ --before INT Only show versions before this number
202
+
203
+ ### venn history server <directory_id>
204
+ Show revision history for a server instance.
205
+ -n, --limit INT Max revisions (default: 50)
206
+ --before INT Only show versions before this number
207
+
208
+ ## Output modes
209
+
210
+ Default output is YAML — structured, minimal tokens, ideal for agents.
211
+ --json Raw JSON (full API response)
212
+ --table Rich formatted tables (for human terminal use)
213
+
214
+ ## Write safety
215
+
216
+ Tools with write operations require confirmation. The --confirm flag on
217
+ `venn tools execute` and `venn workflow run` handles this automatically:
218
+ it fetches a single-use token and includes it in the request. Without
219
+ --confirm, write operations will be rejected by the server.\
220
+ """
221
+
222
+
223
+ @main.command("docs")
224
+ def docs_cmd():
225
+ """Print the full CLI reference for agent consumption."""
226
+ click.echo(DOCS)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # venn tools
231
+ # ---------------------------------------------------------------------------
232
+
233
+ @main.group()
234
+ def tools():
235
+ """Discover, inspect, and execute tools."""
236
+
237
+
238
+ @tools.command("list")
239
+ @click.option("--slug", "-s", help="Toolset slug (e.g. 'salesforce', 'gmail')")
240
+ @click.option("--directory-id", "-d", help="Server directory UUID")
241
+ @click.option("--instance-id", "-i", help="Server instance UUID")
242
+ @click.pass_context
243
+ def tools_list(ctx, slug, directory_id, instance_id):
244
+ """List tools for a server."""
245
+ if not any([slug, directory_id, instance_id]):
246
+ click.echo("Provide at least one of: --slug, --directory-id, --instance-id", err=True)
247
+ raise SystemExit(1)
248
+ data = _client(ctx).list_tools(slug=slug, directory_id=directory_id, instance_id=instance_id)
249
+ result = data.get("result", {})
250
+ _out(ctx,
251
+ yaml_fn=lambda: output.yaml_tool_list(result),
252
+ table_fn=lambda: output.table_tool_list(result),
253
+ data=data)
254
+
255
+
256
+ @tools.command("search")
257
+ @click.argument("query")
258
+ @click.option("--limit", "-n", default=10, type=int, help="Max results")
259
+ @click.option("--offset", default=0, type=int, help="Skip N results")
260
+ @click.option("--min-score", default=0.3, type=float, help="Minimum similarity score")
261
+ @click.option("--refresh", is_flag=True, help="Bypass cache")
262
+ @click.pass_context
263
+ def tools_search(ctx, query, limit, offset, min_score, refresh):
264
+ """Search tools by natural language query."""
265
+ data = _client(ctx).search_tools(query, limit=limit, offset=offset, min_score=min_score, refresh=refresh)
266
+ result = data.get("result", {})
267
+ _out(ctx,
268
+ yaml_fn=lambda: output.yaml_search_results(result),
269
+ table_fn=lambda: output.table_search_results(result),
270
+ data=data)
271
+
272
+
273
+ @tools.command("describe")
274
+ @click.option("--server-id", "-s", required=True, help="Server identifier")
275
+ @click.option("--tool-name", "-t", required=True, multiple=True, help="Tool name (repeatable)")
276
+ @click.option("--refresh", is_flag=True, help="Bypass cache")
277
+ @click.pass_context
278
+ def tools_describe(ctx, server_id, tool_name, refresh):
279
+ """Get detailed schema for one or more tools."""
280
+ tool_ids = [{"server_id": server_id, "tool_name": t} for t in tool_name]
281
+ data = _client(ctx).describe_tools(tool_ids, refresh=refresh)
282
+ results = data.get("result", {}).get("results", [])
283
+ _out(ctx,
284
+ yaml_fn=lambda: output.yaml_tool_details(results),
285
+ table_fn=lambda: output.table_tool_details(results),
286
+ data=data)
287
+
288
+
289
+ @tools.command("execute")
290
+ @click.option("--server-id", "-s", required=True, help="Server identifier")
291
+ @click.option("--tool-name", "-t", required=True, help="Tool name")
292
+ @click.option("--args", "-a", "tool_args", default=None, help="Tool arguments as JSON string")
293
+ @click.option("--confirm", "do_confirm", is_flag=True, help="Auto-obtain confirmation token for write ops")
294
+ @click.pass_context
295
+ def tools_execute(ctx, server_id, tool_name, tool_args, do_confirm):
296
+ """Execute a tool."""
297
+ client = _client(ctx)
298
+ parsed_args = json.loads(tool_args) if tool_args else None
299
+
300
+ confirmation_token = None
301
+ if do_confirm:
302
+ confirm_data = client.confirm_write()
303
+ confirmation_token = confirm_data.get("result", {}).get("confirmation_token")
304
+
305
+ data = client.execute_tool(
306
+ server_id=server_id,
307
+ tool_name=tool_name,
308
+ tool_args=parsed_args,
309
+ confirmed=do_confirm,
310
+ confirmation_token=confirmation_token,
311
+ )
312
+ _out(ctx,
313
+ yaml_fn=lambda: output.yaml_execute_result(data),
314
+ table_fn=lambda: output.table_execute_result(data),
315
+ data=data)
316
+
317
+
318
+ @tools.command("confirm")
319
+ @click.pass_context
320
+ def tools_confirm(ctx):
321
+ """Get a confirmation token for write operations."""
322
+ data = _client(ctx).confirm_write()
323
+ result = data.get("result", {})
324
+ _out(ctx,
325
+ yaml_fn=lambda: output.yaml_confirm(result),
326
+ table_fn=lambda: output.table_confirm(result),
327
+ data=data)
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # venn workflow
332
+ # ---------------------------------------------------------------------------
333
+
334
+ @main.group()
335
+ def workflow():
336
+ """Execute code in a sandboxed environment."""
337
+
338
+
339
+ @workflow.command("run")
340
+ @click.option("--code", "-c", help="Python code to execute")
341
+ @click.option("--file", "-f", "code_file", type=click.Path(exists=True), help="Read code from file")
342
+ @click.option("--timeout", default=180, type=int, help="Max execution time in seconds")
343
+ @click.option("--confirm", "do_confirm", is_flag=True, help="Obtain confirmation for write ops")
344
+ @click.pass_context
345
+ def workflow_run(ctx, code, code_file, timeout, do_confirm):
346
+ """Execute Python code in the Venn sandbox.
347
+
348
+ Two async functions are injected (no imports needed):
349
+
350
+ \b
351
+ await async_call_tool(server_id, tool_name, **kwargs) -> dict
352
+ await async_call_skill(skill_name, **kwargs) -> dict
353
+
354
+ Return results with `return`. Use asyncio.gather() for parallel calls.
355
+ Run `venn docs` for full sandbox reference and examples.
356
+ """
357
+ if code_file:
358
+ code = Path(code_file).read_text()
359
+ if not code:
360
+ click.echo("Provide --code or --file", err=True)
361
+ raise SystemExit(1)
362
+
363
+ client = _client(ctx)
364
+ confirmation_token = None
365
+ if do_confirm:
366
+ confirm_data = client.confirm_write()
367
+ confirmation_token = confirm_data.get("result", {}).get("confirmation_token")
368
+
369
+ data = client.execute_workflow(
370
+ code=code,
371
+ timeout=timeout,
372
+ confirmed=do_confirm,
373
+ confirmation_token=confirmation_token,
374
+ )
375
+ _out(ctx,
376
+ yaml_fn=lambda: output.yaml_execute_result(data),
377
+ table_fn=lambda: output.table_execute_result(data),
378
+ data=data)
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # venn help
383
+ # ---------------------------------------------------------------------------
384
+
385
+ @main.command("help")
386
+ @click.argument("action", required=False, type=click.Choice(
387
+ ["getting_started", "connector_help", "auth_helper", "list_servers"],
388
+ case_sensitive=False,
389
+ ))
390
+ @click.option("--server-id", "-s", help="Server ID (required for auth_helper)")
391
+ @click.option("--refresh", is_flag=True, help="Bypass cache")
392
+ @click.pass_context
393
+ def help_cmd(ctx, action, server_id, refresh):
394
+ """Get help, server status, or auth URLs."""
395
+ data = _client(ctx).help(action=action, server_id=server_id, refresh=refresh)
396
+ result = data.get("result")
397
+ _out(ctx,
398
+ yaml_fn=lambda: output.yaml_help_result(result),
399
+ table_fn=lambda: output.table_help_result(result),
400
+ data=data)
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # venn skills
405
+ # ---------------------------------------------------------------------------
406
+
407
+ @main.group()
408
+ def skills():
409
+ """Manage executable skills."""
410
+
411
+
412
+ @skills.command("upsert")
413
+ @click.option("--file", "-f", "skill_file", required=True, type=click.Path(exists=True), help="Skill YAML file")
414
+ @click.pass_context
415
+ def skills_upsert(ctx, skill_file):
416
+ """Create or update a skill from a YAML file."""
417
+ content = Path(skill_file).read_text()
418
+ data = _client(ctx).upsert_skill(content)
419
+ _out(ctx,
420
+ yaml_fn=lambda: output.yaml_skill_upsert(data),
421
+ table_fn=lambda: output.table_skill_upsert(data),
422
+ data=data)
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # venn history
427
+ # ---------------------------------------------------------------------------
428
+
429
+ @main.group()
430
+ def history():
431
+ """View catalog and server revision history."""
432
+
433
+
434
+ @history.command("toolset")
435
+ @click.argument("toolset_key")
436
+ @click.option("--limit", "-n", default=50, type=int, help="Max revisions")
437
+ @click.option("--before", "before_version", default=None, type=int, help="Only show versions before this")
438
+ @click.pass_context
439
+ def history_toolset(ctx, toolset_key, limit, before_version):
440
+ """Show revision history for a toolset."""
441
+ data = _client(ctx).toolset_history(toolset_key, limit=limit, before_version=before_version)
442
+ result = data.get("result", {})
443
+ _out(ctx,
444
+ yaml_fn=lambda: output.yaml_history(result),
445
+ table_fn=lambda: output.table_history(result),
446
+ data=data)
447
+
448
+
449
+ @history.command("server")
450
+ @click.argument("directory_id")
451
+ @click.option("--limit", "-n", default=50, type=int, help="Max revisions")
452
+ @click.option("--before", "before_version", default=None, type=int, help="Only show versions before this")
453
+ @click.pass_context
454
+ def history_server(ctx, directory_id, limit, before_version):
455
+ """Show revision history for a server instance."""
456
+ data = _client(ctx).server_history(directory_id, limit=limit, before_version=before_version)
457
+ result = data.get("result", {})
458
+ _out(ctx,
459
+ yaml_fn=lambda: output.yaml_history(result),
460
+ table_fn=lambda: output.table_history(result),
461
+ data=data)
@@ -14,7 +14,11 @@ class VennClient:
14
14
  def __init__(self, api_key: str, base_url: str = BASE_URL, timeout: float = 120):
15
15
  self._http = httpx.Client(
16
16
  base_url=base_url,
17
- headers={"Authorization": f"Bearer {api_key}"},
17
+ headers={
18
+ "Authorization": f"Bearer {api_key}",
19
+ "User-Agent": "venn-cli/1.0",
20
+ "Accept": "*/*",
21
+ },
18
22
  timeout=timeout,
19
23
  )
20
24