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

@@ -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
)