mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-28 23:27:04 +08:00
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:
@@ -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/
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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/
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user