workflow: auto-update cli-commands on release (#6755)
This commit is contained in:
+355
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare two CLI structure JSON files and output detected changes.
|
||||
|
||||
Usage:
|
||||
./diff-cli-structures.py <old-file> <new-file> > output/cli-changes.json
|
||||
|
||||
Example:
|
||||
./diff-cli-structures.py output/cli-structure-v1.14.0.json \
|
||||
output/cli-structure-v1.15.0.json \
|
||||
> output/cli-changes.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def get_command_path(command: Dict, parent_path: str = "") -> str:
|
||||
"""Get the full path of a command (e.g., 'session list')."""
|
||||
if parent_path:
|
||||
return f"{parent_path} {command['name']}"
|
||||
return command['name']
|
||||
|
||||
|
||||
def flatten_commands(commands: List[Dict], parent_path: str = "") -> Dict[str, Dict]:
|
||||
"""
|
||||
Flatten nested command structure into a dict keyed by full command path.
|
||||
|
||||
Returns:
|
||||
Dict mapping command path to command data
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for cmd in commands:
|
||||
cmd_path = get_command_path(cmd, parent_path)
|
||||
# Store command without subcommands to avoid recursion in comparisons
|
||||
cmd_copy = cmd.copy()
|
||||
subcommands = cmd_copy.pop('subcommands', [])
|
||||
result[cmd_path] = cmd_copy
|
||||
|
||||
# Recursively flatten subcommands
|
||||
if subcommands:
|
||||
result.update(flatten_commands(subcommands, cmd_path))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def compare_options(old_opts: List[Dict], new_opts: List[Dict]) -> Dict:
|
||||
"""
|
||||
Compare two lists of options and detect changes.
|
||||
|
||||
Returns dict with: added, removed, modified
|
||||
"""
|
||||
# Create dicts keyed by long flag (or short if no long)
|
||||
old_opts_dict = {opt.get('long') or opt.get('short'): opt for opt in old_opts}
|
||||
new_opts_dict = {opt.get('long') or opt.get('short'): opt for opt in new_opts}
|
||||
|
||||
old_keys = set(old_opts_dict.keys())
|
||||
new_keys = set(new_opts_dict.keys())
|
||||
|
||||
added = []
|
||||
removed = []
|
||||
modified = []
|
||||
|
||||
# Find added options
|
||||
for key in new_keys - old_keys:
|
||||
added.append(new_opts_dict[key])
|
||||
|
||||
# Find removed options
|
||||
for key in old_keys - new_keys:
|
||||
removed.append(old_opts_dict[key])
|
||||
|
||||
# Find modified options
|
||||
for key in old_keys & new_keys:
|
||||
old_opt = old_opts_dict[key]
|
||||
new_opt = new_opts_dict[key]
|
||||
|
||||
changes = {}
|
||||
|
||||
# Check each field for changes
|
||||
if old_opt.get('short') != new_opt.get('short'):
|
||||
changes['short'] = {'old': old_opt.get('short'), 'new': new_opt.get('short')}
|
||||
|
||||
if old_opt.get('long') != new_opt.get('long'):
|
||||
changes['long'] = {'old': old_opt.get('long'), 'new': new_opt.get('long')}
|
||||
|
||||
if old_opt.get('value_name') != new_opt.get('value_name'):
|
||||
changes['value_name'] = {'old': old_opt.get('value_name'), 'new': new_opt.get('value_name')}
|
||||
|
||||
if old_opt.get('help') != new_opt.get('help'):
|
||||
changes['help'] = {'old': old_opt.get('help'), 'new': new_opt.get('help')}
|
||||
|
||||
if old_opt.get('default') != new_opt.get('default'):
|
||||
changes['default'] = {'old': old_opt.get('default'), 'new': new_opt.get('default')}
|
||||
|
||||
if old_opt.get('possible_values') != new_opt.get('possible_values'):
|
||||
changes['possible_values'] = {'old': old_opt.get('possible_values'), 'new': new_opt.get('possible_values')}
|
||||
|
||||
if changes:
|
||||
modified.append({
|
||||
'option': key,
|
||||
'changes': changes
|
||||
})
|
||||
|
||||
return {
|
||||
'added': added,
|
||||
'removed': removed,
|
||||
'modified': modified
|
||||
}
|
||||
|
||||
|
||||
def compare_commands(old_cmds: Dict[str, Dict], new_cmds: Dict[str, Dict]) -> Dict:
|
||||
"""
|
||||
Compare two command dictionaries and detect changes.
|
||||
|
||||
Returns dict with: added, removed, modified
|
||||
"""
|
||||
old_paths = set(old_cmds.keys())
|
||||
new_paths = set(new_cmds.keys())
|
||||
|
||||
added = []
|
||||
removed = []
|
||||
modified = []
|
||||
|
||||
# Find added commands
|
||||
for path in new_paths - old_paths:
|
||||
added.append({
|
||||
'command': path,
|
||||
'data': new_cmds[path]
|
||||
})
|
||||
|
||||
# Find removed commands
|
||||
for path in old_paths - new_paths:
|
||||
removed.append({
|
||||
'command': path,
|
||||
'data': old_cmds[path]
|
||||
})
|
||||
|
||||
# Find modified commands
|
||||
for path in old_paths & new_paths:
|
||||
old_cmd = old_cmds[path]
|
||||
new_cmd = new_cmds[path]
|
||||
|
||||
changes = {}
|
||||
|
||||
# Check about text
|
||||
if old_cmd.get('about') != new_cmd.get('about'):
|
||||
changes['about'] = {
|
||||
'old': old_cmd.get('about'),
|
||||
'new': new_cmd.get('about')
|
||||
}
|
||||
|
||||
# Check aliases
|
||||
old_aliases = set(old_cmd.get('aliases', []))
|
||||
new_aliases = set(new_cmd.get('aliases', []))
|
||||
if old_aliases != new_aliases:
|
||||
changes['aliases'] = {
|
||||
'old': sorted(old_aliases),
|
||||
'new': sorted(new_aliases),
|
||||
'added': sorted(new_aliases - old_aliases),
|
||||
'removed': sorted(old_aliases - new_aliases)
|
||||
}
|
||||
|
||||
# Check usage
|
||||
if old_cmd.get('usage') != new_cmd.get('usage'):
|
||||
changes['usage'] = {
|
||||
'old': old_cmd.get('usage'),
|
||||
'new': new_cmd.get('usage')
|
||||
}
|
||||
|
||||
# Check options
|
||||
option_changes = compare_options(
|
||||
old_cmd.get('options', []),
|
||||
new_cmd.get('options', [])
|
||||
)
|
||||
if any(option_changes.values()):
|
||||
changes['options'] = option_changes
|
||||
|
||||
if changes:
|
||||
modified.append({
|
||||
'command': path,
|
||||
'changes': changes
|
||||
})
|
||||
|
||||
return {
|
||||
'added': added,
|
||||
'removed': removed,
|
||||
'modified': modified
|
||||
}
|
||||
|
||||
|
||||
def categorize_breaking_changes(changes: Dict) -> List[Dict]:
|
||||
"""
|
||||
Identify changes that are likely breaking changes.
|
||||
|
||||
Returns list of breaking change descriptions.
|
||||
"""
|
||||
breaking = []
|
||||
|
||||
# Removed commands are breaking
|
||||
for item in changes['commands']['removed']:
|
||||
breaking.append({
|
||||
'type': 'command_removed',
|
||||
'command': item['command'],
|
||||
'severity': 'high',
|
||||
'description': f"Command '{item['command']}' was removed"
|
||||
})
|
||||
|
||||
# Check modified commands for breaking changes
|
||||
for item in changes['commands']['modified']:
|
||||
cmd = item['command']
|
||||
cmd_changes = item['changes']
|
||||
|
||||
# Removed options are breaking
|
||||
if 'options' in cmd_changes:
|
||||
for opt in cmd_changes['options']['removed']:
|
||||
opt_name = f"--{opt.get('long')}" if opt.get('long') else f"-{opt.get('short')}"
|
||||
breaking.append({
|
||||
'type': 'option_removed',
|
||||
'command': cmd,
|
||||
'option': opt_name,
|
||||
'severity': 'high',
|
||||
'description': f"Option '{opt_name}' removed from '{cmd}'"
|
||||
})
|
||||
|
||||
# Changed option flags are breaking
|
||||
for mod in cmd_changes['options']['modified']:
|
||||
if 'short' in mod['changes'] or 'long' in mod['changes']:
|
||||
breaking.append({
|
||||
'type': 'option_renamed',
|
||||
'command': cmd,
|
||||
'option': mod['option'],
|
||||
'severity': 'high',
|
||||
'description': f"Option flags changed in '{cmd}': {mod['option']}"
|
||||
})
|
||||
|
||||
# Changed default values might be breaking
|
||||
if 'default' in mod['changes']:
|
||||
breaking.append({
|
||||
'type': 'default_changed',
|
||||
'command': cmd,
|
||||
'option': mod['option'],
|
||||
'severity': 'medium',
|
||||
'description': f"Default value changed for '{cmd} --{mod['option']}'"
|
||||
})
|
||||
|
||||
# Removed possible values are breaking
|
||||
if 'possible_values' in mod['changes']:
|
||||
old_vals = set(mod['changes']['possible_values']['old'] or [])
|
||||
new_vals = set(mod['changes']['possible_values']['new'] or [])
|
||||
removed_vals = old_vals - new_vals
|
||||
if removed_vals:
|
||||
breaking.append({
|
||||
'type': 'enum_values_removed',
|
||||
'command': cmd,
|
||||
'option': mod['option'],
|
||||
'severity': 'high',
|
||||
'description': f"Possible values removed from '{cmd} --{mod['option']}': {', '.join(removed_vals)}"
|
||||
})
|
||||
|
||||
# Removed aliases might be breaking (users might rely on them)
|
||||
if 'aliases' in cmd_changes and cmd_changes['aliases']['removed']:
|
||||
for alias in cmd_changes['aliases']['removed']:
|
||||
breaking.append({
|
||||
'type': 'alias_removed',
|
||||
'command': cmd,
|
||||
'alias': alias,
|
||||
'severity': 'medium',
|
||||
'description': f"Alias '{alias}' removed from '{cmd}'"
|
||||
})
|
||||
|
||||
return breaking
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: diff-cli-structures.py <old-file> <new-file>", file=sys.stderr)
|
||||
print("Example: diff-cli-structures.py old.json new.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
old_file = sys.argv[1]
|
||||
new_file = sys.argv[2]
|
||||
|
||||
# Load JSON files
|
||||
try:
|
||||
with open(old_file, 'r') as f:
|
||||
old_data = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading {old_file}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with open(new_file, 'r') as f:
|
||||
new_data = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading {new_file}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Comparing {old_data['version']} → {new_data['version']}...", file=sys.stderr)
|
||||
|
||||
# Flatten command structures
|
||||
old_commands = flatten_commands(old_data['commands'])
|
||||
new_commands = flatten_commands(new_data['commands'])
|
||||
|
||||
print(f"Old version: {len(old_commands)} commands", file=sys.stderr)
|
||||
print(f"New version: {len(new_commands)} commands", file=sys.stderr)
|
||||
|
||||
# Compare commands
|
||||
command_changes = compare_commands(old_commands, new_commands)
|
||||
|
||||
# Categorize breaking changes
|
||||
breaking_changes = categorize_breaking_changes({'commands': command_changes})
|
||||
|
||||
# Determine if there are any changes
|
||||
has_changes = (
|
||||
len(command_changes['added']) > 0 or
|
||||
len(command_changes['removed']) > 0 or
|
||||
len(command_changes['modified']) > 0
|
||||
)
|
||||
|
||||
# Build output
|
||||
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
output = {
|
||||
'old_version': old_data['version'],
|
||||
'new_version': new_data['version'],
|
||||
'compared_at': now,
|
||||
'has_changes': has_changes,
|
||||
'summary': {
|
||||
'commands_added': len(command_changes['added']),
|
||||
'commands_removed': len(command_changes['removed']),
|
||||
'commands_modified': len(command_changes['modified']),
|
||||
'breaking_changes': len([b for b in breaking_changes if b['severity'] == 'high'])
|
||||
},
|
||||
'changes': {
|
||||
'commands': command_changes
|
||||
},
|
||||
'breaking_changes': breaking_changes
|
||||
}
|
||||
|
||||
# Output JSON
|
||||
print(json.dumps(output, indent=2))
|
||||
|
||||
# Print summary to stderr
|
||||
print(f"\nSummary:", file=sys.stderr)
|
||||
print(f" Commands added: {output['summary']['commands_added']}", file=sys.stderr)
|
||||
print(f" Commands removed: {output['summary']['commands_removed']}", file=sys.stderr)
|
||||
print(f" Commands modified: {output['summary']['commands_modified']}", file=sys.stderr)
|
||||
print(f" Breaking changes: {output['summary']['breaking_changes']}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract CLI command structure from goose binary using --help output.
|
||||
|
||||
Usage:
|
||||
./extract-cli-structure.py <goose-binary-path> > output/cli-structure.json
|
||||
|
||||
Example:
|
||||
./extract-cli-structure.py /path/to/goose > output/new-cli-structure.json
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def load_skip_commands() -> List[str]:
|
||||
"""Load the list of commands to skip from config file."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
config_path = os.path.join(script_dir, '..', 'config', 'skip-commands.json')
|
||||
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
return [cmd['name'] for cmd in config.get('skip_commands', [])]
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||
print(f"Warning: Could not load skip-commands.json: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
SKIP_COMMANDS = load_skip_commands()
|
||||
|
||||
|
||||
def run_help_command(binary_path: str, command_path: List[str], short: bool = False) -> str:
|
||||
"""
|
||||
Run --help or -h on a command and return the output.
|
||||
|
||||
Args:
|
||||
binary_path: Path to goose binary
|
||||
command_path: List of command parts (e.g., ['session', 'list'])
|
||||
short: If True, use -h instead of --help
|
||||
|
||||
Returns:
|
||||
Help text output
|
||||
"""
|
||||
cmd = [binary_path] + command_path + (['-h'] if short else ['--help'])
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
return result.stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Warning: Command timed out: {' '.join(cmd)}", file=sys.stderr)
|
||||
return ""
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to run {' '.join(cmd)}: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def parse_usage_line(help_text: str) -> Optional[str]:
|
||||
"""Extract the usage line from help text."""
|
||||
match = re.search(r'^Usage:\s*(.+)$', help_text, re.MULTILINE)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def parse_about(help_text: str) -> str:
|
||||
"""Extract the command description (first line before Usage)."""
|
||||
lines = help_text.strip().split('\n')
|
||||
|
||||
# Find the Usage: line
|
||||
usage_index = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('Usage:'):
|
||||
usage_index = i
|
||||
break
|
||||
|
||||
# If Usage is found, look for description before it
|
||||
if usage_index > 0:
|
||||
for i in range(usage_index):
|
||||
line = lines[i].strip()
|
||||
if line and not line.startswith('Options:') and not line.startswith('Commands:'):
|
||||
return line
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_aliases(help_text: str) -> List[str]:
|
||||
"""Extract command aliases from help text."""
|
||||
# Look for "[aliases: x, y]" pattern in the first few lines
|
||||
match = re.search(r'\[aliases?:\s*([^\]]+)\]', help_text[:500])
|
||||
if match:
|
||||
aliases_str = match.group(1)
|
||||
return [a.strip() for a in aliases_str.split(',')]
|
||||
return []
|
||||
|
||||
|
||||
def parse_options(help_text: str) -> List[Dict]:
|
||||
"""
|
||||
Parse options from the Options: section of help text.
|
||||
|
||||
Returns list of option dicts with: short, long, value_name, help, default, possible_values
|
||||
"""
|
||||
options = []
|
||||
|
||||
# Find the Options: section - goes until Commands: section or end of text
|
||||
# Note: clap help has blank lines between options, so we can't stop at ^$
|
||||
options_match = re.search(r'^Options:\s*\n(.+?)(?=^Commands:\s*$|\Z)',
|
||||
help_text, re.MULTILINE | re.DOTALL)
|
||||
if not options_match:
|
||||
return options
|
||||
|
||||
options_text = options_match.group(1)
|
||||
|
||||
# Split into individual option blocks
|
||||
# Each option starts with whitespace followed by a dash (short or long flag)
|
||||
# Use lookahead to split at lines that start a new option
|
||||
option_blocks = re.split(r'\n(?=\s+-)', options_text)
|
||||
|
||||
for block in option_blocks:
|
||||
block = block.strip()
|
||||
if not block or not block.startswith('-'):
|
||||
continue
|
||||
|
||||
option = parse_option_block(block)
|
||||
if option:
|
||||
options.append(option)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def parse_option_block(block: str) -> Optional[Dict]:
|
||||
"""Parse a single option block into structured data."""
|
||||
lines = block.split('\n')
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
# First line has the flags, optional value name, and sometimes inline help (common clap output)
|
||||
first_line = lines[0].strip()
|
||||
inline_help = None
|
||||
|
||||
# Split on 2+ spaces to separate flags from inline help text.
|
||||
# Example: "-o, --output <FILE> Write output to file"
|
||||
parts = re.split(r'\s{2,}', first_line, maxsplit=1)
|
||||
flags_part = parts[0]
|
||||
if len(parts) == 2:
|
||||
inline_help = parts[1].strip() or None
|
||||
|
||||
# Extract short flag (e.g., -f)
|
||||
short_match = re.search(r'-([a-zA-Z])\b', flags_part)
|
||||
short = short_match.group(1) if short_match else None
|
||||
|
||||
# Extract long flag (e.g., --format)
|
||||
long_match = re.search(r'--([a-z][a-z0-9-]*)', flags_part)
|
||||
long = long_match.group(1) if long_match else None
|
||||
|
||||
# Extract value_name (e.g., <FORMAT>)
|
||||
value_name_match = re.search(r'<([^>]+)>', flags_part)
|
||||
value_name = value_name_match.group(1) if value_name_match else None
|
||||
|
||||
# Collect help text from subsequent indented lines
|
||||
help_lines = []
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('['):
|
||||
help_lines.append(line)
|
||||
elif line.startswith('['):
|
||||
# This might be [default: ...] or [possible values: ...]
|
||||
break
|
||||
|
||||
help_text = ' '.join(help_lines)
|
||||
|
||||
if inline_help:
|
||||
help_text = f"{inline_help} {help_text}".strip() if help_text else inline_help
|
||||
|
||||
# Extract default value
|
||||
default = None
|
||||
default_match = re.search(r'\[default:\s*([^\]]+)\]', block)
|
||||
if default_match:
|
||||
default = default_match.group(1).strip()
|
||||
|
||||
# Extract possible values
|
||||
possible_values = None
|
||||
possible_match = re.search(r'\[possible values:\s*([^\]]+)\]', block)
|
||||
if possible_match:
|
||||
values_str = possible_match.group(1)
|
||||
possible_values = [v.strip() for v in values_str.split(',')]
|
||||
|
||||
return {
|
||||
'short': short,
|
||||
'long': long,
|
||||
'value_name': value_name,
|
||||
'help': help_text if help_text else None,
|
||||
'default': default,
|
||||
'possible_values': possible_values
|
||||
}
|
||||
|
||||
|
||||
def parse_subcommands(help_text: str) -> List[Tuple[str, List[str]]]:
|
||||
"""
|
||||
Extract subcommand names and their aliases from the Commands: section.
|
||||
|
||||
Returns:
|
||||
List of tuples: (command_name, [aliases])
|
||||
"""
|
||||
commands = []
|
||||
|
||||
# Find the Commands: section
|
||||
commands_match = re.search(r'^Commands:\s*$(.+?)(?:^Options:|\Z)',
|
||||
help_text, re.MULTILINE | re.DOTALL)
|
||||
if not commands_match:
|
||||
return commands
|
||||
|
||||
commands_text = commands_match.group(1)
|
||||
|
||||
# Each command line starts with the command name (not indented or minimally indented)
|
||||
for raw_line in commands_text.split('\n'):
|
||||
# Preserve indentation to avoid mis-parsing wrapped description lines.
|
||||
# In clap help, actual command entries are typically not indented.
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
|
||||
if raw_line.startswith(' ') or raw_line.startswith('\t'):
|
||||
continue
|
||||
|
||||
line = raw_line.strip()
|
||||
|
||||
# Extract command name (first word)
|
||||
parts = line.split()
|
||||
if parts and not parts[0].startswith('-'):
|
||||
command_name = parts[0]
|
||||
# Skip "help" command as it's auto-generated
|
||||
if command_name == 'help':
|
||||
continue
|
||||
|
||||
# Extract aliases from [aliases: x, y] pattern
|
||||
aliases = []
|
||||
alias_match = re.search(r'\[aliases?:\s*([^\]]+)\]', line)
|
||||
if alias_match:
|
||||
aliases_str = alias_match.group(1)
|
||||
aliases = [a.strip() for a in aliases_str.split(',')]
|
||||
|
||||
commands.append((command_name, aliases))
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def extract_command_structure(binary_path: str, command_path: List[str] = None,
|
||||
parent_aliases: List[str] = None) -> Dict:
|
||||
"""
|
||||
Recursively extract command structure starting from a command path.
|
||||
|
||||
Args:
|
||||
binary_path: Path to goose binary
|
||||
command_path: Current command path (e.g., ['session', 'list'])
|
||||
parent_aliases: Aliases passed from parent (since they appear in parent's help)
|
||||
|
||||
Returns:
|
||||
Dict with command structure
|
||||
"""
|
||||
if command_path is None:
|
||||
command_path = []
|
||||
|
||||
# Get both short and long help
|
||||
help_text_long = run_help_command(binary_path, command_path, short=False)
|
||||
|
||||
if not help_text_long:
|
||||
return None
|
||||
|
||||
# Parse command info
|
||||
command_name = command_path[-1] if command_path else "goose"
|
||||
about = parse_about(help_text_long)
|
||||
# Use parent_aliases if provided, otherwise try to parse from own help
|
||||
aliases = parent_aliases if parent_aliases is not None else parse_aliases(help_text_long)
|
||||
usage = parse_usage_line(help_text_long)
|
||||
options = parse_options(help_text_long)
|
||||
|
||||
# Get subcommands with their aliases and recursively process them
|
||||
subcommand_info = parse_subcommands(help_text_long)
|
||||
subcommands = []
|
||||
|
||||
for subcommand_name, subcommand_aliases in subcommand_info:
|
||||
# Skip commands in the skip list
|
||||
if subcommand_name in SKIP_COMMANDS:
|
||||
print(f"Skipping command: {subcommand_name}", file=sys.stderr)
|
||||
continue
|
||||
sub_path = command_path + [subcommand_name]
|
||||
sub_structure = extract_command_structure(binary_path, sub_path, subcommand_aliases)
|
||||
if sub_structure:
|
||||
subcommands.append(sub_structure)
|
||||
|
||||
return {
|
||||
'name': command_name,
|
||||
'about': about,
|
||||
'aliases': aliases,
|
||||
'usage': usage,
|
||||
'options': options,
|
||||
'subcommands': subcommands
|
||||
}
|
||||
|
||||
|
||||
def extract_version(binary_path: str) -> str:
|
||||
"""Extract version from goose --version."""
|
||||
try:
|
||||
result = subprocess.run([binary_path, '--version'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
# Output is typically "goose 1.15.0" or similar
|
||||
version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout)
|
||||
return version_match.group(1) if version_match else "unknown"
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not extract version: {e}", file=sys.stderr)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: extract-cli-structure.py <goose-binary-path> [source-version]", file=sys.stderr)
|
||||
print("Example: extract-cli-structure.py /usr/local/bin/goose v1.15.0", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
binary_path = sys.argv[1]
|
||||
source_version = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
# Verify binary exists and is executable
|
||||
try:
|
||||
result = subprocess.run([binary_path, '--version'],
|
||||
capture_output=True, timeout=5)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: {binary_path} is not a valid goose binary", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error: Cannot execute {binary_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("Extracting CLI structure...", file=sys.stderr)
|
||||
|
||||
# Extract version
|
||||
version = extract_version(binary_path)
|
||||
print(f"Version: {version}", file=sys.stderr)
|
||||
|
||||
# Extract root command structure (recursively includes all subcommands)
|
||||
root_structure = extract_command_structure(binary_path, [])
|
||||
|
||||
# Build output JSON
|
||||
# Use timezone-aware UTC datetime (Python 3.7+)
|
||||
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
output = {
|
||||
'version': version,
|
||||
'source_version': source_version or version,
|
||||
'extracted_at': now,
|
||||
'binary_path': binary_path,
|
||||
'commands': root_structure['subcommands'] if root_structure else []
|
||||
}
|
||||
|
||||
# Output JSON
|
||||
print(json.dumps(output, indent=2))
|
||||
print("Extraction complete!", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# Extract CLI command structure from goose at a specific version
|
||||
# Usage: ./extract-cli-structure.sh <version>
|
||||
# Example: ./extract-cli-structure.sh v1.15.0
|
||||
#
|
||||
# For tagged releases (v*), downloads pre-built binary from GitHub releases.
|
||||
# For HEAD or non-release refs, builds from source.
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
VERSION=${1:-"HEAD"}
|
||||
GOOSE_REPO=${GOOSE_REPO:-"$HOME/Development/goose"}
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Create a temporary directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
|
||||
# Check if version is a release tag (starts with 'v' followed by numbers)
|
||||
is_release_tag() {
|
||||
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
}
|
||||
|
||||
# Download pre-built binary for a release version
|
||||
download_release_binary() {
|
||||
local version=$1
|
||||
local safe_version=${version//\//-}
|
||||
local bin_dir="$TEMP_DIR/bin"
|
||||
mkdir -p "$bin_dir"
|
||||
|
||||
echo "Downloading goose $version from GitHub releases..." >&2
|
||||
|
||||
# Use the official download script with custom bin dir and specific version
|
||||
curl -fsSL "https://github.com/block/goose/releases/download/stable/download_cli.sh" | \
|
||||
CONFIGURE=false GOOSE_BIN_DIR="$bin_dir" GOOSE_VERSION="$version" bash >&2 2>&1 || {
|
||||
echo "Error: Failed to download goose $version" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "$bin_dir/goose"
|
||||
}
|
||||
|
||||
# Build goose from source
|
||||
build_from_source() {
|
||||
local version=$1
|
||||
local safe_version=${version//\//-}
|
||||
|
||||
if [ ! -d "$GOOSE_REPO" ]; then
|
||||
echo "Error: GOOSE_REPO directory not found: $GOOSE_REPO" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$GOOSE_REPO"
|
||||
|
||||
if [ "$version" = "HEAD" ]; then
|
||||
echo "Building goose from HEAD..." >&2
|
||||
cargo build --release --quiet >&2 2>&1 || {
|
||||
echo "Error: Failed to build goose from HEAD" >&2
|
||||
return 1
|
||||
}
|
||||
echo "$GOOSE_REPO/target/release/goose"
|
||||
else
|
||||
# Verify version exists
|
||||
if ! git rev-parse "$version" >/dev/null 2>&1; then
|
||||
echo "Error: Version $version not found in git history" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Building goose from $version..." >&2
|
||||
|
||||
# Create a worktree for the version
|
||||
local worktree_dir="$TEMP_DIR/goose-$safe_version"
|
||||
git worktree add --quiet "$worktree_dir" "$version" >&2 2>&1 || {
|
||||
echo "Error: Failed to create worktree for $version" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
cd "$worktree_dir"
|
||||
cargo build --release --quiet >&2 2>&1 || {
|
||||
echo "Error: Failed to build goose from $version" >&2
|
||||
cd "$GOOSE_REPO"
|
||||
git worktree remove "$worktree_dir" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# Clean up worktree but keep the binary accessible
|
||||
local bin_path="$worktree_dir/target/release/goose"
|
||||
local temp_bin="$TEMP_DIR/goose-$safe_version-bin"
|
||||
cp "$bin_path" "$temp_bin"
|
||||
|
||||
cd "$GOOSE_REPO"
|
||||
git worktree remove "$worktree_dir" 2>/dev/null || true
|
||||
|
||||
echo "$temp_bin"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the goose binary
|
||||
if is_release_tag "$VERSION"; then
|
||||
GOOSE_BIN=$(download_release_binary "$VERSION")
|
||||
else
|
||||
GOOSE_BIN=$(build_from_source "$VERSION")
|
||||
fi
|
||||
|
||||
if [ -z "$GOOSE_BIN" ] || [ ! -x "$GOOSE_BIN" ]; then
|
||||
echo "Error: Goose binary not found or not executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using binary: $GOOSE_BIN" >&2
|
||||
echo "Binary version: $($GOOSE_BIN --version 2>&1)" >&2
|
||||
|
||||
# Run the Python extraction script
|
||||
python3 "$SCRIPT_DIR/extract-cli-structure.py" "$GOOSE_BIN" "$VERSION"
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/bin/bash
|
||||
# End-to-end pipeline for CLI command tracking
|
||||
# Usage: ./run-pipeline.sh [old_version] [new_version]
|
||||
# Example: ./run-pipeline.sh v1.17.0 v1.19.0
|
||||
#
|
||||
# Version detection:
|
||||
# - If old_version not provided: uses the second-most-recent release tag
|
||||
# - If new_version not provided: uses the most recent release tag (or RELEASE_TAG env var)
|
||||
# - HEAD is only used when explicitly passed for testing unreleased changes
|
||||
|
||||
set -e
|
||||
|
||||
GOOSE_REPO=${GOOSE_REPO:-"$HOME/Development/goose"}
|
||||
|
||||
# Function to get release tags using gh CLI
|
||||
get_latest_release() {
|
||||
if command -v gh &> /dev/null; then
|
||||
gh release list --repo block/goose --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null
|
||||
else
|
||||
# Fallback: get latest version tag from git
|
||||
cd "$GOOSE_REPO" && git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1
|
||||
fi
|
||||
}
|
||||
|
||||
get_previous_release() {
|
||||
if command -v gh &> /dev/null; then
|
||||
gh release list --repo block/goose --limit 2 --json tagName --jq '.[].tagName' 2>/dev/null | sed -n '2p'
|
||||
else
|
||||
# Fallback: get second-latest version tag from git
|
||||
cd "$GOOSE_REPO" && git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed -n '2p'
|
||||
fi
|
||||
}
|
||||
|
||||
# Determine versions
|
||||
if [ -n "$1" ]; then
|
||||
OLD_VERSION="$1"
|
||||
else
|
||||
OLD_VERSION=$(get_previous_release)
|
||||
if [ -z "$OLD_VERSION" ]; then
|
||||
echo "Error: Could not determine previous release version" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$2" ]; then
|
||||
NEW_VERSION="$2"
|
||||
elif [ -n "$RELEASE_TAG" ]; then
|
||||
# Used by GitHub Actions release trigger
|
||||
NEW_VERSION="$RELEASE_TAG"
|
||||
else
|
||||
NEW_VERSION=$(get_latest_release)
|
||||
if [ -z "$NEW_VERSION" ]; then
|
||||
echo "Error: Could not determine latest release version" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "CLI Command Documentation Pipeline"
|
||||
echo "=========================================="
|
||||
echo "Old Version: $OLD_VERSION"
|
||||
echo "New Version: $NEW_VERSION"
|
||||
echo ""
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Change to output directory
|
||||
OUTPUT_DIR="$SCRIPT_DIR/../output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cd "$OUTPUT_DIR"
|
||||
|
||||
# Use a per-run temp directory for logs to avoid collisions
|
||||
LOG_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$LOG_DIR"' EXIT
|
||||
|
||||
echo "Step 1: Extracting CLI structure from $OLD_VERSION..."
|
||||
if ! ../scripts/extract-cli-structure.sh "$OLD_VERSION" > old-cli-structure.json 2>"$LOG_DIR/extract-old.log"; then
|
||||
echo "✗ Failed to extract CLI structure from $OLD_VERSION" >&2
|
||||
echo "Error output:" >&2
|
||||
cat "$LOG_DIR/extract-old.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Extracted $(jq '.commands | length' old-cli-structure.json) commands"
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Extracting CLI structure from $NEW_VERSION..."
|
||||
if ! ../scripts/extract-cli-structure.sh "$NEW_VERSION" > new-cli-structure.json 2>"$LOG_DIR/extract-new.log"; then
|
||||
echo "✗ Failed to extract CLI structure from $NEW_VERSION" >&2
|
||||
echo "Error output:" >&2
|
||||
cat "$LOG_DIR/extract-new.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Extracted $(jq '.commands | length' new-cli-structure.json) commands"
|
||||
|
||||
echo ""
|
||||
echo "Step 3: Comparing CLI structures..."
|
||||
python3 ../scripts/diff-cli-structures.py old-cli-structure.json new-cli-structure.json > cli-changes.json 2>"$LOG_DIR/diff.log"
|
||||
|
||||
HAS_CHANGES=$(jq -r '.has_changes' cli-changes.json)
|
||||
echo "✓ Comparison complete. Has changes: $HAS_CHANGES"
|
||||
|
||||
if [ "$HAS_CHANGES" = "true" ]; then
|
||||
echo ""
|
||||
echo "Changes detected:"
|
||||
echo " - Commands added: $(jq '.summary.commands_added' cli-changes.json)"
|
||||
echo " - Commands removed: $(jq '.summary.commands_removed' cli-changes.json)"
|
||||
echo " - Commands modified: $(jq '.summary.commands_modified' cli-changes.json)"
|
||||
echo " - Breaking changes: $(jq '.summary.breaking_changes' cli-changes.json)"
|
||||
|
||||
echo ""
|
||||
echo "Step 4: Synthesizing CLI changes documentation..."
|
||||
|
||||
# Run goose and capture output, filtering out session logs
|
||||
goose run --recipe ../recipes/synthesize-cli-changes.yaml 2>&1 | \
|
||||
sed -E 's/\x1B\[[0-9;]*[mK]//g' | \
|
||||
grep -v "^starting session" | \
|
||||
grep -v "^ session id:" | \
|
||||
grep -v "^ working directory:" | \
|
||||
grep -v "^─── text_editor" | \
|
||||
grep -v "^path:" | \
|
||||
grep -v "^command:" | \
|
||||
grep -v "^Closing session" | \
|
||||
grep -v "^Loading recipe:" | \
|
||||
grep -v "^Description:" | \
|
||||
cat -s > cli-changes.md.tmp
|
||||
|
||||
# If the pipeline fails, surface the goose error (grep can exit 1 when it matches nothing)
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
echo "✗ Failed to synthesize CLI changes (goose run failed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we got meaningful content
|
||||
if [ -s cli-changes.md.tmp ] && grep -q "# CLI Command Changes" cli-changes.md.tmp; then
|
||||
mv cli-changes.md.tmp cli-changes.md
|
||||
echo "✓ Generated cli-changes.md ($(wc -l < cli-changes.md) lines)"
|
||||
elif [ -f cli-changes.md ] && [ -s cli-changes.md ]; then
|
||||
# File was written directly by goose
|
||||
rm -f cli-changes.md.tmp
|
||||
echo "✓ Generated cli-changes.md ($(wc -l < cli-changes.md) lines)"
|
||||
else
|
||||
echo "✗ Failed to generate cli-changes.md"
|
||||
rm -f cli-changes.md.tmp
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 5: Updating CLI commands documentation..."
|
||||
|
||||
# Set environment variables for the update recipe
|
||||
export CLI_COMMANDS_PATH="${GOOSE_REPO}/documentation/docs/guides/goose-cli-commands.md"
|
||||
|
||||
# Run the update recipe
|
||||
goose run --recipe ../recipes/update-cli-commands.yaml 2>&1 | \
|
||||
sed -E 's/\x1B\[[0-9;]*[mK]//g' | \
|
||||
grep -v "^starting session" | \
|
||||
grep -v "^ session id:" | \
|
||||
grep -v "^ working directory:" | \
|
||||
grep -v "^─── text_editor" | \
|
||||
grep -v "^path:" | \
|
||||
grep -v "^command:" | \
|
||||
grep -v "^Closing session" | \
|
||||
grep -v "^Loading recipe:" | \
|
||||
grep -v "^Description:" | \
|
||||
cat -s
|
||||
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
echo "✗ Failed to update documentation (goose run failed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Documentation update complete"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Pipeline Complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Output files:"
|
||||
echo " - old-cli-structure.json"
|
||||
echo " - new-cli-structure.json"
|
||||
echo " - cli-changes.json"
|
||||
echo " - cli-changes.md"
|
||||
echo ""
|
||||
echo "Review the changes to the CLI commands documentation."
|
||||
else
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "No Changes Detected"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "No CLI changes between $OLD_VERSION and $NEW_VERSION."
|
||||
echo "Documentation update not needed."
|
||||
fi
|
||||
Reference in New Issue
Block a user