feat: add GPU-specific fields to marketplace offers and create dedicated GPU marketplace router

- Add GPU fields (model, memory, count, CUDA version, price, region) to MarketplaceOffer model
- Create new marketplace_gpu router for GPU-specific operations
- Update offer sync to populate GPU fields from miner capabilities
- Move GPU attributes from generic attributes dict to dedicated fields
- Update MarketplaceOfferView schema with GPU fields
- Expand CLI README with comprehensive documentation and
This commit is contained in:
oib
2026-02-12 19:08:17 +01:00
parent 76a2fc9b6d
commit 5120861e17
57 changed files with 11720 additions and 131 deletions

View File

@@ -1,155 +1,344 @@
# AITBC CLI Tools
# AITBC CLI - Command Line Interface
Command-line tools for interacting with the AITBC network without using the web frontend.
A powerful and comprehensive command-line interface for interacting with the AITBC (AI Training & Blockchain Computing) network.
## Tools
### 1. Client CLI (`client.py`)
Submit jobs and check their status.
## Installation
```bash
# Submit an inference job
python3 client.py submit inference --model llama-2-7b --prompt "What is AITBC?"
# Clone the repository
git clone https://github.com/aitbc/aitbc.git
cd aitbc
# Check job status
python3 client.py status <job_id>
# Install in development mode
pip install -e .
# List recent blocks
python3 client.py blocks --limit 5
# Submit a quick demo job
python3 client.py demo
```
### 2. Miner CLI (`miner.py`)
Register as a miner, poll for jobs, and earn AITBC.
```bash
# Register as a miner
python3 miner.py register --gpu "RTX 4060 Ti" --memory 16
# Poll for a single job
python3 miner.py poll --wait 5
# Mine continuously (process jobs as they come)
python3 miner.py mine --jobs 10
# Send heartbeat to coordinator
python3 miner.py heartbeat
```
### 3. Wallet CLI (`wallet.py`)
Track your AITBC earnings and manage your wallet.
```bash
# Check balance
python3 wallet.py balance
# Show transaction history
python3 wallet.py history --limit 10
# Add earnings (after completing a job)
python3 wallet.py earn 10.0 --job abc123 --desc "Inference task"
# Spend AITBC
python3 wallet.py spend 5.0 "Coffee break"
# Show wallet address
python3 wallet.py address
```
## GPU Testing
Before mining, verify your GPU is accessible:
```bash
# Quick GPU check
python3 test_gpu_access.py
# Comprehensive GPU test
python3 gpu_test.py
# Test miner with GPU
python3 miner_gpu_test.py --full
# Or install from PyPI (when published)
pip install aitbc-cli
```
## Quick Start
1. **Start the SSH tunnel to remote server** (if not already running):
1. **Set up your API key**:
```bash
cd /home/oib/windsurf/aitbc
./scripts/start_remote_tunnel.sh
export CLIENT_API_KEY=your_api_key_here
# Or save permanently
aitbc config set api_key your_api_key_here
```
2. **Run the complete workflow test**:
2. **Check your wallet**:
```bash
cd /home/oib/windsurf/aitbc/cli
python3 test_workflow.py
aitbc wallet balance
```
3. **Start mining continuously**:
3. **Submit your first job**:
```bash
# Terminal 1: Start mining
python3 miner.py mine
# Terminal 2: Submit jobs
python3 client.py submit training --model "stable-diffusion"
aitbc client submit inference --prompt "What is AI?" --model gpt-4
```
## Features
- 🚀 **Fast & Efficient**: Optimized for speed with minimal overhead
- 🎨 **Rich Output**: Beautiful tables, JSON, and YAML output formats
- 🔐 **Secure**: Built-in credential management with keyring
- 📊 **Comprehensive**: 40+ commands covering all aspects of the network
- 🧪 **Testing Ready**: Full simulation environment for testing
- 🔧 **Extensible**: Easy to add new commands and features
## Command Groups
### Client Operations
Submit and manage inference jobs:
```bash
aitbc client submit inference --prompt "Your prompt here" --model gpt-4
aitbc client status <job_id>
aitbc client history --status completed
```
### Mining Operations
Register as a miner and process jobs:
```bash
aitbc miner register --gpu-model RTX4090 --memory 24 --price 0.5
aitbc miner poll --interval 5
```
### Wallet Management
Manage your AITBC tokens:
```bash
aitbc wallet balance
aitbc wallet send <address> <amount>
aitbc wallet history
```
### Authentication
Manage API keys and authentication:
```bash
aitbc auth login your_api_key
aitbc auth status
aitbc auth keys create --name "My Key"
```
### Blockchain Queries
Query blockchain information:
```bash
aitbc blockchain blocks --limit 10
aitbc blockchain transaction <tx_hash>
aitbc blockchain sync-status
```
### Marketplace
GPU marketplace operations:
```bash
aitbc marketplace gpu list --available
aitbc marketplace gpu book <gpu_id> --hours 2
aitbc marketplace reviews <gpu_id>
```
### System Administration
Admin operations (requires admin privileges):
```bash
aitbc admin status
aitbc admin analytics --period 24h
aitbc admin logs --component coordinator
```
### Configuration
Manage CLI configuration:
```bash
aitbc config show
aitbc config set coordinator_url http://localhost:8000
aitbc config profiles save production
```
### Simulation
Test and simulate operations:
```bash
aitbc simulate init --distribute 10000,5000
aitbc simulate user create --type client --name testuser
aitbc simulate workflow --jobs 10
```
## Output Formats
All commands support multiple output formats:
```bash
# Table format (default)
aitbc wallet balance
# JSON format
aitbc --output json wallet balance
# YAML format
aitbc --output yaml wallet balance
```
## Global Options
These options can be used with any command:
- `--url TEXT`: Override coordinator URL
- `--api-key TEXT`: Override API key
- `--output [table|json|yaml]`: Output format
- `-v, --verbose`: Increase verbosity (use -vv, -vvv for more)
- `--debug`: Enable debug mode
- `--config-file TEXT`: Path to config file
- `--help`: Show help
- `--version`: Show version
## Shell Completion
Enable tab completion for bash/zsh:
```bash
# For bash
echo 'source /path/to/aitbc_shell_completion.sh' >> ~/.bashrc
source ~/.bashrc
# For zsh
echo 'source /path/to/aitbc_shell_completion.sh' >> ~/.zshrc
source ~/.zshrc
```
## Configuration
All tools default to connecting to `http://localhost:8001` (the remote server via SSH tunnel). You can override this:
The CLI can be configured in multiple ways:
```bash
python3 client.py --url http://localhost:8000 --api-key your_key submit inference
```
1. **Environment variables**:
```bash
export CLIENT_API_KEY=your_key
export AITBC_COORDINATOR_URL=http://localhost:8000
export AITBC_OUTPUT_FORMAT=json
```
Default credentials:
- Client API Key: `${CLIENT_API_KEY}`
- Miner API Key: `${MINER_API_KEY}`
2. **Config file**:
```bash
aitbc config set coordinator_url http://localhost:8000
aitbc config set api_key your_key
```
3. **Profiles**:
```bash
# Save a profile
aitbc config profiles save production
# Switch profiles
aitbc config profiles load production
```
## Examples
### Submit and Process a Job
### Basic Workflow
```bash
# 1. Submit a job
JOB_ID=$(python3 client.py submit inference --prompt "Test" | grep "Job ID" | cut -d' ' -f4)
# 1. Configure
export CLIENT_API_KEY=your_key
# 2. In another terminal, mine it
python3 miner.py poll
# 2. Check balance
aitbc wallet balance
# 3. Check the result
python3 client.py status $JOB_ID
# 3. Submit job
job_id=$(aitbc --output json client submit inference --prompt "What is AI?" | jq -r '.job_id')
# 4. See it in the blockchain
python3 client.py blocks
# 4. Monitor progress
watch -n 5 "aitbc client status $job_id"
# 5. Get results
aitbc client receipts --job-id $job_id
```
### Continuous Mining
### Mining Setup
```bash
# Register and start mining
python3 miner.py register
python3 miner.py mine --jobs 5
# 1. Register as miner
aitbc miner register \
--gpu-model RTX4090 \
--memory 24 \
--price 0.5 \
--region us-west
# In another terminal, submit multiple jobs
for i in {1..5}; do
python3 client.py submit inference --prompt "Job $i"
sleep 1
done
# 2. Start mining
aitbc miner poll --interval 5
# 3. Check earnings
aitbc wallet earn
```
## Tips
### Using the Marketplace
- The wallet is stored in `~/.aitbc_wallet.json`
- Jobs appear as blocks immediately when created
- The proposer is assigned when a miner polls for the job
- Use `--help` with any command to see all options
- Mining earnings are added manually for now (will be automatic in production)
```bash
# 1. Find available GPUs
aitbc marketplace gpu list --available --price-max 1.0
# 2. Book a GPU
gpu_id=$(aitbc marketplace gpu list --available --output json | jq -r '.[0].id')
aitbc marketplace gpu book $gpu_id --hours 4
# 3. Use it for your job
aitbc client submit inference \
--prompt "Generate an image of a sunset" \
--model stable-diffusion \
--gpu $gpu_id
# 4. Release when done
aitbc marketplace gpu release $gpu_id
```
### Testing with Simulation
```bash
# 1. Initialize test environment
aitbc simulate init --distribute 10000,5000
# 2. Create test users
aitbc simulate user create --type client --name alice --balance 1000
aitbc simulate user create --type miner --name bob --balance 500
# 3. Run workflow simulation
aitbc simulate workflow --jobs 10 --rounds 3
# 4. Check results
aitbc simulate results sim_123
```
## Troubleshooting
- If you get "No jobs available", make sure a job was submitted recently
- If registration fails, check the coordinator is running and API key is correct
- If the tunnel is down, restart it with `./scripts/start_remote_tunnel.sh`
### Common Issues
1. **"API key not found"**
```bash
export CLIENT_API_KEY=your_key
# or
aitbc auth login your_key
```
2. **"Connection refused"**
```bash
# Check coordinator URL
aitbc config show
# Update if needed
aitbc config set coordinator_url http://localhost:8000
```
3. **"Permission denied"**
```bash
# Check key permissions
aitbc auth status
# Refresh if needed
aitbc auth refresh
```
### Debug Mode
Enable debug mode for detailed error information:
```bash
aitbc --debug client status <job_id>
```
### Verbose Output
Increase verbosity for more information:
```bash
aitbc -vvv wallet balance
```
## Contributing
We welcome contributions! Please see our [Contributing Guide](../CONTRIBUTING.md) for details.
### Development Setup
```bash
# Clone the repository
git clone https://github.com/aitbc/aitbc.git
cd aitbc
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install in development mode
pip install -e .[dev]
# Run tests
pytest tests/cli/
# Run with local changes
python -m aitbc_cli.main --help
```
## Support
- 📖 [Documentation](../docs/cli-reference.md)
- 🐛 [Issue Tracker](https://github.com/aitbc/aitbc/issues)
- 💬 [Discord Community](https://discord.gg/aitbc)
- 📧 [Email Support](mailto:support@aitbc.net)
## License
This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details.
---
Made with ❤️ by the AITBC team

View File

@@ -0,0 +1,5 @@
"""AITBC CLI - Command Line Interface for AITBC Network"""
__version__ = "0.1.0"
__author__ = "AITBC Team"
__email__ = "team@aitbc.net"

View File

@@ -0,0 +1,70 @@
"""Authentication and credential management for AITBC CLI"""
import keyring
import os
from typing import Optional, Dict
from ..utils import success, error, warning
class AuthManager:
"""Manages authentication credentials using secure keyring storage"""
SERVICE_NAME = "aitbc-cli"
def __init__(self):
self.keyring = keyring.get_keyring()
def store_credential(self, name: str, api_key: str, environment: str = "default"):
"""Store an API key securely"""
try:
key = f"{environment}_{name}"
self.keyring.set_password(self.SERVICE_NAME, key, api_key)
success(f"Credential '{name}' stored for environment '{environment}'")
except Exception as e:
error(f"Failed to store credential: {e}")
def get_credential(self, name: str, environment: str = "default") -> Optional[str]:
"""Retrieve an API key"""
try:
key = f"{environment}_{name}"
return self.keyring.get_password(self.SERVICE_NAME, key)
except Exception as e:
warning(f"Failed to retrieve credential: {e}")
return None
def delete_credential(self, name: str, environment: str = "default"):
"""Delete an API key"""
try:
key = f"{environment}_{name}"
self.keyring.delete_password(self.SERVICE_NAME, key)
success(f"Credential '{name}' deleted for environment '{environment}'")
except Exception as e:
error(f"Failed to delete credential: {e}")
def list_credentials(self, environment: str = None) -> Dict[str, str]:
"""List all stored credentials (without showing the actual keys)"""
# Note: keyring doesn't provide a direct way to list all keys
# This is a simplified version that checks for common credential names
credentials = []
envs = [environment] if environment else ["default", "dev", "staging", "prod"]
names = ["client", "miner", "admin"]
for env in envs:
for name in names:
key = f"{env}_{name}"
if self.get_credential(name, env):
credentials.append(f"{name}@{env}")
return credentials
def store_env_credential(self, name: str):
"""Store credential from environment variable"""
env_var = f"{name.upper()}_API_KEY"
api_key = os.getenv(env_var)
if not api_key:
error(f"Environment variable {env_var} not set")
return False
self.store_credential(name, api_key)
return True

View File

@@ -0,0 +1 @@
"""Command modules for AITBC CLI"""

View File

@@ -0,0 +1,445 @@
"""Admin commands for AITBC CLI"""
import click
import httpx
import json
from typing import Optional, List, Dict, Any
from ..utils import output, error, success
@click.group()
def admin():
"""System administration commands"""
pass
@admin.command()
@click.pass_context
def status(ctx):
"""Get system status"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/status",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
status_data = response.json()
output(status_data, ctx.obj['output_format'])
else:
error(f"Failed to get system status: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.option("--limit", default=50, help="Number of jobs to show")
@click.option("--status", help="Filter by status")
@click.pass_context
def jobs(ctx, limit: int, status: Optional[str]):
"""List all jobs in the system"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if status:
params["status"] = status
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/jobs",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
jobs = response.json()
output(jobs, ctx.obj['output_format'])
else:
error(f"Failed to get jobs: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("job_id")
@click.pass_context
def job_details(ctx, job_id: str):
"""Get detailed job information"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/jobs/{job_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
job_data = response.json()
output(job_data, ctx.obj['output_format'])
else:
error(f"Job not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("job_id")
@click.pass_context
def delete_job(ctx, job_id: str):
"""Delete a job from the system"""
config = ctx.obj['config']
if not click.confirm(f"Are you sure you want to delete job {job_id}?"):
return
try:
with httpx.Client() as client:
response = client.delete(
f"{config.coordinator_url}/v1/admin/jobs/{job_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Job {job_id} deleted")
output({"status": "deleted", "job_id": job_id}, ctx.obj['output_format'])
else:
error(f"Failed to delete job: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.option("--limit", default=50, help="Number of miners to show")
@click.option("--status", help="Filter by status")
@click.pass_context
def miners(ctx, limit: int, status: Optional[str]):
"""List all registered miners"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if status:
params["status"] = status
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/miners",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
miners = response.json()
output(miners, ctx.obj['output_format'])
else:
error(f"Failed to get miners: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("miner_id")
@click.pass_context
def miner_details(ctx, miner_id: str):
"""Get detailed miner information"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/miners/{miner_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
miner_data = response.json()
output(miner_data, ctx.obj['output_format'])
else:
error(f"Miner not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("miner_id")
@click.pass_context
def deactivate_miner(ctx, miner_id: str):
"""Deactivate a miner"""
config = ctx.obj['config']
if not click.confirm(f"Are you sure you want to deactivate miner {miner_id}?"):
return
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/miners/{miner_id}/deactivate",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Miner {miner_id} deactivated")
output({"status": "deactivated", "miner_id": miner_id}, ctx.obj['output_format'])
else:
error(f"Failed to deactivate miner: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("miner_id")
@click.pass_context
def activate_miner(ctx, miner_id: str):
"""Activate a miner"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/miners/{miner_id}/activate",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Miner {miner_id} activated")
output({"status": "activated", "miner_id": miner_id}, ctx.obj['output_format'])
else:
error(f"Failed to activate miner: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.option("--days", type=int, default=7, help="Number of days to analyze")
@click.pass_context
def analytics(ctx, days: int):
"""Get system analytics"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/analytics",
params={"days": days},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
analytics_data = response.json()
output(analytics_data, ctx.obj['output_format'])
else:
error(f"Failed to get analytics: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.option("--level", default="INFO", help="Log level (DEBUG, INFO, WARNING, ERROR)")
@click.option("--limit", default=100, help="Number of log entries to show")
@click.pass_context
def logs(ctx, level: str, limit: int):
"""Get system logs"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/admin/logs",
params={"level": level, "limit": limit},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
logs_data = response.json()
output(logs_data, ctx.obj['output_format'])
else:
error(f"Failed to get logs: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.argument("job_id")
@click.option("--reason", help="Reason for priority change")
@click.pass_context
def prioritize_job(ctx, job_id: str, reason: Optional[str]):
"""Set job to high priority"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/jobs/{job_id}/prioritize",
json={"reason": reason or "Admin priority"},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Job {job_id} prioritized")
output({"status": "prioritized", "job_id": job_id}, ctx.obj['output_format'])
else:
error(f"Failed to prioritize job: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command()
@click.option("--action", required=True, help="Action to perform")
@click.option("--target", help="Target of the action")
@click.option("--data", help="Additional data (JSON)")
@click.pass_context
def execute(ctx, action: str, target: Optional[str], data: Optional[str]):
"""Execute custom admin action"""
config = ctx.obj['config']
# Parse data if provided
parsed_data = {}
if data:
try:
parsed_data = json.loads(data)
except json.JSONDecodeError:
error("Invalid JSON data")
return
if target:
parsed_data["target"] = target
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/execute/{action}",
json=parsed_data,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
result = response.json()
output(result, ctx.obj['output_format'])
else:
error(f"Failed to execute action: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.group()
def maintenance():
"""Maintenance operations"""
pass
@maintenance.command()
@click.pass_context
def cleanup(ctx):
"""Clean up old jobs and data"""
config = ctx.obj['config']
if not click.confirm("This will clean up old jobs and temporary data. Continue?"):
return
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/maintenance/cleanup",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
result = response.json()
success("Cleanup completed")
output(result, ctx.obj['output_format'])
else:
error(f"Cleanup failed: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@maintenance.command()
@click.pass_context
def reindex(ctx):
"""Reindex the database"""
config = ctx.obj['config']
if not click.confirm("This will reindex the entire database. Continue?"):
return
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/maintenance/reindex",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
result = response.json()
success("Reindex started")
output(result, ctx.obj['output_format'])
else:
error(f"Reindex failed: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@maintenance.command()
@click.pass_context
def backup(ctx):
"""Create system backup"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/admin/maintenance/backup",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
result = response.json()
success("Backup created")
output(result, ctx.obj['output_format'])
else:
error(f"Backup failed: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@admin.command(name="audit-log")
@click.option("--limit", default=50, help="Number of entries to show")
@click.option("--action", "action_filter", help="Filter by action type")
@click.pass_context
def audit_log(ctx, limit: int, action_filter: Optional[str]):
"""View audit log"""
from ..utils import AuditLogger
logger = AuditLogger()
entries = logger.get_logs(limit=limit, action_filter=action_filter)
if not entries:
output({"message": "No audit log entries found"}, ctx.obj['output_format'])
return
output(entries, ctx.obj['output_format'])
# Add maintenance group to admin
admin.add_command(maintenance)

View File

@@ -0,0 +1,220 @@
"""Authentication commands for AITBC CLI"""
import click
import os
from typing import Optional
from ..auth import AuthManager
from ..utils import output, success, error, warning
@click.group()
def auth():
"""Manage API keys and authentication"""
pass
@auth.command()
@click.argument("api_key")
@click.option("--environment", default="default", help="Environment name (default, dev, staging, prod)")
@click.pass_context
def login(ctx, api_key: str, environment: str):
"""Store API key for authentication"""
auth_manager = AuthManager()
# Validate API key format (basic check)
if not api_key or len(api_key) < 10:
error("Invalid API key format")
ctx.exit(1)
return
auth_manager.store_credential("client", api_key, environment)
output({
"status": "logged_in",
"environment": environment,
"note": "API key stored securely"
}, ctx.obj['output_format'])
@auth.command()
@click.option("--environment", default="default", help="Environment name")
@click.pass_context
def logout(ctx, environment: str):
"""Remove stored API key"""
auth_manager = AuthManager()
auth_manager.delete_credential("client", environment)
output({
"status": "logged_out",
"environment": environment
}, ctx.obj['output_format'])
@auth.command()
@click.option("--environment", default="default", help="Environment name")
@click.option("--show", is_flag=True, help="Show the actual API key")
@click.pass_context
def token(ctx, environment: str, show: bool):
"""Show stored API key"""
auth_manager = AuthManager()
api_key = auth_manager.get_credential("client", environment)
if api_key:
if show:
output({
"api_key": api_key,
"environment": environment
}, ctx.obj['output_format'])
else:
output({
"api_key": "***REDACTED***",
"environment": environment,
"length": len(api_key)
}, ctx.obj['output_format'])
else:
output({
"message": "No API key stored",
"environment": environment
}, ctx.obj['output_format'])
@auth.command()
@click.pass_context
def status(ctx):
"""Show authentication status"""
auth_manager = AuthManager()
credentials = auth_manager.list_credentials()
if credentials:
output({
"status": "authenticated",
"stored_credentials": credentials
}, ctx.obj['output_format'])
else:
output({
"status": "not_authenticated",
"message": "No stored credentials found"
}, ctx.obj['output_format'])
@auth.command()
@click.option("--environment", default="default", help="Environment name")
@click.pass_context
def refresh(ctx, environment: str):
"""Refresh authentication (placeholder for token refresh)"""
auth_manager = AuthManager()
api_key = auth_manager.get_credential("client", environment)
if api_key:
# In a real implementation, this would refresh the token
output({
"status": "refreshed",
"environment": environment,
"message": "Authentication refreshed (placeholder)"
}, ctx.obj['output_format'])
else:
error(f"No API key found for environment: {environment}")
ctx.exit(1)
@auth.group()
def keys():
"""Manage multiple API keys"""
pass
@keys.command()
@click.pass_context
def list(ctx):
"""List all stored API keys"""
auth_manager = AuthManager()
credentials = auth_manager.list_credentials()
if credentials:
output({
"credentials": credentials
}, ctx.obj['output_format'])
else:
output({
"message": "No credentials stored"
}, ctx.obj['output_format'])
@keys.command()
@click.argument("name")
@click.argument("api_key")
@click.option("--permissions", help="Comma-separated permissions (client,miner,admin)")
@click.option("--environment", default="default", help="Environment name")
@click.pass_context
def create(ctx, name: str, api_key: str, permissions: Optional[str], environment: str):
"""Create a new API key entry"""
auth_manager = AuthManager()
if not api_key or len(api_key) < 10:
error("Invalid API key format")
return
auth_manager.store_credential(name, api_key, environment)
output({
"status": "created",
"name": name,
"environment": environment,
"permissions": permissions or "none"
}, ctx.obj['output_format'])
@keys.command()
@click.argument("name")
@click.option("--environment", default="default", help="Environment name")
@click.pass_context
def revoke(ctx, name: str, environment: str):
"""Revoke an API key"""
auth_manager = AuthManager()
auth_manager.delete_credential(name, environment)
output({
"status": "revoked",
"name": name,
"environment": environment
}, ctx.obj['output_format'])
@keys.command()
@click.pass_context
def rotate(ctx):
"""Rotate all API keys (placeholder)"""
warning("Key rotation not implemented yet")
output({
"message": "Key rotation would update all stored keys",
"status": "placeholder"
}, ctx.obj['output_format'])
@auth.command()
@click.argument("name")
@click.pass_context
def import_env(ctx, name: str):
"""Import API key from environment variable"""
env_var = f"{name.upper()}_API_KEY"
api_key = os.getenv(env_var)
if not api_key:
error(f"Environment variable {env_var} not set")
ctx.exit(1)
return
auth_manager = AuthManager()
auth_manager.store_credential(name, api_key)
output({
"status": "imported",
"name": name,
"source": env_var
}, ctx.obj['output_format'])

View File

@@ -0,0 +1,236 @@
"""Blockchain commands for AITBC CLI"""
import click
import httpx
from typing import Optional, List
from ..utils import output, error
@click.group()
def blockchain():
"""Query blockchain information and status"""
pass
@blockchain.command()
@click.option("--limit", type=int, default=10, help="Number of blocks to show")
@click.option("--from-height", type=int, help="Start from this block height")
@click.pass_context
def blocks(ctx, limit: int, from_height: Optional[int]):
"""List recent blocks"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if from_height:
params["from_height"] = from_height
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/blocks",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
data = response.json()
output(data, ctx.obj['output_format'])
else:
error(f"Failed to fetch blocks: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.argument("block_hash")
@click.pass_context
def block(ctx, block_hash: str):
"""Get details of a specific block"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/blocks/{block_hash}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
block_data = response.json()
output(block_data, ctx.obj['output_format'])
else:
error(f"Block not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.argument("tx_hash")
@click.pass_context
def transaction(ctx, tx_hash: str):
"""Get transaction details"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/transactions/{tx_hash}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
tx_data = response.json()
output(tx_data, ctx.obj['output_format'])
else:
error(f"Transaction not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.option("--node", type=int, default=1, help="Node number (1, 2, or 3)")
@click.pass_context
def status(ctx, node: int):
"""Get blockchain node status"""
config = ctx.obj['config']
# Map node to RPC URL
node_urls = {
1: "http://localhost:8082",
2: "http://localhost:8081",
3: "http://aitbc.keisanki.net/rpc"
}
rpc_url = node_urls.get(node)
if not rpc_url:
error(f"Invalid node number: {node}")
return
try:
with httpx.Client() as client:
response = client.get(
f"{rpc_url}/status",
timeout=5
)
if response.status_code == 200:
status_data = response.json()
output({
"node": node,
"rpc_url": rpc_url,
"status": status_data
}, ctx.obj['output_format'])
else:
error(f"Node {node} not responding: {response.status_code}")
except Exception as e:
error(f"Failed to connect to node {node}: {e}")
@blockchain.command()
@click.pass_context
def sync_status(ctx):
"""Get blockchain synchronization status"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/blockchain/sync",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
sync_data = response.json()
output(sync_data, ctx.obj['output_format'])
else:
error(f"Failed to get sync status: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.pass_context
def peers(ctx):
"""List connected peers"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/blockchain/peers",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
peers_data = response.json()
output(peers_data, ctx.obj['output_format'])
else:
error(f"Failed to get peers: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.pass_context
def info(ctx):
"""Get blockchain information"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/blockchain/info",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
info_data = response.json()
output(info_data, ctx.obj['output_format'])
else:
error(f"Failed to get blockchain info: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.pass_context
def supply(ctx):
"""Get token supply information"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/blockchain/supply",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
supply_data = response.json()
output(supply_data, ctx.obj['output_format'])
else:
error(f"Failed to get supply info: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@blockchain.command()
@click.pass_context
def validators(ctx):
"""List blockchain validators"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/blockchain/validators",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
validators_data = response.json()
output(validators_data, ctx.obj['output_format'])
else:
error(f"Failed to get validators: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")

View File

@@ -0,0 +1,373 @@
"""Client commands for AITBC CLI"""
import click
import httpx
import json
import time
from typing import Optional
from ..utils import output, error, success
@click.group()
def client():
"""Submit and manage jobs"""
pass
@client.command()
@click.option("--type", "job_type", default="inference", help="Job type")
@click.option("--prompt", help="Prompt for inference jobs")
@click.option("--model", help="Model name")
@click.option("--ttl", default=900, help="Time to live in seconds")
@click.option("--file", type=click.File('r'), help="Submit job from JSON file")
@click.option("--retries", default=0, help="Number of retry attempts (0 = no retry)")
@click.option("--retry-delay", default=1.0, help="Initial retry delay in seconds")
@click.pass_context
def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
ttl: int, file, retries: int, retry_delay: float):
"""Submit a job to the coordinator"""
config = ctx.obj['config']
# Build job data
if file:
try:
task_data = json.load(file)
except Exception as e:
error(f"Failed to read job file: {e}")
return
else:
task_data = {"type": job_type}
if prompt:
task_data["prompt"] = prompt
if model:
task_data["model"] = model
# Submit job with retry and exponential backoff
max_attempts = retries + 1
for attempt in range(1, max_attempts + 1):
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/jobs",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={
"payload": task_data,
"ttl_seconds": ttl
}
)
if response.status_code == 201:
job = response.json()
result = {
"job_id": job.get('job_id'),
"status": "submitted",
"message": "Job submitted successfully"
}
if attempt > 1:
result["attempts"] = attempt
output(result, ctx.obj['output_format'])
return
else:
if attempt < max_attempts:
delay = retry_delay * (2 ** (attempt - 1))
click.echo(f"Attempt {attempt}/{max_attempts} failed ({response.status_code}), retrying in {delay:.1f}s...")
time.sleep(delay)
else:
error(f"Failed to submit job: {response.status_code} - {response.text}")
ctx.exit(response.status_code)
except Exception as e:
if attempt < max_attempts:
delay = retry_delay * (2 ** (attempt - 1))
click.echo(f"Attempt {attempt}/{max_attempts} failed ({e}), retrying in {delay:.1f}s...")
time.sleep(delay)
else:
error(f"Network error after {max_attempts} attempts: {e}")
ctx.exit(1)
@client.command()
@click.argument("job_id")
@click.pass_context
def status(ctx, job_id: str):
"""Check job status"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/jobs/{job_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
data = response.json()
output(data, ctx.obj['output_format'])
else:
error(f"Failed to get job status: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.option("--limit", default=10, help="Number of blocks to show")
@click.pass_context
def blocks(ctx, limit: int):
"""List recent blocks"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/blocks",
params={"limit": limit},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
blocks = response.json()
output(blocks, ctx.obj['output_format'])
else:
error(f"Failed to get blocks: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.argument("job_id")
@click.pass_context
def cancel(ctx, job_id: str):
"""Cancel a job"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/jobs/{job_id}/cancel",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"Job {job_id} cancelled")
else:
error(f"Failed to cancel job: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.option("--limit", default=10, help="Number of receipts to show")
@click.option("--job-id", help="Filter by job ID")
@click.option("--status", help="Filter by status")
@click.pass_context
def receipts(ctx, limit: int, job_id: Optional[str], status: Optional[str]):
"""List job receipts"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if job_id:
params["job_id"] = job_id
if status:
params["status"] = status
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/explorer/receipts",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
receipts = response.json()
output(receipts, ctx.obj['output_format'])
else:
error(f"Failed to get receipts: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command()
@click.option("--limit", default=10, help="Number of jobs to show")
@click.option("--status", help="Filter by status (pending, running, completed, failed)")
@click.option("--type", help="Filter by job type")
@click.option("--from-time", help="Filter jobs from this timestamp (ISO format)")
@click.option("--to-time", help="Filter jobs until this timestamp (ISO format)")
@click.pass_context
def history(ctx, limit: int, status: Optional[str], type: Optional[str],
from_time: Optional[str], to_time: Optional[str]):
"""Show job history with filtering options"""
config = ctx.obj['config']
try:
params = {"limit": limit}
if status:
params["status"] = status
if type:
params["type"] = type
if from_time:
params["from_time"] = from_time
if to_time:
params["to_time"] = to_time
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/jobs/history",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
jobs = response.json()
output(jobs, ctx.obj['output_format'])
else:
error(f"Failed to get job history: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@client.command(name="batch-submit")
@click.argument("file_path", type=click.Path(exists=True))
@click.option("--format", "file_format", type=click.Choice(["json", "csv"]), default=None, help="File format (auto-detected if not specified)")
@click.option("--retries", default=0, help="Retry attempts per job")
@click.option("--delay", default=0.5, help="Delay between submissions (seconds)")
@click.pass_context
def batch_submit(ctx, file_path: str, file_format: Optional[str], retries: int, delay: float):
"""Submit multiple jobs from a CSV or JSON file"""
import csv
from pathlib import Path
from ..utils import progress_bar
config = ctx.obj['config']
path = Path(file_path)
if not file_format:
file_format = "csv" if path.suffix.lower() == ".csv" else "json"
jobs_data = []
if file_format == "json":
with open(path) as f:
data = json.load(f)
jobs_data = data if isinstance(data, list) else [data]
else:
with open(path) as f:
reader = csv.DictReader(f)
jobs_data = list(reader)
if not jobs_data:
error("No jobs found in file")
return
results = {"submitted": 0, "failed": 0, "job_ids": []}
with progress_bar("Submitting jobs...", total=len(jobs_data)) as (progress, task):
for i, job in enumerate(jobs_data):
try:
task_data = {"type": job.get("type", "inference")}
if "prompt" in job:
task_data["prompt"] = job["prompt"]
if "model" in job:
task_data["model"] = job["model"]
with httpx.Client() as http_client:
response = http_client.post(
f"{config.coordinator_url}/v1/jobs",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={"payload": task_data, "ttl_seconds": int(job.get("ttl", 900))}
)
if response.status_code == 201:
result = response.json()
results["submitted"] += 1
results["job_ids"].append(result.get("job_id"))
else:
results["failed"] += 1
except Exception:
results["failed"] += 1
progress.update(task, advance=1)
if delay and i < len(jobs_data) - 1:
time.sleep(delay)
output(results, ctx.obj['output_format'])
@client.command(name="template")
@click.argument("action", type=click.Choice(["save", "list", "run", "delete"]))
@click.option("--name", help="Template name")
@click.option("--type", "job_type", help="Job type")
@click.option("--prompt", help="Prompt text")
@click.option("--model", help="Model name")
@click.option("--ttl", type=int, default=900, help="TTL in seconds")
@click.pass_context
def template(ctx, action: str, name: Optional[str], job_type: Optional[str],
prompt: Optional[str], model: Optional[str], ttl: int):
"""Manage job templates for repeated tasks"""
from pathlib import Path
template_dir = Path.home() / ".aitbc" / "templates"
template_dir.mkdir(parents=True, exist_ok=True)
if action == "save":
if not name:
error("Template name required (--name)")
return
template_data = {"type": job_type or "inference", "ttl": ttl}
if prompt:
template_data["prompt"] = prompt
if model:
template_data["model"] = model
with open(template_dir / f"{name}.json", "w") as f:
json.dump(template_data, f, indent=2)
output({"status": "saved", "name": name, "template": template_data}, ctx.obj['output_format'])
elif action == "list":
templates = []
for tf in template_dir.glob("*.json"):
with open(tf) as f:
data = json.load(f)
templates.append({"name": tf.stem, **data})
output(templates if templates else {"message": "No templates found"}, ctx.obj['output_format'])
elif action == "run":
if not name:
error("Template name required (--name)")
return
tf = template_dir / f"{name}.json"
if not tf.exists():
error(f"Template '{name}' not found")
return
with open(tf) as f:
tmpl = json.load(f)
if prompt:
tmpl["prompt"] = prompt
if model:
tmpl["model"] = model
ctx.invoke(submit, job_type=tmpl.get("type", "inference"),
prompt=tmpl.get("prompt"), model=tmpl.get("model"),
ttl=tmpl.get("ttl", 900), file=None, retries=0, retry_delay=1.0)
elif action == "delete":
if not name:
error("Template name required (--name)")
return
tf = template_dir / f"{name}.json"
if not tf.exists():
error(f"Template '{name}' not found")
return
tf.unlink()
output({"status": "deleted", "name": name}, ctx.obj['output_format'])

View File

@@ -0,0 +1,470 @@
"""Configuration commands for AITBC CLI"""
import click
import os
import yaml
import json
from pathlib import Path
from typing import Optional, Dict, Any
from ..config import get_config, Config
from ..utils import output, error, success
@click.group()
def config():
"""Manage CLI configuration"""
pass
@config.command()
@click.pass_context
def show(ctx):
"""Show current configuration"""
config = ctx.obj['config']
config_dict = {
"coordinator_url": config.coordinator_url,
"api_key": "***REDACTED***" if config.api_key else None,
"timeout": getattr(config, 'timeout', 30),
"config_file": getattr(config, 'config_file', None)
}
output(config_dict, ctx.obj['output_format'])
@config.command()
@click.argument("key")
@click.argument("value")
@click.option("--global", "global_config", is_flag=True, help="Set global config")
@click.pass_context
def set(ctx, key: str, value: str, global_config: bool):
"""Set configuration value"""
config = ctx.obj['config']
# Determine config file path
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
# Load existing config
if config_file.exists():
with open(config_file) as f:
config_data = yaml.safe_load(f) or {}
else:
config_data = {}
# Set the value
if key == "api_key":
config_data["api_key"] = value
if ctx.obj['output_format'] == 'table':
success("API key set (use --global to set permanently)")
elif key == "coordinator_url":
config_data["coordinator_url"] = value
if ctx.obj['output_format'] == 'table':
success(f"Coordinator URL set to: {value}")
elif key == "timeout":
try:
config_data["timeout"] = int(value)
if ctx.obj['output_format'] == 'table':
success(f"Timeout set to: {value}s")
except ValueError:
error("Timeout must be an integer")
ctx.exit(1)
else:
error(f"Unknown configuration key: {key}")
ctx.exit(1)
# Save config
with open(config_file, 'w') as f:
yaml.dump(config_data, f, default_flow_style=False)
output({
"config_file": str(config_file),
"key": key,
"value": value
}, ctx.obj['output_format'])
@config.command()
@click.option("--global", "global_config", is_flag=True, help="Show global config")
def path(global_config: bool):
"""Show configuration file path"""
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
output({
"config_file": str(config_file),
"exists": config_file.exists()
})
@config.command()
@click.option("--global", "global_config", is_flag=True, help="Edit global config")
@click.pass_context
def edit(ctx, global_config: bool):
"""Open configuration file in editor"""
# Determine config file path
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
# Create if doesn't exist
if not config_file.exists():
config = ctx.obj['config']
config_data = {
"coordinator_url": config.coordinator_url,
"timeout": getattr(config, 'timeout', 30)
}
with open(config_file, 'w') as f:
yaml.dump(config_data, f, default_flow_style=False)
# Open in editor
editor = os.getenv('EDITOR', 'nano')
os.system(f"{editor} {config_file}")
@config.command()
@click.option("--global", "global_config", is_flag=True, help="Reset global config")
@click.pass_context
def reset(ctx, global_config: bool):
"""Reset configuration to defaults"""
# Determine config file path
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
if not config_file.exists():
output({"message": "No configuration file found"})
return
if not click.confirm(f"Reset configuration at {config_file}?"):
return
# Remove config file
config_file.unlink()
success("Configuration reset to defaults")
@config.command()
@click.option("--format", "output_format", type=click.Choice(['yaml', 'json']), default='yaml', help="Output format")
@click.option("--global", "global_config", is_flag=True, help="Export global config")
@click.pass_context
def export(ctx, output_format: str, global_config: bool):
"""Export configuration"""
# Determine config file path
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
if not config_file.exists():
error("No configuration file found")
ctx.exit(1)
with open(config_file) as f:
config_data = yaml.safe_load(f)
# Redact sensitive data
if 'api_key' in config_data:
config_data['api_key'] = "***REDACTED***"
if output_format == 'json':
click.echo(json.dumps(config_data, indent=2))
else:
click.echo(yaml.dump(config_data, default_flow_style=False))
@config.command()
@click.argument("file_path")
@click.option("--merge", is_flag=True, help="Merge with existing config")
@click.option("--global", "global_config", is_flag=True, help="Import to global config")
@click.pass_context
def import_config(ctx, file_path: str, merge: bool, global_config: bool):
"""Import configuration from file"""
import_file = Path(file_path)
if not import_file.exists():
error(f"File not found: {file_path}")
ctx.exit(1)
# Load import file
try:
with open(import_file) as f:
if import_file.suffix.lower() == '.json':
import_data = json.load(f)
else:
import_data = yaml.safe_load(f)
except json.JSONDecodeError:
error("Invalid JSON data")
ctx.exit(1)
except Exception as e:
error(f"Failed to parse file: {e}")
ctx.exit(1)
# Determine target config file
if global_config:
config_dir = Path.home() / ".config" / "aitbc"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.yaml"
else:
config_file = Path.cwd() / ".aitbc.yaml"
# Load existing config if merging
if merge and config_file.exists():
with open(config_file) as f:
config_data = yaml.safe_load(f) or {}
config_data.update(import_data)
else:
config_data = import_data
# Save config
with open(config_file, 'w') as f:
yaml.dump(config_data, f, default_flow_style=False)
if ctx.obj['output_format'] == 'table':
success(f"Configuration imported to {config_file}")
@config.command()
@click.pass_context
def validate(ctx):
"""Validate configuration"""
config = ctx.obj['config']
errors = []
warnings = []
# Validate coordinator URL
if not config.coordinator_url:
errors.append("Coordinator URL is not set")
elif not config.coordinator_url.startswith(('http://', 'https://')):
errors.append("Coordinator URL must start with http:// or https://")
# Validate API key
if not config.api_key:
warnings.append("API key is not set")
elif len(config.api_key) < 10:
errors.append("API key appears to be too short")
# Validate timeout
timeout = getattr(config, 'timeout', 30)
if not isinstance(timeout, (int, float)) or timeout <= 0:
errors.append("Timeout must be a positive number")
# Output results
result = {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings
}
if errors:
error("Configuration validation failed")
ctx.exit(1)
elif warnings:
if ctx.obj['output_format'] == 'table':
success("Configuration valid with warnings")
else:
if ctx.obj['output_format'] == 'table':
success("Configuration is valid")
output(result, ctx.obj['output_format'])
@config.command()
def environments():
"""List available environments"""
env_vars = [
'AITBC_COORDINATOR_URL',
'AITBC_API_KEY',
'AITBC_TIMEOUT',
'AITBC_CONFIG_FILE',
'CLIENT_API_KEY',
'MINER_API_KEY',
'ADMIN_API_KEY'
]
env_data = {}
for var in env_vars:
value = os.getenv(var)
if value:
if 'API_KEY' in var:
value = "***REDACTED***"
env_data[var] = value
output({
"environment_variables": env_data,
"note": "Use export VAR=value to set environment variables"
})
@config.group()
def profiles():
"""Manage configuration profiles"""
pass
@profiles.command()
@click.argument("name")
@click.pass_context
def save(ctx, name: str):
"""Save current configuration as a profile"""
config = ctx.obj['config']
# Create profiles directory
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / f"{name}.yaml"
# Save profile (without API key)
profile_data = {
"coordinator_url": config.coordinator_url,
"timeout": getattr(config, 'timeout', 30)
}
with open(profile_file, 'w') as f:
yaml.dump(profile_data, f, default_flow_style=False)
if ctx.obj['output_format'] == 'table':
success(f"Profile '{name}' saved")
@profiles.command()
def list():
"""List available profiles"""
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
if not profiles_dir.exists():
output({"profiles": []})
return
profiles = []
for profile_file in profiles_dir.glob("*.yaml"):
with open(profile_file) as f:
profile_data = yaml.safe_load(f)
profiles.append({
"name": profile_file.stem,
"coordinator_url": profile_data.get("coordinator_url"),
"timeout": profile_data.get("timeout", 30)
})
output({"profiles": profiles})
@profiles.command()
@click.argument("name")
@click.pass_context
def load(ctx, name: str):
"""Load a configuration profile"""
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
profile_file = profiles_dir / f"{name}.yaml"
if not profile_file.exists():
error(f"Profile '{name}' not found")
ctx.exit(1)
with open(profile_file) as f:
profile_data = yaml.safe_load(f)
# Load to current config
config_file = Path.cwd() / ".aitbc.yaml"
with open(config_file, 'w') as f:
yaml.dump(profile_data, f, default_flow_style=False)
if ctx.obj['output_format'] == 'table':
success(f"Profile '{name}' loaded")
@profiles.command()
@click.argument("name")
@click.pass_context
def delete(ctx, name: str):
"""Delete a configuration profile"""
profiles_dir = Path.home() / ".config" / "aitbc" / "profiles"
profile_file = profiles_dir / f"{name}.yaml"
if not profile_file.exists():
error(f"Profile '{name}' not found")
ctx.exit(1)
if not click.confirm(f"Delete profile '{name}'?"):
return
profile_file.unlink()
if ctx.obj['output_format'] == 'table':
success(f"Profile '{name}' deleted")
@config.command(name="set-secret")
@click.argument("key")
@click.argument("value")
@click.pass_context
def set_secret(ctx, key: str, value: str):
"""Set an encrypted configuration value"""
from ..utils import encrypt_value
config_dir = Path.home() / ".config" / "aitbc"
config_dir.mkdir(parents=True, exist_ok=True)
secrets_file = config_dir / "secrets.json"
secrets = {}
if secrets_file.exists():
with open(secrets_file) as f:
secrets = json.load(f)
secrets[key] = encrypt_value(value)
with open(secrets_file, "w") as f:
json.dump(secrets, f, indent=2)
# Restrict file permissions
secrets_file.chmod(0o600)
if ctx.obj['output_format'] == 'table':
success(f"Secret '{key}' saved (encrypted)")
output({"key": key, "status": "encrypted"}, ctx.obj['output_format'])
@config.command(name="get-secret")
@click.argument("key")
@click.pass_context
def get_secret(ctx, key: str):
"""Get a decrypted configuration value"""
from ..utils import decrypt_value
secrets_file = Path.home() / ".config" / "aitbc" / "secrets.json"
if not secrets_file.exists():
error("No secrets file found")
ctx.exit(1)
return
with open(secrets_file) as f:
secrets = json.load(f)
if key not in secrets:
error(f"Secret '{key}' not found")
ctx.exit(1)
return
decrypted = decrypt_value(secrets[key])
output({"key": key, "value": decrypted}, ctx.obj['output_format'])
# Add profiles group to config
config.add_command(profiles)

View File

@@ -0,0 +1,307 @@
"""Marketplace commands for AITBC CLI"""
import click
import httpx
import json
from typing import Optional, List
from ..utils import output, error, success
@click.group()
def marketplace():
"""GPU marketplace operations"""
pass
@marketplace.group()
def gpu():
"""GPU marketplace operations"""
pass
@gpu.command()
@click.option("--name", required=True, help="GPU name/model")
@click.option("--memory", type=int, help="GPU memory in GB")
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
@click.option("--compute-capability", help="Compute capability (e.g., 8.9)")
@click.option("--price-per-hour", type=float, help="Price per hour in AITBC")
@click.option("--description", help="GPU description")
@click.option("--miner-id", help="Miner ID (uses auth key if not provided)")
@click.pass_context
def register(ctx, name: str, memory: Optional[int], cuda_cores: Optional[int],
compute_capability: Optional[str], price_per_hour: Optional[float],
description: Optional[str], miner_id: Optional[str]):
"""Register GPU on marketplace"""
config = ctx.obj['config']
# Build GPU specs
gpu_specs = {
"name": name,
"memory_gb": memory,
"cuda_cores": cuda_cores,
"compute_capability": compute_capability,
"price_per_hour": price_per_hour,
"description": description
}
# Remove None values
gpu_specs = {k: v for k, v in gpu_specs.items() if v is not None}
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/marketplace/gpu/register",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id or "default"
},
json={"gpu": gpu_specs}
)
if response.status_code == 201:
result = response.json()
success(f"GPU registered successfully: {result.get('gpu_id')}")
output(result, ctx.obj['output_format'])
else:
error(f"Failed to register GPU: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@gpu.command()
@click.option("--available", is_flag=True, help="Show only available GPUs")
@click.option("--model", help="Filter by GPU model (supports wildcards)")
@click.option("--memory-min", type=int, help="Minimum memory in GB")
@click.option("--price-max", type=float, help="Maximum price per hour")
@click.option("--limit", type=int, default=20, help="Maximum number of results")
@click.pass_context
def list(ctx, available: bool, model: Optional[str], memory_min: Optional[int],
price_max: Optional[float], limit: int):
"""List available GPUs"""
config = ctx.obj['config']
# Build query params
params = {"limit": limit}
if available:
params["available"] = "true"
if model:
params["model"] = model
if memory_min:
params["memory_min"] = memory_min
if price_max:
params["price_max"] = price_max
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/gpu/list",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
gpus = response.json()
output(gpus, ctx.obj['output_format'])
else:
error(f"Failed to list GPUs: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@gpu.command()
@click.argument("gpu_id")
@click.pass_context
def details(ctx, gpu_id: str):
"""Get GPU details"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
gpu_data = response.json()
output(gpu_data, ctx.obj['output_format'])
else:
error(f"GPU not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@gpu.command()
@click.argument("gpu_id")
@click.option("--hours", type=float, required=True, help="Rental duration in hours")
@click.option("--job-id", help="Job ID to associate with rental")
@click.pass_context
def book(ctx, gpu_id: str, hours: float, job_id: Optional[str]):
"""Book a GPU"""
config = ctx.obj['config']
try:
booking_data = {
"gpu_id": gpu_id,
"duration_hours": hours
}
if job_id:
booking_data["job_id"] = job_id
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/book",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json=booking_data
)
if response.status_code == 201:
booking = response.json()
success(f"GPU booked successfully: {booking.get('booking_id')}")
output(booking, ctx.obj['output_format'])
else:
error(f"Failed to book GPU: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@gpu.command()
@click.argument("gpu_id")
@click.pass_context
def release(ctx, gpu_id: str):
"""Release a booked GPU"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/release",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
success(f"GPU {gpu_id} released")
output({"status": "released", "gpu_id": gpu_id}, ctx.obj['output_format'])
else:
error(f"Failed to release GPU: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.command()
@click.option("--status", help="Filter by status (active, completed, cancelled)")
@click.option("--limit", type=int, default=10, help="Number of orders to show")
@click.pass_context
def orders(ctx, status: Optional[str], limit: int):
"""List marketplace orders"""
config = ctx.obj['config']
params = {"limit": limit}
if status:
params["status"] = status
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/orders",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
orders = response.json()
output(orders, ctx.obj['output_format'])
else:
error(f"Failed to get orders: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.command()
@click.argument("model")
@click.pass_context
def pricing(ctx, model: str):
"""Get pricing information for a GPU model"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/pricing/{model}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
pricing_data = response.json()
output(pricing_data, ctx.obj['output_format'])
else:
error(f"Pricing not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.command()
@click.argument("gpu_id")
@click.option("--limit", type=int, default=10, help="Number of reviews to show")
@click.pass_context
def reviews(ctx, gpu_id: str, limit: int):
"""Get GPU reviews"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/reviews",
params={"limit": limit},
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
reviews = response.json()
output(reviews, ctx.obj['output_format'])
else:
error(f"Failed to get reviews: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.command()
@click.argument("gpu_id")
@click.option("--rating", type=int, required=True, help="Rating (1-5)")
@click.option("--comment", help="Review comment")
@click.pass_context
def review(ctx, gpu_id: str, rating: int, comment: Optional[str]):
"""Add a review for a GPU"""
config = ctx.obj['config']
if not 1 <= rating <= 5:
error("Rating must be between 1 and 5")
return
try:
review_data = {
"rating": rating,
"comment": comment
}
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/marketplace/gpu/{gpu_id}/reviews",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json=review_data
)
if response.status_code == 201:
success("Review added successfully")
output({"status": "review_added", "gpu_id": gpu_id}, ctx.obj['output_format'])
else:
error(f"Failed to add review: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")

View File

@@ -0,0 +1,457 @@
"""Miner commands for AITBC CLI"""
import click
import httpx
import json
import time
import concurrent.futures
from typing import Optional, Dict, Any, List
from ..utils import output, error, success
@click.group()
def miner():
"""Register as miner and process jobs"""
pass
@miner.command()
@click.option("--gpu", help="GPU model name")
@click.option("--memory", type=int, help="GPU memory in GB")
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def register(ctx, gpu: Optional[str], memory: Optional[int],
cuda_cores: Optional[int], miner_id: str):
"""Register as a miner with the coordinator"""
config = ctx.obj['config']
# Build capabilities
capabilities = {}
if gpu:
capabilities["gpu"] = {"model": gpu}
if memory:
if "gpu" not in capabilities:
capabilities["gpu"] = {}
capabilities["gpu"]["memory_gb"] = memory
if cuda_cores:
if "gpu" not in capabilities:
capabilities["gpu"] = {}
capabilities["gpu"]["cuda_cores"] = cuda_cores
# Default capabilities if none provided
if not capabilities:
capabilities = {
"cpu": {"cores": 4},
"memory": {"gb": 16}
}
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/miners/register?miner_id={miner_id}",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={"capabilities": capabilities}
)
if response.status_code == 200:
output({
"miner_id": miner_id,
"status": "registered",
"capabilities": capabilities
}, ctx.obj['output_format'])
else:
error(f"Failed to register: {response.status_code} - {response.text}")
except Exception as e:
error(f"Network error: {e}")
@miner.command()
@click.option("--wait", type=int, default=5, help="Max wait time in seconds")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def poll(ctx, wait: int, miner_id: str):
"""Poll for a single job"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/miners/poll",
headers={
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id
},
timeout=wait + 5
)
if response.status_code == 200:
job = response.json()
if job:
output(job, ctx.obj['output_format'])
else:
output({"message": "No jobs available"}, ctx.obj['output_format'])
else:
error(f"Failed to poll: {response.status_code}")
except httpx.TimeoutException:
output({"message": f"No jobs available within {wait} seconds"}, ctx.obj['output_format'])
except Exception as e:
error(f"Network error: {e}")
@miner.command()
@click.option("--jobs", type=int, default=1, help="Number of jobs to process")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def mine(ctx, jobs: int, miner_id: str):
"""Mine continuously for specified number of jobs"""
config = ctx.obj['config']
processed = 0
while processed < jobs:
try:
with httpx.Client() as client:
# Poll for job
response = client.get(
f"{config.coordinator_url}/v1/miners/poll",
headers={
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id
},
timeout=30
)
if response.status_code == 200:
job = response.json()
if job:
job_id = job.get('job_id')
output({
"job_id": job_id,
"status": "processing",
"job_number": processed + 1
}, ctx.obj['output_format'])
# Simulate processing (in real implementation, do actual work)
time.sleep(2)
# Submit result
result_response = client.post(
f"{config.coordinator_url}/v1/miners/{job_id}/result",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id
},
json={
"result": f"Processed job {job_id}",
"success": True
}
)
if result_response.status_code == 200:
success(f"Job {job_id} completed successfully")
processed += 1
else:
error(f"Failed to submit result: {result_response.status_code}")
else:
# No job available, wait a bit
time.sleep(5)
else:
error(f"Failed to poll: {response.status_code}")
break
except Exception as e:
error(f"Error: {e}")
break
output({
"total_processed": processed,
"miner_id": miner_id
}, ctx.obj['output_format'])
@miner.command()
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def heartbeat(ctx, miner_id: str):
"""Send heartbeat to coordinator"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/miners/heartbeat?miner_id={miner_id}",
headers={
"X-Api-Key": config.api_key or ""
}
)
if response.status_code == 200:
output({
"miner_id": miner_id,
"status": "heartbeat_sent",
"timestamp": time.time()
}, ctx.obj['output_format'])
else:
error(f"Failed to send heartbeat: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@miner.command()
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def status(ctx, miner_id: str):
"""Check miner status"""
config = ctx.obj['config']
# This would typically query a miner status endpoint
# For now, we'll just show the miner info
output({
"miner_id": miner_id,
"coordinator": config.coordinator_url,
"status": "active"
}, ctx.obj['output_format'])
@miner.command()
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.option("--from-time", help="Filter from timestamp (ISO format)")
@click.option("--to-time", help="Filter to timestamp (ISO format)")
@click.pass_context
def earnings(ctx, miner_id: str, from_time: Optional[str], to_time: Optional[str]):
"""Show miner earnings"""
config = ctx.obj['config']
try:
params = {"miner_id": miner_id}
if from_time:
params["from_time"] = from_time
if to_time:
params["to_time"] = to_time
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/miners/{miner_id}/earnings",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
data = response.json()
output(data, ctx.obj['output_format'])
else:
error(f"Failed to get earnings: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@miner.command(name="update-capabilities")
@click.option("--gpu", help="GPU model name")
@click.option("--memory", type=int, help="GPU memory in GB")
@click.option("--cuda-cores", type=int, help="Number of CUDA cores")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def update_capabilities(ctx, gpu: Optional[str], memory: Optional[int],
cuda_cores: Optional[int], miner_id: str):
"""Update miner GPU capabilities"""
config = ctx.obj['config']
capabilities = {}
if gpu:
capabilities["gpu"] = {"model": gpu}
if memory:
if "gpu" not in capabilities:
capabilities["gpu"] = {}
capabilities["gpu"]["memory_gb"] = memory
if cuda_cores:
if "gpu" not in capabilities:
capabilities["gpu"] = {}
capabilities["gpu"]["cuda_cores"] = cuda_cores
if not capabilities:
error("No capabilities specified. Use --gpu, --memory, or --cuda-cores.")
return
try:
with httpx.Client() as client:
response = client.put(
f"{config.coordinator_url}/v1/miners/{miner_id}/capabilities",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json={"capabilities": capabilities}
)
if response.status_code == 200:
output({
"miner_id": miner_id,
"status": "capabilities_updated",
"capabilities": capabilities
}, ctx.obj['output_format'])
else:
error(f"Failed to update capabilities: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@miner.command()
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.option("--force", is_flag=True, help="Force deregistration without confirmation")
@click.pass_context
def deregister(ctx, miner_id: str, force: bool):
"""Deregister miner from the coordinator"""
if not force:
if not click.confirm(f"Deregister miner '{miner_id}'?"):
click.echo("Cancelled.")
return
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.delete(
f"{config.coordinator_url}/v1/miners/{miner_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
output({
"miner_id": miner_id,
"status": "deregistered"
}, ctx.obj['output_format'])
else:
error(f"Failed to deregister: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
@miner.command()
@click.option("--limit", default=10, help="Number of jobs to show")
@click.option("--type", "job_type", help="Filter by job type")
@click.option("--min-reward", type=float, help="Minimum reward threshold")
@click.option("--status", "job_status", help="Filter by status (pending, running, completed, failed)")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def jobs(ctx, limit: int, job_type: Optional[str], min_reward: Optional[float],
job_status: Optional[str], miner_id: str):
"""List miner jobs with filtering"""
config = ctx.obj['config']
try:
params = {"limit": limit, "miner_id": miner_id}
if job_type:
params["type"] = job_type
if min_reward is not None:
params["min_reward"] = min_reward
if job_status:
params["status"] = job_status
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/miners/{miner_id}/jobs",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
data = response.json()
output(data, ctx.obj['output_format'])
else:
error(f"Failed to get jobs: {response.status_code}")
ctx.exit(1)
except Exception as e:
error(f"Network error: {e}")
ctx.exit(1)
def _process_single_job(config, miner_id: str, worker_id: int) -> Dict[str, Any]:
"""Process a single job (used by concurrent mine)"""
try:
with httpx.Client() as http_client:
response = http_client.get(
f"{config.coordinator_url}/v1/miners/poll",
headers={
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id
},
timeout=30
)
if response.status_code == 200:
job = response.json()
if job:
job_id = job.get('job_id')
time.sleep(2) # Simulate processing
result_response = http_client.post(
f"{config.coordinator_url}/v1/miners/{job_id}/result",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or "",
"X-Miner-ID": miner_id
},
json={"result": f"Processed by worker {worker_id}", "success": True}
)
return {
"worker": worker_id,
"job_id": job_id,
"status": "completed" if result_response.status_code == 200 else "failed"
}
return {"worker": worker_id, "status": "no_job"}
except Exception as e:
return {"worker": worker_id, "status": "error", "error": str(e)}
@miner.command(name="concurrent-mine")
@click.option("--workers", type=int, default=2, help="Number of concurrent workers")
@click.option("--jobs", "total_jobs", type=int, default=5, help="Total jobs to process")
@click.option("--miner-id", default="cli-miner", help="Miner ID")
@click.pass_context
def concurrent_mine(ctx, workers: int, total_jobs: int, miner_id: str):
"""Mine with concurrent job processing"""
config = ctx.obj['config']
success(f"Starting concurrent mining: {workers} workers, {total_jobs} jobs")
completed = 0
failed = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
remaining = total_jobs
while remaining > 0:
batch_size = min(remaining, workers)
futures = [
executor.submit(_process_single_job, config, miner_id, i)
for i in range(batch_size)
]
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result.get("status") == "completed":
completed += 1
remaining -= 1
output(result, ctx.obj['output_format'])
elif result.get("status") == "no_job":
time.sleep(2)
else:
failed += 1
remaining -= 1
output({
"status": "finished",
"completed": completed,
"failed": failed,
"workers": workers
}, ctx.obj['output_format'])

View File

@@ -0,0 +1,381 @@
"""Monitoring and dashboard commands for AITBC CLI"""
import click
import httpx
import json
import time
from pathlib import Path
from typing import Optional
from datetime import datetime, timedelta
from ..utils import output, error, success, console
@click.group()
def monitor():
"""Monitoring, metrics, and alerting commands"""
pass
@monitor.command()
@click.option("--refresh", type=int, default=5, help="Refresh interval in seconds")
@click.option("--duration", type=int, default=0, help="Duration in seconds (0 = indefinite)")
@click.pass_context
def dashboard(ctx, refresh: int, duration: int):
"""Real-time system dashboard"""
config = ctx.obj['config']
start_time = time.time()
try:
while True:
elapsed = time.time() - start_time
if duration > 0 and elapsed >= duration:
break
console.clear()
console.rule("[bold blue]AITBC Dashboard[/bold blue]")
console.print(f"[dim]Refreshing every {refresh}s | Elapsed: {int(elapsed)}s[/dim]\n")
# Fetch system status
try:
with httpx.Client(timeout=5) as client:
# Node status
try:
resp = client.get(
f"{config.coordinator_url}/v1/status",
headers={"X-Api-Key": config.api_key or ""}
)
if resp.status_code == 200:
status = resp.json()
console.print("[bold green]Coordinator:[/bold green] Online")
for k, v in status.items():
console.print(f" {k}: {v}")
else:
console.print(f"[bold yellow]Coordinator:[/bold yellow] HTTP {resp.status_code}")
except Exception:
console.print("[bold red]Coordinator:[/bold red] Offline")
console.print()
# Jobs summary
try:
resp = client.get(
f"{config.coordinator_url}/v1/jobs",
headers={"X-Api-Key": config.api_key or ""},
params={"limit": 5}
)
if resp.status_code == 200:
jobs = resp.json()
if isinstance(jobs, list):
console.print(f"[bold cyan]Recent Jobs:[/bold cyan] {len(jobs)}")
for job in jobs[:5]:
status_color = "green" if job.get("status") == "completed" else "yellow"
console.print(f" [{status_color}]{job.get('id', 'N/A')}: {job.get('status', 'unknown')}[/{status_color}]")
except Exception:
console.print("[dim]Jobs: unavailable[/dim]")
console.print()
# Miners summary
try:
resp = client.get(
f"{config.coordinator_url}/v1/miners",
headers={"X-Api-Key": config.api_key or ""}
)
if resp.status_code == 200:
miners = resp.json()
if isinstance(miners, list):
online = sum(1 for m in miners if m.get("status") == "ONLINE")
console.print(f"[bold cyan]Miners:[/bold cyan] {online}/{len(miners)} online")
except Exception:
console.print("[dim]Miners: unavailable[/dim]")
except Exception as e:
console.print(f"[red]Error fetching data: {e}[/red]")
console.print(f"\n[dim]Press Ctrl+C to exit[/dim]")
time.sleep(refresh)
except KeyboardInterrupt:
console.print("\n[bold]Dashboard stopped[/bold]")
@monitor.command()
@click.option("--period", default="24h", help="Time period (1h, 24h, 7d, 30d)")
@click.option("--export", "export_path", type=click.Path(), help="Export metrics to file")
@click.pass_context
def metrics(ctx, period: str, export_path: Optional[str]):
"""Collect and display system metrics"""
config = ctx.obj['config']
# Parse period
multipliers = {"h": 3600, "d": 86400}
unit = period[-1]
value = int(period[:-1])
seconds = value * multipliers.get(unit, 3600)
since = datetime.now() - timedelta(seconds=seconds)
metrics_data = {
"period": period,
"since": since.isoformat(),
"collected_at": datetime.now().isoformat(),
"coordinator": {},
"jobs": {},
"miners": {}
}
try:
with httpx.Client(timeout=10) as client:
# Coordinator metrics
try:
resp = client.get(
f"{config.coordinator_url}/v1/status",
headers={"X-Api-Key": config.api_key or ""}
)
if resp.status_code == 200:
metrics_data["coordinator"] = resp.json()
metrics_data["coordinator"]["status"] = "online"
else:
metrics_data["coordinator"]["status"] = f"error_{resp.status_code}"
except Exception:
metrics_data["coordinator"]["status"] = "offline"
# Job metrics
try:
resp = client.get(
f"{config.coordinator_url}/v1/jobs",
headers={"X-Api-Key": config.api_key or ""},
params={"limit": 100}
)
if resp.status_code == 200:
jobs = resp.json()
if isinstance(jobs, list):
metrics_data["jobs"] = {
"total": len(jobs),
"completed": sum(1 for j in jobs if j.get("status") == "completed"),
"pending": sum(1 for j in jobs if j.get("status") == "pending"),
"failed": sum(1 for j in jobs if j.get("status") == "failed"),
}
except Exception:
metrics_data["jobs"] = {"error": "unavailable"}
# Miner metrics
try:
resp = client.get(
f"{config.coordinator_url}/v1/miners",
headers={"X-Api-Key": config.api_key or ""}
)
if resp.status_code == 200:
miners = resp.json()
if isinstance(miners, list):
metrics_data["miners"] = {
"total": len(miners),
"online": sum(1 for m in miners if m.get("status") == "ONLINE"),
"offline": sum(1 for m in miners if m.get("status") != "ONLINE"),
}
except Exception:
metrics_data["miners"] = {"error": "unavailable"}
except Exception as e:
error(f"Failed to collect metrics: {e}")
if export_path:
with open(export_path, "w") as f:
json.dump(metrics_data, f, indent=2)
success(f"Metrics exported to {export_path}")
output(metrics_data, ctx.obj['output_format'])
@monitor.command()
@click.argument("action", type=click.Choice(["add", "list", "remove", "test"]))
@click.option("--name", help="Alert name")
@click.option("--type", "alert_type", type=click.Choice(["coordinator_down", "miner_offline", "job_failed", "low_balance"]), help="Alert type")
@click.option("--threshold", type=float, help="Alert threshold value")
@click.option("--webhook", help="Webhook URL for notifications")
@click.pass_context
def alerts(ctx, action: str, name: Optional[str], alert_type: Optional[str],
threshold: Optional[float], webhook: Optional[str]):
"""Configure monitoring alerts"""
alerts_dir = Path.home() / ".aitbc" / "alerts"
alerts_dir.mkdir(parents=True, exist_ok=True)
alerts_file = alerts_dir / "alerts.json"
# Load existing alerts
existing = []
if alerts_file.exists():
with open(alerts_file) as f:
existing = json.load(f)
if action == "add":
if not name or not alert_type:
error("Alert name and type required (--name, --type)")
return
alert = {
"name": name,
"type": alert_type,
"threshold": threshold,
"webhook": webhook,
"created_at": datetime.now().isoformat(),
"enabled": True
}
existing.append(alert)
with open(alerts_file, "w") as f:
json.dump(existing, f, indent=2)
success(f"Alert '{name}' added")
output(alert, ctx.obj['output_format'])
elif action == "list":
if not existing:
output({"message": "No alerts configured"}, ctx.obj['output_format'])
else:
output(existing, ctx.obj['output_format'])
elif action == "remove":
if not name:
error("Alert name required (--name)")
return
existing = [a for a in existing if a["name"] != name]
with open(alerts_file, "w") as f:
json.dump(existing, f, indent=2)
success(f"Alert '{name}' removed")
elif action == "test":
if not name:
error("Alert name required (--name)")
return
alert = next((a for a in existing if a["name"] == name), None)
if not alert:
error(f"Alert '{name}' not found")
return
if alert.get("webhook"):
try:
with httpx.Client(timeout=10) as client:
resp = client.post(alert["webhook"], json={
"alert": name,
"type": alert["type"],
"message": f"Test alert from AITBC CLI",
"timestamp": datetime.now().isoformat()
})
output({"status": "sent", "response_code": resp.status_code}, ctx.obj['output_format'])
except Exception as e:
error(f"Webhook test failed: {e}")
else:
output({"status": "no_webhook", "alert": alert}, ctx.obj['output_format'])
@monitor.command()
@click.option("--period", default="7d", help="Analysis period (1d, 7d, 30d)")
@click.pass_context
def history(ctx, period: str):
"""Historical data analysis"""
config = ctx.obj['config']
multipliers = {"h": 3600, "d": 86400}
unit = period[-1]
value = int(period[:-1])
seconds = value * multipliers.get(unit, 3600)
since = datetime.now() - timedelta(seconds=seconds)
analysis = {
"period": period,
"since": since.isoformat(),
"analyzed_at": datetime.now().isoformat(),
"summary": {}
}
try:
with httpx.Client(timeout=10) as client:
try:
resp = client.get(
f"{config.coordinator_url}/v1/jobs",
headers={"X-Api-Key": config.api_key or ""},
params={"limit": 500}
)
if resp.status_code == 200:
jobs = resp.json()
if isinstance(jobs, list):
completed = [j for j in jobs if j.get("status") == "completed"]
failed = [j for j in jobs if j.get("status") == "failed"]
analysis["summary"] = {
"total_jobs": len(jobs),
"completed": len(completed),
"failed": len(failed),
"success_rate": f"{len(completed) / max(1, len(jobs)) * 100:.1f}%",
}
except Exception:
analysis["summary"] = {"error": "Could not fetch job data"}
except Exception as e:
error(f"Analysis failed: {e}")
output(analysis, ctx.obj['output_format'])
@monitor.command()
@click.argument("action", type=click.Choice(["add", "list", "remove", "test"]))
@click.option("--name", help="Webhook name")
@click.option("--url", help="Webhook URL")
@click.option("--events", help="Comma-separated event types (job_completed,miner_offline,alert)")
@click.pass_context
def webhooks(ctx, action: str, name: Optional[str], url: Optional[str], events: Optional[str]):
"""Manage webhook notifications"""
webhooks_dir = Path.home() / ".aitbc" / "webhooks"
webhooks_dir.mkdir(parents=True, exist_ok=True)
webhooks_file = webhooks_dir / "webhooks.json"
existing = []
if webhooks_file.exists():
with open(webhooks_file) as f:
existing = json.load(f)
if action == "add":
if not name or not url:
error("Webhook name and URL required (--name, --url)")
return
webhook = {
"name": name,
"url": url,
"events": events.split(",") if events else ["all"],
"created_at": datetime.now().isoformat(),
"enabled": True
}
existing.append(webhook)
with open(webhooks_file, "w") as f:
json.dump(existing, f, indent=2)
success(f"Webhook '{name}' added")
output(webhook, ctx.obj['output_format'])
elif action == "list":
if not existing:
output({"message": "No webhooks configured"}, ctx.obj['output_format'])
else:
output(existing, ctx.obj['output_format'])
elif action == "remove":
if not name:
error("Webhook name required (--name)")
return
existing = [w for w in existing if w["name"] != name]
with open(webhooks_file, "w") as f:
json.dump(existing, f, indent=2)
success(f"Webhook '{name}' removed")
elif action == "test":
if not name:
error("Webhook name required (--name)")
return
wh = next((w for w in existing if w["name"] == name), None)
if not wh:
error(f"Webhook '{name}' not found")
return
try:
with httpx.Client(timeout=10) as client:
resp = client.post(wh["url"], json={
"event": "test",
"source": "aitbc-cli",
"message": "Test webhook notification",
"timestamp": datetime.now().isoformat()
})
output({"status": "sent", "response_code": resp.status_code}, ctx.obj['output_format'])
except Exception as e:
error(f"Webhook test failed: {e}")

View File

@@ -0,0 +1,441 @@
"""Simulation commands for AITBC CLI"""
import click
import json
import time
import random
from pathlib import Path
from typing import Optional, List, Dict, Any
from ..utils import output, error, success
@click.group()
def simulate():
"""Run simulations and manage test users"""
pass
@simulate.command()
@click.option("--distribute", default="10000,1000",
help="Initial distribution: client_amount,miner_amount")
@click.option("--reset", is_flag=True, help="Reset existing simulation")
@click.pass_context
def init(ctx, distribute: str, reset: bool):
"""Initialize test economy"""
home_dir = Path("/home/oib/windsurf/aitbc/home")
if reset:
success("Resetting simulation...")
# Reset wallet files
for wallet_file in ["client_wallet.json", "miner_wallet.json"]:
wallet_path = home_dir / wallet_file
if wallet_path.exists():
wallet_path.unlink()
# Parse distribution
try:
client_amount, miner_amount = map(float, distribute.split(","))
except:
error("Invalid distribution format. Use: client_amount,miner_amount")
return
# Initialize genesis wallet
genesis_path = home_dir / "genesis_wallet.json"
if not genesis_path.exists():
genesis_wallet = {
"address": "aitbc1genesis",
"balance": 1000000,
"transactions": []
}
with open(genesis_path, 'w') as f:
json.dump(genesis_wallet, f, indent=2)
success("Genesis wallet created")
# Initialize client wallet
client_path = home_dir / "client_wallet.json"
if not client_path.exists():
client_wallet = {
"address": "aitbc1client",
"balance": client_amount,
"transactions": [{
"type": "receive",
"amount": client_amount,
"from": "aitbc1genesis",
"timestamp": time.time()
}]
}
with open(client_path, 'w') as f:
json.dump(client_wallet, f, indent=2)
success(f"Client wallet initialized with {client_amount} AITBC")
# Initialize miner wallet
miner_path = home_dir / "miner_wallet.json"
if not miner_path.exists():
miner_wallet = {
"address": "aitbc1miner",
"balance": miner_amount,
"transactions": [{
"type": "receive",
"amount": miner_amount,
"from": "aitbc1genesis",
"timestamp": time.time()
}]
}
with open(miner_path, 'w') as f:
json.dump(miner_wallet, f, indent=2)
success(f"Miner wallet initialized with {miner_amount} AITBC")
output({
"status": "initialized",
"distribution": {
"client": client_amount,
"miner": miner_amount
},
"total_supply": client_amount + miner_amount
}, ctx.obj['output_format'])
@simulate.group()
def user():
"""Manage test users"""
pass
@user.command()
@click.option("--type", type=click.Choice(["client", "miner"]), required=True)
@click.option("--name", required=True, help="User name")
@click.option("--balance", type=float, default=100, help="Initial balance")
@click.pass_context
def create(ctx, type: str, name: str, balance: float):
"""Create a test user"""
home_dir = Path("/home/oib/windsurf/aitbc/home")
user_id = f"{type}_{name}"
wallet_path = home_dir / f"{user_id}_wallet.json"
if wallet_path.exists():
error(f"User {name} already exists")
return
wallet = {
"address": f"aitbc1{user_id}",
"balance": balance,
"transactions": [{
"type": "receive",
"amount": balance,
"from": "aitbc1genesis",
"timestamp": time.time()
}]
}
with open(wallet_path, 'w') as f:
json.dump(wallet, f, indent=2)
success(f"Created {type} user: {name}")
output({
"user_id": user_id,
"address": wallet["address"],
"balance": balance
}, ctx.obj['output_format'])
@user.command()
@click.pass_context
def list(ctx):
"""List all test users"""
home_dir = Path("/home/oib/windsurf/aitbc/home")
users = []
for wallet_file in home_dir.glob("*_wallet.json"):
if wallet_file.name in ["genesis_wallet.json"]:
continue
with open(wallet_file) as f:
wallet = json.load(f)
user_type = "client" if "client" in wallet_file.name else "miner"
user_name = wallet_file.stem.replace("_wallet", "").replace(f"{user_type}_", "")
users.append({
"name": user_name,
"type": user_type,
"address": wallet["address"],
"balance": wallet["balance"]
})
output({"users": users}, ctx.obj['output_format'])
@user.command()
@click.argument("user")
@click.pass_context
def balance(ctx, user: str):
"""Check user balance"""
home_dir = Path("/home/oib/windsurf/aitbc/home")
wallet_path = home_dir / f"{user}_wallet.json"
if not wallet_path.exists():
error(f"User {user} not found")
return
with open(wallet_path) as f:
wallet = json.load(f)
output({
"user": user,
"address": wallet["address"],
"balance": wallet["balance"]
}, ctx.obj['output_format'])
@user.command()
@click.argument("user")
@click.argument("amount", type=float)
@click.pass_context
def fund(ctx, user: str, amount: float):
"""Fund a test user"""
home_dir = Path("/home/oib/windsurf/aitbc/home")
# Load genesis wallet
genesis_path = home_dir / "genesis_wallet.json"
with open(genesis_path) as f:
genesis = json.load(f)
if genesis["balance"] < amount:
error(f"Insufficient genesis balance: {genesis['balance']}")
return
# Load user wallet
wallet_path = home_dir / f"{user}_wallet.json"
if not wallet_path.exists():
error(f"User {user} not found")
return
with open(wallet_path) as f:
wallet = json.load(f)
# Transfer funds
genesis["balance"] -= amount
genesis["transactions"].append({
"type": "send",
"amount": -amount,
"to": wallet["address"],
"timestamp": time.time()
})
wallet["balance"] += amount
wallet["transactions"].append({
"type": "receive",
"amount": amount,
"from": genesis["address"],
"timestamp": time.time()
})
# Save wallets
with open(genesis_path, 'w') as f:
json.dump(genesis, f, indent=2)
with open(wallet_path, 'w') as f:
json.dump(wallet, f, indent=2)
success(f"Funded {user} with {amount} AITBC")
output({
"user": user,
"amount": amount,
"new_balance": wallet["balance"]
}, ctx.obj['output_format'])
@simulate.command()
@click.option("--jobs", type=int, default=5, help="Number of jobs to simulate")
@click.option("--rounds", type=int, default=3, help="Number of rounds")
@click.option("--delay", type=float, default=1.0, help="Delay between operations (seconds)")
@click.pass_context
def workflow(ctx, jobs: int, rounds: int, delay: float):
"""Simulate complete workflow"""
config = ctx.obj['config']
success(f"Starting workflow simulation: {jobs} jobs x {rounds} rounds")
for round_num in range(1, rounds + 1):
click.echo(f"\n--- Round {round_num} ---")
# Submit jobs
submitted_jobs = []
for i in range(jobs):
prompt = f"Test job {i+1} (round {round_num})"
# Simulate job submission
job_id = f"job_{round_num}_{i+1}_{int(time.time())}"
submitted_jobs.append(job_id)
output({
"action": "submit_job",
"job_id": job_id,
"prompt": prompt,
"round": round_num
}, ctx.obj['output_format'])
time.sleep(delay)
# Simulate job processing
for job_id in submitted_jobs:
# Simulate miner picking up job
output({
"action": "job_assigned",
"job_id": job_id,
"miner": f"miner_{random.randint(1, 3)}",
"status": "processing"
}, ctx.obj['output_format'])
time.sleep(delay * 0.5)
# Simulate job completion
earnings = random.uniform(1, 10)
output({
"action": "job_completed",
"job_id": job_id,
"earnings": earnings,
"status": "completed"
}, ctx.obj['output_format'])
time.sleep(delay * 0.5)
output({
"status": "completed",
"total_jobs": jobs * rounds,
"rounds": rounds
}, ctx.obj['output_format'])
@simulate.command()
@click.option("--clients", type=int, default=10, help="Number of clients")
@click.option("--miners", type=int, default=3, help="Number of miners")
@click.option("--duration", type=int, default=300, help="Test duration in seconds")
@click.option("--job-rate", type=float, default=1.0, help="Jobs per second")
@click.pass_context
def load_test(ctx, clients: int, miners: int, duration: int, job_rate: float):
"""Run load test"""
start_time = time.time()
end_time = start_time + duration
job_interval = 1.0 / job_rate
success(f"Starting load test: {clients} clients, {miners} miners, {duration}s")
stats = {
"jobs_submitted": 0,
"jobs_completed": 0,
"errors": 0,
"start_time": start_time
}
while time.time() < end_time:
# Submit jobs
for client_id in range(clients):
if time.time() >= end_time:
break
job_id = f"load_test_{stats['jobs_submitted']}_{int(time.time())}"
stats["jobs_submitted"] += 1
# Simulate random job completion
if random.random() > 0.1: # 90% success rate
stats["jobs_completed"] += 1
else:
stats["errors"] += 1
time.sleep(job_interval)
# Show progress
elapsed = time.time() - start_time
if elapsed % 30 < 1: # Every 30 seconds
output({
"elapsed": elapsed,
"jobs_submitted": stats["jobs_submitted"],
"jobs_completed": stats["jobs_completed"],
"errors": stats["errors"],
"success_rate": stats["jobs_completed"] / max(1, stats["jobs_submitted"]) * 100
}, ctx.obj['output_format'])
# Final stats
total_time = time.time() - start_time
output({
"status": "completed",
"duration": total_time,
"jobs_submitted": stats["jobs_submitted"],
"jobs_completed": stats["jobs_completed"],
"errors": stats["errors"],
"avg_jobs_per_second": stats["jobs_submitted"] / total_time,
"success_rate": stats["jobs_completed"] / max(1, stats["jobs_submitted"]) * 100
}, ctx.obj['output_format'])
@simulate.command()
@click.option("--file", required=True, help="Scenario file path")
@click.pass_context
def scenario(ctx, file: str):
"""Run predefined scenario"""
scenario_path = Path(file)
if not scenario_path.exists():
error(f"Scenario file not found: {file}")
return
with open(scenario_path) as f:
scenario = json.load(f)
success(f"Running scenario: {scenario.get('name', 'Unknown')}")
# Execute scenario steps
for step in scenario.get("steps", []):
step_type = step.get("type")
step_name = step.get("name", "Unnamed step")
click.echo(f"\nExecuting: {step_name}")
if step_type == "submit_jobs":
count = step.get("count", 1)
for i in range(count):
output({
"action": "submit_job",
"step": step_name,
"job_num": i + 1,
"prompt": step.get("prompt", f"Scenario job {i+1}")
}, ctx.obj['output_format'])
elif step_type == "wait":
duration = step.get("duration", 1)
time.sleep(duration)
elif step_type == "check_balance":
user = step.get("user", "client")
# Would check actual balance
output({
"action": "check_balance",
"user": user
}, ctx.obj['output_format'])
output({
"status": "completed",
"scenario": scenario.get('name', 'Unknown')
}, ctx.obj['output_format'])
@simulate.command()
@click.argument("simulation_id")
@click.pass_context
def results(ctx, simulation_id: str):
"""Show simulation results"""
# In a real implementation, this would query stored results
# For now, return mock data
output({
"simulation_id": simulation_id,
"status": "completed",
"start_time": time.time() - 3600,
"end_time": time.time(),
"duration": 3600,
"total_jobs": 50,
"successful_jobs": 48,
"failed_jobs": 2,
"success_rate": 96.0
}, ctx.obj['output_format'])

View File

@@ -0,0 +1,990 @@
"""Wallet commands for AITBC CLI"""
import click
import httpx
import json
import os
import shutil
import yaml
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from ..utils import output, error, success
@click.group()
@click.option("--wallet-name", help="Name of the wallet to use")
@click.option("--wallet-path", help="Direct path to wallet file (overrides --wallet-name)")
@click.pass_context
def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]):
"""Manage your AITBC wallets and transactions"""
# Ensure wallet object exists
ctx.ensure_object(dict)
# If direct wallet path is provided, use it
if wallet_path:
wp = Path(wallet_path)
wp.parent.mkdir(parents=True, exist_ok=True)
ctx.obj['wallet_name'] = wp.stem
ctx.obj['wallet_dir'] = wp.parent
ctx.obj['wallet_path'] = wp
return
# Set wallet directory
wallet_dir = Path.home() / ".aitbc" / "wallets"
wallet_dir.mkdir(parents=True, exist_ok=True)
# Set active wallet
if not wallet_name:
# Try to get from config or use 'default'
config_file = Path.home() / ".aitbc" / "config.yaml"
if config_file.exists():
with open(config_file, 'r') as f:
config = yaml.safe_load(f)
if config:
wallet_name = config.get('active_wallet', 'default')
else:
wallet_name = 'default'
else:
wallet_name = 'default'
ctx.obj['wallet_name'] = wallet_name
ctx.obj['wallet_dir'] = wallet_dir
ctx.obj['wallet_path'] = wallet_dir / f"{wallet_name}.json"
@wallet.command()
@click.argument('name')
@click.option('--type', 'wallet_type', default='hd', help='Wallet type (hd, simple)')
@click.pass_context
def create(ctx, name: str, wallet_type: str):
"""Create a new wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if wallet_path.exists():
error(f"Wallet '{name}' already exists")
return
# Generate new wallet
if wallet_type == 'hd':
# Hierarchical Deterministic wallet
import secrets
seed = secrets.token_hex(32)
address = f"aitbc1{seed[:40]}"
private_key = f"0x{seed}"
public_key = f"0x{secrets.token_hex(32)}"
else:
# Simple wallet
import secrets
private_key = f"0x{secrets.token_hex(32)}"
public_key = f"0x{secrets.token_hex(32)}"
address = f"aitbc1{secrets.token_hex(20)}"
wallet_data = {
"wallet_id": name,
"type": wallet_type,
"address": address,
"public_key": public_key,
"private_key": private_key,
"created_at": datetime.utcnow().isoformat() + "Z",
"balance": 0,
"transactions": []
}
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Wallet '{name}' created successfully")
output({
"name": name,
"type": wallet_type,
"address": address,
"path": str(wallet_path)
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def list(ctx):
"""List all wallets"""
wallet_dir = ctx.obj['wallet_dir']
config_file = Path.home() / ".aitbc" / "config.yaml"
# Get active wallet
active_wallet = 'default'
if config_file.exists():
with open(config_file, 'r') as f:
config = yaml.safe_load(f)
active_wallet = config.get('active_wallet', 'default')
wallets = []
for wallet_file in wallet_dir.glob("*.json"):
with open(wallet_file, 'r') as f:
wallet_data = json.load(f)
wallets.append({
"name": wallet_data['wallet_id'],
"type": wallet_data.get('type', 'simple'),
"address": wallet_data['address'],
"created_at": wallet_data['created_at'],
"active": wallet_data['wallet_id'] == active_wallet
})
output(wallets, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument('name')
@click.pass_context
def switch(ctx, name: str):
"""Switch to a different wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if not wallet_path.exists():
error(f"Wallet '{name}' does not exist")
return
# Update config
config_file = Path.home() / ".aitbc" / "config.yaml"
config = {}
if config_file.exists():
import yaml
with open(config_file, 'r') as f:
config = yaml.safe_load(f) or {}
config['active_wallet'] = name
# Save config
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
success(f"Switched to wallet '{name}'")
output({
"active_wallet": name,
"address": json.load(open(wallet_path))['address']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument('name')
@click.option('--confirm', is_flag=True, help='Skip confirmation prompt')
@click.pass_context
def delete(ctx, name: str, confirm: bool):
"""Delete a wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if not wallet_path.exists():
error(f"Wallet '{name}' does not exist")
return
if not confirm:
if not click.confirm(f"Are you sure you want to delete wallet '{name}'? This cannot be undone."):
return
wallet_path.unlink()
success(f"Wallet '{name}' deleted")
# If deleted wallet was active, reset to default
config_file = Path.home() / ".aitbc" / "config.yaml"
if config_file.exists():
import yaml
with open(config_file, 'r') as f:
config = yaml.safe_load(f) or {}
if config.get('active_wallet') == name:
config['active_wallet'] = 'default'
with open(config_file, 'w') as f:
yaml.dump(config, f, default_flow_style=False)
@wallet.command()
@click.argument('name')
@click.option('--destination', help='Destination path for backup file')
@click.pass_context
def backup(ctx, name: str, destination: Optional[str]):
"""Backup a wallet"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if not wallet_path.exists():
error(f"Wallet '{name}' does not exist")
return
if not destination:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
destination = f"{name}_backup_{timestamp}.json"
# Copy wallet file
shutil.copy2(wallet_path, destination)
success(f"Wallet '{name}' backed up to '{destination}'")
output({
"wallet": name,
"backup_path": destination,
"timestamp": datetime.utcnow().isoformat() + "Z"
})
@wallet.command()
@click.argument('backup_path')
@click.argument('name')
@click.option('--force', is_flag=True, help='Override existing wallet')
@click.pass_context
def restore(ctx, backup_path: str, name: str, force: bool):
"""Restore a wallet from backup"""
wallet_dir = ctx.obj['wallet_dir']
wallet_path = wallet_dir / f"{name}.json"
if wallet_path.exists() and not force:
error(f"Wallet '{name}' already exists. Use --force to override.")
return
if not Path(backup_path).exists():
error(f"Backup file '{backup_path}' not found")
return
# Load and verify backup
with open(backup_path, 'r') as f:
wallet_data = json.load(f)
# Update wallet name if needed
wallet_data['wallet_id'] = name
wallet_data['restored_at'] = datetime.utcnow().isoformat() + "Z"
# Save restored wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Wallet '{name}' restored from backup")
output({
"wallet": name,
"restored_from": backup_path,
"address": wallet_data['address']
})
@wallet.command()
@click.pass_context
def info(ctx):
"""Show current wallet information"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
config_file = Path.home() / ".aitbc" / "config.yaml"
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found. Use 'aitbc wallet create' to create one.")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Get active wallet from config
active_wallet = 'default'
if config_file.exists():
import yaml
with open(config_file, 'r') as f:
config = yaml.safe_load(f)
active_wallet = config.get('active_wallet', 'default')
wallet_info = {
"name": wallet_data['wallet_id'],
"type": wallet_data.get('type', 'simple'),
"address": wallet_data['address'],
"public_key": wallet_data['public_key'],
"created_at": wallet_data['created_at'],
"active": wallet_data['wallet_id'] == active_wallet,
"path": str(wallet_path)
}
if 'balance' in wallet_data:
wallet_info['balance'] = wallet_data['balance']
output(wallet_info, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def balance(ctx):
"""Check wallet balance"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
config = ctx.obj.get('config')
# Auto-create wallet if it doesn't exist
if not wallet_path.exists():
import secrets
wallet_data = {
"wallet_id": wallet_name,
"type": "simple",
"address": f"aitbc1{secrets.token_hex(20)}",
"public_key": f"0x{secrets.token_hex(32)}",
"private_key": f"0x{secrets.token_hex(32)}",
"created_at": datetime.utcnow().isoformat() + "Z",
"balance": 0.0,
"transactions": []
}
wallet_path.parent.mkdir(parents=True, exist_ok=True)
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
else:
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Try to get balance from blockchain if available
if config:
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url.replace('/api', '')}/rpc/balance/{wallet_data['address']}",
timeout=5
)
if response.status_code == 200:
blockchain_balance = response.json().get('balance', 0)
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"local_balance": wallet_data.get('balance', 0),
"blockchain_balance": blockchain_balance,
"synced": wallet_data.get('balance', 0) == blockchain_balance
}, ctx.obj.get('output_format', 'table'))
return
except:
pass
# Fallback to local balance only
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"balance": wallet_data.get('balance', 0),
"note": "Local balance only (blockchain not accessible)"
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.option("--limit", type=int, default=10, help="Number of transactions to show")
@click.pass_context
def history(ctx, limit: int):
"""Show transaction history"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
transactions = wallet_data.get('transactions', [])[-limit:]
# Format transactions
formatted_txs = []
for tx in transactions:
formatted_txs.append({
"type": tx['type'],
"amount": tx['amount'],
"description": tx.get('description', ''),
"timestamp": tx['timestamp']
})
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"transactions": formatted_txs
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.argument("job_id")
@click.option("--desc", help="Description of the work")
@click.pass_context
def earn(ctx, amount: float, job_id: str, desc: Optional[str]):
"""Add earnings from completed job"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Add transaction
transaction = {
"type": "earn",
"amount": amount,
"job_id": job_id,
"description": desc or f"Job {job_id}",
"timestamp": datetime.now().isoformat()
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = wallet_data.get('balance', 0) + amount
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Earnings added: {amount} AITBC")
output({
"wallet": wallet_name,
"amount": amount,
"job_id": job_id,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.argument("description")
@click.pass_context
def spend(ctx, amount: float, description: str):
"""Spend AITBC"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# Add transaction
transaction = {
"type": "spend",
"amount": -amount,
"description": description,
"timestamp": datetime.now().isoformat()
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = balance - amount
# Save wallet
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Spent: {amount} AITBC")
output({
"wallet": wallet_name,
"amount": amount,
"description": description,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def address(ctx):
"""Show wallet address"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
output({
"wallet": wallet_name,
"address": wallet_data['address']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("to_address")
@click.argument("amount", type=float)
@click.option("--description", help="Transaction description")
@click.pass_context
def send(ctx, to_address: str, amount: float, description: Optional[str]):
"""Send AITBC to another address"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
config = ctx.obj.get('config')
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# Try to send via blockchain
if config:
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url.replace('/api', '')}/rpc/transactions",
json={
"from": wallet_data['address'],
"to": to_address,
"amount": amount,
"description": description or ""
},
headers={"X-Api-Key": getattr(config, 'api_key', '') or ""}
)
if response.status_code == 201:
tx = response.json()
# Update local wallet
transaction = {
"type": "send",
"amount": -amount,
"to_address": to_address,
"tx_hash": tx.get('hash'),
"description": description or "",
"timestamp": datetime.now().isoformat()
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = balance - amount
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Sent {amount} AITBC to {to_address}")
output({
"wallet": wallet_name,
"tx_hash": tx.get('hash'),
"amount": amount,
"to": to_address,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
return
except Exception as e:
error(f"Network error: {e}")
# Fallback: just record locally
transaction = {
"type": "send",
"amount": -amount,
"to_address": to_address,
"description": description or "",
"timestamp": datetime.now().isoformat(),
"pending": True
}
wallet_data['transactions'].append(transaction)
wallet_data['balance'] = balance - amount
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
output({
"wallet": wallet_name,
"amount": amount,
"to": to_address,
"new_balance": wallet_data['balance'],
"note": "Transaction recorded locally (pending blockchain confirmation)"
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("to_address")
@click.argument("amount", type=float)
@click.option("--description", help="Transaction description")
@click.pass_context
def request_payment(ctx, to_address: str, amount: float, description: Optional[str]):
"""Request payment from another address"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
# Create payment request
request = {
"from_address": to_address,
"to_address": wallet_data['address'],
"amount": amount,
"description": description or "",
"timestamp": datetime.now().isoformat()
}
output({
"wallet": wallet_name,
"payment_request": request,
"note": "Share this with the payer to request payment"
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.pass_context
def stats(ctx):
"""Show wallet statistics"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
transactions = wallet_data.get('transactions', [])
# Calculate stats
total_earned = sum(tx['amount'] for tx in transactions if tx['type'] == 'earn' and tx['amount'] > 0)
total_spent = sum(abs(tx['amount']) for tx in transactions if tx['type'] in ['spend', 'send'] and tx['amount'] < 0)
jobs_completed = len([tx for tx in transactions if tx['type'] == 'earn'])
output({
"wallet": wallet_name,
"address": wallet_data['address'],
"current_balance": wallet_data.get('balance', 0),
"total_earned": total_earned,
"total_spent": total_spent,
"jobs_completed": jobs_completed,
"transaction_count": len(transactions),
"wallet_created": wallet_data.get('created_at')
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("amount", type=float)
@click.option("--duration", type=int, default=30, help="Staking duration in days")
@click.pass_context
def stake(ctx, amount: float, duration: int):
"""Stake AITBC tokens"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
balance = wallet_data.get('balance', 0)
if balance < amount:
error(f"Insufficient balance. Available: {balance}, Required: {amount}")
ctx.exit(1)
return
# Record stake
stake_id = f"stake_{int(datetime.now().timestamp())}"
stake_record = {
"stake_id": stake_id,
"amount": amount,
"duration_days": duration,
"start_date": datetime.now().isoformat(),
"end_date": (datetime.now() + timedelta(days=duration)).isoformat(),
"status": "active",
"apy": 5.0 + (duration / 30) * 1.5 # Higher APY for longer stakes
}
staking = wallet_data.setdefault('staking', [])
staking.append(stake_record)
wallet_data['balance'] = balance - amount
# Add transaction
wallet_data['transactions'].append({
"type": "stake",
"amount": -amount,
"stake_id": stake_id,
"description": f"Staked {amount} AITBC for {duration} days",
"timestamp": datetime.now().isoformat()
})
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Staked {amount} AITBC for {duration} days")
output({
"wallet": wallet_name,
"stake_id": stake_id,
"amount": amount,
"duration_days": duration,
"apy": stake_record['apy'],
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command()
@click.argument("stake_id")
@click.pass_context
def unstake(ctx, stake_id: str):
"""Unstake AITBC tokens"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
staking = wallet_data.get('staking', [])
stake_record = next((s for s in staking if s['stake_id'] == stake_id and s['status'] == 'active'), None)
if not stake_record:
error(f"Active stake '{stake_id}' not found")
ctx.exit(1)
return
# Calculate rewards
start = datetime.fromisoformat(stake_record['start_date'])
days_staked = max(1, (datetime.now() - start).days)
daily_rate = stake_record['apy'] / 100 / 365
rewards = stake_record['amount'] * daily_rate * days_staked
# Return principal + rewards
returned = stake_record['amount'] + rewards
wallet_data['balance'] = wallet_data.get('balance', 0) + returned
stake_record['status'] = 'completed'
stake_record['rewards'] = rewards
stake_record['completed_date'] = datetime.now().isoformat()
# Add transaction
wallet_data['transactions'].append({
"type": "unstake",
"amount": returned,
"stake_id": stake_id,
"rewards": rewards,
"description": f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards",
"timestamp": datetime.now().isoformat()
})
with open(wallet_path, 'w') as f:
json.dump(wallet_data, f, indent=2)
success(f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards")
output({
"wallet": wallet_name,
"stake_id": stake_id,
"principal": stake_record['amount'],
"rewards": rewards,
"total_returned": returned,
"days_staked": days_staked,
"new_balance": wallet_data['balance']
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="staking-info")
@click.pass_context
def staking_info(ctx):
"""Show staking information"""
wallet_name = ctx.obj['wallet_name']
wallet_path = ctx.obj['wallet_path']
if not wallet_path.exists():
error(f"Wallet '{wallet_name}' not found")
return
with open(wallet_path, 'r') as f:
wallet_data = json.load(f)
staking = wallet_data.get('staking', [])
active_stakes = [s for s in staking if s['status'] == 'active']
completed_stakes = [s for s in staking if s['status'] == 'completed']
total_staked = sum(s['amount'] for s in active_stakes)
total_rewards = sum(s.get('rewards', 0) for s in completed_stakes)
output({
"wallet": wallet_name,
"total_staked": total_staked,
"total_rewards_earned": total_rewards,
"active_stakes": len(active_stakes),
"completed_stakes": len(completed_stakes),
"stakes": [
{
"stake_id": s['stake_id'],
"amount": s['amount'],
"apy": s['apy'],
"duration_days": s['duration_days'],
"status": s['status'],
"start_date": s['start_date']
}
for s in staking
]
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-create")
@click.argument("signers", nargs=-1, required=True)
@click.option("--threshold", type=int, required=True, help="Required signatures to approve")
@click.option("--name", required=True, help="Multisig wallet name")
@click.pass_context
def multisig_create(ctx, signers: tuple, threshold: int, name: str):
"""Create a multi-signature wallet"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
wallet_dir.mkdir(parents=True, exist_ok=True)
multisig_path = wallet_dir / f"{name}_multisig.json"
if multisig_path.exists():
error(f"Multisig wallet '{name}' already exists")
return
if threshold > len(signers):
error(f"Threshold ({threshold}) cannot exceed number of signers ({len(signers)})")
return
import secrets
multisig_data = {
"wallet_id": name,
"type": "multisig",
"address": f"aitbc1ms{secrets.token_hex(18)}",
"signers": list(signers),
"threshold": threshold,
"created_at": datetime.now().isoformat(),
"balance": 0.0,
"transactions": [],
"pending_transactions": []
}
with open(multisig_path, "w") as f:
json.dump(multisig_data, f, indent=2)
success(f"Multisig wallet '{name}' created ({threshold}-of-{len(signers)})")
output({
"name": name,
"address": multisig_data["address"],
"signers": list(signers),
"threshold": threshold
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-propose")
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
@click.argument("to_address")
@click.argument("amount", type=float)
@click.option("--description", help="Transaction description")
@click.pass_context
def multisig_propose(ctx, wallet_name: str, to_address: str, amount: float, description: Optional[str]):
"""Propose a multisig transaction"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
if not multisig_path.exists():
error(f"Multisig wallet '{wallet_name}' not found")
return
with open(multisig_path) as f:
ms_data = json.load(f)
if ms_data.get("balance", 0) < amount:
error(f"Insufficient balance. Available: {ms_data['balance']}, Required: {amount}")
ctx.exit(1)
return
import secrets
tx_id = f"mstx_{secrets.token_hex(8)}"
pending_tx = {
"tx_id": tx_id,
"to": to_address,
"amount": amount,
"description": description or "",
"proposed_at": datetime.now().isoformat(),
"proposed_by": os.environ.get("USER", "unknown"),
"signatures": [],
"status": "pending"
}
ms_data.setdefault("pending_transactions", []).append(pending_tx)
with open(multisig_path, "w") as f:
json.dump(ms_data, f, indent=2)
success(f"Transaction proposed: {tx_id}")
output({
"tx_id": tx_id,
"to": to_address,
"amount": amount,
"signatures_needed": ms_data["threshold"],
"status": "pending"
}, ctx.obj.get('output_format', 'table'))
@wallet.command(name="multisig-sign")
@click.option("--wallet", "wallet_name", required=True, help="Multisig wallet name")
@click.argument("tx_id")
@click.option("--signer", required=True, help="Signer address")
@click.pass_context
def multisig_sign(ctx, wallet_name: str, tx_id: str, signer: str):
"""Sign a pending multisig transaction"""
wallet_dir = ctx.obj.get('wallet_dir', Path.home() / ".aitbc" / "wallets")
multisig_path = wallet_dir / f"{wallet_name}_multisig.json"
if not multisig_path.exists():
error(f"Multisig wallet '{wallet_name}' not found")
return
with open(multisig_path) as f:
ms_data = json.load(f)
if signer not in ms_data.get("signers", []):
error(f"'{signer}' is not an authorized signer")
ctx.exit(1)
return
pending = ms_data.get("pending_transactions", [])
tx = next((t for t in pending if t["tx_id"] == tx_id and t["status"] == "pending"), None)
if not tx:
error(f"Pending transaction '{tx_id}' not found")
ctx.exit(1)
return
if signer in tx["signatures"]:
error(f"'{signer}' has already signed this transaction")
return
tx["signatures"].append(signer)
# Check if threshold met
if len(tx["signatures"]) >= ms_data["threshold"]:
tx["status"] = "approved"
# Execute the transaction
ms_data["balance"] = ms_data.get("balance", 0) - tx["amount"]
ms_data["transactions"].append({
"type": "multisig_send",
"amount": -tx["amount"],
"to": tx["to"],
"tx_id": tx["tx_id"],
"signatures": tx["signatures"],
"timestamp": datetime.now().isoformat()
})
success(f"Transaction {tx_id} approved and executed!")
else:
success(f"Signed. {len(tx['signatures'])}/{ms_data['threshold']} signatures collected")
with open(multisig_path, "w") as f:
json.dump(ms_data, f, indent=2)
output({
"tx_id": tx_id,
"signatures": tx["signatures"],
"threshold": ms_data["threshold"],
"status": tx["status"]
}, ctx.obj.get('output_format', 'table'))

View File

@@ -0,0 +1,68 @@
"""Configuration management for AITBC CLI"""
import os
import yaml
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
from dotenv import load_dotenv
@dataclass
class Config:
"""Configuration object for AITBC CLI"""
coordinator_url: str = "http://127.0.0.1:18000"
api_key: Optional[str] = None
config_dir: Path = field(default_factory=lambda: Path.home() / ".aitbc")
config_file: Optional[str] = None
def __post_init__(self):
"""Initialize configuration"""
# Load environment variables
load_dotenv()
# Set default config file if not specified
if not self.config_file:
self.config_file = str(self.config_dir / "config.yaml")
# Load config from file if it exists
self.load_from_file()
# Override with environment variables
if os.getenv("AITBC_URL"):
self.coordinator_url = os.getenv("AITBC_URL")
if os.getenv("AITBC_API_KEY"):
self.api_key = os.getenv("AITBC_API_KEY")
def load_from_file(self):
"""Load configuration from YAML file"""
if self.config_file and Path(self.config_file).exists():
try:
with open(self.config_file, 'r') as f:
data = yaml.safe_load(f) or {}
self.coordinator_url = data.get('coordinator_url', self.coordinator_url)
self.api_key = data.get('api_key', self.api_key)
except Exception as e:
print(f"Warning: Could not load config file: {e}")
def save_to_file(self):
"""Save configuration to YAML file"""
if not self.config_file:
return
# Ensure config directory exists
Path(self.config_file).parent.mkdir(parents=True, exist_ok=True)
data = {
'coordinator_url': self.coordinator_url,
'api_key': self.api_key
}
with open(self.config_file, 'w') as f:
yaml.dump(data, f, default_flow_style=False)
def get_config(config_file: Optional[str] = None) -> Config:
"""Get configuration instance"""
return Config(config_file=config_file)

136
cli/aitbc_cli/main.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
AITBC CLI - Main entry point for the AITBC Command Line Interface
"""
import click
import sys
from typing import Optional
from . import __version__
from .config import get_config
from .utils import output, setup_logging
from .commands.client import client
from .commands.miner import miner
from .commands.wallet import wallet
from .commands.auth import auth
from .commands.blockchain import blockchain
from .commands.marketplace import marketplace
from .commands.simulate import simulate
from .commands.admin import admin
from .commands.config import config
from .commands.monitor import monitor
from .plugins import plugin, load_plugins
@click.group()
@click.option(
"--url",
default=None,
help="Coordinator API URL (overrides config)"
)
@click.option(
"--api-key",
default=None,
help="API key (overrides config)"
)
@click.option(
"--output",
type=click.Choice(["table", "json", "yaml"]),
default="table",
help="Output format"
)
@click.option(
"--verbose", "-v",
count=True,
help="Increase verbosity (use -v, -vv, -vvv)"
)
@click.option(
"--debug",
is_flag=True,
help="Enable debug mode"
)
@click.option(
"--config-file",
default=None,
help="Path to config file"
)
@click.version_option(version=__version__, prog_name="aitbc")
@click.pass_context
def cli(ctx, url: Optional[str], api_key: Optional[str], output: str,
verbose: int, debug: bool, config_file: Optional[str]):
"""
AITBC CLI - Command Line Interface for AITBC Network
Manage jobs, mining, wallets, and blockchain operations from the command line.
"""
# Ensure context object exists
ctx.ensure_object(dict)
# Setup logging based on verbosity
log_level = setup_logging(verbose, debug)
# Load configuration
config = get_config(config_file)
# Override config with command line options
if url:
config.coordinator_url = url
if api_key:
config.api_key = api_key
# Store in context for subcommands
ctx.obj['config'] = config
ctx.obj['output_format'] = output
ctx.obj['log_level'] = log_level
# Add command groups
cli.add_command(client)
cli.add_command(miner)
cli.add_command(wallet)
cli.add_command(auth)
cli.add_command(blockchain)
cli.add_command(marketplace)
cli.add_command(simulate)
cli.add_command(admin)
cli.add_command(config)
cli.add_command(monitor)
cli.add_command(plugin)
load_plugins(cli)
@cli.command()
@click.pass_context
def version(ctx):
"""Show version information"""
output(f"AITBC CLI version {__version__}", ctx.obj['output_format'])
@cli.command()
@click.pass_context
def config_show(ctx):
"""Show current configuration"""
config = ctx.obj['config']
output({
"coordinator_url": config.coordinator_url,
"api_key": "***REDACTED***" if config.api_key else None,
"output_format": ctx.obj['output_format'],
"config_file": config.config_file
}, ctx.obj['output_format'])
def main():
"""Main entry point"""
try:
cli()
except KeyboardInterrupt:
click.echo("\nAborted by user", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
if __name__ == "__main__":
main()

186
cli/aitbc_cli/plugins.py Normal file
View File

@@ -0,0 +1,186 @@
"""Plugin system for AITBC CLI custom commands"""
import importlib
import importlib.util
import json
import click
from pathlib import Path
from typing import Optional
PLUGIN_DIR = Path.home() / ".aitbc" / "plugins"
def get_plugin_dir() -> Path:
"""Get and ensure plugin directory exists"""
PLUGIN_DIR.mkdir(parents=True, exist_ok=True)
return PLUGIN_DIR
def load_plugins(cli_group):
"""Load all plugins and register them with the CLI group"""
plugin_dir = get_plugin_dir()
manifest_file = plugin_dir / "plugins.json"
if not manifest_file.exists():
return
with open(manifest_file) as f:
manifest = json.load(f)
for plugin_info in manifest.get("plugins", []):
if not plugin_info.get("enabled", True):
continue
plugin_path = plugin_dir / plugin_info["file"]
if not plugin_path.exists():
continue
try:
spec = importlib.util.spec_from_file_location(
plugin_info["name"], str(plugin_path)
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Look for a click group or command named 'plugin_command'
if hasattr(module, "plugin_command"):
cli_group.add_command(module.plugin_command)
except Exception:
pass # Skip broken plugins silently
@click.group()
def plugin():
"""Manage CLI plugins"""
pass
@plugin.command(name="list")
@click.pass_context
def list_plugins(ctx):
"""List installed plugins"""
from .utils import output
plugin_dir = get_plugin_dir()
manifest_file = plugin_dir / "plugins.json"
if not manifest_file.exists():
output({"message": "No plugins installed"}, ctx.obj.get('output_format', 'table'))
return
with open(manifest_file) as f:
manifest = json.load(f)
plugins = manifest.get("plugins", [])
if not plugins:
output({"message": "No plugins installed"}, ctx.obj.get('output_format', 'table'))
else:
output(plugins, ctx.obj.get('output_format', 'table'))
@plugin.command()
@click.argument("name")
@click.argument("file_path", type=click.Path(exists=True))
@click.option("--description", default="", help="Plugin description")
@click.pass_context
def install(ctx, name: str, file_path: str, description: str):
"""Install a plugin from a Python file"""
import shutil
from .utils import output, error, success
plugin_dir = get_plugin_dir()
manifest_file = plugin_dir / "plugins.json"
# Copy plugin file
dest = plugin_dir / f"{name}.py"
shutil.copy2(file_path, dest)
# Update manifest
manifest = {"plugins": []}
if manifest_file.exists():
with open(manifest_file) as f:
manifest = json.load(f)
# Remove existing entry with same name
manifest["plugins"] = [p for p in manifest["plugins"] if p["name"] != name]
manifest["plugins"].append({
"name": name,
"file": f"{name}.py",
"description": description,
"enabled": True
})
with open(manifest_file, "w") as f:
json.dump(manifest, f, indent=2)
success(f"Plugin '{name}' installed")
output({"name": name, "file": str(dest), "status": "installed"}, ctx.obj.get('output_format', 'table'))
@plugin.command()
@click.argument("name")
@click.pass_context
def uninstall(ctx, name: str):
"""Uninstall a plugin"""
from .utils import output, error, success
plugin_dir = get_plugin_dir()
manifest_file = plugin_dir / "plugins.json"
if not manifest_file.exists():
error(f"Plugin '{name}' not found")
return
with open(manifest_file) as f:
manifest = json.load(f)
plugin_entry = next((p for p in manifest["plugins"] if p["name"] == name), None)
if not plugin_entry:
error(f"Plugin '{name}' not found")
return
# Remove file
plugin_file = plugin_dir / plugin_entry["file"]
if plugin_file.exists():
plugin_file.unlink()
# Update manifest
manifest["plugins"] = [p for p in manifest["plugins"] if p["name"] != name]
with open(manifest_file, "w") as f:
json.dump(manifest, f, indent=2)
success(f"Plugin '{name}' uninstalled")
output({"name": name, "status": "uninstalled"}, ctx.obj.get('output_format', 'table'))
@plugin.command()
@click.argument("name")
@click.argument("state", type=click.Choice(["enable", "disable"]))
@click.pass_context
def toggle(ctx, name: str, state: str):
"""Enable or disable a plugin"""
from .utils import output, error, success
plugin_dir = get_plugin_dir()
manifest_file = plugin_dir / "plugins.json"
if not manifest_file.exists():
error(f"Plugin '{name}' not found")
return
with open(manifest_file) as f:
manifest = json.load(f)
plugin_entry = next((p for p in manifest["plugins"] if p["name"] == name), None)
if not plugin_entry:
error(f"Plugin '{name}' not found")
return
plugin_entry["enabled"] = (state == "enable")
with open(manifest_file, "w") as f:
json.dump(manifest, f, indent=2)
success(f"Plugin '{name}' {'enabled' if state == 'enable' else 'disabled'}")
output({"name": name, "enabled": plugin_entry["enabled"]}, ctx.obj.get('output_format', 'table'))

View File

@@ -0,0 +1,268 @@
"""Utility functions for AITBC CLI"""
import time
import logging
import sys
import os
from pathlib import Path
from typing import Any, Optional, Callable, Iterator
from contextlib import contextmanager
from rich.console import Console
from rich.logging import RichHandler
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
import json
import yaml
from tabulate import tabulate
console = Console()
@contextmanager
def progress_bar(description: str = "Working...", total: Optional[int] = None):
"""Context manager for progress bar display"""
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TimeElapsedColumn(),
console=console,
) as progress:
task = progress.add_task(description, total=total)
yield progress, task
def progress_spinner(description: str = "Working..."):
"""Simple spinner for indeterminate operations"""
return console.status(f"[bold blue]{description}")
class AuditLogger:
"""Audit logging for CLI operations"""
def __init__(self, log_dir: Optional[Path] = None):
self.log_dir = log_dir or Path.home() / ".aitbc" / "audit"
self.log_dir.mkdir(parents=True, exist_ok=True)
self.log_file = self.log_dir / "audit.jsonl"
def log(self, action: str, details: dict = None, user: str = None):
"""Log an audit event"""
import datetime
entry = {
"timestamp": datetime.datetime.now().isoformat(),
"action": action,
"user": user or os.environ.get("USER", "unknown"),
"details": details or {}
}
with open(self.log_file, "a") as f:
f.write(json.dumps(entry) + "\n")
def get_logs(self, limit: int = 50, action_filter: str = None) -> list:
"""Read audit log entries"""
if not self.log_file.exists():
return []
entries = []
with open(self.log_file) as f:
for line in f:
line = line.strip()
if line:
entry = json.loads(line)
if action_filter and entry.get("action") != action_filter:
continue
entries.append(entry)
return entries[-limit:]
def encrypt_value(value: str, key: str = None) -> str:
"""Simple XOR-based obfuscation for config values (not cryptographic security)"""
import base64
key = key or "aitbc_config_key_2026"
encrypted = bytes([ord(c) ^ ord(key[i % len(key)]) for i, c in enumerate(value)])
return base64.b64encode(encrypted).decode()
def decrypt_value(encrypted: str, key: str = None) -> str:
"""Decrypt an XOR-obfuscated config value"""
import base64
key = key or "aitbc_config_key_2026"
data = base64.b64decode(encrypted)
return ''.join(chr(b ^ ord(key[i % len(key)])) for i, b in enumerate(data))
def setup_logging(verbosity: int, debug: bool = False) -> str:
"""Setup logging with Rich"""
log_level = "WARNING"
if verbosity >= 3 or debug:
log_level = "DEBUG"
elif verbosity == 2:
log_level = "INFO"
elif verbosity == 1:
log_level = "WARNING"
logging.basicConfig(
level=log_level,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(console=console, rich_tracebacks=True)]
)
return log_level
def output(data: Any, format_type: str = "table"):
"""Format and output data"""
if format_type == "json":
console.print(json.dumps(data, indent=2, default=str))
elif format_type == "yaml":
console.print(yaml.dump(data, default_flow_style=False, sort_keys=False))
elif format_type == "table":
if isinstance(data, dict) and not isinstance(data, list):
# Simple key-value table
table = Table(show_header=False, box=None)
table.add_column("Key", style="cyan")
table.add_column("Value", style="green")
for key, value in data.items():
if isinstance(value, (dict, list)):
value = json.dumps(value, default=str)
table.add_row(str(key), str(value))
console.print(table)
elif isinstance(data, list) and data:
if all(isinstance(item, dict) for item in data):
# Table from list of dicts
headers = list(data[0].keys())
table = Table()
for header in headers:
table.add_column(header, style="cyan")
for item in data:
row = [str(item.get(h, "")) for h in headers]
table.add_row(*row)
console.print(table)
else:
# Simple list
for item in data:
console.print(f"{item}")
else:
console.print(data)
else:
console.print(data)
def error(message: str):
"""Print error message"""
console.print(Panel(f"[red]Error: {message}[/red]", title=""))
def success(message: str):
"""Print success message"""
console.print(Panel(f"[green]{message}[/green]", title=""))
def warning(message: str):
"""Print warning message"""
console.print(Panel(f"[yellow]{message}[/yellow]", title="⚠️"))
def retry_with_backoff(
func,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
backoff_factor: float = 2.0,
exceptions: tuple = (Exception,)
):
"""
Retry function with exponential backoff
Args:
func: Function to retry
max_retries: Maximum number of retries
base_delay: Initial delay in seconds
max_delay: Maximum delay in seconds
backoff_factor: Multiplier for delay after each retry
exceptions: Tuple of exceptions to catch and retry on
Returns:
Result of function call
"""
last_exception = None
for attempt in range(max_retries + 1):
try:
return func()
except exceptions as e:
last_exception = e
if attempt == max_retries:
error(f"Max retries ({max_retries}) exceeded. Last error: {e}")
raise
# Calculate delay with exponential backoff
delay = min(base_delay * (backoff_factor ** attempt), max_delay)
warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay:.1f}s...")
time.sleep(delay)
raise last_exception
def create_http_client_with_retry(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
timeout: float = 30.0
):
"""
Create an HTTP client with retry capabilities
Args:
max_retries: Maximum number of retries
base_delay: Initial delay in seconds
max_delay: Maximum delay in seconds
timeout: Request timeout in seconds
Returns:
httpx.Client with retry transport
"""
import httpx
class RetryTransport(httpx.Transport):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.backoff_factor = 2.0
def handle_request(self, request):
last_exception = None
for attempt in range(self.max_retries + 1):
try:
return super().handle_request(request)
except (httpx.NetworkError, httpx.TimeoutException) as e:
last_exception = e
if attempt == self.max_retries:
break
delay = min(
self.base_delay * (self.backoff_factor ** attempt),
self.max_delay
)
time.sleep(delay)
raise last_exception
return httpx.Client(
transport=RetryTransport(),
timeout=timeout
)

View File

@@ -0,0 +1,251 @@
#!/bin/bash
# AITBC CLI Shell Completion Script
# Source this file in your .bashrc or .zshrc to enable tab completion
# AITBC CLI completion for bash
_aitbc_completion() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Main commands
if [[ ${COMP_CWORD} -eq 1 ]]; then
opts="client miner wallet auth blockchain marketplace admin config simulate help --help --version --url --api-key --output -v --debug --config-file"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi
# Command-specific completions
case "${COMP_WORDS[1]}" in
client)
_aitbc_client_completion
;;
miner)
_aitbc_miner_completion
;;
wallet)
_aitbc_wallet_completion
;;
auth)
_aitbc_auth_completion
;;
blockchain)
_aitbc_blockchain_completion
;;
marketplace)
_aitbc_marketplace_completion
;;
admin)
_aitbc_admin_completion
;;
config)
_aitbc_config_completion
;;
simulate)
_aitbc_simulate_completion
;;
--output)
opts="table json yaml"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
}
# Client command completion
_aitbc_client_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="submit status blocks receipts cancel history"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
submit)
opts="inference training fine-tuning"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
status|cancel)
# Complete with job IDs (placeholder)
COMPREPLY=( $(compgen -W "job_123 job_456 job_789" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Miner command completion
_aitbc_miner_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="register poll mine heartbeat status"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
fi
}
# Wallet command completion
_aitbc_wallet_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="balance earn spend history address stats send request"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
fi
}
# Auth command completion
_aitbc_auth_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="login logout token status refresh keys import-env"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
keys)
opts="create list revoke"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Blockchain command completion
_aitbc_blockchain_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="blocks block transaction status sync-status peers info supply validators"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
fi
}
# Marketplace command completion
_aitbc_marketplace_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="gpu orders pricing reviews"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
gpu)
opts="list details book release"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Admin command completion
_aitbc_admin_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="status jobs miners analytics logs maintenance action"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
jobs|miners)
opts="list details cancel suspend"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Config command completion
_aitbc_config_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="show set path edit reset export import validate environments profiles"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
set)
opts="coordinator_url api_key timeout"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
export|import)
opts="--format json yaml"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
profiles)
opts="save list load delete"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Simulate command completion
_aitbc_simulate_completion() {
local cur prev opts
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ ${COMP_CWORD} -eq 2 ]]; then
opts="init user workflow load-test scenario results reset"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
elif [[ ${COMP_CWORD} -eq 3 ]]; then
case "${COMP_WORDS[2]}" in
user)
opts="create list balance fund"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
scenario)
opts="list run"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
;;
*)
;;
esac
fi
}
# Register the completion
complete -F _aitbc_completion aitbc
# For zsh compatibility
if [[ -n "$ZSH_VERSION" ]]; then
autoload -U compinit
compinit -i
_aitbc_completion() {
local matches
matches=($(compgen -W "$(aitbc _completion_helper "${words[@]}")" -- "${words[CURRENT]}"))
_describe 'aitbc commands' matches
}
compdef _aitbc_completion aitbc
fi
echo "AITBC CLI shell completion loaded!"
echo "Tab completion is now enabled for the aitbc command."

