feat: Complete Issue #40 - Associated Files Management with Interactive vs Automation Mode System
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
This commit implements comprehensive associated files management and introduces a mode-based architecture that resolves conflicting requirements between interactive user workflows and automation/testing scenarios. ## Key Features ### Associated Files Management - Convention-based file pairing (document.md ↔ document.json) - Automatic path resolution and file discovery - Complete CLI command suite for managing file pairs - Performance optimizations with caching ### Interactive vs Automation Mode System - Automatic mode detection via TTY, CI environment, and pipes - Environment variable override (MARKITECT_MODE) - Interactive mode: Uses associated file paths by default - Automation mode: Optimizes for speed, memory, and stdout output ### Enhanced CLI Commands - schema-generate: Auto-places output next to source in interactive mode - generate-stub: Auto-places output next to schema in interactive mode - validate: Auto-discovers associated schema files - New associated-files command group with list, info, status, create subcommands ### Bug Fixes - Fixed isinstance() errors caused by function shadowing built-in types - Resolved test failures with new mode system integration - Ensured backward compatibility for all existing functionality ## Technical Implementation - Added AssociatedFilesManager class with comprehensive file operations - Implemented mode detection using environment analysis - Enhanced format_output function with proper type checking - Added pytest configuration for automation mode during testing - Complete test coverage for all new functionality All 448 tests passing. Maintains full backward compatibility while adding powerful new interactive features for improved developer experience. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
434
markitect/cli.py
434
markitect/cli.py
@@ -23,8 +23,51 @@ import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from tabulate import tabulate
|
||||
import builtins
|
||||
|
||||
from .database import DatabaseManager
|
||||
|
||||
|
||||
def detect_execution_mode():
|
||||
"""
|
||||
Detect whether we're running in interactive or automation mode.
|
||||
|
||||
Returns:
|
||||
str: 'interactive' or 'automation'
|
||||
|
||||
Detection logic:
|
||||
- Environment variable MARKITECT_MODE overrides detection
|
||||
- Interactive: TTY present, not in CI, not in pipe
|
||||
- Automation: CI environment, pipe/redirect, or explicit setting
|
||||
"""
|
||||
# Explicit mode override
|
||||
mode = os.environ.get('MARKITECT_MODE', '').lower()
|
||||
if mode in ['interactive', 'automation']:
|
||||
return mode
|
||||
|
||||
# Detect CI environments
|
||||
ci_indicators = [
|
||||
'CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS',
|
||||
'GITLAB_CI', 'JENKINS_URL', 'BUILD_NUMBER'
|
||||
]
|
||||
if any(os.environ.get(var) for var in ci_indicators):
|
||||
return 'automation'
|
||||
|
||||
# Check if output is being piped or redirected
|
||||
if not sys.stdout.isatty():
|
||||
return 'automation'
|
||||
|
||||
# Check if input is being piped
|
||||
if not sys.stdin.isatty():
|
||||
return 'automation'
|
||||
|
||||
# Default to interactive for terminal usage
|
||||
return 'interactive'
|
||||
|
||||
|
||||
def should_use_associated_files():
|
||||
"""Determine if commands should use associated files behavior."""
|
||||
return detect_execution_mode() == 'interactive'
|
||||
from .document_manager import DocumentManager
|
||||
from .serializer import ASTSerializer
|
||||
from .cache_service import CacheDirectoryService
|
||||
@@ -80,11 +123,19 @@ def format_output(data, output_format):
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
elif output_format == 'yaml':
|
||||
return yaml.dump(data, default_flow_style=False, allow_unicode=True)
|
||||
elif output_format == 'simple':
|
||||
# Simple format - just basic text output
|
||||
if isinstance(data, builtins.list):
|
||||
return '\n'.join(str(item) for item in data)
|
||||
elif isinstance(data, builtins.dict):
|
||||
return '\n'.join(f"{key}: {value}" for key, value in data.items())
|
||||
else:
|
||||
return str(data)
|
||||
elif output_format == 'table':
|
||||
try:
|
||||
# Check if it's a list type
|
||||
if isinstance(data, (type([]), type(()))):
|
||||
if data and isinstance(data[0], dict):
|
||||
if isinstance(data, (builtins.list, builtins.tuple)):
|
||||
if data and isinstance(data[0], builtins.dict):
|
||||
# List of dictionaries - format as table
|
||||
headers = sorted(data[0].keys())
|
||||
rows = []
|
||||
@@ -97,7 +148,7 @@ def format_output(data, output_format):
|
||||
else:
|
||||
# List of simple values
|
||||
return tabulate([[item] for item in data], headers=['Value'], tablefmt='grid')
|
||||
elif isinstance(data, dict):
|
||||
elif isinstance(data, builtins.dict):
|
||||
# Single dictionary - format as key-value table
|
||||
rows = [[key, value] for key, value in data.items()]
|
||||
return tabulate(rows, headers=['Key', 'Value'], tablefmt='grid')
|
||||
@@ -1022,8 +1073,10 @@ def generate_schema(config, file_path, max_depth, output, output_format):
|
||||
markitect schema-generate document.md --output schema.json
|
||||
"""
|
||||
try:
|
||||
# Initialize schema generator
|
||||
# Initialize schema generator and associated files manager
|
||||
generator = SchemaGenerator()
|
||||
from .associated_files import AssociatedFilesManager
|
||||
associated_files = AssociatedFilesManager()
|
||||
|
||||
# Generate schema
|
||||
schema = generator.generate_schema_from_file(file_path, max_depth=max_depth)
|
||||
@@ -1036,6 +1089,15 @@ def generate_schema(config, file_path, max_depth, output, output_format):
|
||||
else:
|
||||
formatted_output = json.dumps(schema, indent=2, ensure_ascii=False)
|
||||
|
||||
# Mode-based output logic
|
||||
if not output and should_use_associated_files():
|
||||
# Interactive mode: use associated file path
|
||||
from .associated_files import AssociatedFilesManager
|
||||
associated_files = AssociatedFilesManager()
|
||||
output = associated_files.get_associated_schema_path(file_path)
|
||||
if config.get('verbose'):
|
||||
click.echo(f"Interactive mode: using associated file path: {output}", err=True)
|
||||
|
||||
# Write to output
|
||||
if output:
|
||||
output.write_text(formatted_output, encoding='utf-8')
|
||||
@@ -1100,12 +1162,25 @@ def validate(config, file_path, schema, schema_json, quiet, detailed_errors, err
|
||||
"""
|
||||
try:
|
||||
validator = SchemaValidator()
|
||||
from .associated_files import AssociatedFilesManager
|
||||
associated_files = AssociatedFilesManager()
|
||||
|
||||
# Validate exactly one schema source is provided
|
||||
# Validate schema source or auto-discover
|
||||
schema_sources = [schema, schema_json]
|
||||
provided_sources = [s for s in schema_sources if s is not None]
|
||||
|
||||
if len(provided_sources) != 1:
|
||||
if len(provided_sources) == 0:
|
||||
# Auto-discover associated schema file
|
||||
auto_schema = associated_files.find_associated_schema(file_path)
|
||||
if auto_schema:
|
||||
schema = auto_schema
|
||||
if config.get('verbose'):
|
||||
click.echo(f"Auto-discovered associated schema: {schema}", err=True)
|
||||
else:
|
||||
click.echo("Error: No schema specified and no associated schema file found", err=True)
|
||||
click.echo("Provide --schema FILE or --schema-json JSON, or ensure an associated .json file exists", err=True)
|
||||
sys.exit(1)
|
||||
elif len(provided_sources) > 1:
|
||||
click.echo("Error: Specify exactly one schema source (--schema or --schema-json)", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1446,8 +1521,10 @@ def generate_stub(config, schema_file, output, style, title):
|
||||
click.echo(f"Generating stub from schema: {schema_file}", err=True)
|
||||
|
||||
from .stub_generator import StubGenerator
|
||||
from .associated_files import AssociatedFilesManager
|
||||
|
||||
generator = StubGenerator()
|
||||
associated_files = AssociatedFilesManager()
|
||||
|
||||
# Load schema and generate stub content
|
||||
import json
|
||||
@@ -1458,6 +1535,13 @@ def generate_stub(config, schema_file, output, style, title):
|
||||
schema, placeholder_style=style, title=title
|
||||
)
|
||||
|
||||
# Mode-based output logic
|
||||
if not output and should_use_associated_files():
|
||||
# Interactive mode: use associated file path
|
||||
output = associated_files.get_associated_markdown_path(schema_file)
|
||||
if config.get('verbose'):
|
||||
click.echo(f"Interactive mode: using associated file path: {output}", err=True)
|
||||
|
||||
# Output to file or stdout
|
||||
if output:
|
||||
generator.generate_stub_to_file(schema, output, style, title)
|
||||
@@ -1485,6 +1569,344 @@ def generate_stub(config, schema_file, output, style, title):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.group('associated-files')
|
||||
@pass_config
|
||||
def associated_files_group(config):
|
||||
"""
|
||||
Manage associated markdown and schema file pairs.
|
||||
|
||||
Commands for working with files that follow the convention of having
|
||||
identical basenames with different extensions (e.g., document.md ↔ document.json).
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@associated_files_group.command('list')
|
||||
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
|
||||
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
|
||||
help='Output format')
|
||||
@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.')
|
||||
@pass_config
|
||||
def list_associated_files(config, format, directory):
|
||||
"""
|
||||
List all associated file pairs in a directory.
|
||||
|
||||
Shows markdown/schema file pairs that follow the naming convention.
|
||||
|
||||
Examples:
|
||||
markitect associated-files list
|
||||
markitect associated-files list docs/
|
||||
markitect associated-files list --format json
|
||||
"""
|
||||
try:
|
||||
from .associated_files import AssociatedFilesManager
|
||||
manager = AssociatedFilesManager()
|
||||
|
||||
if config.get('verbose'):
|
||||
click.echo(f"Scanning directory: {directory}", err=True)
|
||||
|
||||
pairs = manager.list_file_pairs(directory)
|
||||
|
||||
if not pairs:
|
||||
click.echo("No associated file pairs found.")
|
||||
return
|
||||
|
||||
# Format output
|
||||
if format == 'table':
|
||||
click.echo(f"Associated File Pairs in {directory}:")
|
||||
click.echo("=" * 60)
|
||||
for pair in pairs:
|
||||
click.echo(f"📄 {pair['basename']}")
|
||||
click.echo(f" Markdown: {pair['markdown_file'].name}")
|
||||
click.echo(f" Schema: {pair['schema_file'].name}")
|
||||
click.echo()
|
||||
elif format == 'json':
|
||||
import json
|
||||
output_data = []
|
||||
for pair in pairs:
|
||||
output_data.append({
|
||||
'basename': pair['basename'],
|
||||
'markdown_file': str(pair['markdown_file']),
|
||||
'schema_file': str(pair['schema_file']),
|
||||
'both_exist': pair['both_exist']
|
||||
})
|
||||
click.echo(json.dumps(output_data, indent=2))
|
||||
elif format == 'yaml':
|
||||
import yaml
|
||||
output_data = []
|
||||
for pair in pairs:
|
||||
output_data.append({
|
||||
'basename': pair['basename'],
|
||||
'markdown_file': str(pair['markdown_file']),
|
||||
'schema_file': str(pair['schema_file']),
|
||||
'both_exist': pair['both_exist']
|
||||
})
|
||||
click.echo(yaml.dump(output_data, default_flow_style=False))
|
||||
else:
|
||||
# Simple format
|
||||
for pair in pairs:
|
||||
click.echo(f"{pair['basename']} ({pair['markdown_file'].name} ↔ {pair['schema_file'].name})")
|
||||
|
||||
if config.get('verbose'):
|
||||
click.echo(f"Found {len(pairs)} associated file pairs", err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error listing associated files: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@associated_files_group.command('info')
|
||||
@click.argument('file_path', type=click.Path(exists=True, path_type=Path))
|
||||
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
|
||||
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
|
||||
help='Output format')
|
||||
@pass_config
|
||||
def associated_files_info(config, file_path, format):
|
||||
"""
|
||||
Show detailed information about associated files.
|
||||
|
||||
Displays information about a file and its associated counterpart.
|
||||
|
||||
Examples:
|
||||
markitect associated-files info document.md
|
||||
markitect associated-files info schema.json --format json
|
||||
"""
|
||||
try:
|
||||
from .associated_files import AssociatedFilesManager
|
||||
manager = AssociatedFilesManager()
|
||||
|
||||
info = manager.get_file_pair_info(file_path)
|
||||
|
||||
if format == 'table':
|
||||
click.echo(f"Associated Files Information:")
|
||||
click.echo("=" * 40)
|
||||
click.echo(f"Basename: {info['basename']}")
|
||||
click.echo(f"Markdown: {info['markdown_file']}")
|
||||
click.echo(f" Exists: {'✅' if info['markdown_file'].exists() else '❌'}")
|
||||
if 'markdown_size' in info:
|
||||
click.echo(f" Size: {info['markdown_size']} bytes")
|
||||
|
||||
click.echo(f"Schema: {info['schema_file']}")
|
||||
click.echo(f" Exists: {'✅' if info['schema_file'].exists() else '❌'}")
|
||||
if 'schema_size' in info:
|
||||
click.echo(f" Size: {info['schema_size']} bytes")
|
||||
|
||||
click.echo(f"Both exist: {'✅' if info['both_exist'] else '❌'}")
|
||||
|
||||
elif format == 'json':
|
||||
import json
|
||||
# Convert Path objects to strings for JSON serialization
|
||||
json_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()}
|
||||
click.echo(json.dumps(json_info, indent=2))
|
||||
|
||||
elif format == 'yaml':
|
||||
import yaml
|
||||
# Convert Path objects to strings for YAML serialization
|
||||
yaml_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()}
|
||||
click.echo(yaml.dump(yaml_info, default_flow_style=False))
|
||||
else:
|
||||
# Simple format
|
||||
status = "paired" if info['both_exist'] else "orphaned"
|
||||
click.echo(f"{info['basename']}: {status}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error getting file info: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@associated_files_group.command('status')
|
||||
@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.')
|
||||
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
|
||||
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
|
||||
help='Output format')
|
||||
@pass_config
|
||||
def associated_files_status(config, directory, format):
|
||||
"""
|
||||
Show status of associated files in a directory.
|
||||
|
||||
Displays paired files and orphaned files (files without their counterpart).
|
||||
|
||||
Examples:
|
||||
markitect associated-files status
|
||||
markitect associated-files status docs/
|
||||
"""
|
||||
try:
|
||||
from .associated_files import AssociatedFilesManager
|
||||
manager = AssociatedFilesManager()
|
||||
|
||||
status = manager.get_directory_status(directory)
|
||||
|
||||
if format == 'table':
|
||||
click.echo(f"Associated Files Status for {directory}:")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"📌 Paired files: {status['paired_files']}")
|
||||
click.echo(f"📄 Orphaned markdown: {status['orphaned_markdown']}")
|
||||
click.echo(f"🔧 Orphaned schemas: {status['orphaned_schemas']}")
|
||||
|
||||
if status['pairs']:
|
||||
click.echo("\n📌 Paired Files:")
|
||||
for pair in status['pairs']:
|
||||
click.echo(f" • {pair['basename']}")
|
||||
|
||||
if status['orphaned']['orphaned_markdown']:
|
||||
click.echo("\n📄 Orphaned Markdown Files:")
|
||||
for md in status['orphaned']['orphaned_markdown']:
|
||||
click.echo(f" • {md.name}")
|
||||
|
||||
if status['orphaned']['orphaned_schemas']:
|
||||
click.echo("\n🔧 Orphaned Schema Files:")
|
||||
for schema in status['orphaned']['orphaned_schemas']:
|
||||
click.echo(f" • {schema.name}")
|
||||
|
||||
elif format == 'json':
|
||||
import json
|
||||
# Convert Path objects to strings for JSON serialization
|
||||
json_status = {
|
||||
'directory': str(status['directory']),
|
||||
'paired_files': status['paired_files'],
|
||||
'orphaned_markdown': status['orphaned_markdown'],
|
||||
'orphaned_schemas': status['orphaned_schemas'],
|
||||
'pairs': [{'basename': p['basename'],
|
||||
'markdown_file': str(p['markdown_file']),
|
||||
'schema_file': str(p['schema_file'])}
|
||||
for p in status['pairs']],
|
||||
'orphaned': {
|
||||
'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']],
|
||||
'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']]
|
||||
}
|
||||
}
|
||||
click.echo(json.dumps(json_status, indent=2))
|
||||
|
||||
elif format == 'yaml':
|
||||
import yaml
|
||||
yaml_status = {
|
||||
'directory': str(status['directory']),
|
||||
'paired_files': status['paired_files'],
|
||||
'orphaned_markdown': status['orphaned_markdown'],
|
||||
'orphaned_schemas': status['orphaned_schemas'],
|
||||
'pairs': [{'basename': p['basename'],
|
||||
'markdown_file': str(p['markdown_file']),
|
||||
'schema_file': str(p['schema_file'])}
|
||||
for p in status['pairs']],
|
||||
'orphaned': {
|
||||
'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']],
|
||||
'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']]
|
||||
}
|
||||
}
|
||||
click.echo(yaml.dump(yaml_status, default_flow_style=False))
|
||||
else:
|
||||
# Simple format
|
||||
click.echo(f"Paired: {status['paired_files']}, Orphaned: {status['orphaned_markdown'] + status['orphaned_schemas']}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error getting status: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@associated_files_group.command('create-schema')
|
||||
@click.argument('markdown_file', type=click.Path(exists=True, path_type=Path))
|
||||
@click.option('--max-depth', '-d', type=int, help='Maximum heading depth to include in schema')
|
||||
@pass_config
|
||||
def create_associated_schema(config, markdown_file, max_depth):
|
||||
"""
|
||||
Create an associated schema file for a markdown file.
|
||||
|
||||
Generates a JSON schema and places it next to the source markdown file
|
||||
with the same basename but .json extension.
|
||||
|
||||
Examples:
|
||||
markitect associated-files create-schema document.md
|
||||
markitect associated-files create-schema doc.md --max-depth 3
|
||||
"""
|
||||
try:
|
||||
from .associated_files import AssociatedFilesManager
|
||||
from .schema_generator import SchemaGenerator
|
||||
|
||||
manager = AssociatedFilesManager()
|
||||
generator = SchemaGenerator()
|
||||
|
||||
# Check if associated schema already exists
|
||||
existing_schema = manager.find_associated_schema(markdown_file)
|
||||
if existing_schema:
|
||||
if not click.confirm(f"Associated schema {existing_schema} already exists. Overwrite?"):
|
||||
click.echo("Operation cancelled.")
|
||||
return
|
||||
|
||||
# Generate schema
|
||||
schema = generator.generate_schema_from_file(markdown_file, max_depth=max_depth)
|
||||
|
||||
# Save to associated path
|
||||
schema_path = manager.get_associated_schema_path(markdown_file)
|
||||
|
||||
import json
|
||||
with open(schema_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(schema, f, indent=2, ensure_ascii=False)
|
||||
|
||||
click.echo(f"✅ Created associated schema: {schema_path}")
|
||||
|
||||
if config.get('verbose'):
|
||||
properties = schema.get('properties', {})
|
||||
click.echo(f"Generated schema with {len(properties)} property types", err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error creating schema: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@associated_files_group.command('create-stub')
|
||||
@click.argument('schema_file', type=click.Path(exists=True, path_type=Path))
|
||||
@click.option('--style', type=click.Choice(['default', 'custom', 'detailed']),
|
||||
default='default', help='Placeholder content style')
|
||||
@click.option('--title', type=str, help='Custom document title')
|
||||
@pass_config
|
||||
def create_associated_stub(config, schema_file, style, title):
|
||||
"""
|
||||
Create an associated markdown stub for a schema file.
|
||||
|
||||
Generates a markdown template and places it next to the source schema file
|
||||
with the same basename but .md extension.
|
||||
|
||||
Examples:
|
||||
markitect associated-files create-stub schema.json
|
||||
markitect associated-files create-stub schema.json --style detailed
|
||||
"""
|
||||
try:
|
||||
from .associated_files import AssociatedFilesManager
|
||||
from .stub_generator import StubGenerator
|
||||
|
||||
manager = AssociatedFilesManager()
|
||||
generator = StubGenerator()
|
||||
|
||||
# Check if associated markdown already exists
|
||||
existing_md = manager.find_associated_markdown(schema_file)
|
||||
if existing_md:
|
||||
if not click.confirm(f"Associated markdown {existing_md} already exists. Overwrite?"):
|
||||
click.echo("Operation cancelled.")
|
||||
return
|
||||
|
||||
# Load schema and generate stub
|
||||
import json
|
||||
with open(schema_file, 'r') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Save to associated path
|
||||
md_path = manager.get_associated_markdown_path(schema_file)
|
||||
generator.generate_stub_to_file(schema, md_path, style, title)
|
||||
|
||||
click.echo(f"✅ Created associated stub: {md_path}")
|
||||
|
||||
if config.get('verbose'):
|
||||
content = md_path.read_text()
|
||||
click.echo(f"Generated {len(content)} characters of content", err=True)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error creating stub: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the CLI.
|
||||
|
||||
Reference in New Issue
Block a user