feat: add Tigris CLI harness for S3-compatible object storage (#301)

* add tigris data harness

* use Tigris CLI

* Fix Tigris harness safety and packaging

---------

Co-authored-by: yuhao <itsyuhao@icloud.com>
This commit is contained in:
David Myriel
2026-06-11 07:19:11 -04:00
committed by GitHub
parent ac62f3802f
commit 0c2fe65e34
27 changed files with 3255 additions and 0 deletions
+4
View File
@@ -100,6 +100,7 @@
!/3MF/
!/calibre/
!/rekordbox/
!/tigris/
!/cc-switch/
!/siyuan/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
@@ -209,6 +210,8 @@
/calibre/.*
/rekordbox/*
/rekordbox/.*
/tigris/*
/tigris/.*
/cc-switch/*
/cc-switch/.*
/siyuan/*
@@ -278,6 +281,7 @@
!/quietshrink/agent-harness/
!/mailchimp/agent-harness/
!/rekordbox/agent-harness/
!/tigris/agent-harness/
!/cc-switch/agent-harness/
!/siyuan/agent-harness/
+7
View File
@@ -1306,6 +1306,13 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 50</td>
</tr>
<tr>
<td align="center"><strong>🗄️ <a href="tigris/agent-harness/">Tigris</a></strong></td>
<td>Object Storage (S3-compatible, global, no egress) — buckets, objects, snapshots, IAM, access keys</td>
<td><code>cli-anything-tigris</code></td>
<td>subprocess wrapping the official <code>tigris</code> CLI</td>
<td align="center">✅ <a href="tigris/agent-harness/">New</a></td>
</tr>
<tr>
<td align="center" colspan="4"><strong>Total</strong></td>
<td align="center"><strong>✅ 2,461</strong></td>
</tr>
+19
View File
@@ -1342,6 +1342,25 @@
"url": "https://github.com/hito0512"
}
]
},
{
"name": "tigris",
"display_name": "Tigris",
"version": "1.0.0",
"description": "Object storage management — buckets, objects, presigned URLs, snapshots, IAM, scoped access keys. Wraps the official `tigris` CLI (S3-compatible, globally distributed, no egress fees)",
"requires": "Tigris CLI on PATH (`npm install -g @tigrisdata/cli` or `brew install tigrisdata/tap/tigris`) + `tigris login`",
"homepage": "https://www.tigrisdata.com",
"source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=tigris/agent-harness",
"entry_point": "cli-anything-tigris",
"skill_md": "skills/cli-anything-tigris/SKILL.md",
"category": "storage",
"contributors": [
{
"name": "davidmyriel",
"url": "https://github.com/davidmyriel"
}
]
}
]
}
+199
View File
@@ -0,0 +1,199 @@
---
name: "cli-anything-tigris"
description: >-
Command-line interface for Tigris object storage — wraps the official `tigris` CLI to expose buckets, objects, presigned URLs, snapshots, IAM, and scoped access keys to AI agents. Globally distributed, S3-compatible, no egress fees.
---
# cli-anything-tigris
A stateless command-line interface for [Tigris](https://www.tigrisdata.com)
object storage. Wraps the official `tigris` CLI so every Tigris primitive
(snapshots, IAM, scoped credentials, OAuth) is reachable through a single
agent-friendly entry point with `--json` everywhere.
## Scope
This harness is for the official Tigris CLI only. It shells out to the
`tigris` binary and assumes Tigris auth through `tigris login` or Tigris
access keys. It is not a generic S3 endpoint tool, and it does not manage
MinIO, Cloudflare R2, AWS S3, or arbitrary S3-compatible endpoints.
## Installation
```bash
# 1. Install the underlying Tigris CLI
npm install -g @tigrisdata/cli
# or:
brew install tigrisdata/tap/tigris
# 2. Authenticate (browser OAuth)
tigris login
# 3. Install this harness
pip install cli-anything-tigris
```
**Prerequisites:**
- Python 3.10+
- `tigris` CLI on PATH (the binary's alias is `t3`)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-tigris --help
# Start interactive REPL
cli-anything-tigris
# Whoami (JSON output for agents)
cli-anything-tigris --json auth whoami
# List buckets
cli-anything-tigris --json bucket list
# Upload a local file
cli-anything-tigris --json object put --bucket my-bucket --key path/to/file.txt --file ./local.txt
# Download an object
cli-anything-tigris --json object get --bucket my-bucket --key path/to/file.txt --output ./out.txt
# Server-side copy
cli-anything-tigris --json object cp t3://my-bucket/src.txt t3://my-bucket/dst.txt
# Take a snapshot
cli-anything-tigris --json snapshot take my-bucket --name baseline-v1
# Delete a bucket; --yes is required
cli-anything-tigris --json bucket delete --name old-bucket --yes
# Create a scoped access key for an agent run
cli-anything-tigris --json access-key create my-agent-key
cli-anything-tigris --json access-key assign tid_AaBb --bucket my-bucket --role Editor
# Rotate or delete access keys; --yes is required
cli-anything-tigris --json access-key rotate tid_AaBb --yes
cli-anything-tigris --json access-key delete tid_AaBb --yes
# Presigned download URL (1 hour)
cli-anything-tigris --json presign get --bucket my-bucket --key path/to/file.txt --expires 3600
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL with
tab-completion and history.
## Command Groups
### auth
OAuth-based authentication.
| Command | Description |
|---------|-------------|
| `login` | Browser OAuth login (`tigris login`) |
| `logout` | Log out of current session |
| `whoami` | Print authenticated user / org |
### bucket
Bucket CRUD.
| Command | Description |
|---------|-------------|
| `list` | List all buckets |
| `create --name NAME` | Create a bucket |
| `delete --name NAME --yes` | Delete an empty bucket (`--yes` required) |
| `info NAME` | Get bucket info |
### object
Object operations (wraps `tigris ls/cp/rm/stat`).
| Command | Description |
|---------|-------------|
| `list --bucket B [--prefix P] [--limit N]` | List objects |
| `put --bucket B --key K (--file F \| --text T)` | Upload an object |
| `get --bucket B --key K --output F` | Download an object |
| `delete --bucket B --key K` | Delete an object |
| `info --bucket B --key K` | Object metadata (HEAD / stat) |
| `cp SRC DST [-r]` | Copy. Accepts `t3://` or `tigris://` URIs. |
### presign
Time-limited URLs.
| Command | Description |
|---------|-------------|
| `get --bucket B --key K [--expires SEC]` | Presigned download URL |
| `put --bucket B --key K [--expires SEC]` | Presigned upload URL |
### snapshot
Point-in-time bucket snapshots — a Tigris-specific primitive.
| Command | Description |
|---------|-------------|
| `list BUCKET` | List snapshots for a bucket |
| `take BUCKET [--name N]` | Take a snapshot |
### access-key
Scoped programmatic credentials — combine with `snapshot` for per-agent isolation.
| Command | Description |
|---------|-------------|
| `list` | List all access keys |
| `create NAME` | Create a new access key (secret shown ONCE) |
| `get KEY_ID` | Show key details |
| `delete KEY_ID --yes` | Permanently delete a key (`--yes` required) |
| `assign KEY_ID --bucket B --role R` | Scope a key to a bucket + role |
| `rotate KEY_ID --yes` | Rotate a key's secret (`--yes` required) |
### iam
Policies and organization users.
| Command | Description |
|---------|-------------|
| `policy list` | List IAM policies |
| `policy create NAME --document FILE` | Create a policy from a JSON file |
| `user list` | List org users |
| `user invite EMAIL [--role R]` | Invite a user |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): tables, colors, formatted text
- **Machine-readable** (`--json`): JSON envelope (or upstream CLI's
`--format json` output, echoed verbatim)
## For AI Agents
When using this CLI programmatically:
1. Always pass `--json` for parseable output.
2. Check return codes — 0 for success, non-zero for errors.
3. Read stderr for error messages.
4. `object cp` accepts `t3://bucket/key` or `tigris://bucket/key` URIs;
server-side copies (t3 → t3) skip the round trip entirely.
5. `presign` returns a URL on stdout in human mode; in JSON mode it's the
`url` field.
6. **For destructive work**: bucket deletion, access-key deletion, and
access-key rotation require explicit `--yes`; take a `snapshot` of the
target bucket first, then do the work, then either keep the snapshot or
discard.
7. **For per-agent isolation**: `access-key create` + `access-key assign
--bucket B --role Editor` to mint a key scoped to one bucket; revoke with
`access-key delete` when the agent run ends.
## Why Tigris
- **Globally distributed.** Data placed close to wherever it's read.
- **No egress fees.** Agents pulling artifacts from anywhere don't incur
per-region bandwidth charges.
- **Snapshots + scoped credentials.** Primitives generic S3-compatible
providers don't ship — the foundation for per-agent isolation.
- **S3-compatible.** Useful alongside S3-aware tools, but this harness
itself is not a generic S3/MinIO/R2/AWS endpoint manager.
## Version
1.0.0
+28
View File
@@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Package build
*.egg-info/
dist/
build/
*.egg
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
# Local virtual envs
.venv/
venv/
# Session files
.cli-anything-tigris/
+124
View File
@@ -0,0 +1,124 @@
# Agent Harness: Tigris Object Storage CLI
## Purpose
This harness provides a standard operating procedure (SOP) and toolkit for
coding agents (Claude Code, Codex, etc.) to interact with
[Tigris](https://www.tigrisdata.com) — a globally distributed, S3-compatible
object storage service with no egress fees.
The harness **wraps the official `tigris` CLI** rather than reimplementing
the S3 protocol. This means agents get access to every Tigris primitive
(snapshots, IAM, scoped access keys, organizations, OAuth) — not just
generic S3 ops — and the harness inherits new commands as the upstream CLI
ships them.
This is intentionally Tigris CLI-only tooling. Do not use this harness as a
generic S3 endpoint wrapper, and do not treat it as MinIO, Cloudflare R2, AWS
S3, or arbitrary S3-compatible endpoint management. It shells out to the
official `tigris` binary and follows that CLI's auth and command model.
## Requirements
- **Python 3.10+** (uses PEP 604 union syntax and PEP 585 generic types).
On macOS the system Python is 3.9 — use `pyenv`, `uv`, or
`brew install python@3.12`.
- **The Tigris CLI on PATH.** Install with one of:
```bash
npm install -g @tigrisdata/cli
brew install tigrisdata/tap/tigris
```
Then `tigris login` once to authenticate (browser OAuth).
## Backend Description
Each command in this harness builds a `tigris <args> --format json`
invocation, runs it via `subprocess.run`, and parses the JSON output.
Commands the upstream CLI does not JSON-format (e.g. `login`, `cp`) are
streamed directly to the caller's TTY or captured as text.
Credentials are normally resolved by the `tigris` CLI itself (via the OAuth
session created by `tigris login`). Explicit `--access-key` / `--secret-key`
flags export `TIGRIS_STORAGE_*` and `AWS_*` env vars into the child process
for setups that rely on env-based auth.
## Architecture
```
agent-harness/
├── .gitignore
├── setup.py # cli, prompt-toolkit; no boto3
├── TIGRIS.md # this file
└── cli_anything/
└── tigris/
├── __init__.py
├── __main__.py # python -m entry point
├── README.md # usage docs
├── tigris_cli.py # click CLI + REPL dispatcher
├── core/
│ ├── auth.py # login, logout, whoami
│ ├── bucket.py # list, create, delete, info
│ ├── object.py # list, put, get, delete, info, cp
│ ├── presign.py # presign get/put
│ ├── snapshot.py # list, take
│ ├── access_key.py # list, create, get, delete, assign, rotate
│ └── iam.py # policies + users
├── utils/
│ ├── tigris_backend.py # subprocess wrapper around `tigris`
│ └── repl_skin.py # unified REPL skin (unmodified copy)
├── skills/
│ └── SKILL.md
└── tests/
├── TEST.md
├── test_core.py # subprocess.run mocked
└── test_full_e2e.py # real `tigris` CLI on PATH, env-gated
```
## Command Groups
| Group | What it wraps | Operations |
|--------------|----------------------------------------|------------|
| `auth` | `tigris login/logout/whoami` | login, logout, whoami |
| `bucket` | `tigris buckets ...` | list, create, delete --yes, info |
| `object` | `tigris ls/cp/rm/stat` | list, put, get, delete, info, cp |
| `presign` | `tigris presign` | get, put |
| `snapshot` | `tigris snapshots ...` | list, take |
| `access-key` | `tigris access-keys ...` | list, create, get, delete --yes, assign, rotate --yes |
| `iam` | `tigris iam policies / users ...` | policy list/create, user list/invite |
## Output Modes
- **Human-readable** (default): tables, colors, formatted text via the REPL skin
- **Machine-readable** (`--json`): the upstream CLI's `--format json` output,
echoed verbatim (with a thin envelope for commands that don't natively
support JSON, like `cp`).
## Agent Usage
When agents drive this CLI:
1. Pass `--json` for parseable output.
2. Inspect return codes (0 = success).
3. Read stderr for error messages.
4. Use `object cp t3://src/key t3://dst/key` for server-side copies — no data
flows through the agent, no egress charges.
5. Use `presign get/put` to hand off object access to other tools or
downstream agents without sharing credentials.
6. Use `snapshot take` before destructive work to make a recovery point;
`snapshot list` to find one to restore from.
7. Use `access-key create` + `access-key assign --bucket B --role Editor` to
mint a scoped key for an agent run, then `access-key delete` to revoke.
8. Bucket deletion, access-key deletion, and access-key rotation require
explicit `--yes`; without it, the harness refuses to call the backend.
## Testing
- `tests/test_core.py` — unit tests with `subprocess.run` fully mocked;
passable without the `tigris` CLI installed or any network access.
- `tests/test_full_e2e.py` — real-bucket tests; gated on
`CLI_ANYTHING_TIGRIS_RUN_E2E=1` plus `tigris` on PATH and an authenticated
session.
See `tests/TEST.md` for run instructions.
@@ -0,0 +1,46 @@
# cli-anything-tigris
CLI-Anything harness for [Tigris](https://www.tigrisdata.com) — a globally
distributed, S3-compatible object storage service with no egress fees.
This harness **wraps the official `tigris` CLI**, so every Tigris primitive
(snapshots, IAM, scoped access keys, OAuth login) is reachable through one
agent-friendly entry point with `--json` everywhere.
It is Tigris CLI-only tooling, not a generic S3/MinIO/R2/AWS endpoint
manager.
## Install
```bash
# 1. Install the underlying Tigris CLI
npm install -g @tigrisdata/cli # or: brew install tigrisdata/tap/tigris
# 2. Authenticate (browser OAuth)
tigris login
# 3. Install this harness
pip install cli-anything-tigris
```
## Quick start
```bash
# Interactive REPL
cli-anything-tigris
# Or drive directly
cli-anything-tigris --json auth whoami
cli-anything-tigris --json bucket list
cli-anything-tigris --json object cp ./local.bin t3://my-bucket/remote.bin
cli-anything-tigris --json snapshot take my-bucket --name baseline-v1
cli-anything-tigris --json access-key create agent-run-42
cli-anything-tigris --json access-key rotate tid_AaBb --yes
cli-anything-tigris --json presign get --bucket my-bucket --key hello.txt
```
Bucket deletion, access-key deletion, and access-key rotation require
explicit `--yes`.
See [SKILL.md](skills/SKILL.md) for the full command reference and
agent-usage guidance.
@@ -0,0 +1,6 @@
"""Entry point for `python -m cli_anything.tigris`."""
from .tigris_cli import main
if __name__ == "__main__":
main()
@@ -0,0 +1,188 @@
"""Access key commands -- list, create, get, delete, assign, rotate.
Wraps `tigris access-keys`. Scoped per-bucket roles are one of Tigris's
agent-storage primitives — combine with `snapshot` and per-agent buckets to
give each agent its own least-privilege credentials.
"""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("access-key")
@click.pass_context
def access_key_group(ctx):
"""Manage Tigris access keys (wraps `tigris access-keys`)."""
pass
@access_key_group.command("list")
@click.pass_context
def list_keys(ctx):
"""List all access keys in the current organization."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
keys = backend.list_access_keys()
if use_json:
click.echo(json_mod.dumps(keys, indent=2))
else:
if not keys:
skin.info("No access keys found.")
return
if isinstance(keys, list):
headers = ["ID", "Name", "Created"]
rows = []
for k in keys:
if not isinstance(k, dict):
rows.append([str(k), "", ""])
continue
rows.append([
str(k.get("id") or k.get("accessKeyId") or "?"),
str(k.get("name") or ""),
str(k.get("created") or k.get("createdAt") or ""),
])
skin.table(headers, rows)
else:
click.echo(keys)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list access keys: {e}")
raise SystemExit(1)
@access_key_group.command("create")
@click.argument("name")
@click.pass_context
def create_key(ctx, name):
"""Create a new access key. Secret is shown only once."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.create_access_key(name)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Access key '{name}' created — secret shown ONCE, save it now")
if isinstance(result, dict):
for k, v in result.items():
skin.status(k, str(v))
else:
click.echo(result)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to create access key: {e}")
raise SystemExit(1)
@access_key_group.command("get")
@click.argument("key_id")
@click.pass_context
def get_key(ctx, key_id):
"""Show details for an access key."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.get_access_key(key_id)
if use_json:
click.echo(json_mod.dumps(info, indent=2))
else:
skin.section(f"Access key: {key_id}")
if isinstance(info, dict):
for k, v in info.items():
skin.status(k, str(v))
else:
click.echo(info)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to get access key: {e}")
raise SystemExit(1)
@access_key_group.command("delete")
@click.argument("key_id")
@click.option("--yes", is_flag=True, default=False,
help="Required. Confirm access-key deletion and pass --yes to Tigris.")
@click.pass_context
def delete_key(ctx, key_id, yes):
"""Permanently delete an access key."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
if not yes:
_emit_error(use_json, skin, "Refusing to delete access key without --yes")
raise SystemExit(1)
try:
result = backend.delete_access_key(key_id, yes=yes)
if use_json:
click.echo(json_mod.dumps(result or {"id": key_id, "status": "deleted"}, indent=2))
else:
skin.success(f"Access key {key_id} deleted")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to delete access key: {e}")
raise SystemExit(1)
@access_key_group.command("assign")
@click.argument("key_id")
@click.option("--bucket", required=True, help="Bucket to scope the key to")
@click.option("--role", required=True,
help="Role to grant (e.g. Editor, Viewer)")
@click.pass_context
def assign_key(ctx, key_id, bucket, role):
"""Assign a per-bucket role to an access key (scoped credentials)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.assign_access_key(key_id, bucket=bucket, role=role)
if use_json:
click.echo(json_mod.dumps(
result or {"id": key_id, "bucket": bucket, "role": role,
"status": "assigned"}, indent=2,
))
else:
skin.success(f"Key {key_id} assigned {role} on {bucket}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to assign access key: {e}")
raise SystemExit(1)
@access_key_group.command("rotate")
@click.argument("key_id")
@click.option("--yes", is_flag=True, default=False,
help="Required. Confirm secret rotation and pass --yes to Tigris.")
@click.pass_context
def rotate_key(ctx, key_id, yes):
"""Rotate an access key's secret."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
if not yes:
_emit_error(use_json, skin, "Refusing to rotate access key without --yes")
raise SystemExit(1)
try:
result = backend.rotate_access_key(key_id, yes=yes)
if use_json:
click.echo(json_mod.dumps(result or {"id": key_id, "status": "rotated"}, indent=2))
else:
skin.success(f"Access key {key_id} rotated — new secret shown ONCE")
if isinstance(result, dict):
for k, v in result.items():
skin.status(k, str(v))
else:
click.echo(result)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to rotate access key: {e}")
raise SystemExit(1)
def _emit_error(use_json: bool, skin, message: str) -> None:
if use_json:
click.echo(json_mod.dumps({"error": message}, indent=2))
elif skin:
skin.error(message)
else:
click.echo(message, err=True)
@@ -0,0 +1,74 @@
"""Auth commands -- login, logout, whoami.
Wraps `tigris login`, `tigris logout`, `tigris whoami`.
"""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("auth")
@click.pass_context
def auth_group(ctx):
"""Authentication (wraps `tigris login/logout/whoami`)."""
pass
@auth_group.command("login")
@click.pass_context
def login(ctx):
"""Interactive OAuth login. Streams `tigris login` to your terminal."""
backend: TigrisBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
try:
backend.login()
if skin:
skin.success("Logged in.")
except TigrisCliError as e:
if skin:
skin.error(f"Login failed: {e}")
raise SystemExit(1)
@auth_group.command("logout")
@click.pass_context
def logout(ctx):
"""Log out of the current Tigris session."""
backend: TigrisBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
try:
backend.logout()
if skin:
skin.success("Logged out.")
except TigrisCliError as e:
if skin:
skin.error(f"Logout failed: {e}")
raise SystemExit(1)
@auth_group.command("whoami")
@click.pass_context
def whoami(ctx):
"""Print the currently authenticated user / organization."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.whoami()
if use_json:
click.echo(json_mod.dumps(info, indent=2))
else:
skin.section("Authentication")
if isinstance(info, dict):
for k, v in info.items():
skin.status(k, str(v))
else:
click.echo(info)
except TigrisCliError as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"whoami failed: {e}")
raise SystemExit(1)
@@ -0,0 +1,120 @@
"""Bucket commands -- list, create, delete, info."""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("bucket")
@click.pass_context
def bucket_group(ctx):
"""Manage Tigris buckets (wraps `tigris buckets`)."""
pass
@bucket_group.command("list")
@click.pass_context
def list_buckets(ctx):
"""List all buckets in the current organization."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
buckets = backend.list_buckets()
if use_json:
click.echo(json_mod.dumps(buckets, indent=2))
else:
if not buckets:
skin.info("No buckets found.")
return
if isinstance(buckets, list):
# Normalize to a couple of expected fields if present.
rows = []
headers = ["Name", "Created"]
for b in buckets:
name = b.get("name") or b.get("Name") or "?"
created = b.get("created") or b.get("CreationDate") or ""
rows.append([name, str(created)])
skin.table(headers, rows)
else:
click.echo(buckets)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list buckets: {e}")
raise SystemExit(1)
@bucket_group.command("create")
@click.option("--name", required=True, help="Bucket name to create")
@click.pass_context
def create_bucket(ctx, name):
"""Create a new bucket."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.create_bucket(name)
if use_json:
click.echo(json_mod.dumps(result or {"name": name, "status": "created"}, indent=2))
else:
skin.success(f"Bucket '{name}' created")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to create bucket: {e}")
raise SystemExit(1)
@bucket_group.command("delete")
@click.option("--name", required=True, help="Bucket name to delete")
@click.option("--yes", is_flag=True, default=False,
help="Required. Confirm bucket deletion and pass --yes to Tigris.")
@click.pass_context
def delete_bucket(ctx, name, yes):
"""Delete an empty bucket."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
if not yes:
_emit_error(use_json, skin, "Refusing to delete bucket without --yes")
raise SystemExit(1)
try:
result = backend.delete_bucket(name, yes=yes)
if use_json:
click.echo(json_mod.dumps(result or {"name": name, "status": "deleted"}, indent=2))
else:
skin.success(f"Bucket '{name}' deleted")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to delete bucket: {e}")
raise SystemExit(1)
@bucket_group.command("info")
@click.argument("name")
@click.pass_context
def bucket_info(ctx, name):
"""Get info about a bucket (`tigris buckets get`)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.head_bucket(name)
if use_json:
click.echo(json_mod.dumps(info or {"name": name}, indent=2))
else:
skin.section(f"Bucket: {name}")
if isinstance(info, dict):
for k, v in info.items():
skin.status(k, str(v))
else:
click.echo(info)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to get bucket info: {e}")
raise SystemExit(1)
def _emit_error(use_json: bool, skin, message: str) -> None:
if use_json:
click.echo(json_mod.dumps({"error": message}, indent=2))
elif skin:
skin.error(message)
else:
click.echo(message, err=True)
@@ -0,0 +1,162 @@
"""IAM commands -- policies and users.
Wraps `tigris iam policies` and `tigris iam users`. Together with
`access-key assign`, IAM policies let agents/orgs grant narrow, auditable
permissions to programmatic clients.
"""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("iam")
@click.pass_context
def iam_group(ctx):
"""IAM — policies and users (wraps `tigris iam`)."""
pass
# ── policies ──────────────────────────────────────────────────────────
@iam_group.group("policy")
@click.pass_context
def policy_group(ctx):
"""Manage IAM policies."""
pass
@policy_group.command("list")
@click.pass_context
def list_policies(ctx):
"""List all IAM policies."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
policies = backend.list_iam_policies()
if use_json:
click.echo(json_mod.dumps(policies, indent=2))
else:
if not policies:
skin.info("No IAM policies found.")
return
if isinstance(policies, list):
headers = ["Name", "ARN / ID", "Created"]
rows = []
for p in policies:
if not isinstance(p, dict):
rows.append([str(p), "", ""])
continue
rows.append([
str(p.get("name") or "?"),
str(p.get("arn") or p.get("id") or ""),
str(p.get("created") or p.get("createdAt") or ""),
])
skin.table(headers, rows)
else:
click.echo(policies)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list policies: {e}")
raise SystemExit(1)
@policy_group.command("create")
@click.argument("name")
@click.option("--document", required=True,
help="Path to a JSON policy document")
@click.pass_context
def create_policy(ctx, name, document):
"""Create a new IAM policy from a JSON document."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.create_iam_policy(name, document)
if use_json:
click.echo(json_mod.dumps(result or {"name": name, "status": "created"}, indent=2))
else:
skin.success(f"Policy '{name}' created from {document}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to create policy: {e}")
raise SystemExit(1)
# ── users ─────────────────────────────────────────────────────────────
@iam_group.group("user")
@click.pass_context
def user_group(ctx):
"""Manage organization users and invitations."""
pass
@user_group.command("list")
@click.pass_context
def list_users(ctx):
"""List all users in the current organization."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
users = backend.list_iam_users()
if use_json:
click.echo(json_mod.dumps(users, indent=2))
else:
if not users:
skin.info("No users found.")
return
if isinstance(users, list):
headers = ["Email", "Role", "Status"]
rows = []
for u in users:
if not isinstance(u, dict):
rows.append([str(u), "", ""])
continue
rows.append([
str(u.get("email") or "?"),
str(u.get("role") or ""),
str(u.get("status") or ""),
])
skin.table(headers, rows)
else:
click.echo(users)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list users: {e}")
raise SystemExit(1)
@user_group.command("invite")
@click.argument("email")
@click.option("--role", default="member",
help="Role to assign on join (default: member)")
@click.pass_context
def invite_user(ctx, email, role):
"""Invite a user by email."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.invite_iam_user(email, role=role)
if use_json:
click.echo(json_mod.dumps(
result or {"email": email, "role": role, "status": "invited"},
indent=2,
))
else:
skin.success(f"Invited {email} as {role}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to invite user: {e}")
raise SystemExit(1)
def _emit_error(use_json: bool, skin, message: str) -> None:
if use_json:
click.echo(json_mod.dumps({"error": message}, indent=2))
elif skin:
skin.error(message)
else:
click.echo(message, err=True)
@@ -0,0 +1,220 @@
"""Object commands -- list, put, get, delete, info, cp."""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
T3_SCHEMES = ("t3://", "tigris://")
def _is_remote(path: str) -> bool:
return path.startswith(T3_SCHEMES)
def _parse_tigris_uri(uri: str) -> tuple[str, str]:
"""Parse t3://bucket/key or tigris://bucket/key into (bucket, key)."""
for scheme in T3_SCHEMES:
if uri.startswith(scheme):
rest = uri[len(scheme):]
parts = rest.split("/", 1)
if len(parts) == 2 and parts[0] and parts[1]:
return parts[0], parts[1]
raise click.UsageError(
f"Expected {scheme}<bucket>/<key>, got: {uri}"
)
raise click.UsageError(
f"Expected URI starting with t3:// or tigris://, got: {uri}"
)
@click.group("object")
@click.pass_context
def object_group(ctx):
"""Manage Tigris objects (wraps `tigris ls/cp/rm/stat`)."""
pass
@object_group.command("list")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--prefix", default=None, help="Filter objects by key prefix")
@click.option("--limit", default=None, type=int,
help="Limit to first N results (client-side trim)")
@click.pass_context
def list_objects(ctx, bucket, prefix, limit):
"""List objects in a bucket (`tigris ls`)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
objs = backend.list_objects(bucket, prefix=prefix, limit=limit)
if use_json:
click.echo(json_mod.dumps(objs, indent=2))
else:
if not objs:
skin.info(f"No objects found in '{bucket}'.")
return
if isinstance(objs, list):
headers = ["Key", "Size", "Modified"]
rows = []
for o in objs:
if not isinstance(o, dict):
rows.append([str(o), "", ""])
continue
rows.append([
str(o.get("key") or o.get("Key") or "?"),
str(o.get("size") or o.get("Size") or ""),
str(o.get("modified") or o.get("LastModified") or ""),
])
skin.table(headers, rows)
else:
click.echo(objs)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list objects: {e}")
raise SystemExit(1)
@object_group.command("put")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.option("--file", "file_path", default=None,
help="Local file path to upload (uses `tigris cp`)")
@click.option("--text", default=None,
help="Inline text content (staged to a tempfile then `tigris cp`)")
@click.pass_context
def put_object(ctx, bucket, key, file_path, text):
"""Upload an object from a file or inline text."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
if (file_path is None) == (text is None):
msg = "Provide exactly one of --file or --text"
_emit_error(use_json, skin, msg)
raise SystemExit(2)
try:
if file_path:
backend.put_object_from_file(bucket, key, file_path)
else:
backend.put_object_inline(bucket, key, text)
if use_json:
click.echo(json_mod.dumps(
{"bucket": bucket, "key": key, "status": "uploaded"}, indent=2
))
else:
skin.success(f"Uploaded {bucket}/{key}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to upload: {e}")
raise SystemExit(1)
@object_group.command("get")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.option("--output", required=True,
help="Local path to write the object to")
@click.pass_context
def get_object(ctx, bucket, key, output):
"""Download an object to a local file (`tigris cp`)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
backend.get_object_to_file(bucket, key, output)
if use_json:
click.echo(json_mod.dumps(
{"bucket": bucket, "key": key, "path": output}, indent=2
))
else:
skin.success(f"Downloaded {bucket}/{key} -> {output}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to download: {e}")
raise SystemExit(1)
@object_group.command("delete")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.pass_context
def delete_object(ctx, bucket, key):
"""Delete an object (`tigris rm`)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
backend.delete_object(bucket, key)
if use_json:
click.echo(json_mod.dumps(
{"bucket": bucket, "key": key, "status": "deleted"}, indent=2
))
else:
skin.success(f"Deleted {bucket}/{key}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to delete: {e}")
raise SystemExit(1)
@object_group.command("info")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.pass_context
def object_info(ctx, bucket, key):
"""Get object metadata (`tigris stat`)."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.head_object(bucket, key)
if use_json:
click.echo(json_mod.dumps(info or {"bucket": bucket, "key": key}, indent=2))
else:
skin.section(f"Object: {bucket}/{key}")
if isinstance(info, dict):
for k, v in info.items():
skin.status(k, str(v))
else:
click.echo(info)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to get object info: {e}")
raise SystemExit(1)
@object_group.command("cp")
@click.argument("src")
@click.argument("dst")
@click.option("--recursive", "-r", is_flag=True, help="Copy directories recursively")
@click.pass_context
def copy_object(ctx, src, dst, recursive):
"""Copy. SRC and DST are local paths or t3://bucket/key (or tigris://...).
At least one side must be a t3:// or tigris:// URI.
"""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
if not (_is_remote(src) or _is_remote(dst)):
_emit_error(use_json, skin,
"At least one of SRC or DST must be t3:// or tigris://")
raise SystemExit(2)
try:
backend.cp(src, dst, recursive=recursive)
if use_json:
click.echo(json_mod.dumps(
{"src": src, "dst": dst, "status": "copied"}, indent=2
))
else:
skin.success(f"Copied {src} -> {dst}")
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to copy: {e}")
raise SystemExit(1)
def _emit_error(use_json: bool, skin, message: str) -> None:
if use_json:
click.echo(json_mod.dumps({"error": message}, indent=2))
elif skin:
skin.error(message)
else:
click.echo(message, err=True)
@@ -0,0 +1,66 @@
"""Presigned URL commands -- get, put.
Wraps `tigris presign <path> --method get|put --expires-in <sec>`.
"""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("presign")
@click.pass_context
def presign_group(ctx):
"""Generate presigned URLs (wraps `tigris presign`)."""
pass
def _presign(ctx, bucket: str, key: str, method: str, expires: int,
access_key: str | None) -> None:
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
url = backend.presign(bucket, key, method=method,
expires_in=expires, access_key=access_key)
if use_json:
click.echo(json_mod.dumps(
{"url": url, "method": method.upper(), "expires_in": expires},
indent=2,
))
else:
skin.success(f"Presigned {method.upper()} for {bucket}/{key} ({expires}s)")
click.echo(url)
except TigrisCliError as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to presign: {e}")
raise SystemExit(1)
@presign_group.command("get")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.option("--expires", default=3600, type=int,
help="URL lifetime in seconds (default: 3600)")
@click.option("--access-key", default=None,
help="Access key ID to sign with (default: resolved automatically)")
@click.pass_context
def presign_get(ctx, bucket, key, expires, access_key):
"""Presigned URL for downloading an object."""
_presign(ctx, bucket, key, "get", expires, access_key)
@presign_group.command("put")
@click.option("--bucket", required=True, help="Bucket name")
@click.option("--key", required=True, help="Object key")
@click.option("--expires", default=3600, type=int,
help="URL lifetime in seconds (default: 3600)")
@click.option("--access-key", default=None,
help="Access key ID to sign with (default: resolved automatically)")
@click.pass_context
def presign_put(ctx, bucket, key, expires, access_key):
"""Presigned URL for uploading an object."""
_presign(ctx, bucket, key, "put", expires, access_key)
@@ -0,0 +1,82 @@
"""Snapshot commands -- list, take.
Wraps `tigris snapshots list/take`. Snapshots are point-in-time, read-only
copies of a bucket's state — one of Tigris's agent-storage primitives that
generic S3-compatible providers don't ship.
"""
import json as json_mod
import click
from ..utils.tigris_backend import TigrisBackend, TigrisCliError
@click.group("snapshot")
@click.pass_context
def snapshot_group(ctx):
"""Manage bucket snapshots (wraps `tigris snapshots`)."""
pass
@snapshot_group.command("list")
@click.argument("bucket")
@click.pass_context
def list_snapshots(ctx, bucket):
"""List snapshots for a bucket."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
snaps = backend.list_snapshots(bucket)
if use_json:
click.echo(json_mod.dumps(snaps, indent=2))
else:
if not snaps:
skin.info(f"No snapshots found for '{bucket}'.")
return
if isinstance(snaps, list):
headers = ["Name / ID", "Created"]
rows = []
for s in snaps:
if not isinstance(s, dict):
rows.append([str(s), ""])
continue
rows.append([
str(s.get("name") or s.get("id") or "?"),
str(s.get("created") or s.get("createdAt") or ""),
])
skin.table(headers, rows)
else:
click.echo(snaps)
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to list snapshots: {e}")
raise SystemExit(1)
@snapshot_group.command("take")
@click.argument("bucket")
@click.option("--name", default=None, help="Optional snapshot label/name")
@click.pass_context
def take_snapshot(ctx, bucket, name):
"""Take a point-in-time snapshot of a bucket."""
backend: TigrisBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.take_snapshot(bucket, name=name)
if use_json:
click.echo(json_mod.dumps(result or {"bucket": bucket, "status": "snapshot_taken"}, indent=2))
else:
skin.success(f"Snapshot taken for '{bucket}'" + (f" ({name})" if name else ""))
except TigrisCliError as e:
_emit_error(use_json, skin, f"Failed to take snapshot: {e}")
raise SystemExit(1)
def _emit_error(use_json: bool, skin, message: str) -> None:
if use_json:
click.echo(json_mod.dumps({"error": message}, indent=2))
elif skin:
skin.error(message)
else:
click.echo(message, err=True)
@@ -0,0 +1,199 @@
---
name: "cli-anything-tigris"
description: >-
Command-line interface for Tigris object storage — wraps the official `tigris` CLI to expose buckets, objects, presigned URLs, snapshots, IAM, and scoped access keys to AI agents. Globally distributed, S3-compatible, no egress fees.
---
# cli-anything-tigris
A stateless command-line interface for [Tigris](https://www.tigrisdata.com)
object storage. Wraps the official `tigris` CLI so every Tigris primitive
(snapshots, IAM, scoped credentials, OAuth) is reachable through a single
agent-friendly entry point with `--json` everywhere.
## Scope
This harness is for the official Tigris CLI only. It shells out to the
`tigris` binary and assumes Tigris auth through `tigris login` or Tigris
access keys. It is not a generic S3 endpoint tool, and it does not manage
MinIO, Cloudflare R2, AWS S3, or arbitrary S3-compatible endpoints.
## Installation
```bash
# 1. Install the underlying Tigris CLI
npm install -g @tigrisdata/cli
# or:
brew install tigrisdata/tap/tigris
# 2. Authenticate (browser OAuth)
tigris login
# 3. Install this harness
pip install cli-anything-tigris
```
**Prerequisites:**
- Python 3.10+
- `tigris` CLI on PATH (the binary's alias is `t3`)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-tigris --help
# Start interactive REPL
cli-anything-tigris
# Whoami (JSON output for agents)
cli-anything-tigris --json auth whoami
# List buckets
cli-anything-tigris --json bucket list
# Upload a local file
cli-anything-tigris --json object put --bucket my-bucket --key path/to/file.txt --file ./local.txt
# Download an object
cli-anything-tigris --json object get --bucket my-bucket --key path/to/file.txt --output ./out.txt
# Server-side copy
cli-anything-tigris --json object cp t3://my-bucket/src.txt t3://my-bucket/dst.txt
# Take a snapshot
cli-anything-tigris --json snapshot take my-bucket --name baseline-v1
# Delete a bucket; --yes is required
cli-anything-tigris --json bucket delete --name old-bucket --yes
# Create a scoped access key for an agent run
cli-anything-tigris --json access-key create my-agent-key
cli-anything-tigris --json access-key assign tid_AaBb --bucket my-bucket --role Editor
# Rotate or delete access keys; --yes is required
cli-anything-tigris --json access-key rotate tid_AaBb --yes
cli-anything-tigris --json access-key delete tid_AaBb --yes
# Presigned download URL (1 hour)
cli-anything-tigris --json presign get --bucket my-bucket --key path/to/file.txt --expires 3600
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL with
tab-completion and history.
## Command Groups
### auth
OAuth-based authentication.
| Command | Description |
|---------|-------------|
| `login` | Browser OAuth login (`tigris login`) |
| `logout` | Log out of current session |
| `whoami` | Print authenticated user / org |
### bucket
Bucket CRUD.
| Command | Description |
|---------|-------------|
| `list` | List all buckets |
| `create --name NAME` | Create a bucket |
| `delete --name NAME --yes` | Delete an empty bucket (`--yes` required) |
| `info NAME` | Get bucket info |
### object
Object operations (wraps `tigris ls/cp/rm/stat`).
| Command | Description |
|---------|-------------|
| `list --bucket B [--prefix P] [--limit N]` | List objects |
| `put --bucket B --key K (--file F \| --text T)` | Upload an object |
| `get --bucket B --key K --output F` | Download an object |
| `delete --bucket B --key K` | Delete an object |
| `info --bucket B --key K` | Object metadata (HEAD / stat) |
| `cp SRC DST [-r]` | Copy. Accepts `t3://` or `tigris://` URIs. |
### presign
Time-limited URLs.
| Command | Description |
|---------|-------------|
| `get --bucket B --key K [--expires SEC]` | Presigned download URL |
| `put --bucket B --key K [--expires SEC]` | Presigned upload URL |
### snapshot
Point-in-time bucket snapshots — a Tigris-specific primitive.
| Command | Description |
|---------|-------------|
| `list BUCKET` | List snapshots for a bucket |
| `take BUCKET [--name N]` | Take a snapshot |
### access-key
Scoped programmatic credentials — combine with `snapshot` for per-agent isolation.
| Command | Description |
|---------|-------------|
| `list` | List all access keys |
| `create NAME` | Create a new access key (secret shown ONCE) |
| `get KEY_ID` | Show key details |
| `delete KEY_ID --yes` | Permanently delete a key (`--yes` required) |
| `assign KEY_ID --bucket B --role R` | Scope a key to a bucket + role |
| `rotate KEY_ID --yes` | Rotate a key's secret (`--yes` required) |
### iam
Policies and organization users.
| Command | Description |
|---------|-------------|
| `policy list` | List IAM policies |
| `policy create NAME --document FILE` | Create a policy from a JSON file |
| `user list` | List org users |
| `user invite EMAIL [--role R]` | Invite a user |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): tables, colors, formatted text
- **Machine-readable** (`--json`): JSON envelope (or upstream CLI's
`--format json` output, echoed verbatim)
## For AI Agents
When using this CLI programmatically:
1. Always pass `--json` for parseable output.
2. Check return codes — 0 for success, non-zero for errors.
3. Read stderr for error messages.
4. `object cp` accepts `t3://bucket/key` or `tigris://bucket/key` URIs;
server-side copies (t3 → t3) skip the round trip entirely.
5. `presign` returns a URL on stdout in human mode; in JSON mode it's the
`url` field.
6. **For destructive work**: bucket deletion, access-key deletion, and
access-key rotation require explicit `--yes`; take a `snapshot` of the
target bucket first, then do the work, then either keep the snapshot or
discard.
7. **For per-agent isolation**: `access-key create` + `access-key assign
--bucket B --role Editor` to mint a key scoped to one bucket; revoke with
`access-key delete` when the agent run ends.
## Why Tigris
- **Globally distributed.** Data placed close to wherever it's read.
- **No egress fees.** Agents pulling artifacts from anywhere don't incur
per-region bandwidth charges.
- **Snapshots + scoped credentials.** Primitives generic S3-compatible
providers don't ship — the foundation for per-agent isolation.
- **S3-compatible.** Useful alongside S3-aware tools, but this harness
itself is not a generic S3/MinIO/R2/AWS endpoint manager.
## Version
1.0.0
@@ -0,0 +1,65 @@
# Tigris CLI Harness — Test Plan
## Layout
- `test_core.py` — unit tests for the `TigrisBackend` subprocess wrapper and
the Click CLI. `subprocess.run` is fully mocked, and `shutil.which` is
patched at module load so the backend believes `tigris` is on PATH. These
tests run with **no `tigris` CLI installed and no network access** — safe
for any CI environment.
- `test_full_e2e.py` — real-world tests that shell out to the actual `tigris`
CLI against a real bucket. **Skipped by default**; gated on the
`CLI_ANYTHING_TIGRIS_RUN_E2E` env var plus `tigris` being on PATH plus a
configured test bucket.
## Running unit tests
```bash
cd tigris/agent-harness
pip install -e .[dev]
pytest cli_anything/tigris/tests/test_core.py -v
```
All tests should pass with no Tigris CLI and no credentials.
## Coverage areas (unit tests)
| Area | Tests |
|------|-------|
| URI/path helpers (`_path_to_t3`, `_parse_tigris_uri`) | scheme normalization, happy + reject paths |
| Backend init | binary resolution via `shutil.which`, missing-binary error, env-var export for credentials |
| Bucket ops | `list/create/delete/head` invoke `tigris buckets …` with correct args + `--format json`; `delete` only passes `--yes` when explicitly requested |
| Object ops | `list` (with prefix + client-side limit), `cp` (with `--recursive`), `put_from_file`, `put_inline` (tempfile path), `delete` (uses `rm --yes`), `head` (uses `stat`) |
| Presign | flags forwarded, URL extracted from JSON dict, fallback parse from raw string |
| Snapshots | `list/take` invoke `tigris snapshots …` |
| Access keys | `list/create/get/delete/assign/rotate` flag wiring; `delete` and `rotate` only pass `--yes` when explicitly requested |
| IAM | `policies list/create`, `users list/invite` flag wiring |
| Error path | non-zero exit code raises `TigrisCliError` with stderr |
| CLI integration | `--json` output works for bucket/object/presign/snapshot/access-key/auth; `object put` without `--file`/`--text` errors; `object cp` with no `t3://` errors |
## Running e2e tests
```bash
# Install + auth
npm install -g @tigrisdata/cli # or: brew install tigrisdata/tap/tigris
tigris login
# Configure
export CLI_ANYTHING_TIGRIS_TEST_BUCKET=<your-test-bucket>
export CLI_ANYTHING_TIGRIS_RUN_E2E=1
# Run
pytest cli_anything/tigris/tests/test_full_e2e.py -v
```
E2e tests cover:
- `whoami` returns a non-empty session
- `list_buckets` includes the configured test bucket
- put / get / head / list / delete round trip for a single object
- presigned URL is well-formed and includes the key's last segment
- `snapshots list` succeeds against the test bucket
Each test uses a per-run UUID prefix for keys to avoid collisions when
multiple devs / CI runners hit the same bucket. Cleanup happens in
`finally` blocks so partial failures don't leave litter.
@@ -0,0 +1,441 @@
"""Unit tests for the Tigris CLI harness.
`subprocess.run` is fully mocked so these tests run without the `tigris` CLI
installed or any network access. End-to-end tests against a real Tigris CLI
live in `test_full_e2e.py`.
"""
import json as json_mod
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
# Patch shutil.which BEFORE the backend is constructed so the resolver
# always claims `tigris` is available.
_WHICH_PATCH = patch(
"cli_anything.tigris.utils.tigris_backend.shutil.which",
return_value="/usr/local/bin/tigris",
)
_WHICH_PATCH.start()
from cli_anything.tigris.tigris_cli import cli # noqa: E402
from cli_anything.tigris.utils.tigris_backend import ( # noqa: E402
TigrisBackend,
TigrisCliError,
_path_to_t3,
)
from cli_anything.tigris.core.object import _parse_tigris_uri # noqa: E402
# ── Helpers ───────────────────────────────────────────────────────────
def _mock_run(stdout: str = "", stderr: str = "", returncode: int = 0):
"""Build a mock subprocess.run that returns the given completed-process."""
return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=returncode)
def _patch_run(json_out=None, text_out="", returncode=0):
"""Convenience: patch subprocess.run inside the backend module."""
if json_out is not None:
stdout = json_mod.dumps(json_out)
else:
stdout = text_out
return patch(
"cli_anything.tigris.utils.tigris_backend.subprocess.run",
return_value=_mock_run(stdout=stdout, returncode=returncode),
)
# ── URI / path helpers ────────────────────────────────────────────────
def test_path_to_t3_normalizes_schemes():
assert _path_to_t3("t3://b/k") == "t3://b/k"
assert _path_to_t3("tigris://b/k") == "t3://b/k"
assert _path_to_t3("b/k") == "t3://b/k"
assert _path_to_t3("/b/k") == "t3://b/k"
def test_parse_tigris_uri_happy():
assert _parse_tigris_uri("t3://b/k") == ("b", "k")
assert _parse_tigris_uri("tigris://b/a/b/c") == ("b", "a/b/c")
def test_parse_tigris_uri_rejects_bad():
import click as _click
for bad in ("s3://b/k", "tigris://nokey", "t3:///nobucket", "plainpath"):
with pytest.raises(_click.UsageError):
_parse_tigris_uri(bad)
# ── Backend init ──────────────────────────────────────────────────────
def test_backend_init_resolves_tigris_path():
# _WHICH_PATCH at module scope makes this succeed.
b = TigrisBackend()
assert b.cli_path == "/usr/local/bin/tigris"
def test_backend_init_raises_when_tigris_missing():
with patch(
"cli_anything.tigris.utils.tigris_backend.shutil.which",
return_value=None,
):
with pytest.raises(TigrisCliError, match="not found on PATH"):
TigrisBackend()
def test_backend_extra_env_set_when_credentials_passed():
b = TigrisBackend(access_key="ak", secret_key="sk")
assert b._extra_env["TIGRIS_STORAGE_ACCESS_KEY_ID"] == "ak"
assert b._extra_env["AWS_ACCESS_KEY_ID"] == "ak"
assert b._extra_env["TIGRIS_STORAGE_SECRET_ACCESS_KEY"] == "sk"
assert b._extra_env["AWS_SECRET_ACCESS_KEY"] == "sk"
# ── Backend method invocations (verify the args passed to subprocess) ──
def _run_call_args(mock_run) -> list[str]:
"""Extract the argv list passed to subprocess.run."""
args, _ = mock_run.call_args
return args[0]
def test_list_buckets_invokes_correct_args():
with _patch_run(json_out=[{"name": "alpha"}]) as m:
b = TigrisBackend()
result = b.list_buckets()
assert result == [{"name": "alpha"}]
argv = _run_call_args(m)
assert argv[1:] == ["buckets", "list", "--format", "json"]
def test_create_bucket_invokes_correct_args():
with _patch_run(json_out={"name": "x", "status": "created"}) as m:
b = TigrisBackend()
b.create_bucket("x")
assert _run_call_args(m)[1:] == ["buckets", "create", "x", "--format", "json"]
def test_delete_bucket_defaults_to_confirmation():
with _patch_run(json_out={"status": "deleted"}) as m:
b = TigrisBackend()
b.delete_bucket("x")
argv = _run_call_args(m)
assert "--yes" not in argv
assert "buckets" in argv and "delete" in argv and "x" in argv
def test_delete_bucket_includes_yes_flag_when_requested():
with _patch_run(json_out={"status": "deleted"}) as m:
b = TigrisBackend()
b.delete_bucket("x", yes=True)
argv = _run_call_args(m)
assert "--yes" in argv
assert "buckets" in argv and "delete" in argv and "x" in argv
def test_head_bucket_uses_get():
with _patch_run(json_out={"name": "x"}) as m:
b = TigrisBackend()
b.head_bucket("x")
assert _run_call_args(m)[1:] == ["buckets", "get", "x", "--format", "json"]
def test_list_objects_with_prefix_and_limit():
payload = [{"key": "foo"}, {"key": "bar"}, {"key": "baz"}]
with _patch_run(json_out=payload) as m:
b = TigrisBackend()
result = b.list_objects("my-bucket", prefix="dir/", limit=2)
argv = _run_call_args(m)
assert argv[1:] == ["ls", "t3://my-bucket/dir", "--format", "json"]
assert result == payload[:2] # client-side limit applied
def test_cp_uses_recursive_flag_when_set():
with _patch_run(text_out="ok") as m:
b = TigrisBackend()
b.cp("./local/", "t3://my-bucket/dst/", recursive=True)
argv = _run_call_args(m)
assert "--recursive" in argv
# JSON-mode is OFF for cp (no --format json appended)
assert "--format" not in argv
def test_put_object_from_file_uses_cp():
with _patch_run(text_out="") as m:
b = TigrisBackend()
b.put_object_from_file("my-bucket", "k", "./local.txt")
argv = _run_call_args(m)
assert argv[1] == "cp"
assert argv[2] == "./local.txt"
assert argv[3] == "t3://my-bucket/k"
def test_put_object_inline_writes_tempfile_then_cleans_up():
with _patch_run(text_out="") as m:
b = TigrisBackend()
b.put_object_inline("my-bucket", "k", "hello body")
argv = _run_call_args(m)
assert argv[1] == "cp"
# tmp path -> t3 url
assert argv[3] == "t3://my-bucket/k"
def test_delete_object_uses_rm_with_yes():
with _patch_run(text_out="") as m:
b = TigrisBackend()
b.delete_object("my-bucket", "k")
argv = _run_call_args(m)
assert argv[1] == "rm"
assert argv[2] == "t3://my-bucket/k"
assert "--yes" in argv
def test_head_object_uses_stat():
with _patch_run(json_out={"size": 42}) as m:
b = TigrisBackend()
info = b.head_object("my-bucket", "k")
argv = _run_call_args(m)
assert argv[1:] == ["stat", "t3://my-bucket/k", "--format", "json"]
assert info == {"size": 42}
def test_presign_returns_url_from_dict():
with _patch_run(json_out={"url": "https://signed/url"}) as m:
b = TigrisBackend()
url = b.presign("my-bucket", "k", method="get", expires_in=600)
argv = _run_call_args(m)
assert "presign" in argv
assert "t3://my-bucket/k" in argv
assert "--method" in argv and "get" in argv
assert "--expires-in" in argv and "600" in argv
assert url == "https://signed/url"
def test_presign_falls_back_to_string():
with _patch_run(text_out="https://signed/url\n") as m:
b = TigrisBackend()
# text_out is returned through JSONDecodeError fallback
url = b.presign("my-bucket", "k")
assert url == "https://signed/url"
# ── Snapshots ─────────────────────────────────────────────────────────
def test_list_snapshots_invokes_correct_args():
with _patch_run(json_out=[]) as m:
b = TigrisBackend()
b.list_snapshots("my-bucket")
assert _run_call_args(m)[1:] == ["snapshots", "list", "my-bucket", "--format", "json"]
def test_take_snapshot_with_name():
with _patch_run(json_out={"status": "ok"}) as m:
b = TigrisBackend()
b.take_snapshot("my-bucket", name="v1")
argv = _run_call_args(m)
assert "snapshots" in argv and "take" in argv
assert "--name" in argv and "v1" in argv
# ── Access keys ───────────────────────────────────────────────────────
def test_list_access_keys():
with _patch_run(json_out=[]) as m:
b = TigrisBackend()
b.list_access_keys()
assert _run_call_args(m)[1:] == ["access-keys", "list", "--format", "json"]
def test_delete_access_key_defaults_to_confirmation():
with _patch_run(json_out={"status": "deleted"}) as m:
b = TigrisBackend()
b.delete_access_key("tid_AaBb")
argv = _run_call_args(m)
assert "--yes" not in argv
assert "access-keys" in argv and "delete" in argv and "tid_AaBb" in argv
def test_delete_access_key_includes_yes_flag_when_requested():
with _patch_run(json_out={"status": "deleted"}) as m:
b = TigrisBackend()
b.delete_access_key("tid_AaBb", yes=True)
argv = _run_call_args(m)
assert "--yes" in argv
assert "access-keys" in argv and "delete" in argv and "tid_AaBb" in argv
def test_assign_access_key_uses_role_and_bucket():
with _patch_run(json_out={"status": "assigned"}) as m:
b = TigrisBackend()
b.assign_access_key("tid_AaBb", bucket="my-bucket", role="Editor")
argv = _run_call_args(m)
assert "access-keys" in argv and "assign" in argv and "tid_AaBb" in argv
assert "--bucket" in argv and "my-bucket" in argv
assert "--role" in argv and "Editor" in argv
def test_rotate_access_key_defaults_to_confirmation():
with _patch_run(json_out={"status": "rotated"}) as m:
b = TigrisBackend()
b.rotate_access_key("tid_AaBb")
argv = _run_call_args(m)
assert "--yes" not in argv
assert "access-keys" in argv and "rotate" in argv and "tid_AaBb" in argv
def test_rotate_access_key_includes_yes_flag_when_requested():
with _patch_run(json_out={"status": "rotated"}) as m:
b = TigrisBackend()
b.rotate_access_key("tid_AaBb", yes=True)
argv = _run_call_args(m)
assert "--yes" in argv
assert "access-keys" in argv and "rotate" in argv and "tid_AaBb" in argv
# ── IAM ───────────────────────────────────────────────────────────────
def test_list_iam_policies():
with _patch_run(json_out=[]) as m:
b = TigrisBackend()
b.list_iam_policies()
assert _run_call_args(m)[1:] == ["iam", "policies", "list", "--format", "json"]
def test_invite_iam_user():
with _patch_run(json_out={"status": "invited"}) as m:
b = TigrisBackend()
b.invite_iam_user("user@example.com", role="admin")
argv = _run_call_args(m)
assert "iam" in argv and "users" in argv and "invite" in argv
assert "user@example.com" in argv
assert "--role" in argv and "admin" in argv
# ── Error handling ────────────────────────────────────────────────────
def test_nonzero_exit_raises_tigris_cli_error():
with patch(
"cli_anything.tigris.utils.tigris_backend.subprocess.run",
return_value=_mock_run(stderr="boom", returncode=1),
):
b = TigrisBackend()
with pytest.raises(TigrisCliError, match="boom"):
b.list_buckets()
# ── CLI integration tests (backend uses mocked subprocess) ────────────
def test_cli_bucket_list_json():
with _patch_run(json_out=[{"name": "demo"}]):
runner = CliRunner()
r = runner.invoke(cli, ["--json", "bucket", "list"])
assert r.exit_code == 0, r.output
assert "demo" in r.output
def test_cli_bucket_delete_requires_yes():
with patch("cli_anything.tigris.utils.tigris_backend.subprocess.run") as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "bucket", "delete", "--name", "b"])
assert r.exit_code != 0
assert "without --yes" in r.output
m.assert_not_called()
def test_cli_bucket_delete_with_yes_passes_yes_flag():
with _patch_run(json_out={"status": "deleted"}) as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "bucket", "delete", "--name", "b", "--yes"])
assert r.exit_code == 0, r.output
assert "--yes" in _run_call_args(m)
def test_cli_object_put_requires_file_or_text():
runner = CliRunner()
r = runner.invoke(cli, ["--json", "object", "put", "--bucket", "b", "--key", "k"])
assert r.exit_code != 0
def test_cli_object_cp_requires_one_remote():
runner = CliRunner()
r = runner.invoke(cli, ["--json", "object", "cp", "./a", "./b"])
assert r.exit_code != 0
def test_cli_presign_get_json():
with _patch_run(json_out={"url": "https://signed"}):
runner = CliRunner()
r = runner.invoke(cli, ["--json", "presign", "get", "--bucket", "b", "--key", "k"])
assert r.exit_code == 0, r.output
assert "https://signed" in r.output
def test_cli_snapshot_take_json():
with _patch_run(json_out={"status": "ok"}):
runner = CliRunner()
r = runner.invoke(cli, ["--json", "snapshot", "take", "my-bucket", "--name", "v1"])
assert r.exit_code == 0, r.output
def test_cli_access_key_assign_json():
with _patch_run(json_out={"status": "assigned"}):
runner = CliRunner()
r = runner.invoke(cli, [
"--json", "access-key", "assign", "tid_x",
"--bucket", "b", "--role", "Editor",
])
assert r.exit_code == 0, r.output
def test_cli_access_key_delete_requires_yes():
with patch("cli_anything.tigris.utils.tigris_backend.subprocess.run") as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "access-key", "delete", "tid_x"])
assert r.exit_code != 0
assert "without --yes" in r.output
m.assert_not_called()
def test_cli_access_key_delete_with_yes_passes_yes_flag():
with _patch_run(json_out={"status": "deleted"}) as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "access-key", "delete", "tid_x", "--yes"])
assert r.exit_code == 0, r.output
assert "--yes" in _run_call_args(m)
def test_cli_access_key_rotate_requires_yes():
with patch("cli_anything.tigris.utils.tigris_backend.subprocess.run") as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "access-key", "rotate", "tid_x"])
assert r.exit_code != 0
assert "without --yes" in r.output
m.assert_not_called()
def test_cli_access_key_rotate_with_yes_passes_yes_flag():
with _patch_run(json_out={"status": "rotated"}) as m:
runner = CliRunner()
r = runner.invoke(cli, ["--json", "access-key", "rotate", "tid_x", "--yes"])
assert r.exit_code == 0, r.output
assert "--yes" in _run_call_args(m)
def test_cli_auth_whoami_json():
with _patch_run(json_out={"user": "dave", "org": "tigris"}):
runner = CliRunner()
r = runner.invoke(cli, ["--json", "auth", "whoami"])
assert r.exit_code == 0, r.output
assert "dave" in r.output
@@ -0,0 +1,110 @@
"""End-to-end tests against a real Tigris CLI and a real bucket.
These tests are SKIPPED by default. To run them locally:
npm install -g @tigrisdata/cli # or: brew install tigrisdata/tap/tigris
tigris login # one-time OAuth setup
export CLI_ANYTHING_TIGRIS_TEST_BUCKET=<a bucket you can write to>
export CLI_ANYTHING_TIGRIS_RUN_E2E=1
pytest cli_anything/tigris/tests/test_full_e2e.py -v
Test objects live under a per-run UUID prefix and are cleaned up in
teardown so concurrent runs do not collide.
"""
import os
import shutil
import uuid
import pytest
from cli_anything.tigris.utils.tigris_backend import TigrisBackend
RUN_E2E = os.environ.get("CLI_ANYTHING_TIGRIS_RUN_E2E") == "1"
TEST_BUCKET = os.environ.get("CLI_ANYTHING_TIGRIS_TEST_BUCKET")
HAS_TIGRIS_CLI = shutil.which("tigris") is not None
pytestmark = pytest.mark.skipif(
not (RUN_E2E and TEST_BUCKET and HAS_TIGRIS_CLI),
reason=(
"Set CLI_ANYTHING_TIGRIS_RUN_E2E=1 + "
"CLI_ANYTHING_TIGRIS_TEST_BUCKET=<bucket>, install the `tigris` CLI "
"(npm install -g @tigrisdata/cli), and run `tigris login`."
),
)
@pytest.fixture(scope="module")
def backend():
return TigrisBackend()
@pytest.fixture
def test_key():
return f"cli-anything-e2e/{uuid.uuid4()}.txt"
def test_whoami_returns_user(backend):
info = backend.whoami()
# whoami output shape varies — just check it returned something truthy
assert info, f"whoami returned empty: {info!r}"
def test_list_buckets_includes_test_bucket(backend):
buckets = backend.list_buckets()
if isinstance(buckets, list):
names = {
b.get("name") or b.get("Name")
for b in buckets if isinstance(b, dict)
}
assert TEST_BUCKET in names, f"{TEST_BUCKET} not in {names}"
def test_put_get_delete_round_trip(backend, test_key, tmp_path):
body = "hello from cli-anything-tigris e2e\n"
# PUT via inline-text path
backend.put_object_inline(TEST_BUCKET, test_key, body)
try:
# GET to a local file and verify contents
out = tmp_path / "downloaded.txt"
backend.get_object_to_file(TEST_BUCKET, test_key, str(out))
assert out.read_text() == body
# HEAD / stat — returns *some* metadata
info = backend.head_object(TEST_BUCKET, test_key)
assert info, f"head_object returned empty: {info!r}"
# LIST narrowed by prefix should include our key
prefix = test_key.rsplit("/", 1)[0]
listing = backend.list_objects(TEST_BUCKET, prefix=prefix, limit=50)
if isinstance(listing, list):
found = any(
(o.get("key") or o.get("Key") or "") == test_key
for o in listing if isinstance(o, dict)
)
assert found, f"{test_key} not in {listing}"
finally:
backend.delete_object(TEST_BUCKET, test_key)
def test_presigned_url_includes_key(backend, test_key):
backend.put_object_inline(TEST_BUCKET, test_key, "presign-test")
try:
url = backend.presign(TEST_BUCKET, test_key, method="get", expires_in=60)
assert isinstance(url, str) and url.startswith("http"), url
# Last path segment should appear in the URL
leaf = test_key.split("/")[-1]
assert leaf in url, f"{leaf} not in {url}"
finally:
backend.delete_object(TEST_BUCKET, test_key)
def test_snapshot_list_works(backend):
snaps = backend.list_snapshots(TEST_BUCKET)
# We don't assert on count — just that the call succeeded and returned
# something parseable (list, dict, or string).
assert snaps is not None or snaps == []
@@ -0,0 +1,157 @@
"""Tigris CLI-Anything harness — Click CLI + REPL.
Wraps the official Tigris CLI (`tigris`) so all of its primitives
including snapshots, IAM, scoped access keys, and OAuth are reachable
through a single agent-friendly entry point with `--json` everywhere.
Tigris is a globally distributed, S3-compatible object storage service
with no egress fees. https://www.tigrisdata.com
"""
import shlex
import click
from .utils.tigris_backend import TigrisBackend, TigrisCliError
from .utils.repl_skin import ReplSkin
from .core.auth import auth_group
from .core.bucket import bucket_group
from .core.object import object_group
from .core.presign import presign_group
from .core.snapshot import snapshot_group
from .core.access_key import access_key_group
from .core.iam import iam_group
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, default=False,
help="Output in JSON format")
@click.option("--cli-path", default="tigris",
help="Path to the tigris CLI binary (default: 'tigris' on PATH)")
@click.option("--access-key", default=None,
help="Optional access key ID (exported to env for child processes)")
@click.option("--secret-key", default=None,
help="Optional secret key (exported to env for child processes)")
@click.pass_context
def cli(ctx, use_json, cli_path, access_key, secret_key):
"""CLI-Anything harness for Tigris (S3-compatible object storage).
Wraps the official `tigris` CLI. Install it once with
`npm install -g @tigrisdata/cli` or `brew install tigrisdata/tap/tigris`,
run `tigris login`, then drive everything from here.
"""
ctx.ensure_object(dict)
ctx.obj["json"] = use_json
try:
ctx.obj["backend"] = TigrisBackend(
cli_path=cli_path,
access_key=access_key,
secret_key=secret_key,
)
except TigrisCliError as e:
# Defer the error so `--help` still works without the binary.
ctx.obj["backend"] = None
ctx.obj["backend_error"] = str(e)
ctx.obj["skin"] = ReplSkin("tigris", version="1.0.0")
if ctx.invoked_subcommand is None:
_run_repl(ctx)
cli.add_command(auth_group)
cli.add_command(bucket_group)
cli.add_command(object_group)
cli.add_command(presign_group)
cli.add_command(snapshot_group)
cli.add_command(access_key_group)
cli.add_command(iam_group)
# ── REPL Commands Map (for help display) ─────────────────────────────
_REPL_COMMANDS = {
"auth login": "Browser OAuth login (`tigris login`)",
"auth logout": "Log out of current session",
"auth whoami": "Print current user / org",
"bucket list": "List buckets",
"bucket create --name NAME": "Create a bucket",
"bucket delete --name NAME --yes": "Delete an empty bucket",
"bucket info NAME": "Get bucket info",
"object list --bucket B [--prefix P]": "List objects",
"object put --bucket B --key K --file F": "Upload a file",
"object put --bucket B --key K --text T": "Upload inline text",
"object get --bucket B --key K --output F": "Download to file",
"object delete --bucket B --key K": "Delete an object",
"object info --bucket B --key K": "Object metadata (stat)",
"object cp SRC DST [-r]": "Copy local↔t3 or t3↔t3",
"presign get --bucket B --key K [--expires SEC]": "Presigned GET URL",
"presign put --bucket B --key K [--expires SEC]": "Presigned PUT URL",
"snapshot list BUCKET": "List bucket snapshots",
"snapshot take BUCKET [--name N]": "Take a point-in-time snapshot",
"access-key list": "List access keys",
"access-key create NAME": "Create an access key",
"access-key get KEY_ID": "Get access key details",
"access-key delete KEY_ID --yes": "Delete an access key",
"access-key assign KEY_ID --bucket B --role R": "Scope a key to a bucket/role",
"access-key rotate KEY_ID --yes": "Rotate a key's secret",
"iam policy list / create NAME --document F": "Manage IAM policies",
"iam user list / invite EMAIL [--role R]": "Manage org users",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
def _run_repl(ctx):
"""Launch the interactive REPL."""
skin: ReplSkin = ctx.obj["skin"]
skin.print_banner()
if ctx.obj.get("backend") is None:
skin.error(ctx.obj.get("backend_error", "tigris CLI not available"))
return
session = skin.create_prompt_session()
while True:
try:
user_input = skin.get_input(session, context="tigris")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
cmd = user_input.strip().lower()
if cmd in ("quit", "exit", "q"):
skin.print_goodbye()
break
if cmd in ("help", "h", "?"):
skin.help(_REPL_COMMANDS)
continue
try:
args = shlex.split(user_input)
except ValueError as e:
skin.error(f"Parse error: {e}")
continue
try:
cli.main(args=args, obj=ctx.obj, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.error(str(e))
except Exception as e:
skin.error(f"Error: {e}")
def main():
"""Entry point."""
cli(auto_envvar_prefix="TIGRIS_CLI")
if __name__ == "__main__":
main()
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,315 @@
"""Subprocess wrapper around the official Tigris CLI (`tigris`).
This harness shells out to the Tigris CLI rather than reimplementing the S3
protocol, so:
* snapshots, IAM, access-keys, and organization primitives features
unique to Tigris are surfaced for agents.
* the harness inherits new commands automatically as the upstream CLI ships
them.
* authentication uses Tigris's OAuth flow (`tigris login`) by default.
Install the underlying CLI with one of:
npm install -g @tigrisdata/cli
brew install tigrisdata/tap/tigris
Then `tigris login` once, and this harness can call any operation as the
authenticated user.
"""
import json as json_mod
import os
import shutil
import subprocess
from typing import Any
class TigrisCliError(RuntimeError):
"""Raised when the tigris CLI returns a non-zero exit code."""
def _path_to_t3(path: str) -> str:
"""Normalize 'tigris://...', 't3://...', or bare 'bucket/key' to t3://..."""
if path.startswith("t3://"):
return path
if path.startswith("tigris://"):
return "t3://" + path[len("tigris://"):]
return "t3://" + path.lstrip("/")
class TigrisBackend:
"""Subprocess wrapper for the `tigris` CLI."""
def __init__(self, cli_path: str = "tigris", access_key: str | None = None,
secret_key: str | None = None):
"""Resolve the CLI binary; verify it's on PATH.
Args:
cli_path: Name or absolute path of the tigris binary. Default
`tigris`; the binary's alias `t3` also works.
access_key, secret_key: Optional explicit credentials. When set,
exported into env for child processes so commands that honor
AWS_* env vars pick them up. Most users should `tigris login`
instead.
"""
resolved = shutil.which(cli_path)
if not resolved:
raise TigrisCliError(
f"`{cli_path}` not found on PATH. Install with "
"`npm install -g @tigrisdata/cli` or "
"`brew install tigrisdata/tap/tigris`, then `tigris login`."
)
self.cli_path = resolved
self._extra_env: dict[str, str] = {}
if access_key:
self._extra_env["TIGRIS_STORAGE_ACCESS_KEY_ID"] = access_key
self._extra_env["AWS_ACCESS_KEY_ID"] = access_key
if secret_key:
self._extra_env["TIGRIS_STORAGE_SECRET_ACCESS_KEY"] = secret_key
self._extra_env["AWS_SECRET_ACCESS_KEY"] = secret_key
# ── core invoker ──────────────────────────────────────────────────
def _run(
self,
args: list[str],
json: bool = True,
check: bool = True,
capture: bool = True,
) -> Any:
"""Run `tigris <args>` and return parsed JSON when possible.
Args:
args: CLI subcommand + flags, excluding the binary name itself.
json: If True, append `--format json` and parse stdout as JSON.
Falls back to raw stdout on parse failure.
check: If True, raise TigrisCliError on non-zero exit.
capture: If True, capture stdout/stderr; if False, stream to the
caller's TTY (used for `login` / interactive flows).
Returns:
Parsed JSON object on success when json=True, raw string when
json=False or parse fails, or None when capture=False.
"""
cmd = [self.cli_path] + list(args)
if json:
# Append at the end so it doesn't fight with subcommand parsing.
cmd.extend(["--format", "json"])
env = {**os.environ, **self._extra_env}
if not capture:
result = subprocess.run(cmd, env=env, check=False)
if check and result.returncode != 0:
raise TigrisCliError(
f"`{' '.join(cmd)}` exited with code {result.returncode}"
)
return None
result = subprocess.run(
cmd, env=env, check=False, capture_output=True, text=True
)
if check and result.returncode != 0:
stderr = result.stderr.strip() or result.stdout.strip()
raise TigrisCliError(
f"`{' '.join(cmd)}` failed: {stderr or 'no output'}"
)
if json:
stdout = result.stdout.strip()
if not stdout:
return None
try:
return json_mod.loads(stdout)
except json_mod.JSONDecodeError:
# Not all commands support --format json; return raw text.
return stdout
return result.stdout
# ── version / auth ────────────────────────────────────────────────
def version(self) -> str:
"""Return the tigris CLI version string."""
return (self._run(["--version"], json=False) or "").strip()
def whoami(self) -> Any:
"""Return the currently authenticated user/org."""
return self._run(["whoami"])
def login(self) -> None:
"""Trigger the interactive `tigris login` flow (browser OAuth).
Streams to the caller's TTY; no return value. Use --json=False since
the flow prompts and prints progress.
"""
self._run(["login"], json=False, capture=False)
def logout(self) -> None:
"""Log out of the currently authenticated session."""
self._run(["logout"], json=False, capture=False)
# ── buckets ───────────────────────────────────────────────────────
def list_buckets(self) -> Any:
return self._run(["buckets", "list"])
def create_bucket(self, name: str) -> Any:
return self._run(["buckets", "create", name])
def delete_bucket(self, name: str, yes: bool = False) -> Any:
args = ["buckets", "delete", name]
if yes:
args.append("--yes")
return self._run(args)
def head_bucket(self, name: str) -> Any:
"""Get bucket info via `buckets get` (falls back to `stat`)."""
# The CLI exposes `tigris buckets get <name>` as the head equivalent.
return self._run(["buckets", "get", name])
# ── objects ───────────────────────────────────────────────────────
def list_objects(
self,
bucket: str,
prefix: str | None = None,
limit: int | None = None,
) -> Any:
"""`tigris ls <bucket[/prefix]>` — limit handled client-side if set."""
path = bucket if not prefix else f"{bucket}/{prefix}".rstrip("/")
result = self._run(["ls", _path_to_t3(path)])
if isinstance(result, list) and limit:
return result[:limit]
return result
def cp(self, src: str, dst: str, recursive: bool = False) -> Any:
"""Server-side or local↔remote copy.
`src` and `dst` may be local paths or t3:// / tigris:// URIs.
At least one side must be remote (enforced by the underlying CLI).
"""
src_norm = _path_to_t3(src) if src.startswith(("t3://", "tigris://")) else src
dst_norm = _path_to_t3(dst) if dst.startswith(("t3://", "tigris://")) else dst
args = ["cp", src_norm, dst_norm]
if recursive:
args.append("--recursive")
return self._run(args, json=False)
def put_object_from_file(self, bucket: str, key: str, file_path: str) -> Any:
"""Upload a local file via `tigris cp <file> t3://bucket/key`."""
return self.cp(file_path, f"t3://{bucket}/{key}")
def put_object_inline(self, bucket: str, key: str, text: str) -> Any:
"""Upload inline text by staging to a tempfile and `tigris cp`-ing it."""
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", suffix=".txt", delete=False
) as f:
f.write(text)
tmp_path = f.name
try:
return self.cp(tmp_path, f"t3://{bucket}/{key}")
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def get_object_to_file(self, bucket: str, key: str, file_path: str) -> Any:
"""Download an object via `tigris cp t3://bucket/key <file>`."""
return self.cp(f"t3://{bucket}/{key}", file_path)
def delete_object(self, bucket: str, key: str) -> Any:
return self._run(["rm", _path_to_t3(f"{bucket}/{key}"), "--yes"], json=False)
def head_object(self, bucket: str, key: str) -> Any:
"""`tigris stat t3://bucket/key`."""
return self._run(["stat", _path_to_t3(f"{bucket}/{key}")])
# ── presign ───────────────────────────────────────────────────────
def presign(
self,
bucket: str,
key: str,
method: str = "get",
expires_in: int = 3600,
access_key: str | None = None,
) -> str:
"""Generate a presigned URL via `tigris presign`."""
args = [
"presign",
_path_to_t3(f"{bucket}/{key}"),
"--method", method,
"--expires-in", str(expires_in),
]
if access_key:
args.extend(["--access-key", access_key])
result = self._run(args)
# The CLI returns either {"url": "..."} (json mode) or the bare URL.
if isinstance(result, dict) and "url" in result:
return result["url"]
if isinstance(result, str):
return result.strip()
return str(result)
# ── snapshots ─────────────────────────────────────────────────────
def list_snapshots(self, bucket: str) -> Any:
return self._run(["snapshots", "list", bucket])
def take_snapshot(self, bucket: str, name: str | None = None) -> Any:
args = ["snapshots", "take", bucket]
if name:
args.extend(["--name", name])
return self._run(args)
# ── access keys ───────────────────────────────────────────────────
def list_access_keys(self) -> Any:
return self._run(["access-keys", "list"])
def create_access_key(self, name: str) -> Any:
return self._run(["access-keys", "create", name])
def get_access_key(self, key_id: str) -> Any:
return self._run(["access-keys", "get", key_id])
def delete_access_key(self, key_id: str, yes: bool = False) -> Any:
args = ["access-keys", "delete", key_id]
if yes:
args.append("--yes")
return self._run(args)
def assign_access_key(
self, key_id: str, bucket: str, role: str
) -> Any:
"""`tigris access-keys assign <id> --bucket <b> --role <r>`."""
return self._run([
"access-keys", "assign", key_id,
"--bucket", bucket, "--role", role,
])
def rotate_access_key(self, key_id: str, yes: bool = False) -> Any:
args = ["access-keys", "rotate", key_id]
if yes:
args.append("--yes")
return self._run(args)
# ── IAM ───────────────────────────────────────────────────────────
def list_iam_policies(self) -> Any:
return self._run(["iam", "policies", "list"])
def create_iam_policy(self, name: str, document_path: str) -> Any:
return self._run([
"iam", "policies", "create", name,
"--document", document_path,
])
def list_iam_users(self) -> Any:
return self._run(["iam", "users", "list"])
def invite_iam_user(self, email: str, role: str = "member") -> Any:
return self._run([
"iam", "users", "invite", email, "--role", role,
])
+56
View File
@@ -0,0 +1,56 @@
"""Setup for cli-anything-tigris — CLI harness for the Tigris object storage CLI.
This harness shells out to the official `tigris` CLI rather than reimplementing
the S3 protocol, so it surfaces every Tigris primitive (snapshots, IAM,
scoped access keys, OAuth login) not just generic S3 ops.
Install the underlying CLI first with one of:
npm install -g @tigrisdata/cli
brew install tigrisdata/tap/tigris
"""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-tigris",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI-Anything harness wrapping the official Tigris CLI (object storage, snapshots, IAM, presign)",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
# NB: no boto3. This harness shells out to the `tigris` CLI, which is
# an external runtime dependency installed via npm or brew.
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-tigris=cli_anything.tigris.tigris_cli:main",
],
},
package_data={
"cli_anything.tigris": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)