workflow: auto-update cli-commands on release (#6755)

This commit is contained in:
dianed-square
2026-02-12 08:23:47 -08:00
committed by GitHub
parent a00c25c7b5
commit b90484297c
11 changed files with 2517 additions and 0 deletions
@@ -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()
@@ -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()
@@ -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