aliases.py
1 #!/usr/bin/env python3 2 """ 3 Command alias parser for cspec templates. 4 Extracts executable aliases from alpha-delta-context/infra/machine/commands/*.cspec 5 Generates aliases.json for Claude reference. 6 """ 7 import os 8 import re 9 import json 10 from typing import Dict, Optional, Tuple 11 12 from config import CSPEC_COMMANDS_DIR, ALIASES_JSON_PATH 13 14 15 def parse_cspec_value(value: str) -> str: 16 """Clean a cspec value, handling multi-line strings.""" 17 value = value.strip() 18 # Remove leading pipe for multi-line 19 if value.startswith('|'): 20 value = value[1:].strip() 21 # Remove quotes 22 if (value.startswith('"') and value.endswith('"')) or \ 23 (value.startswith("'") and value.endswith("'")): 24 value = value[1:-1] 25 return value 26 27 28 def parse_cspec_file(filepath: str) -> Dict[str, dict]: 29 """ 30 Parse a cspec file and extract command definitions. 31 Returns dict of {alias_name: {command: str, placeholders: list, source: str}} 32 """ 33 aliases = {} 34 35 if not os.path.exists(filepath): 36 return aliases 37 38 with open(filepath, 'r', encoding='utf-8') as f: 39 content = f.read() 40 41 filename = os.path.basename(filepath) 42 current_section = None 43 current_key = None 44 multiline_buffer = [] 45 in_multiline = False 46 47 for line in content.split('\n'): 48 stripped = line.strip() 49 50 # Skip comments and empty lines 51 if not stripped or stripped.startswith('#'): 52 if in_multiline: 53 multiline_buffer.append(line) 54 continue 55 56 # Section header (e.g., "ssh_port:", "source_server:") 57 section_match = re.match(r'^([a-z_][a-z0-9_]*):(.*)$', stripped, re.IGNORECASE) 58 if section_match and not line.startswith(' ') and not line.startswith('\t'): 59 # Save any pending multiline 60 if in_multiline and current_section and current_key: 61 cmd = '\n'.join(multiline_buffer).strip() 62 if cmd: 63 alias_name = f"{current_section}-{current_key}".replace('_', '-') 64 placeholders = re.findall(r'\{\{(\w+)\}\}', cmd) 65 aliases[alias_name] = { 66 'command': cmd, 67 'placeholders': placeholders, 68 'source': filename 69 } 70 71 current_section = section_match.group(1) 72 remaining = section_match.group(2).strip() 73 74 # Simple key: value on same line 75 if remaining and not remaining.startswith('|'): 76 # It's a direct command (e.g., ssh_port: 2584) 77 # Skip non-command values 78 pass 79 80 in_multiline = False 81 multiline_buffer = [] 82 current_key = None 83 continue 84 85 # Nested key (e.g., " ssh: ssh -p 2584...") 86 nested_match = re.match(r'^(\s+)([a-z_][a-z0-9_]*):(.*)$', stripped if not stripped else line) 87 if not nested_match: 88 nested_match = re.match(r'^(\s+)([a-z_][a-z0-9_]*):\s*(.*)$', line) 89 90 if line.startswith(' ') or line.startswith('\t'): 91 key_match = re.match(r'^\s+([a-z_][a-z0-9_]*):\s*(.*)$', line, re.IGNORECASE) 92 if key_match: 93 # Save any pending multiline 94 if in_multiline and current_section and current_key: 95 cmd = '\n'.join(multiline_buffer).strip() 96 if cmd: 97 alias_name = f"{current_section}-{current_key}".replace('_', '-') 98 placeholders = re.findall(r'\{\{(\w+)\}\}', cmd) 99 aliases[alias_name] = { 100 'command': cmd, 101 'placeholders': placeholders, 102 'source': filename 103 } 104 105 current_key = key_match.group(1) 106 value = key_match.group(2).strip() 107 108 if value == '|': 109 # Start multiline 110 in_multiline = True 111 multiline_buffer = [] 112 elif value: 113 # Single line command 114 cmd = parse_cspec_value(value) 115 if current_section: 116 alias_name = f"{current_section}-{current_key}".replace('_', '-') 117 placeholders = re.findall(r'\{\{(\w+)\}\}', cmd) 118 aliases[alias_name] = { 119 'command': cmd, 120 'placeholders': placeholders, 121 'source': filename 122 } 123 in_multiline = False 124 multiline_buffer = [] 125 elif in_multiline: 126 multiline_buffer.append(line) 127 128 # Final multiline flush 129 if in_multiline and current_section and current_key: 130 cmd = '\n'.join(multiline_buffer).strip() 131 if cmd: 132 alias_name = f"{current_section}-{current_key}".replace('_', '-') 133 placeholders = re.findall(r'\{\{(\w+)\}\}', cmd) 134 aliases[alias_name] = { 135 'command': cmd, 136 'placeholders': placeholders, 137 'source': filename 138 } 139 140 return aliases 141 142 143 def add_shorthand_aliases(aliases: Dict[str, dict]) -> Dict[str, dict]: 144 """Add convenient shorthand aliases for common commands.""" 145 shorthands = { 146 # SSH connections 147 'connect-ci': aliases.get('ci-server-ssh', {}), 148 'connect-source': aliases.get('source-server-ssh', {}), 149 150 # Runner operations 151 'runner-status': aliases.get('runner-status', {}), 152 'runner-logs': aliases.get('runner-logs', {}), 153 'runner-restart': aliases.get('runner-restart', {}), 154 155 # Cargo shortcuts 156 'build': aliases.get('cargo-build', {}), 157 'check': aliases.get('cargo-check', {}), 158 'test': aliases.get('cargo-test', {}), 159 'clippy': aliases.get('cargo-clippy', {}), 160 'fmt': aliases.get('cargo-fmt', {}), 161 162 # Service management 163 'service-status': aliases.get('systemd-status', {}), 164 'service-restart': aliases.get('systemd-restart', {}), 165 'service-logs': aliases.get('systemd-logs', {}), 166 } 167 168 for name, value in shorthands.items(): 169 if value and name not in aliases: 170 aliases[name] = value.copy() 171 aliases[name]['shorthand_for'] = next( 172 (k for k, v in aliases.items() if v == value), None 173 ) 174 175 return aliases 176 177 178 def load_all_aliases() -> Dict[str, dict]: 179 """Load aliases from all cspec files in commands directory.""" 180 all_aliases = {} 181 182 if not os.path.isdir(CSPEC_COMMANDS_DIR): 183 return all_aliases 184 185 for filename in os.listdir(CSPEC_COMMANDS_DIR): 186 if filename.endswith('.cspec') and filename != 'index.cspec': 187 filepath = os.path.join(CSPEC_COMMANDS_DIR, filename) 188 file_aliases = parse_cspec_file(filepath) 189 all_aliases.update(file_aliases) 190 191 # Add shorthand aliases 192 all_aliases = add_shorthand_aliases(all_aliases) 193 194 return all_aliases 195 196 197 def resolve_alias(alias: str, args: Dict[str, str] = None) -> Optional[str]: 198 """ 199 Resolve an alias to its command, substituting placeholders. 200 201 Args: 202 alias: The alias name (e.g., 'connect-ci') 203 args: Dict of placeholder values (e.g., {'repo': 'alphavm'}) 204 205 Returns: 206 Resolved command string or None if alias not found 207 """ 208 aliases = load_all_aliases() 209 210 if alias not in aliases: 211 return None 212 213 cmd = aliases[alias]['command'] 214 placeholders = aliases[alias].get('placeholders', []) 215 216 if args: 217 for key, value in args.items(): 218 cmd = cmd.replace(f'{{{{{key}}}}}', value) 219 220 # Check for unresolved placeholders 221 remaining = re.findall(r'\{\{(\w+)\}\}', cmd) 222 if remaining: 223 return None # Missing required placeholders 224 225 return cmd 226 227 228 def parse_alias_invocation(args: list) -> Tuple[str, Dict[str, str]]: 229 """ 230 Parse alias invocation from command line args. 231 232 Examples: 233 ['connect-ci'] -> ('connect-ci', {}) 234 ['ci-status', 'repo=alphavm'] -> ('ci-status', {'repo': 'alphavm'}) 235 """ 236 if not args: 237 return ('', {}) 238 239 alias = args[0] 240 params = {} 241 242 for arg in args[1:]: 243 if '=' in arg: 244 key, value = arg.split('=', 1) 245 params[key] = value 246 247 return (alias, params) 248 249 250 def generate_aliases_json(): 251 """Generate aliases.json for Claude reference.""" 252 aliases = load_all_aliases() 253 254 # Simplify for JSON output 255 output = {} 256 for name, data in sorted(aliases.items()): 257 output[name] = { 258 'cmd': data['command'], 259 'args': data.get('placeholders', []), 260 'src': data.get('source', 'unknown') 261 } 262 263 with open(ALIASES_JSON_PATH, 'w', encoding='utf-8') as f: 264 json.dump(output, f, indent=2) 265 266 return len(output) 267 268 269 def list_aliases() -> str: 270 """Return formatted list of available aliases.""" 271 aliases = load_all_aliases() 272 273 lines = ["Available aliases:\n"] 274 275 # Group by source file 276 by_source = {} 277 for name, data in sorted(aliases.items()): 278 source = data.get('source', 'unknown') 279 if source not in by_source: 280 by_source[source] = [] 281 by_source[source].append((name, data)) 282 283 for source, items in sorted(by_source.items()): 284 lines.append(f"\n[{source}]") 285 for name, data in items: 286 placeholders = data.get('placeholders', []) 287 if placeholders: 288 args_str = ' ' + ' '.join(f'{{{p}}}' for p in placeholders) 289 else: 290 args_str = '' 291 lines.append(f" {name}{args_str}") 292 293 return '\n'.join(lines) 294 295 296 if __name__ == "__main__": 297 import sys 298 299 if len(sys.argv) > 1 and sys.argv[1] == '--generate': 300 count = generate_aliases_json() 301 print(f"[S:PASS] Generated {ALIASES_JSON_PATH} with {count} aliases.") 302 elif len(sys.argv) > 1 and sys.argv[1] == '--list': 303 print(list_aliases()) 304 else: 305 print("Usage:") 306 print(" python aliases.py --generate # Generate aliases.json") 307 print(" python aliases.py --list # List all aliases")