View File

@@ -11,7 +11,7 @@ from datetime import datetime
from typing import Optional
# Configuration
DEFAULT_COORDINATOR = "http://127.0.0.1:18000"
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
class AITBCClient:

328
cli/client_enhanced.py Normal file
View File

@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
AITBC Client CLI Tool - Enhanced version with output formatting
"""
import argparse
import httpx
import json
import sys
import yaml
from datetime import datetime
from typing import Optional, Dict, Any
from tabulate import tabulate
# Configuration
DEFAULT_COORDINATOR = "http://127.0.0.1:18000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
class OutputFormatter:
"""Handle different output formats"""
@staticmethod
def format(data: Any, format_type: str = "table") -> str:
"""Format data according to specified type"""
if format_type == "json":
return json.dumps(data, indent=2, default=str)
elif format_type == "yaml":
return yaml.dump(data, default_flow_style=False, sort_keys=False)
elif format_type == "table":
return OutputFormatter._format_table(data)
else:
return str(data)
@staticmethod
def _format_table(data: Any) -> str:
"""Format data as table"""
if isinstance(data, dict):
# Simple key-value table
rows = [[k, v] for k, v in data.items()]
return tabulate(rows, headers=["Key", "Value"], tablefmt="grid")
elif isinstance(data, list) and data:
if all(isinstance(item, dict) for item in data):
# Table from list of dicts
headers = list(data[0].keys())
rows = [[item.get(h, "") for h in headers] for item in data]
return tabulate(rows, headers=headers, tablefmt="grid")
else:
# Simple list
return "\n".join(f"{item}" for item in data)
else:
return str(data)
class AITBCClient:
def __init__(self, coordinator_url: str, api_key: str):
self.coordinator_url = coordinator_url
self.api_key = api_key
self.client = httpx.Client()
def submit_job(self, job_type: str, task_data: dict, ttl: int = 900) -> Optional[str]:
"""Submit a job to the coordinator"""
job_payload = {
"payload": {
"type": job_type,
**task_data
},
"ttl_seconds": ttl
}
try:
response = self.client.post(
f"{self.coordinator_url}/v1/jobs",
headers={
"Content-Type": "application/json",
"X-Api-Key": self.api_key
},
json=job_payload
)
if response.status_code == 201:
job = response.json()
return job['job_id']
else:
print(f"❌ Error submitting job: {response.status_code}")
print(f" Response: {response.text}")
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
def get_job_status(self, job_id: str) -> Optional[Dict]:
"""Get job status"""
try:
response = self.client.get(
f"{self.coordinator_url}/v1/jobs/{job_id}",
headers={"X-Api-Key": self.api_key}
)
if response.status_code == 200:
return response.json()
else:
print(f"❌ Error getting status: {response.status_code}")
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
def list_blocks(self, limit: int = 10) -> Optional[list]:
"""List recent blocks"""
try:
response = self.client.get(
f"{self.coordinator_url}/v1/explorer/blocks",
params={"limit": limit},
headers={"X-Api-Key": self.api_key}
)
if response.status_code == 200:
return response.json()
else:
print(f"❌ Error getting blocks: {response.status_code}")
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
def list_transactions(self, limit: int = 10) -> Optional[list]:
"""List recent transactions"""
try:
response = self.client.get(
f"{self.coordinator_url}/v1/explorer/transactions",
params={"limit": limit},
headers={"X-Api-Key": self.api_key}
)
if response.status_code == 200:
return response.json()
else:
print(f"❌ Error getting transactions: {response.status_code}")
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
def list_receipts(self, limit: int = 10, job_id: str = None) -> Optional[list]:
"""List job receipts"""
try:
params = {"limit": limit}
if job_id:
params["job_id"] = job_id
response = self.client.get(
f"{self.coordinator_url}/v1/explorer/receipts",
params=params,
headers={"X-Api-Key": self.api_key}
)
if response.status_code == 200:
return response.json()
else:
print(f"❌ Error getting receipts: {response.status_code}")
return None
except Exception as e:
print(f"❌ Error: {e}")
return None
def cancel_job(self, job_id: str) -> bool:
"""Cancel a job"""
try:
response = self.client.post(
f"{self.coordinator_url}/v1/jobs/{job_id}/cancel",
headers={"X-Api-Key": self.api_key}
)
if response.status_code == 200:
return True
else:
print(f"❌ Error cancelling job: {response.status_code}")
return False
except Exception as e:
print(f"❌ Error: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="AITBC Client CLI Tool")
parser.add_argument("--url", default=DEFAULT_COORDINATOR, help="Coordinator URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
parser.add_argument("--output", choices=["table", "json", "yaml"],
default="table", help="Output format")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# Submit command
submit_parser = subparsers.add_parser("submit", help="Submit a job")
submit_parser.add_argument("type", help="Job type (e.g., inference, training)")
submit_parser.add_argument("--prompt", help="Prompt for inference jobs")
submit_parser.add_argument("--model", help="Model name")
submit_parser.add_argument("--ttl", type=int, default=900, help="Time to live (seconds)")
submit_parser.add_argument("--file", type=argparse.FileType('r'),
help="Submit job from JSON file")
# Status command
status_parser = subparsers.add_parser("status", help="Check job status")
status_parser.add_argument("job_id", help="Job ID")
# Blocks command
blocks_parser = subparsers.add_parser("blocks", help="List recent blocks")
blocks_parser.add_argument("--limit", type=int, default=10, help="Number of blocks")
# Browser command
browser_parser = subparsers.add_parser("browser", help="Browse blockchain")
browser_parser.add_argument("--block-limit", type=int, default=5, help="Block limit")
browser_parser.add_argument("--tx-limit", type=int, default=10, help="Transaction limit")
browser_parser.add_argument("--receipt-limit", type=int, default=10, help="Receipt limit")
browser_parser.add_argument("--job-id", help="Filter by job ID")
# Cancel command
cancel_parser = subparsers.add_parser("cancel", help="Cancel a job")
cancel_parser.add_argument("job_id", help="Job ID")
# Receipts command
receipts_parser = subparsers.add_parser("receipts", help="List receipts")
receipts_parser.add_argument("--limit", type=int, default=10, help="Number of receipts")
receipts_parser.add_argument("--job-id", help="Filter by job ID")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
# Create client
client = AITBCClient(args.url, args.api_key)
# Execute command
if args.command == "submit":
# Build job data
if args.file:
try:
task_data = json.load(args.file)
except Exception as e:
print(f"❌ Error reading job file: {e}")
sys.exit(1)
else:
task_data = {"type": args.type}
if args.prompt:
task_data["prompt"] = args.prompt
if args.model:
task_data["model"] = args.model
# Submit job
job_id = client.submit_job(args.type, task_data, args.ttl)
if job_id:
result = {
"status": "success",
"job_id": job_id,
"message": "Job submitted successfully",
"track_command": f"python3 cli/client_enhanced.py status {job_id}"
}
print(OutputFormatter.format(result, args.output))
sys.exit(0)
else:
sys.exit(1)
elif args.command == "status":
status = client.get_job_status(args.job_id)
if status:
print(OutputFormatter.format(status, args.output))
sys.exit(0)
else:
sys.exit(1)
elif args.command == "blocks":
blocks = client.list_blocks(args.limit)
if blocks:
print(OutputFormatter.format(blocks, args.output))
sys.exit(0)
else:
sys.exit(1)
elif args.command == "browser":
blocks = client.list_blocks(args.block_limit) or []
transactions = client.list_transactions(args.tx_limit) or []
receipts = client.list_receipts(args.receipt_limit, job_id=args.job_id) or []
result = {
"latest_block": blocks[0] if blocks else None,
"recent_transactions": transactions,
"recent_receipts": receipts
}
print(OutputFormatter.format(result, args.output))
sys.exit(0)
elif args.command == "cancel":
if client.cancel_job(args.job_id):
result = {
"status": "success",
"job_id": args.job_id,
"message": "Job cancelled successfully"
}
print(OutputFormatter.format(result, args.output))
sys.exit(0)
else:
sys.exit(1)
elif args.command == "receipts":
receipts = client.list_receipts(args.limit, args.job_id)
if receipts:
print(OutputFormatter.format(receipts, args.output))
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

120
cli/man/aitbc.1 Normal file
View File

@@ -0,0 +1,120 @@
.TH AITBC 1 "February 2026" "AITBC CLI" "User Commands"
.SH NAME
aitbc \- command-line interface for the AITBC network
.SH SYNOPSIS
.B aitbc
[\fIOPTIONS\fR] \fICOMMAND\fR [\fIARGS\fR]...
.SH DESCRIPTION
The AITBC CLI provides a comprehensive command-line interface for interacting
with the AITBC network. It supports job submission, mining operations, wallet
management, blockchain queries, marketplace operations, system administration,
monitoring, and test simulations.
.SH GLOBAL OPTIONS
.TP
\fB\-\-url\fR \fITEXT\fR
Coordinator API URL (overrides config)
.TP
\fB\-\-api\-key\fR \fITEXT\fR
API key (overrides config)
.TP
\fB\-\-output\fR [table|json|yaml]
Output format (default: table)
.TP
\fB\-v\fR, \fB\-\-verbose\fR
Increase verbosity (use -v, -vv, -vvv)
.TP
\fB\-\-debug\fR
Enable debug mode
.TP
\fB\-\-config\-file\fR \fITEXT\fR
Path to config file
.TP
\fB\-\-version\fR
Show version and exit
.TP
\fB\-\-help\fR
Show help message and exit
.SH COMMANDS
.TP
\fBclient\fR
Submit and manage inference jobs (submit, status, blocks, receipts, cancel, history, batch-submit, template)
.TP
\fBminer\fR
Register as a miner and process jobs (register, poll, mine, heartbeat, status, earnings, update-capabilities, deregister, jobs, concurrent-mine)
.TP
\fBwallet\fR
Manage wallets and transactions (balance, earn, spend, send, history, address, stats, stake, unstake, staking-info, multisig-create, multisig-propose, multisig-sign, create, list, switch, delete, backup, restore, info, request-payment)
.TP
\fBauth\fR
Manage API keys and authentication (login, logout, token, status, refresh, keys, import-env)
.TP
\fBblockchain\fR
Query blockchain information (blocks, block, transaction, status, sync-status, peers, info, supply, validators)
.TP
\fBmarketplace\fR
GPU marketplace operations (gpu register/list/details/book/release, orders, pricing, reviews)
.TP
\fBadmin\fR
System administration (status, jobs, miners, analytics, logs, maintenance, audit-log)
.TP
\fBconfig\fR
Manage CLI configuration (show, set, path, edit, reset, export, import, validate, environments, profiles, set-secret, get-secret)
.TP
\fBmonitor\fR
Monitoring and alerting (dashboard, metrics, alerts, history, webhooks)
.TP
\fBsimulate\fR
Run simulations (init, user, workflow, load-test, scenario, results, reset)
.SH EXAMPLES
.PP
Submit a job:
.RS
aitbc client submit --prompt "What is AI?" --model gpt-4
.RE
.PP
Check wallet balance:
.RS
aitbc wallet balance
.RE
.PP
Start mining:
.RS
aitbc miner register --gpu-model RTX4090 --memory 24 --price 0.5
.br
aitbc miner poll --interval 5
.RE
.PP
Monitor system:
.RS
aitbc monitor dashboard --refresh 5
.RE
.SH ENVIRONMENT
.TP
\fBCLIENT_API_KEY\fR
API key for authentication
.TP
\fBAITBC_COORDINATOR_URL\fR
Coordinator API URL
.TP
\fBAITBC_OUTPUT_FORMAT\fR
Default output format
.TP
\fBAITBC_CONFIG_FILE\fR
Path to configuration file
.SH FILES
.TP
\fB~/.config/aitbc/config.yaml\fR
Default configuration file
.TP
\fB~/.aitbc/wallets/\fR
Wallet storage directory
.TP
\fB~/.aitbc/audit/audit.jsonl\fR
Audit log file
.TP
\fB~/.aitbc/templates/\fR
Job template storage
.SH SEE ALSO
Full documentation: https://docs.aitbc.net
.SH AUTHORS
AITBC Development Team

View File

@@ -12,7 +12,7 @@ from datetime import datetime
from typing import Optional
# Configuration
DEFAULT_COORDINATOR = "http://localhost:8001"
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${MINER_API_KEY}"
DEFAULT_MINER_ID = "cli-miner"

View File

@@ -10,7 +10,7 @@ import time
import sys
# Configuration
DEFAULT_COORDINATOR = "http://localhost:8001"
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${MINER_API_KEY}"
DEFAULT_MINER_ID = "localhost-gpu-miner"

View File

@@ -17,7 +17,7 @@ import json
import httpx
# Configuration
DEFAULT_COORDINATOR = "http://127.0.0.1:18000"
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_BLOCKCHAIN = "http://127.0.0.1:19000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
DEFAULT_PROMPT = "What is the capital of France?"

View File

@@ -11,7 +11,7 @@ from typing import Optional
import httpx
DEFAULT_COORDINATOR = "http://127.0.0.1:18000"
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
DEFAULT_PROMPT = "hello"
DEFAULT_TIMEOUT = 180