Files
aitbc/cli/commands/governance.py
aitbc1 c0952c2525 refactor: flatten CLI directory structure - remove 'box in a box'
BEFORE:
/opt/aitbc/cli/
├── aitbc_cli/                    # Python package (box in a box)
│   ├── commands/
│   ├── main.py
│   └── ...
├── setup.py

AFTER:
/opt/aitbc/cli/                    # Flat structure
├── commands/                      # Direct access
├── main.py                        # Direct access
├── auth/
├── config/
├── core/
├── models/
├── utils/
├── plugins.py
└── setup.py

CHANGES MADE:
- Moved all files from aitbc_cli/ to cli/ root
- Fixed all relative imports (from . to absolute imports)
- Updated setup.py entry point: aitbc_cli.main → main
- Added CLI directory to Python path in entry script
- Simplified deployment.py to remove dependency on deleted core.deployment
- Fixed import paths in all command files
- Recreated virtual environment with new structure

BENEFITS:
- Eliminated 'box in a box' nesting
- Simpler directory structure
- Direct access to all modules
- Cleaner imports
- Easier maintenance and development
- CLI works with both 'python main.py' and 'aitbc' commands
2026-03-26 09:12:02 +01:00

254 lines
8.3 KiB
Python
Executable File

"""Governance commands for AITBC CLI"""
import click
import httpx
import json
import os
import time
from pathlib import Path
from typing import Optional
from datetime import datetime, timedelta
from utils import output, error, success
GOVERNANCE_DIR = Path.home() / ".aitbc" / "governance"
def _ensure_governance_dir():
GOVERNANCE_DIR.mkdir(parents=True, exist_ok=True)
proposals_file = GOVERNANCE_DIR / "proposals.json"
if not proposals_file.exists():
with open(proposals_file, "w") as f:
json.dump({"proposals": []}, f, indent=2)
return proposals_file
def _load_proposals():
proposals_file = _ensure_governance_dir()
with open(proposals_file) as f:
return json.load(f)
def _save_proposals(data):
proposals_file = _ensure_governance_dir()
with open(proposals_file, "w") as f:
json.dump(data, f, indent=2)
@click.group()
def governance():
"""Governance proposals and voting"""
pass
@governance.command()
@click.argument("title")
@click.option("--description", required=True, help="Proposal description")
@click.option("--type", "proposal_type", type=click.Choice(["parameter_change", "feature_toggle", "funding", "general"]), default="general", help="Proposal type")
@click.option("--parameter", help="Parameter to change (for parameter_change type)")
@click.option("--value", help="New value (for parameter_change type)")
@click.option("--amount", type=float, help="Funding amount (for funding type)")
@click.option("--duration", type=int, default=7, help="Voting duration in days")
@click.pass_context
def propose(ctx, title: str, description: str, proposal_type: str,
parameter: Optional[str], value: Optional[str],
amount: Optional[float], duration: int):
"""Create a governance proposal"""
import secrets
data = _load_proposals()
proposal_id = f"prop_{secrets.token_hex(6)}"
now = datetime.now()
proposal = {
"id": proposal_id,
"title": title,
"description": description,
"type": proposal_type,
"proposer": os.environ.get("USER", "unknown"),
"created_at": now.isoformat(),
"voting_ends": (now + timedelta(days=duration)).isoformat(),
"duration_days": duration,
"status": "active",
"votes": {"for": 0, "against": 0, "abstain": 0},
"voters": [],
}
if proposal_type == "parameter_change":
proposal["parameter"] = parameter
proposal["new_value"] = value
elif proposal_type == "funding":
proposal["amount"] = amount
data["proposals"].append(proposal)
_save_proposals(data)
success(f"Proposal '{title}' created: {proposal_id}")
output({
"proposal_id": proposal_id,
"title": title,
"type": proposal_type,
"status": "active",
"voting_ends": proposal["voting_ends"],
"duration_days": duration
}, ctx.obj.get('output_format', 'table'))
@governance.command()
@click.argument("proposal_id")
@click.argument("choice", type=click.Choice(["for", "against", "abstain"]))
@click.option("--voter", default=None, help="Voter identity (defaults to $USER)")
@click.option("--weight", type=float, default=1.0, help="Vote weight")
@click.pass_context
def vote(ctx, proposal_id: str, choice: str, voter: Optional[str], weight: float):
"""Cast a vote on a proposal"""
data = _load_proposals()
voter = voter or os.environ.get("USER", "unknown")
proposal = next((p for p in data["proposals"] if p["id"] == proposal_id), None)
if not proposal:
error(f"Proposal '{proposal_id}' not found")
ctx.exit(1)
return
if proposal["status"] != "active":
error(f"Proposal is '{proposal['status']}', not active")
ctx.exit(1)
return
# Check if voting period has ended
voting_ends = datetime.fromisoformat(proposal["voting_ends"])
if datetime.now() > voting_ends:
proposal["status"] = "closed"
_save_proposals(data)
error("Voting period has ended")
ctx.exit(1)
return
# Check if already voted
if voter in proposal["voters"]:
error(f"'{voter}' has already voted on this proposal")
ctx.exit(1)
return
proposal["votes"][choice] += weight
proposal["voters"].append(voter)
_save_proposals(data)
total_votes = sum(proposal["votes"].values())
success(f"Vote recorded: {choice} (weight: {weight})")
output({
"proposal_id": proposal_id,
"voter": voter,
"choice": choice,
"weight": weight,
"current_tally": proposal["votes"],
"total_votes": total_votes
}, ctx.obj.get('output_format', 'table'))
@governance.command(name="list")
@click.option("--status", type=click.Choice(["active", "closed", "approved", "rejected", "all"]), default="all", help="Filter by status")
@click.option("--type", "proposal_type", help="Filter by proposal type")
@click.option("--limit", type=int, default=20, help="Max proposals to show")
@click.pass_context
def list_proposals(ctx, status: str, proposal_type: Optional[str], limit: int):
"""List governance proposals"""
data = _load_proposals()
proposals = data["proposals"]
# Auto-close expired proposals
now = datetime.now()
for p in proposals:
if p["status"] == "active":
voting_ends = datetime.fromisoformat(p["voting_ends"])
if now > voting_ends:
total = sum(p["votes"].values())
if total > 0 and p["votes"]["for"] > p["votes"]["against"]:
p["status"] = "approved"
else:
p["status"] = "rejected"
_save_proposals(data)
# Filter
if status != "all":
proposals = [p for p in proposals if p["status"] == status]
if proposal_type:
proposals = [p for p in proposals if p["type"] == proposal_type]
proposals = proposals[-limit:]
if not proposals:
output({"message": "No proposals found", "filter": status}, ctx.obj.get('output_format', 'table'))
return
summary = [{
"id": p["id"],
"title": p["title"],
"type": p["type"],
"status": p["status"],
"votes_for": p["votes"]["for"],
"votes_against": p["votes"]["against"],
"votes_abstain": p["votes"]["abstain"],
"created_at": p["created_at"]
} for p in proposals]
output(summary, ctx.obj.get('output_format', 'table'))
@governance.command()
@click.argument("proposal_id")
@click.pass_context
def result(ctx, proposal_id: str):
"""Show voting results for a proposal"""
data = _load_proposals()
proposal = next((p for p in data["proposals"] if p["id"] == proposal_id), None)
if not proposal:
error(f"Proposal '{proposal_id}' not found")
ctx.exit(1)
return
# Auto-close if expired
now = datetime.now()
if proposal["status"] == "active":
voting_ends = datetime.fromisoformat(proposal["voting_ends"])
if now > voting_ends:
total = sum(proposal["votes"].values())
if total > 0 and proposal["votes"]["for"] > proposal["votes"]["against"]:
proposal["status"] = "approved"
else:
proposal["status"] = "rejected"
_save_proposals(data)
votes = proposal["votes"]
total = sum(votes.values())
pct_for = (votes["for"] / total * 100) if total > 0 else 0
pct_against = (votes["against"] / total * 100) if total > 0 else 0
result_data = {
"proposal_id": proposal["id"],
"title": proposal["title"],
"type": proposal["type"],
"status": proposal["status"],
"proposer": proposal["proposer"],
"created_at": proposal["created_at"],
"voting_ends": proposal["voting_ends"],
"votes_for": votes["for"],
"votes_against": votes["against"],
"votes_abstain": votes["abstain"],
"total_votes": total,
"pct_for": round(pct_for, 1),
"pct_against": round(pct_against, 1),
"voter_count": len(proposal["voters"]),
"outcome": proposal["status"]
}
if proposal.get("parameter"):
result_data["parameter"] = proposal["parameter"]
result_data["new_value"] = proposal.get("new_value")
if proposal.get("amount"):
result_data["amount"] = proposal["amount"]
output(result_data, ctx.obj.get('output_format', 'table'))