tamarind-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.
tamarind/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Tamarind Bio CLI and Python client.
2
+
3
+ This package is a thin client over the Tamarind platform. Two surfaces:
4
+
5
+ - REST passthrough (``tamarind.rest``): submit/validate/batch, jobs, result,
6
+ files, cancel, delete — these hit the Tamarind API directly with an API key.
7
+ The request/response contract is the same OpenAPI spec the Tamarind MCP server
8
+ is built from, so the CLI and the MCP cannot drift on this surface.
9
+
10
+ - Discovery (``tamarind.catalog``): tools, schema, modalities, functions. The
11
+ catalog lives behind per-org visibility logic that runs server-side, so the
12
+ CLI consumes it over HTTP (the ``/catalog/*`` routes) rather than reading the
13
+ database directly.
14
+ """
15
+
16
+ __version__ = "0.1.0"
tamarind/catalog.py ADDED
@@ -0,0 +1,70 @@
1
+ """Discovery / catalog client.
2
+
3
+ The tool catalog and per-tool schemas are gated by per-org visibility logic
4
+ that runs server-side, so the CLI consumes them over HTTP from the catalog
5
+ service (the ``/catalog/*`` routes) rather than reading the database directly.
6
+
7
+ These routes return exactly the JSON the MCP's discovery tools return
8
+ (``getAvailableTools``, ``listModalities``, ``listTags``, ``getJobSchema``),
9
+ because both are served by the same shared implementation. So whatever a tool
10
+ looks like in the MCP, it looks identical here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from .http import HTTPClient
18
+
19
+ CATALOG_PREFIX = "catalog"
20
+
21
+
22
+ def list_tools(
23
+ client: HTTPClient,
24
+ *,
25
+ modality: str | None = None,
26
+ function: str | None = None,
27
+ search: str | None = None,
28
+ custom: bool | None = None,
29
+ ) -> dict:
30
+ """GET /catalog/tools — the filtered tool catalog (mirrors getAvailableTools)."""
31
+ params = {
32
+ "modality": modality,
33
+ "function": function,
34
+ "search": search,
35
+ "custom": "true" if custom else None,
36
+ }
37
+ return client.get_json(f"{CATALOG_PREFIX}/tools", params=params)
38
+
39
+
40
+ def list_modalities(client: HTTPClient) -> dict:
41
+ """GET /catalog/modalities — molecule types you can filter by."""
42
+ return client.get_json(f"{CATALOG_PREFIX}/modalities")
43
+
44
+
45
+ def list_functions(client: HTTPClient) -> dict:
46
+ """GET /catalog/functions — functions (tags) you can filter by."""
47
+ return client.get_json(f"{CATALOG_PREFIX}/functions")
48
+
49
+
50
+ def get_schema(client: HTTPClient, job_type: str) -> dict:
51
+ """GET /catalog/tools/{jobType}/schema — full parameter schema + example job."""
52
+ return client.get_json(f"{CATALOG_PREFIX}/tools/{job_type}/schema")
53
+
54
+
55
+ # -- helpers for rendering / example extraction ---------------------------
56
+
57
+
58
+ def example_settings(schema: dict[str, Any]) -> dict[str, Any]:
59
+ """Pull a runnable ``settings`` dict out of a schema's exampleJob, if present."""
60
+ example = schema.get("exampleJob") or {}
61
+ return dict(example.get("settings") or {})
62
+
63
+
64
+ def required_param_names(schema: dict[str, Any]) -> list[str]:
65
+ """Names of parameters marked required (top-level; ignores task-gated ones)."""
66
+ out = []
67
+ for p in schema.get("parameters", []):
68
+ if p.get("required") and p.get("name"):
69
+ out.append(p["name"])
70
+ return out
@@ -0,0 +1 @@
1
+ """Typer command-line interface for Tamarind."""
@@ -0,0 +1 @@
1
+ """CLI command groups."""
@@ -0,0 +1,90 @@
1
+ """`tamarind auth` — credential management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ... import rest
10
+ from ...config import mask_key, save_profile
11
+ from ...errors import AuthError
12
+ from ...http import HTTPClient
13
+ from .. import output
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+
17
+
18
+ def _check_key(api_base: str, api_key: str) -> bool:
19
+ """Return True if the key authenticates against the job API."""
20
+ with HTTPClient(api_base, api_key) as client:
21
+ try:
22
+ rest.get_jobs(client, limit=1)
23
+ return True
24
+ except AuthError:
25
+ return False
26
+
27
+
28
+ @app.command()
29
+ def login(
30
+ ctx: typer.Context,
31
+ api_key: Optional[str] = typer.Option(
32
+ None, "--api-key", help="API key. If omitted, you'll be prompted.", show_default=False
33
+ ),
34
+ no_verify: bool = typer.Option(False, "--no-verify", help="Skip verifying the key."),
35
+ ) -> None:
36
+ """Store an API key in the current profile (~/.tamarind/config.json).
37
+
38
+ Get a key from https://app.tamarind.bio (Settings → API), or set
39
+ TAMARIND_API_KEY in the environment to skip storing one.
40
+ """
41
+ state = ctx.obj
42
+ cfg = state.config()
43
+ key = api_key or typer.prompt("Tamarind API key", hide_input=True)
44
+ key = key.strip()
45
+
46
+ if not no_verify and not _check_key(cfg.api_base, key):
47
+ raise AuthError("That API key was rejected by the API. Not saved.")
48
+
49
+ save_profile(cfg.profile, api_key=key)
50
+ output.emit(
51
+ {"ok": True, "profile": cfg.profile, "verified": not no_verify},
52
+ state.output,
53
+ human=f"Saved API key to profile '{cfg.profile}'.",
54
+ )
55
+
56
+
57
+ @app.command()
58
+ def status(ctx: typer.Context) -> None:
59
+ """Show the active profile, endpoints, and whether the key works."""
60
+ state = ctx.obj
61
+ cfg = state.config()
62
+ verified = cfg.has_key and _check_key(cfg.api_base, cfg.api_key)
63
+ result = {
64
+ "profile": cfg.profile,
65
+ "apiKey": mask_key(cfg.api_key),
66
+ "hasKey": cfg.has_key,
67
+ "verified": verified,
68
+ "apiBase": cfg.api_base,
69
+ "catalogBase": cfg.catalog_base,
70
+ }
71
+ human = (
72
+ f"profile: {cfg.profile}\n"
73
+ f"api key: {mask_key(cfg.api_key)} ({'verified' if verified else 'not verified'})\n"
74
+ f"job api: {cfg.api_base}\n"
75
+ f"catalog api: {cfg.catalog_base}"
76
+ )
77
+ output.emit(result, state.output, human=human)
78
+
79
+
80
+ @app.command()
81
+ def logout(ctx: typer.Context) -> None:
82
+ """Remove the stored API key from the current profile."""
83
+ state = ctx.obj
84
+ cfg = state.config()
85
+ save_profile(cfg.profile, api_key="", make_current=False)
86
+ output.emit(
87
+ {"ok": True, "profile": cfg.profile},
88
+ state.output,
89
+ human=f"Cleared API key for profile '{cfg.profile}'.",
90
+ )
@@ -0,0 +1,114 @@
1
+ """Discovery commands: `tamarind tools|modalities|functions|schema`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ... import catalog
10
+ from .. import output
11
+
12
+
13
+ def register(app: typer.Typer) -> None:
14
+ @app.command()
15
+ def tools(
16
+ ctx: typer.Context,
17
+ modality: Optional[str] = typer.Option(None, "--modality", "-m", help="Filter by molecule type (see `tamarind modalities`)."),
18
+ function: Optional[str] = typer.Option(None, "--function", "-f", help="Filter by function/tag (see `tamarind functions`)."),
19
+ search: Optional[str] = typer.Option(None, "--search", "-s", help="Free-text search in name/description."),
20
+ custom: bool = typer.Option(False, "--custom", help="Show only your org's custom tools."),
21
+ ) -> None:
22
+ """List available tools. Filter to narrow the (large) catalog."""
23
+ state = ctx.obj
24
+ with state.catalog_client() as client:
25
+ resp = catalog.list_tools(
26
+ client, modality=modality, function=function, search=search, custom=custom or None
27
+ )
28
+ rows = [
29
+ {
30
+ "name": t.get("name"),
31
+ "displayName": t.get("displayName"),
32
+ "modalities": ",".join(t.get("categories", []) or []),
33
+ }
34
+ for t in resp.get("tools", [])
35
+ ]
36
+ human = (
37
+ output.render_table(rows, ["name", "displayName", "modalities"])
38
+ + f"\n\n{resp.get('totalTools', len(rows))} tools. "
39
+ "Use `tamarind schema <name>` for parameters."
40
+ )
41
+ output.emit(resp, state.output, human=human)
42
+
43
+ @app.command()
44
+ def modalities(ctx: typer.Context) -> None:
45
+ """List molecule types (modalities) you can filter tools by."""
46
+ state = ctx.obj
47
+ with state.catalog_client() as client:
48
+ resp = catalog.list_modalities(client)
49
+ rows = [
50
+ {"value": m.get("value"), "label": m.get("label"), "tools": m.get("toolCount")}
51
+ for m in resp.get("modalities", [])
52
+ ]
53
+ output.emit(resp, state.output, human=output.render_table(rows, ["value", "label", "tools"]))
54
+
55
+ @app.command()
56
+ def functions(ctx: typer.Context) -> None:
57
+ """List functions (tags) you can filter tools by."""
58
+ state = ctx.obj
59
+ with state.catalog_client() as client:
60
+ resp = catalog.list_functions(client)
61
+ rows = [
62
+ {"value": f.get("value"), "label": f.get("label"), "tools": f.get("toolCount")}
63
+ for f in resp.get("functions", [])
64
+ ]
65
+ output.emit(resp, state.output, human=output.render_table(rows, ["value", "label", "tools"]))
66
+
67
+ @app.command()
68
+ def schema(
69
+ ctx: typer.Context,
70
+ tool: str = typer.Argument(..., help="Tool name (lowercase, e.g. 'boltz')."),
71
+ example: bool = typer.Option(False, "--example", help="Print only the runnable example settings (YAML)."),
72
+ ) -> None:
73
+ """Show a tool's parameters and a runnable example job."""
74
+ state = ctx.obj
75
+ with state.catalog_client() as client:
76
+ resp = catalog.get_schema(client, tool)
77
+ # The catalog returns HTTP 200 with {"error": ...} for an unknown/hidden
78
+ # tool; turn that into a not-found exit instead of printing it as success.
79
+ if isinstance(resp, dict) and resp.get("error"):
80
+ from ...errors import NotFoundError
81
+
82
+ raise NotFoundError(resp["error"])
83
+
84
+ if example:
85
+ import yaml
86
+
87
+ settings = catalog.example_settings(resp)
88
+ output.emit(
89
+ {"type": tool, "settings": settings},
90
+ state.output,
91
+ human=yaml.safe_dump(settings, sort_keys=False).rstrip(),
92
+ )
93
+ return
94
+
95
+ param_rows = []
96
+ for p in resp.get("parameters", []):
97
+ param_rows.append(
98
+ {
99
+ "name": p.get("name"),
100
+ "type": p.get("type"),
101
+ "required": "yes" if p.get("required") else "",
102
+ "default": p.get("default"),
103
+ "description": (p.get("descr") or p.get("displayName") or "")[:60],
104
+ }
105
+ )
106
+ human = (
107
+ f"{resp.get('displayName', tool)} [{tool}]\n"
108
+ f"{resp.get('description', '')}\n\n"
109
+ + output.render_table(param_rows, ["name", "type", "required", "default", "description"])
110
+ + "\n\nRun `tamarind schema "
111
+ + tool
112
+ + " --example` for runnable example settings."
113
+ )
114
+ output.emit(resp, state.output, human=human)
@@ -0,0 +1,113 @@
1
+ """`tamarind files` — workspace file management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from ... import rest
12
+ from ...errors import TamarindError
13
+ from .. import output
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+
17
+
18
+ @app.command("list")
19
+ def list_files(
20
+ ctx: typer.Context,
21
+ types: Optional[str] = typer.Option(None, "--types", help="Comma-separated extensions, e.g. 'pdb,cif'."),
22
+ search: Optional[str] = typer.Option(None, "--search", "-s", help="Filter filenames by substring."),
23
+ folder: Optional[str] = typer.Option(None, "--folder", help="List within this folder."),
24
+ limit: int = typer.Option(50, "--limit", help="Max files to return."),
25
+ offset: int = typer.Option(0, "--offset", help="Pagination offset."),
26
+ metadata: bool = typer.Option(False, "--metadata", help="Include size/lastModified."),
27
+ all_dirs: bool = typer.Option(False, "--all", help="Recurse into subdirectories."),
28
+ ) -> None:
29
+ """List files in your workspace."""
30
+ state = ctx.obj
31
+ with state.rest_client() as client:
32
+ resp = rest.get_files(
33
+ client,
34
+ limit=limit,
35
+ offset=offset,
36
+ types=types,
37
+ search=search,
38
+ folder=folder,
39
+ include_all=all_dirs,
40
+ include_metadata=metadata,
41
+ )
42
+ files = resp.get("files") if isinstance(resp, dict) else resp
43
+ if not isinstance(files, list):
44
+ files = []
45
+ if files and isinstance(files[0], dict):
46
+ rows = [{"name": f.get("name"), "size": f.get("size"), "lastModified": f.get("lastModified")} for f in files]
47
+ human = output.render_table(rows, ["name", "size", "lastModified"])
48
+ else:
49
+ human = "\n".join(str(f) for f in files) or "(none)"
50
+ output.emit(resp, state.output, human=human)
51
+
52
+
53
+ @app.command()
54
+ def folders(
55
+ ctx: typer.Context,
56
+ limit: int = typer.Option(50, "--limit", help="Max folders to return."),
57
+ all_folders: bool = typer.Option(False, "--all", help="Load all folders."),
58
+ ) -> None:
59
+ """List folders in your workspace."""
60
+ state = ctx.obj
61
+ with state.rest_client() as client:
62
+ resp = rest.get_folders(client, limit=limit, load_all=all_folders)
63
+ folder_list = resp.get("folders") if isinstance(resp, dict) else resp
64
+ if not isinstance(folder_list, list):
65
+ folder_list = []
66
+ output.emit(resp, state.output, human="\n".join(str(f) for f in folder_list) or "(none)")
67
+
68
+
69
+ @app.command()
70
+ def upload(
71
+ ctx: typer.Context,
72
+ path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Local file to upload."),
73
+ name: Optional[str] = typer.Option(None, "--name", help="Remote filename (default: the local basename)."),
74
+ ) -> None:
75
+ """Upload a local file to your workspace (two-step presigned PUT)."""
76
+ state = ctx.obj
77
+ remote = name or path.name
78
+ with state.rest_client() as client:
79
+ signed = rest.upload_file_url(client, filename=remote)
80
+ # The endpoint may return a non-dict error sentinel (e.g. -1) instead of an
81
+ # object; don't crash on .get — surface a clean error.
82
+ url = signed.get("signedUrl") if isinstance(signed, dict) else None
83
+ if not url:
84
+ raise TamarindError("Upload did not return a signed URL.", detail=signed)
85
+ output.info(f"Uploading {path} → {remote}…", state.output)
86
+ with path.open("rb") as fh:
87
+ put = httpx.put(url, content=fh.read(), timeout=300.0)
88
+ put.raise_for_status()
89
+ output.emit(
90
+ {"ok": True, "filename": remote, "bytes": path.stat().st_size},
91
+ state.output,
92
+ human=f"uploaded {remote}",
93
+ )
94
+
95
+
96
+ @app.command()
97
+ def delete(
98
+ ctx: typer.Context,
99
+ path: Optional[str] = typer.Argument(None, help="File path to delete."),
100
+ folder: Optional[str] = typer.Option(None, "--folder", help="Delete every file under this folder instead."),
101
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
102
+ ) -> None:
103
+ """Delete a file, or every file under a folder."""
104
+ state = ctx.obj
105
+ if not path and not folder:
106
+ raise TamarindError("Provide a file path or --folder <name>.")
107
+ target = path or f"folder {folder}"
108
+ if not yes and not state.output.json:
109
+ typer.confirm(f"Delete {target}?", abort=True)
110
+ with state.rest_client() as client:
111
+ resp = rest.delete_file(client, file_path=path, folder=folder)
112
+ human = resp.get("message", resp) if isinstance(resp, dict) else resp
113
+ output.emit(resp, state.output, human=str(human))