Files
opus-orchestrator-ai/opus_orchestrator/cli.py
T
Gemini AI 13bce7500c feat: Full code review, bug fixes, and philosophy book generation
This commit includes:
- A full code review and bug fixes for language drift, package loading, and CLI crashes.
- The generated 15,000-word philosophy manuscript.
- CODE_REVIEW.md and CHANGELOG.md documenting the process.
2026-05-20 21:15:11 +00:00

1241 lines
38 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Opus Orchestrator CLI.
Standalone CLI for running Opus book generation without OpenClaw.
Usage:
opus --help
opus generate --concept "Your story idea"
opus serve --port 8000 # Start API server
opus docs # Show documentation
# Or use as API client:
opus --api-url http://localhost:8000 generate --concept "..."
Local mode (default): Runs generation locally using LangGraph/CrewAI
API mode: Sends requests to Opus API server
Usage:
opus [GLOBAL_OPTIONS] <command> [OPTIONS]
Examples:
# Local generation
opus generate --concept "A robot dreams of love"
# API client mode
opus --api-url http://localhost:8000 generate --concept "..."
opus --api-url https://opus-api.example.com generate --repo owner/repo
"""
import argparse
import asyncio
import os
import sys
import json
import requests
from pathlib import Path
# Add the project root to the path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
# Load environment variables
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
# =============================================================================
# API CLIENT
# =============================================================================
class OpusAPIClient:
"""Client for Opus REST API."""
def __init__(self, base_url: str):
"""Initialize API client.
Args:
base_url: Base URL of Opus API server
"""
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def health(self) -> dict:
"""Check API health."""
return self._get("/health")
def frameworks(self) -> dict:
"""List available frameworks."""
return self._get("/frameworks")
def generate(
self,
concept: str = None,
repo: str = None,
framework: str = "snowflake",
genre: str = "fiction",
book_type: str = "fiction",
target_word_count: int = 5000,
chapters: int = 3,
tone: str = "literary",
use_crewai: bool = False,
use_autogen: bool = True,
# Nonfiction options
purpose: str = None,
category: str = None,
) -> dict:
"""Generate a manuscript.
Args:
concept: Seed concept
repo: GitHub repo to ingest
framework: Story framework
genre: Genre
book_type: Book type
target_word_count: Target word count
chapters: Number of chapters
tone: Writing tone
use_crewai: Use CrewAI
use_autogen: Use AutoGen critique
Returns:
Generation result dict
"""
payload = {
"framework": framework,
"genre": genre,
"book_type": book_type,
"target_word_count": target_word_count,
"chapters": chapters,
"tone": tone,
"use_crewai": use_crewai,
"purpose": purpose,
"category": category,
}
if concept:
payload["concept"] = concept
if repo:
payload["repo"] = repo
return self._post("/generate", payload)
def ingest(self, repo: str, include_readme: bool = True) -> dict:
"""Ingest from GitHub.
Args:
repo: GitHub repo (owner/repo)
include_readme: Include README files
Returns:
Ingested content
"""
return self._post("/ingest", {
"repo": repo,
"include_readme": include_readme,
})
def _get(self, endpoint: str) -> dict:
"""GET request."""
url = f"{self.base_url}{endpoint}"
response = self.session.get(url)
response.raise_for_status()
return response.json()
def _post(self, endpoint: str, data: dict) -> dict:
"""POST request."""
url = f"{self.base_url}{endpoint}"
response = self.session.post(url, json=data)
response.raise_for_status()
return response.json()
def get_api_client(api_url: str = None) -> OpusAPIClient | None:
"""Get API client if URL provided.
Args:
api_url: API base URL
Returns:
OpusAPIClient or None
"""
if api_url:
return OpusAPIClient(api_url)
return None
def setup_cli() -> argparse.ArgumentParser:
"""Set up the CLI argument parser."""
parser = argparse.ArgumentParser(
prog="opus",
description="""Opus Orchestrator AI - Full-flow AI book generation
A comprehensive book generation system using LangGraph, CrewAI, AutoGen,
and PydanticAI for professional manuscript production.
Examples:
opus generate --concept "A robot dreams of love" --framework snowflake
opus serve --port 8080
opus --api-url http://localhost:8000 generate --concept "..."
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version", "-v",
action="version",
version="Opus Orchestrator AI v0.2.0",
)
# Global option for API client mode
parser.add_argument(
"--api-url",
help="Use API server at this URL (client mode). Without this, runs locally.",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# -------------------------------------------------------------------------
# GENERATE COMMAND
# -------------------------------------------------------------------------
gen_parser = subparsers.add_parser(
"generate",
help="Generate a book/manuscript",
description="Generate a complete manuscript from a concept or GitHub repo",
)
gen_parser.add_argument(
"--concept", "-c",
help="Seed concept or story idea",
)
gen_parser.add_argument(
"--repo", "-r",
help="GitHub repo to ingest (owner/repo format)",
)
gen_parser.add_argument(
"--framework", "-f",
default="snowflake",
choices=[
# Story frameworks
"snowflake", "three-act", "save-the-cat", "hero-journey",
"story-circle", "seven-point", "fichtean",
# Nonfiction frameworks (NEW!)
"technical-manual", "codebase-tour", "diataxis-tutorial",
"diataxis-howto", "diataxis-explanation", "diataxis-reference",
"api-documentation",
],
help="Framework to use (story or nonfiction)",
)
gen_parser.add_argument(
"--genre", "-g",
default="fiction",
help="Genre (fiction, nonfiction, sci-fi, fantasy, romance, etc.)",
)
gen_parser.add_argument(
"--type", "-t",
dest="book_type",
default="fiction",
choices=["fiction", "nonfiction"],
help="Book type",
)
# Nonfiction-specific options
gen_parser.add_argument(
"--purpose",
choices=["learn", "understand", "transform", "decide", "reference", "inspire"],
help="Reader purpose (nonfiction): learn (hands-on), understand (concepts), transform (change), decide (choose), reference (manual), inspire (motivation)",
)
gen_parser.add_argument(
"--category",
choices=["business", "leadership", "entrepreneurship", "self_help", "memoir", "philosophy", "science", "history", "technology", "finance", "health", "relationships", "creativity", "spirituality", "how_to"],
help="Nonfiction category (optional): business, leadership, memoir, etc.",
)
gen_parser.add_argument(
"--words", "-w",
type=int,
default=5000,
help="Target word count (default: 5000)",
)
gen_parser.add_argument(
"--chapters", "-n",
type=int,
default=3,
help="Number of chapters (default: 3)",
)
gen_parser.add_argument(
"--tone",
default="literary",
help="Writing tone (default: literary)",
)
gen_parser.add_argument(
"--output", "-o",
help="Output file path (local)",
)
gen_parser.add_argument(
"--save-s3",
help="Save to S3 bucket (bucket/path format)",
)
gen_parser.add_argument(
"--save-s3-endpoint",
help="S3 endpoint URL (for MinIO, DO Spaces, etc.)",
)
gen_parser.add_argument(
"--save-repo",
help="Save to GitHub repo (owner/repo format)",
)
gen_parser.add_argument(
"--save-branch",
default="main",
help="GitHub branch to commit to (default: main)",
)
gen_parser.add_argument(
"--save-commit-msg",
help="Commit message for GitHub save",
)
gen_parser.add_argument(
"--local",
"-l",
help="Local file or directory to use as source",
)
gen_parser.add_argument(
"--use-crewai",
action="store_true",
help="Use CrewAI crews instead of LangGraph",
)
gen_parser.add_argument(
"--no-autogen",
action="store_true",
help="Disable AutoGen critique",
)
gen_parser.add_argument(
"--thread-id",
type=str,
default=None,
help="Thread ID for checkpointing/resume (if resuming, use same ID)",
)
gen_parser.add_argument(
"--resume",
action="store_true",
help="Resume from last checkpoint using --thread-id",
)
gen_parser.add_argument(
"--verbose", "-V",
action="store_true",
help="Enable verbose output",
)
# -------------------------------------------------------------------------
# SERVE COMMAND (OpenAPI Server)
# -------------------------------------------------------------------------
serve_parser = subparsers.add_parser(
"serve",
help="Start OpenAPI REST server",
description="Start a REST API server with OpenAPI documentation",
)
serve_parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
serve_parser.add_argument(
"--port", "-p",
type=int,
default=8000,
help="Port to bind to (default: 8000)",
)
serve_parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload on code changes",
)
# -------------------------------------------------------------------------
# UI COMMAND (Web Interface)
# -------------------------------------------------------------------------
ui_parser = subparsers.add_parser(
"ui",
help="Start web UI only (no API)",
description="Start the novice-friendly web interface",
)
ui_parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
ui_parser.add_argument(
"--port", "-p",
type=int,
default=8080,
help="Port to bind to (default: 8080)",
)
# -------------------------------------------------------------------------
# INGEST COMMAND (GitHub)
# -------------------------------------------------------------------------
ingest_parser = subparsers.add_parser(
"ingest",
help="Ingest content from GitHub",
description="Fetch and analyze content from a GitHub repository",
)
ingest_parser.add_argument(
"--repo", "-r",
required=True,
help="GitHub repo (owner/repo format)",
)
ingest_parser.add_argument(
"--output", "-o",
help="Output file for ingested content",
)
ingest_parser.add_argument(
"--include-readme",
action="store_true",
default=True,
help="Include README files (default: True)",
)
ingest_parser.add_argument(
"--preview",
action="store_true",
help="Show preview of ingested content",
)
# -------------------------------------------------------------------------
# INGEST-S3 COMMAND
# -------------------------------------------------------------------------
s3_parser = subparsers.add_parser(
"ingest-s3",
help="Ingest content from S3/MinIO",
description="Fetch and analyze content from S3-compatible storage",
)
s3_parser.add_argument(
"--bucket", "-b",
required=True,
help="S3 bucket name",
)
s3_parser.add_argument(
"--prefix", "-p",
default="",
help="Object key prefix",
)
s3_parser.add_argument(
"--endpoint", "-e",
help="S3 endpoint URL (for MinIO, DO Spaces, etc.)",
)
s3_parser.add_argument(
"--output", "-o",
help="Output file for ingested content",
)
s3_parser.add_argument(
"--preview",
action="store_true",
help="Show preview of ingested content",
)
s3_parser.add_argument(
"--list-objects",
action="store_true",
help="List objects instead of downloading",
)
# -------------------------------------------------------------------------
# INGEST-LOCAL COMMAND
# -------------------------------------------------------------------------
local_parser = subparsers.add_parser(
"ingest-local",
help="Ingest content from local files/directories",
description="Fetch and analyze content from local files and directories",
)
local_parser.add_argument(
"path",
help="File or directory path to ingest",
)
local_parser.add_argument(
"--extensions", "-e",
help="Comma-separated file extensions (default: txt,md,markdown,notes,draft)",
)
local_parser.add_argument(
"--no-recursive",
action="store_true",
help="Don't scan subdirectories",
)
local_parser.add_argument(
"--output", "-o",
help="Output file for ingested content",
)
local_parser.add_argument(
"--preview",
action="store_true",
help="Show preview of ingested content",
)
local_parser.add_argument(
"--summarize",
action="store_true",
help="Summarize content instead of full ingest",
)
local_parser.add_argument(
"--max-length",
type=int,
default=10000,
help="Max length for summary (default: 10000)",
)
# -------------------------------------------------------------------------
# FRAMEWORKS COMMAND
# -------------------------------------------------------------------------
subparsers.add_parser(
"frameworks",
help="List available story frameworks",
description="Show all available story frameworks with descriptions",
)
# -------------------------------------------------------------------------
# CONFIG COMMAND
# -------------------------------------------------------------------------
config_parser = subparsers.add_parser(
"config",
help="Show configuration",
description="Display current configuration settings",
)
config_parser.add_argument(
"--show-keys",
action="store_true",
help="Show API keys (masked)",
)
config_parser.add_argument(
"--env",
action="store_true",
help="Show environment variables needed",
)
# -------------------------------------------------------------------------
# DOCS COMMAND
# -------------------------------------------------------------------------
docs_parser = subparsers.add_parser(
"docs",
help="Show documentation",
description="Display comprehensive documentation",
)
docs_parser.add_argument(
"--format", "-f",
choices=["terminal", "markdown", "html"],
default="terminal",
help="Output format (default: terminal)",
)
docs_parser.add_argument(
"--output", "-o",
help="Output file path",
)
# -------------------------------------------------------------------------
# API COMMAND
# -------------------------------------------------------------------------
api_parser = subparsers.add_parser(
"api",
help="Show OpenAPI specification",
description="Display or export OpenAPI schema",
)
api_parser.add_argument(
"--format", "-f",
choices=["json", "yaml"],
default="yaml",
help="Output format (default: yaml)",
)
api_parser.add_argument(
"--output", "-o",
help="Output file path",
)
return parser
async def run_generate(args: argparse.Namespace) -> int:
"""Run the generation command."""
from opus_orchestrator import run_opus, OpusOrchestrator
from opus_orchestrator.crews import create_fiction_crew, create_nonfiction_crew
print(f"
{'='*60}")
print("📚 OPUS ORCHESTRATOR AI")
print(f"{'='*60}
")
# Check for API client mode
if args.api_url:
client = OpusAPIClient(args.api_url)
print(f"🌐 API Client Mode")
print(f" Server: {args.api_url}
")
# Call API
try:
result = client.generate(
concept=args.concept,
repo=args.repo,
framework=args.framework,
genre=args.genre,
book_type=args.book_type,
target_word_count=args.words,
chapters=args.chapters,
tone=args.tone,
use_crewai=args.use_crewai,
use_autogen=not args.no_autogen,
purpose=args.purpose,
category=args.category,
)
print(f"✅ Generation complete!")
print(f" Words: {result.get('word_count', 'N/A'):,}")
print(f" Chapters: {result.get('chapters', 'N/A')}")
print(f" Framework: {result.get('framework', 'N/A')}
")
manuscript = result.get("manuscript", "")
except Exception as e:
print(f"❌ API Error: {e}")
return 1
else:
# LOCAL MODE - run locally
# Determine the seed concept
seed_concept = args.concept
if args.local:
# Ingest from local files/directory
from opus_orchestrator import LocalIngestor
print(f"📂 Ingesting from local: {args.local}")
ingestor = LocalIngestor()
result = ingestor.ingest(args.local)
full_text = result["combined_text"]
print(f" ✅ Loaded {len(full_text):,} characters from {result['file_count']} files")
print(f" 📄 Files: {', '.join(list(result['files'].keys())[:5])}")
if result['file_count'] > 5:
print(f" ... and {result['file_count'] - 5} more")
print()
seed_concept = full_text
elif args.repo:
# Ingest from GitHub - use FULL content
print(f"📥 Ingesting from GitHub: {args.repo}")
orch = OpusOrchestrator(
book_type=args.book_type,
genre=args.genre,
target_word_count=args.words,
framework=args.framework,
)
content = orch.ingest_from_github(args.repo)
# Use full content as seed
full_text = content.text
print(f" ✅ Loaded {len(full_text):,} characters from {content.metadata['file_count']} files")
print(f" 📄 Files: {', '.join(content.metadata['files'])}
")
seed_concept = full_text
if not seed_concept:
print("Error: Please provide --concept or --repo")
return 1
# Show generation parameters
print(f"🏠 Local Mode")
print(f"🎯 Generating {args.words:,} words ({args.chapters} chapters)")
print(f" Framework: {args.framework}")
print(f" Genre: {args.genre}")
print(f" Type: {args.book_type}")
print(f" Tone: {args.tone}")
print(f" CrewAI: {args.use_crewai}")
print(f" AutoGen: {not args.no_autogen}")
print()
use_autogen = not args.no_autogen
# Check for nonfiction frameworks (NEW!)
nonfiction_frameworks = [
"technical-manual", "codebase-tour", "diataxis-tutorial",
"diataxis-howto", "diataxis-explanation", "diataxis-reference",
"api-documentation",
]
if args.framework in nonfiction_frameworks:
# Use NonfictionGenerator for rigorous technical frameworks
from opus_orchestrator.nonfiction_generator import NonfictionGenerator
from opus_orchestrator.nonfiction_frameworks import NonfictionFramework
print("📚 Using Nonfiction Framework...
")
# Map framework string to enum
framework_map = {
"technical-manual": NonfictionFramework.TECHNICAL_MANUAL,
"codebase-tour": NonfictionFramework.CODEBASE_TOUR,
"diataxis-tutorial": NonfictionFramework.DIAXIS_TUTORIAL,
"diataxis-howto": NonfictionFramework.DIAXIS_HOWTO,
"diataxis-explanation": NonfictionFramework.DIAXIS_EXPLANATION,
"diataxis-reference": NonfictionFramework.DIAXIS_REFERENCE,
"api-documentation": NonfictionFramework.API_DOCUMENTATION,
}
nf_framework = framework_map.get(args.framework, NonfictionFramework.TECHNICAL_MANUAL)
gen = NonfictionGenerator(
framework=nf_framework,
topic=args.concept or "Technical Topic",
source_content=seed_concept[:50000], # Limit for context
)
manuscript = gen.generate(target_word_count=args.words)
print(f" ✅ Generated {len(manuscript.split())} words using {nf_framework.value}")
elif args.use_crewai:
# Use CrewAI crews
print("🛠️ Using CrewAI crews...
")
if args.book_type == "fiction":
crew = create_fiction_crew(
genre=args.genre,
tone=args.tone,
target_word_count=args.words // args.chapters,
)
story = crew.write_full_story(
story_outline=seed_concept[:10000], # Limit for crew context
character_sheets="",
style_guide=f"Tone: {args.tone}",
num_chapters=args.chapters,
)
manuscript = "
---
".join(story)
else:
crew = create_nonfiction_crew(
topic=args.genre,
tone=args.tone,
target_word_count=args.words,
)
manuscript = crew.write_section(
section_outline=seed_concept[:10000],
style_guide=f"Tone: {args.tone}",
)
else:
# Use LangGraph pipeline
# Generate thread_id if not provided
import uuid
thread_id = args.thread_id or str(uuid.uuid4())
print(f"🧵 Thread ID: {thread_id}")
if args.resume:
print(f" ↪️ Resuming from checkpoint
")
else:
print()
result = run_opus(
seed_concept=seed_concept,
framework=args.framework,
genre=args.genre,
target_word_count=args.words,
thread_id=thread_id,
)
manuscript = result.get("manuscript", str(result))
# Build full manuscript content with metadata
manuscript_content = f"""# Opus Generated Manuscript
Framework: {args.framework}
Genre: {args.genre}
Type: {args.book_type}
Chapters: {args.chapters}
Target Words: {args.words:,}
---
{manuscript}
"""
word_count = len(manuscript.split())
# Save to local file
output_path = args.output
if output_path or args.save_s3 or args.save_repo:
# Determine filename
if not output_path:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = f"opus_manuscript_{timestamp}.md"
with open(output_path, "w") as f:
f.write(manuscript_content)
print(f" 💾 Saved locally: {output_path}")
# Save to S3
if args.save_s3:
from opus_orchestrator import S3Ingestor
bucket, key = args.save_s3.split("/", 1) if "/" in args.save_s3 else (args.save_s3, "")
if not key:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
key = f"manuscripts/opus_{timestamp}.md"
print(f" 🪣 Saving to S3: {bucket}/{key}")
s3 = S3Ingestor(endpoint_url=args.save_s3_endpoint, bucket=bucket)
# Upload the manuscript content
import io
from botocore.config import Config
s3.s3_client.put_object(
Bucket=bucket,
Key=key,
Body=manuscript_content.encode("utf-8"),
ContentType="text/markdown",
)
print(f" ✅ Uploaded to S3: s3://{bucket}/{key}")
# Save to GitHub repo
if args.save_repo:
import requests
print(f" 📤 Saving to GitHub: {args.save_repo}")
github_token = os.environ.get("GITHUB_TOKEN")
if not github_token:
print(" ⚠️ GITHUB_TOKEN not set, cannot save to repo")
else:
# Parse owner/repo
owner, repo = args.save_repo.split("/")
# Create filename
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"manuscript_{timestamp}.md"
# Get current branch
branch = args.save_branch
# Create/update file via GitHub API
import base64
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/manuscripts/{filename}"
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json",
}
# Check if file exists to get SHA
existing = requests.get(api_url, headers=headers)
sha = existing.json().get("sha") if existing.status_code == 200 else None
# Create content
content_b64 = base64.b64encode(manuscript_content.encode("utf-8")).decode("utf-8")
data = {
"message": args.save_commit_msg or f"Add generated manuscript: {filename}",
"content": content_b64,
"branch": branch,
}
if sha:
data["sha"] = sha
resp = requests.put(api_url, headers=headers, json=data)
if resp.status_code in [200, 201]:
print(f" ✅ Committed to GitHub: {args.save_repo}/manuscripts/{filename}")
else:
print(f" ⚠️ GitHub save failed: {resp.status_code} - {resp.text}")
print(f"
{'='*60}")
print(f"✅ COMPLETE!")
print(f" Words: {word_count:,}")
if not args.output and not args.save_s3 and not args.save_repo:
print(f" Output: {output_path}")
print(f"{'='*60}
")
return 0
async def run_serve(args: argparse.Namespace) -> int:
"""Start the OpenAPI server."""
print(f"
🚀 Starting Opus API Server...")
print(f" Host: {args.host}")
print(f" Port: {args.port}")
print(f" Docs: http://{args.host}:{args.port}/docs
")
try:
from opus_orchestrator.server import run_server
await run_server(host=args.host, port=args.port, reload=args.reload)
except ImportError:
print("Error: Run `pip install fastapi uvicorn` to enable API server")
return 1
return 0
async def run_ui(args: argparse.Namespace) -> int:
"""Start the web UI only."""
print(f"
🎨 Starting Opus Web UI...")
print(f" Host: {args.host}")
print(f" Port: {args.port}")
print(f" UI: http://{args.host}:{args.port}/
")
try:
from opus_orchestrator.server import create_app
import uvicorn
app = create_app(include_ui=True)
config = uvicorn.Config(app, host=args.host, port=args.port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
except ImportError as e:
print(f"Error: {e}")
print("Run `pip install fastapi uvicorn` to enable web UI")
return 1
return 0
def run_ingest(args: argparse.Namespace) -> int:
"""Ingest content from GitHub."""
from opus_orchestrator import OpusOrchestrator
print(f"
📥 Ingesting from GitHub: {args.repo}
")
# Check for API client mode
if args.api_url:
client = OpusAPIClient(args.api_url)
print(f"🌐 API Client Mode: {args.api_url}
")
try:
result = client.ingest(args.repo, include_readme=args.include_readme)
content_text = result.get("content", "")
file_count = result.get("file_count", 0)
files = result.get("files", [])
except Exception as e:
print(f"❌ API Error: {e}")
return 1
else:
# Local mode
orch = OpusOrchestrator(book_type="fiction")
content = orch.ingest_from_github(args.repo, include_readme=args.include_readme)
content_text = content.text
file_count = content.metadata["file_count"]
files = content.metadata["files"]
print(f"✅ Loaded {len(content_text):,} characters")
print(f" Files: {file_count}")
print(f" File list: {', '.join(files)}
")
if args.preview:
print("📄 PREVIEW (first 2000 chars):")
print("-" * 40)
print(content_text[:2000])
print("-" * 40)
if args.output:
with open(args.output, "w") as f:
f.write(content_text)
print(f"
💾 Saved to: {args.output}")
return 0
def run_s3_ingest(args: argparse.Namespace) -> int:
"""Ingest content from S3/MinIO."""
from opus_orchestrator import S3Ingestor
print(f"
🪣 Ingesting from S3: {args.bucket}/{args.prefix}
")
if args.endpoint:
print(f" Endpoint: {args.endpoint}")
ingestor = S3Ingestor(
endpoint_url=args.endpoint,
bucket=args.bucket,
)
if args.list_objects:
# Just list objects
objects = ingestor.list_objects(bucket=args.bucket, prefix=args.prefix)
print(f"📦 Objects ({len(objects)}):")
for obj in objects[:20]:
print(f" {obj['key']} ({obj['size']:,} bytes)")
if len(objects) > 20:
print(f" ... and {len(objects) - 20} more")
return 0
# Ingest content
result = ingestor.ingest_bucket(
bucket=args.bucket,
prefix=args.prefix,
)
print(f"✅ Loaded {result['total_chars']:,} characters")
print(f" Files: {result['file_count']}")
print(f" File list: {', '.join(result['files'].keys())}
")
if args.preview:
print("📄 PREVIEW (first 2000 chars):")
print("-" * 40)
print(result["combined_text"][:2000])
print("-" * 40)
if args.output:
with open(args.output, "w") as f:
f.write(result["combined_text"])
print(f"
💾 Saved to: {args.output}")
return 0
def run_local_ingest(args: argparse.Namespace) -> int:
"""Ingest content from local files/directories."""
from opus_orchestrator import LocalIngestor
print(f"
📂 Ingesting from local: {args.path}
")
# Parse extensions
extensions = None
if args.extensions:
extensions = [ext.strip() for ext in args.extensions.split(",")]
# Create ingestor
ingestor = LocalIngestor()
# Ingest
result = ingestor.ingest(
path=args.path,
extensions=extensions,
recursive=not args.no_recursive,
)
if args.summarize:
content = ingestor.summarize(result["combined_text"], args.max_length)
else:
content = result["combined_text"]
print(f"✅ Loaded {result['total_chars']:,} characters")
print(f" Files: {result['file_count']}")
print(f" Root: {result['path']}")
files_list = list(result["files"].keys())
print(f" File list: {', '.join(files_list[:10])}")
if len(files_list) > 10:
print(f" ... and {len(files_list) - 10} more")
if args.summarize:
print(f" 📝 Summarized to {args.max_length} characters")
print()
if args.preview:
print("📄 PREVIEW (first 2000 chars):")
print("-" * 40)
print(content[:2000])
print("-" * 40)
if args.output:
with open(args.output, "w") as f:
f.write(content)
print(f"
💾 Saved to: {args.output}")
return 0
def run_frameworks(args: argparse.Namespace) -> int:
"""List available frameworks."""
from opus_orchestrator.frameworks import FRAMEWORKS
print("
📚 AVAILABLE STORY FRAMEWORKS
")
print("=" * 50)
for framework, info in FRAMEWORKS.items():
name = info.get("name", framework.value if hasattr(framework, "value") else str(framework))
desc = info.get("description", "")
stages = info.get("stages", [])
beats = info.get("beats", [])
print(f"
{name}")
print(f" {desc}")
if stages:
print(f" Stages: {len(stages)}")
for i, stage in enumerate(stages[:3], 1):
print(f" {i}. {stage}")
if len(stages) > 3:
print(f" ... and {len(stages) - 3} more")
if beats:
print(f" Beats: {len(beats)}")
for beat in beats[:3]:
print(f"{beat}")
if len(beats) > 3:
print(f" ... and {len(beats) - 3} more")
print("
" + "=" * 50)
return 0
def run_config(args: argparse.Namespace) -> int:
"""Show configuration."""
from opus_orchestrator.config import get_config
config = get_config()
print("
OPUS CONFIGURATION
")
print("=" * 40)
print(f"
🔹 Agent")
print(f" Provider: {config.agent.provider}")
print(f" Model: {config.agent.model}")
print(f" Temperature: {config.agent.temperature}")
print(f" Max Tokens: {config.agent.max_tokens or 'None'}")
print(f"
🔹 Iteration")
print(f" Min Critic Rounds: {config.iteration.min_critic_rounds}")
print(f" Max Critic Rounds: {config.iteration.max_critic_rounds}")
print(f" Approval Threshold: {config.iteration.approval_threshold}")
print(f"
🔹 Output")
print(f" Format: {config.output.format}")
print(f" Include TOC: {config.output.include_toc}")
print(f" Output Dir: {config.output.output_dir}")
print(f"
🔹 Integrations")
print(f" GitHub Token: {'✓ Set' if config.github_token else '✗ Not Set'}")
print(f" API Key: {'✓ Set' if config.agent.api_key else '✗ Not Set'}")
if args.show_keys:
print(f"
🔹 API Keys (unmasked)")
print(f" OPENAI_API_KEY: {os.environ.get('OPENAI_API_KEY', 'Not Set')[:20]}...")
print(f" MINIMAX_API_KEY: {os.environ.get('MINIMAX_API_KEY', 'Not Set')[:20]}...")
print(f" GITHUB_TOKEN: {os.environ.get('GITHUB_TOKEN', 'Not Set')[:20]}...")
if args.env:
print(f"
📋 ENVIRONMENT VARIABLES NEEDED:")
print("-" * 40)
print("OPENAI_API_KEY=sk-... # Required for LLM")
print("GITHUB_TOKEN=ghp_... # For private repos")
print("MINIMAX_API_KEY=sk-... # Optional alternative")
print()
return 0
def run_docs(args: argparse.Namespace) -> int:
"""Show documentation."""
from opus_orchestrator.utils.docs import generate_docs
docs = generate_docs(format=args.format)
if args.output:
with open(args.output, "w") as f:
f.write(docs)
print(f"📄 Documentation saved to: {args.output}")
else:
print(docs)
return 0
def run_api(args: argparse.Namespace) -> int:
"""Show OpenAPI spec."""
from opus_orchestrator.server import get_openapi_spec
spec = get_openapi_spec(format=args.format)
if args.output:
with open(args.output, "w") as f:
f.write(spec)
print(f"📄 OpenAPI spec saved to: {args.output}")
else:
print(spec)
return 0
async def main_async(args: argparse.Namespace) -> int:
"""Async main function."""
commands = {
"generate": run_generate,
"serve": run_serve,
"ui": run_ui,
"ingest": run_ingest,
"ingest-s3": run_s3_ingest,
"ingest-local": run_local_ingest,
"frameworks": run_frameworks,
"config": run_config,
"docs": run_docs,
"api": run_api,
}
if args.command in commands:
if args.command in ["generate", "serve", "ui"]:
return await commands[args.command](args)
else:
return commands[args.command](args)
else:
# No command given, show help
args.parser.print_help()
return 0
def main():
"""Main entry point."""
parser = setup_cli()
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
return asyncio.run(main_async(args))
if __name__ == "__main__":
sys.exit(main())