Class: Carson::CLI
- Inherits:
-
Object
- Object
- Carson::CLI
- Defined in:
- lib/carson/cli.rb
Constant Summary collapse
- PORTFOLIO_COMMANDS =
%w[onboard offboard list refresh version].freeze
- REPO_COMMANDS =
%w[deliver receive sync status audit prune housekeep worktree abandon recover review template setup].freeze
- ALL_COMMANDS =
( PORTFOLIO_COMMANDS + REPO_COMMANDS ).freeze
Class Method Summary collapse
- .build_parser ⇒ Object
-
.canonicalise_repo_root(repo_root:) ⇒ Object
Returns the canonical main worktree root for a repo_root.
-
.detect_legacy_grammar(arguments:) ⇒ Object
Detects legacy grammar patterns and returns a migration message, or nil.
-
.dispatch(parsed:, runtime:) ⇒ Object
— dispatch —.
-
.ensure_global_artefacts!(tool_root:) ⇒ Object
Ensures global (non-repo) artefacts are installed at CLI startup.
-
.parse_abandon_command(arguments:, error:) ⇒ Object
— abandon —.
- .parse_args(arguments:, output:, error:) ⇒ Object
-
.parse_audit_command(arguments:, error:) ⇒ Object
— audit —.
-
.parse_deliver_command(arguments:, error:) ⇒ Object
— deliver —.
-
.parse_housekeep_command(arguments:, error:) ⇒ Object
— housekeep —.
-
.parse_list_command(arguments:, error:) ⇒ Object
— list —.
- .parse_offboard_command(arguments:, error:) ⇒ Object
-
.parse_onboard_command(arguments:, error:) ⇒ Object
— onboard / offboard —.
-
.parse_portfolio_command(command:, arguments:, error:) ⇒ Object
— portfolio command routing —.
- .parse_preset_command(arguments:, output:, parser:) ⇒ Object
-
.parse_prune_command(arguments:, error:) ⇒ Object
— prune —.
-
.parse_receive_command(arguments:, error:) ⇒ Object
— receive —.
-
.parse_recover_command(arguments:, error:) ⇒ Object
— recover —.
-
.parse_refresh_command(arguments:, error:) ⇒ Object
— refresh —.
-
.parse_repo_command(command:, arguments:, error:) ⇒ Object
— repo command routing —.
-
.parse_review_subcommand(arguments:, error:) ⇒ Object
— review —.
-
.parse_setup_command(arguments:, error:) ⇒ Object
— setup —.
-
.parse_status_command(arguments:, error:) ⇒ Object
— status —.
-
.parse_sync_command(arguments:, error:) ⇒ Object
— sync —.
-
.parse_template_subcommand(arguments:, error:) ⇒ Object
— template —.
-
.parse_worktree_subcommand(arguments:, error:) ⇒ Object
— worktree —.
-
.resolve_cwd_repo(repo_root:, config:) ⇒ Object
Resolves the CWD repo_root to a governed repository path.
-
.resolve_repo_target(name:, config:) ⇒ Object
Resolves an explicit repo name/path to a governed repository path.
- .start(arguments:, repo_root:, tool_root:, output:, error:) ⇒ Object
Class Method Details
.build_parser ⇒ Object
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/carson/cli.rb', line 130 def self.build_parser OptionParser.new do |parser| parser. = "Usage: carson <command> [options]\n carson <repo> <command> [options]" parser.separator "" parser.separator "Repository governance and workflow automation for coding agents." parser.separator "" parser.separator "Portfolio commands:" parser.separator " list List governed repositories" parser.separator " onboard Register a repository for governance (requires repo path)" parser.separator " offboard Remove a repository from governance (requires repo path)" parser.separator " refresh Re-install hooks and configuration (all governed repos)" parser.separator " version Show Carson version" parser.separator "" parser.separator "Repository commands (from CWD or with explicit repo):" parser.separator " status Show repository delivery state" parser.separator " setup Initialise Carson configuration" parser.separator " audit Run pre-commit health checks" parser.separator " abandon Close and clean up abandoned delivery work" parser.separator " sync Sync local main with remote" parser.separator " deliver Start autonomous branch delivery" parser.separator " recover Merge the repair PR for one baseline-red governance check" parser.separator " prune Remove stale local branches" parser.separator " worktree Manage isolated coding worktrees" parser.separator " housekeep Sync, reap worktrees, and prune branches" parser.separator " review Manage PR review workflow" parser.separator " template Manage canonical template files" parser.separator " receive Triage and advance deliveries for one repo" parser.separator "" parser.separator "Run `carson <command> --help` for details on a specific command." end end |
.canonicalise_repo_root(repo_root:) ⇒ Object
Returns the canonical main worktree root for a repo_root. If inside a worktree, follows git-common-dir back to the main tree.
894 895 896 897 898 899 900 901 902 903 |
# File 'lib/carson/cli.rb', line 894 def self.canonicalise_repo_root( repo_root: ) stdout, _, status = Open3.capture3( "git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir" ) if status.success? && !stdout.strip.empty? return File.dirname( stdout.strip ) end File.( repo_root ) rescue StandardError File.( repo_root ) end |
.detect_legacy_grammar(arguments:) ⇒ Object
Detects legacy grammar patterns and returns a migration message, or nil.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/carson/cli.rb', line 175 def self.detect_legacy_grammar( arguments: ) tokens = arguments.dup # Check for --all anywhere. if tokens.include?( "--all" ) return "--all has been removed. Use carson list --json to script batch operations." end # Check first two tokens for legacy commands. first = tokens[0].to_s second = tokens[1].to_s if first == "govern" || second == "govern" return "carson govern has been replaced by carson <repo> receive" end if first == "repos" || second == "repos" return "carson repos has been replaced by carson list" end nil end |
.dispatch(parsed:, runtime:) ⇒ Object
— dispatch —
928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 |
# File 'lib/carson/cli.rb', line 928 def self.dispatch( parsed:, runtime: ) command = parsed.fetch( :command ) return Runtime::EXIT_ERROR if command == :invalid case command when "status" runtime.status!( json_output: parsed.fetch( :json, false ) ) when "setup" runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) ) when "audit" runtime.audit!( json_output: parsed.fetch( :json, false ) ) when "abandon" runtime.abandon!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) ) when "sync" runtime.sync!( json_output: parsed.fetch( :json, false ) ) when "prune" runtime.prune!( json_output: parsed.fetch( :json, false ) ) when "worktree:create" runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) ) when "worktree:list" runtime.worktree_list!( json_output: parsed.fetch( :json, false ) ) when "worktree:remove" runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) ) when "onboard" runtime.onboard! when "refresh:all" runtime.refresh_all! when "offboard" runtime.offboard! when "template:check" runtime.template_check! when "template:apply" runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) ) when "deliver" runtime.deliver!( title: parsed.fetch( :title, nil ), body_file: parsed.fetch( :body_file, nil ), commit_message: parsed.fetch( :commit_message, nil ), json_output: parsed.fetch( :json, false ) ) when "recover" runtime.recover!( check_name: parsed.fetch( :check_name ), json_output: parsed.fetch( :json, false ) ) when "review:gate" runtime.review_gate! when "review:sweep" runtime.review_sweep! when "list" runtime.list!( json_output: parsed.fetch( :json, false ) ) when "receive" runtime.receive!( dry_run: parsed.fetch( :dry_run, false ), json_output: parsed.fetch( :json, false ), loop_seconds: parsed.fetch( :loop_seconds, nil ) ) when "housekeep" runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) ) else runtime.send( :puts_line, "Unknown command: #{command}" ) Runtime::EXIT_ERROR end end |
.ensure_global_artefacts!(tool_root:) ⇒ Object
Ensures global (non-repo) artefacts are installed at CLI startup. The command-guard lives at a stable path (~/.carson/hooks/command-guard) referenced by Claude Code’s PreToolUse hook. It must exist regardless of whether ‘carson refresh` has been run in any governed repo.
911 912 913 914 915 916 917 918 919 920 921 922 923 924 |
# File 'lib/carson/cli.rb', line 911 def self.ensure_global_artefacts!( tool_root: ) source = File.join( tool_root, "config", ".github", "hooks", "command-guard" ) return unless File.file?( source ) hooks_base = File.( "~/.carson/hooks" ) target = File.join( hooks_base, "command-guard" ) return if File.file?( target ) && FileUtils.identical?( source, target ) FileUtils.mkdir_p( hooks_base ) FileUtils.cp( source, target ) FileUtils.chmod( 0o755, target ) rescue StandardError # Best-effort — do not block any command if this fails. end |
.parse_abandon_command(arguments:, error:) ⇒ Object
— abandon —
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 |
# File 'lib/carson/cli.rb', line 658 def self.parse_abandon_command( arguments:, error: ) = { json: false } abandon_parser = OptionParser.new do |parser| parser. = "Usage: carson abandon <pr-number|pr-url|branch> [--json]" parser.separator "" parser.separator "Close an abandoned delivery and clean up its worktree and branch when safe." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson abandon 301" parser.separator " carson abandon https://github.com/acme/widgets/pull/301" parser.separator " carson abandon codex/feature-branch" end abandon_parser.parse!( arguments ) target = arguments.shift.to_s.strip if target.empty? || !arguments.empty? error.puts "#{BADGE} Use: carson abandon <pr-number|pr-url|branch>" error.puts abandon_parser return { command: :invalid } end { command: "abandon", target: target, json: .fetch( :json ) } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts abandon_parser { command: :invalid } end |
.parse_args(arguments:, output:, error:) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/carson/cli.rb', line 73 def self.parse_args( arguments:, output:, error: ) verbose = arguments.delete( "--verbose" ) ? true : false parser = build_parser preset = parse_preset_command( arguments: arguments, output: output, parser: parser ) return preset.merge( verbose: verbose ) unless preset.nil? # Pre-scan for legacy grammar before OptionParser can reject tokens. legacy = detect_legacy_grammar( arguments: arguments ) if legacy error.puts "#{BADGE} #{legacy}" return { command: :invalid, verbose: verbose } end first = arguments.first # Portfolio command as first token. if PORTFOLIO_COMMANDS.include?( first ) arguments.shift result = parse_portfolio_command( command: first, arguments: arguments, error: error ) return result.merge( verbose: verbose ) end # Repo command as first token — resolve from CWD. if REPO_COMMANDS.include?( first ) arguments.shift result = parse_repo_command( command: first, arguments: arguments, error: error ) return result.merge( verbose: verbose ) end # Otherwise: first token is an explicit repo subject, second is the repo command. repo_subject = arguments.shift repo_command = arguments.shift if repo_command.nil? || repo_command.strip.empty? error.puts "#{BADGE} Unknown command: #{repo_subject}. Run carson --help for usage." return { command: :invalid, verbose: verbose } end # Catch portfolio commands used with a repo subject. if PORTFOLIO_COMMANDS.include?( repo_command ) && !REPO_COMMANDS.include?( repo_command ) error.puts "#{BADGE} #{repo_command} is a portfolio command. Use: carson #{repo_command}" return { command: :invalid, verbose: verbose } end unless REPO_COMMANDS.include?( repo_command ) error.puts "#{BADGE} Unknown command: #{repo_command}. Run carson --help for usage." return { command: :invalid, verbose: verbose } end result = parse_repo_command( command: repo_command, arguments: arguments, error: error ) result.merge( verbose: verbose, repo_subject: repo_subject ) rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts parser { command: :invalid } end |
.parse_audit_command(arguments:, error:) ⇒ Object
— audit —
628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 |
# File 'lib/carson/cli.rb', line 628 def self.parse_audit_command( arguments:, error: ) = { json: false } audit_parser = OptionParser.new do |parser| parser. = "Usage: carson audit [--json]" parser.separator "" parser.separator "Run pre-commit health checks on the repository." parser.separator "Validates hooks, main-branch sync, PR status, and CI baseline." parser.separator "Exits with a non-zero status when policy violations are found." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson audit Check repository health (also the default command)" parser.separator " carson audit --json Structured output for agent consumption" end audit_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for audit: #{arguments.join( ' ' )}" return { command: :invalid } end { command: "audit", json: [ :json ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts audit_parser { command: :invalid } end |
.parse_deliver_command(arguments:, error:) ⇒ Object
— deliver —
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 |
# File 'lib/carson/cli.rb', line 748 def self.parse_deliver_command( arguments:, error: ) if arguments.include?( "--merge" ) error.puts "#{BADGE} carson deliver --merge is no longer supported; use carson deliver" return { command: :invalid } end = { json: false, title: nil, body_file: nil, commit_message: nil } deliver_parser = OptionParser.new do |parser| parser. = "Usage: carson deliver [--json] [--title TITLE] [--body-file PATH] [--commit MESSAGE]" parser.separator "" parser.separator "Push the current branch, create or refresh the pull request, and hand the branch to Carson." parser.separator "Use --commit to create one all-dirty delivery commit before Carson pushes and opens the PR." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.on( "--title TITLE", "PR title (defaults to branch name)" ) { |value| [ :title ] = value } parser.on( "--body-file PATH", "File containing PR body text" ) { |value| [ :body_file ] = value } parser.on( "--commit MESSAGE", "Commit all dirty user changes before delivery" ) { |value| [ :commit_message ] = value } parser.separator "" parser.separator "Examples:" parser.separator " carson deliver Deliver existing commits" parser.separator " carson deliver --commit \"fix: harden flow\" Commit dirty changes, then deliver" end deliver_parser.parse!( arguments ) if .fetch( :commit_message, nil ).to_s.strip.empty? && !.fetch( :commit_message, nil ).nil? error.puts "#{BADGE} --commit requires a non-empty message" error.puts deliver_parser return { command: :invalid } end unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for deliver: #{arguments.join( ' ' )}" error.puts deliver_parser return { command: :invalid } end { command: "deliver", json: .fetch( :json ), title: [ :title ], body_file: [ :body_file ], commit_message: [ :commit_message ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts deliver_parser { command: :invalid } end |
.parse_housekeep_command(arguments:, error:) ⇒ Object
— housekeep —
838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 |
# File 'lib/carson/cli.rb', line 838 def self.parse_housekeep_command( arguments:, error: ) = { json: false, dry_run: false } housekeep_parser = OptionParser.new do |parser| parser. = "Usage: carson housekeep [--dry-run] [--json]" parser.separator "" parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches." parser.separator "Operates on the current repository (or explicit repo via carson <repo> housekeep)." parser.separator "" parser.separator "Options:" parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { [ :dry_run ] = true } parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson housekeep Housekeep the current repository" parser.separator " carson housekeep --dry-run Preview what housekeep would do" end housekeep_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for housekeep: #{arguments.join( ' ' )}" error.puts housekeep_parser return { command: :invalid } end { command: "housekeep", json: [ :json ], dry_run: [ :dry_run ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts housekeep_parser { command: :invalid } end |
.parse_list_command(arguments:, error:) ⇒ Object
— list —
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 |
# File 'lib/carson/cli.rb', line 354 def self.parse_list_command( arguments:, error: ) = { json: false } list_parser = OptionParser.new do |parser| parser. = "Usage: carson list [--json]" parser.separator "" parser.separator "List all repositories governed by Carson." parser.separator "Shows the portfolio of repos registered via carson onboard." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson list List governed repositories" parser.separator " carson list --json Structured output for agent consumption" end list_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for list: #{arguments.join( ' ' )}" return { command: :invalid } end { command: "list", json: [ :json ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts list_parser { command: :invalid } end |
.parse_offboard_command(arguments:, error:) ⇒ Object
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/carson/cli.rb', line 321 def self.parse_offboard_command( arguments:, error: ) offboard_parser = OptionParser.new do |parser| parser. = "Usage: carson offboard <REPO_PATH>" parser.separator "" parser.separator "Remove a repository from Carson governance." parser.separator "Unregisters the repo from Carson's portfolio and removes hooks." parser.separator "" parser.separator "Examples:" parser.separator " carson offboard ~/Dev/app Offboard a specific repository" end offboard_parser.parse!( arguments ) if arguments.empty? error.puts "#{BADGE} Missing repo path. Use: carson offboard <repo_path>" error.puts offboard_parser return { command: :invalid } end if arguments.length > 1 error.puts "#{BADGE} Too many arguments for offboard. Use: carson offboard <repo_path>" error.puts offboard_parser return { command: :invalid } end { command: "offboard", repo_root: File.( arguments.first ) } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts offboard_parser { command: :invalid } end |
.parse_onboard_command(arguments:, error:) ⇒ Object
— onboard / offboard —
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/carson/cli.rb', line 290 def self.parse_onboard_command( arguments:, error: ) onboard_parser = OptionParser.new do |parser| parser. = "Usage: carson onboard <REPO_PATH>" parser.separator "" parser.separator "Register a repository for Carson governance." parser.separator "Detects the remote, installs hooks, applies templates, and runs initial audit." parser.separator "" parser.separator "Examples:" parser.separator " carson onboard ~/Dev/app Onboard a specific repository" end onboard_parser.parse!( arguments ) if arguments.empty? error.puts "#{BADGE} Missing repo path. Use: carson onboard <repo_path>" error.puts onboard_parser return { command: :invalid } end if arguments.length > 1 error.puts "#{BADGE} Too many arguments for onboard. Use: carson onboard <repo_path>" error.puts onboard_parser return { command: :invalid } end { command: "onboard", repo_root: File.( arguments.first ) } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts onboard_parser { command: :invalid } end |
.parse_portfolio_command(command:, arguments:, error:) ⇒ Object
— portfolio command routing —
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/carson/cli.rb', line 200 def self.parse_portfolio_command( command:, arguments:, error: ) case command when "version" { command: "version" } when "list" parse_list_command( arguments: arguments, error: error ) when "onboard" parse_onboard_command( arguments: arguments, error: error ) when "offboard" parse_offboard_command( arguments: arguments, error: error ) when "refresh" parse_refresh_command( arguments: arguments, error: error ) else error.puts "#{BADGE} Unknown portfolio command: #{command}" { command: :invalid } end end |
.parse_preset_command(arguments:, output:, parser:) ⇒ Object
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/carson/cli.rb', line 162 def self.parse_preset_command( arguments:, output:, parser: ) first = arguments.first if [ "--help", "-h" ].include?( first ) output.puts parser return { command: :help } end return { command: "version" } if [ "--version", "-v" ].include?( first ) return { command: "audit" } if arguments.empty? nil end |
.parse_prune_command(arguments:, error:) ⇒ Object
— prune —
453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 |
# File 'lib/carson/cli.rb', line 453 def self.parse_prune_command( arguments:, error: ) = { json: false } prune_parser = OptionParser.new do |parser| parser. = "Usage: carson prune [--json]" parser.separator "" parser.separator "Remove stale local branches." parser.separator "Cleans up branches gone from the remote, orphan branches with merged PRs," parser.separator "and absorbed branches whose content is already on main." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson prune Clean up stale branches in this repo" end prune_parser.parse!( arguments ) { command: "prune", json: [ :json ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts prune_parser { command: :invalid } end |
.parse_receive_command(arguments:, error:) ⇒ Object
— receive —
407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'lib/carson/cli.rb', line 407 def self.parse_receive_command( arguments:, error: ) = { dry_run: false, json: false, loop_seconds: nil } receive_parser = OptionParser.new do |parser| parser. = "Usage: carson <repo> receive [--dry-run] [--json] [--loop SECONDS]" parser.separator "" parser.separator "Triage and advance deliveries for one repository." parser.separator "Scans open PRs, classifies them, and takes action" parser.separator "(merge, request review, or report). Runs once by default." parser.separator "" parser.separator "Options:" parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { [ :dry_run ] = true } parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds| error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1 [ :loop_seconds ] = seconds end parser.separator "" parser.separator "Examples:" parser.separator " carson nexus receive Triage deliveries for nexus" parser.separator " carson nexus receive --dry-run Preview actions without applying them" parser.separator " carson nexus receive --loop 300 Run continuously every 5 minutes" end receive_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for receive: #{arguments.join( ' ' )}" error.puts receive_parser return { command: :invalid } end { command: "receive", dry_run: .fetch( :dry_run ), json: .fetch( :json ), loop_seconds: [ :loop_seconds ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts receive_parser { command: :invalid } end |
.parse_recover_command(arguments:, error:) ⇒ Object
— recover —
797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 |
# File 'lib/carson/cli.rb', line 797 def self.parse_recover_command( arguments:, error: ) = { json: false, check_name: nil } recover_parser = OptionParser.new do |parser| parser. = "Usage: carson recover --check NAME [--json]" parser.separator "" parser.separator "Merge the current repair PR when one governance-owned required check is already red on the default branch." parser.separator "Recovery is narrow: Carson verifies the baseline failure, keeps every other gate intact, and records an audit event." parser.separator "" parser.separator "Options:" parser.on( "--check NAME", "Name of the governance-owned required check to recover" ) { |value| [ :check_name ] = value } parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson recover --check \"Carson governance\"" parser.separator " carson recover --check \"Carson governance\" --json" end recover_parser.parse!( arguments ) if .fetch( :check_name, nil ).to_s.strip.empty? error.puts "#{BADGE} --check requires a non-empty governance check name" error.puts recover_parser return { command: :invalid } end unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for recover: #{arguments.join( ' ' )}" error.puts recover_parser return { command: :invalid } end { command: "recover", json: .fetch( :json ), check_name: .fetch( :check_name ) } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts recover_parser { command: :invalid } end |
.parse_refresh_command(arguments:, error:) ⇒ Object
— refresh —
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
# File 'lib/carson/cli.rb', line 383 def self.parse_refresh_command( arguments:, error: ) refresh_parser = OptionParser.new do |parser| parser. = "Usage: carson refresh" parser.separator "" parser.separator "Re-install Carson hooks and configuration for all governed repositories." parser.separator "" parser.separator "Examples:" parser.separator " carson refresh Refresh all governed repos" end refresh_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for refresh: #{arguments.join( ' ' )}" error.puts refresh_parser return { command: :invalid } end { command: "refresh:all" } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts refresh_parser { command: :invalid } end |
.parse_repo_command(command:, arguments:, error:) ⇒ Object
— repo command routing —
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/carson/cli.rb', line 220 def self.parse_repo_command( command:, arguments:, error: ) case command when "setup" parse_setup_command( arguments: arguments, error: error ) when "deliver" parse_deliver_command( arguments: arguments, error: error ) when "receive" parse_receive_command( arguments: arguments, error: error ) when "sync" parse_sync_command( arguments: arguments, error: error ) when "status" parse_status_command( arguments: arguments, error: error ) when "audit" parse_audit_command( arguments: arguments, error: error ) when "prune" parse_prune_command( arguments: arguments, error: error ) when "housekeep" parse_housekeep_command( arguments: arguments, error: error ) when "worktree" parse_worktree_subcommand( arguments: arguments, error: error ) when "abandon" parse_abandon_command( arguments: arguments, error: error ) when "recover" parse_recover_command( arguments: arguments, error: error ) when "review" parse_review_subcommand( arguments: arguments, error: error ) when "template" parse_template_subcommand( arguments: arguments, error: error ) else error.puts "#{BADGE} Unknown repo command: #{command}" { command: :invalid } end end |
.parse_review_subcommand(arguments:, error:) ⇒ Object
— review —
539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 |
# File 'lib/carson/cli.rb', line 539 def self.parse_review_subcommand( arguments:, error: ) review_parser = OptionParser.new do |parser| parser. = "Usage: carson review <gate|sweep>" parser.separator "" parser.separator "Manage PR review workflow." parser.separator "" parser.separator "Subcommands:" parser.separator " gate Check if review requirements are met for merge" parser.separator " sweep Scan and resolve pending review threads" parser.separator "" parser.separator "Examples:" parser.separator " carson review gate Check merge readiness" parser.separator " carson review sweep Resolve pending review threads" end review_parser.parse!( arguments ) action = arguments.shift if action.to_s.strip.empty? error.puts "#{BADGE} Missing subcommand for review. Use: carson review gate|sweep" error.puts review_parser return { command: :invalid } end { command: "review:#{action}" } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts review_parser { command: :invalid } end |
.parse_setup_command(arguments:, error:) ⇒ Object
— setup —
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/carson/cli.rb', line 256 def self.parse_setup_command( arguments:, error: ) = {} setup_parser = OptionParser.new do |parser| parser. = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--canonical PATH]" parser.separator "" parser.separator "Initialise Carson configuration for the current repository." parser.separator "Detects git remote, main branch, and workflow style, then writes .carson.yml." parser.separator "Pass flags to override detected values." parser.separator "" parser.separator "Options:" parser.on( "--remote NAME", "Git remote name" ) { |value| [ "git.remote" ] = value } parser.on( "--main-branch NAME", "Main branch name" ) { |value| [ "git.main_branch" ] = value } parser.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |value| [ "workflow.style" ] = value } parser.on( "--canonical PATH", "Canonical lint policy directory path" ) { |value| [ "lint.canonical" ] = value } parser.separator "" parser.separator "Examples:" parser.separator " carson setup Auto-detect and write config" parser.separator " carson setup --remote github Use 'github' as the git remote" end setup_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for setup: #{arguments.join( ' ' )}" error.puts setup_parser return { command: :invalid } end { command: "setup", cli_choices: } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts setup_parser { command: :invalid } end |
.parse_status_command(arguments:, error:) ⇒ Object
— status —
719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 |
# File 'lib/carson/cli.rb', line 719 def self.parse_status_command( arguments:, error: ) = { json: false } status_parser = OptionParser.new do |parser| parser. = "Usage: carson status [--json]" parser.separator "" parser.separator "Show the current state of the repository." parser.separator "Reports branch, worktrees, open PRs, stale branches, and version." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson status Quick overview of repository state" parser.separator " carson status --json Structured output for agent consumption" end status_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for status: #{arguments.join( ' ' )}" return { command: :invalid } end { command: "status", json: [ :json ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts status_parser { command: :invalid } end |
.parse_sync_command(arguments:, error:) ⇒ Object
— sync —
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/carson/cli.rb', line 690 def self.parse_sync_command( arguments:, error: ) = { json: false } sync_parser = OptionParser.new do |parser| parser. = "Usage: carson sync [--json]" parser.separator "" parser.separator "Sync the local main branch with the remote." parser.separator "Fetches and fast-forwards main without switching branches." parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson sync Pull latest changes from remote main" parser.separator " carson sync --json Structured output for agent consumption" end sync_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for sync: #{arguments.join( ' ' )}" return { command: :invalid } end { command: "sync", json: [ :json ] } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts sync_parser { command: :invalid } end |
.parse_template_subcommand(arguments:, error:) ⇒ Object
— template —
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 |
# File 'lib/carson/cli.rb', line 570 def self.parse_template_subcommand( arguments:, error: ) # Handle parent-level help or missing subcommand. if arguments.empty? || [ "--help", "-h" ].include?( arguments.first ) template_parser = OptionParser.new do |parser| parser. = "Usage: carson template <check|apply> [options]" parser.separator "" parser.separator "Manage canonical template files (CI workflows, lint configs)." parser.separator "" parser.separator "Subcommands:" parser.separator " check Show template drift without making changes" parser.separator " apply [--push-prep] Sync templates into the repository" parser.separator "" parser.separator "Examples:" parser.separator " carson template check Check for template drift" parser.separator " carson template apply Apply canonical templates" end if arguments.empty? error.puts "#{BADGE} Missing subcommand for template. Use: carson template check|apply" error.puts template_parser return { command: :invalid } end # Let OptionParser handle --help (prints and exits). template_parser.parse!( arguments ) return { command: :help } end action = arguments.shift return { command: "template:#{action}" } unless action == "apply" = { push_prep: false } apply_parser = OptionParser.new do |parser| parser. = "Usage: carson template apply [--push-prep]" parser.separator "" parser.separator "Sync canonical template files (CI workflows, lint configs) into the repository." parser.separator "Copies managed files from the configured canonical directory." parser.separator "" parser.separator "Options:" parser.on( "--push-prep", "Apply templates and auto-commit any managed file changes (used by pre-push hook)" ) do [ :push_prep ] = true end end apply_parser.parse!( arguments ) unless arguments.empty? error.puts "#{BADGE} Unexpected arguments for template apply: #{arguments.join( ' ' )}" error.puts apply_parser return { command: :invalid } end { command: "template:apply", push_prep: .fetch( :push_prep ) } rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts( apply_parser || template_parser ) { command: :invalid } end |
.parse_worktree_subcommand(arguments:, error:) ⇒ Object
— worktree —
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 |
# File 'lib/carson/cli.rb', line 478 def self.parse_worktree_subcommand( arguments:, error: ) = { json: false, force: false } worktree_parser = OptionParser.new do |parser| parser. = "Usage: carson worktree <create|list|remove> <name> [options]" parser.separator "" parser.separator "Manage isolated worktrees for coding agents." parser.separator "Create auto-syncs main before branching. Remove guards against" parser.separator "unpushed commits and CWD-inside-worktree by default." parser.separator "" parser.separator "Subcommands:" parser.separator " create <name> Create a new worktree with a fresh branch" parser.separator " list List registered worktrees with cleanup status" parser.separator " remove <name> [--force] Remove a worktree (--force skips safety checks)" parser.separator "" parser.separator "Options:" parser.on( "--json", "Machine-readable JSON output" ) { [ :json ] = true } parser.on( "--force", "Skip safety checks on remove" ) { [ :force ] = true } parser.separator "" parser.separator "Examples:" parser.separator " carson worktree create feature-x Create an isolated worktree" parser.separator " carson worktree list Show registered worktrees" parser.separator " carson worktree remove feature-x Remove after work is pushed" end worktree_parser.parse!( arguments ) action = arguments.shift if action.to_s.strip.empty? error.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|remove <name>" error.puts worktree_parser return { command: :invalid } end case action when "create" name = arguments.shift if name.to_s.strip.empty? error.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>" return { command: :invalid } end { command: "worktree:create", worktree_name: name, json: [ :json ] } when "list" { command: "worktree:list", json: [ :json ] } when "remove" worktree_path = arguments.shift if worktree_path.to_s.strip.empty? error.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>" return { command: :invalid } end { command: "worktree:remove", worktree_path: worktree_path, force: [ :force ], json: [ :json ] } else error.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|remove <name>" { command: :invalid } end rescue OptionParser::ParseError => exception error.puts "#{BADGE} #{exception.}" error.puts worktree_parser { command: :invalid } end |
.resolve_cwd_repo(repo_root:, config:) ⇒ Object
Resolves the CWD repo_root to a governed repository path. Canonicalises worktree vs main-tree via git common-dir, then matches. Compares real paths to handle symlinks (e.g., /tmp → /private/tmp on macOS).
883 884 885 886 887 888 889 890 |
# File 'lib/carson/cli.rb', line 883 def self.resolve_cwd_repo( repo_root:, config: ) canonical = canonicalise_repo_root( repo_root: repo_root ) repos = config.govern_repos repos.find do |repo_path| = File.( repo_path ) == canonical || ( File.exist?( ) && File.realpath( ) == canonical ) end end |
.resolve_repo_target(name:, config:) ⇒ Object
Resolves an explicit repo name/path to a governed repository path. Tries exact configured path first, then basename match (case-insensitive).
871 872 873 874 875 876 877 878 |
# File 'lib/carson/cli.rb', line 871 def self.resolve_repo_target( name:, config: ) repos = config.govern_repos = File.( name ) return if repos.include?( ) downcased = File.basename( name ).downcase repos.find { |repo_path| File.basename( repo_path ).downcase == downcased } end |
.start(arguments:, repo_root:, tool_root:, output:, error:) ⇒ Object
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/carson/cli.rb', line 11 def self.start( arguments:, repo_root:, tool_root:, output:, error: ) ensure_global_artefacts!( tool_root: tool_root ) parsed = parse_args( arguments: arguments, output: output, error: error ) command = parsed.fetch( :command ) return Runtime::EXIT_OK if command == :help return Runtime::EXIT_ERROR if command == :invalid if command == "version" output.puts "#{BADGE} #{Carson::VERSION}" return Runtime::EXIT_OK end verbose = parsed.fetch( :verbose, false ) # Portfolio commands — no repo resolution needed. # onboard/offboard carry a parsed repo_root from their <repo_path> argument; # list and refresh:all use the invoking CWD. if %w[list refresh:all onboard offboard].include?( command ) effective_root = parsed.key?( :repo_root ) ? parsed.fetch( :repo_root ) : repo_root runtime = Runtime.new( repo_root: effective_root, tool_root: tool_root, output: output, error: error, verbose: verbose ) return dispatch( parsed: parsed, runtime: runtime ) end # Repo commands with an explicit repo subject — resolve it. if parsed.key?( :repo_subject ) config = Config.load( repo_root: repo_root ) resolved = resolve_repo_target( name: parsed.fetch( :repo_subject ), config: config ) if resolved.nil? error.puts "#{BADGE} Not a governed repo: #{parsed.fetch( :repo_subject )}" return Runtime::EXIT_ERROR end runtime = Runtime.new( repo_root: resolved, tool_root: tool_root, output: output, error: error, verbose: verbose ) return dispatch( parsed: parsed, runtime: runtime ) end # Repo commands resolved from CWD. target_repo_root = parsed.fetch( :repo_root, nil ) target_repo_root = repo_root if target_repo_root.to_s.strip.empty? unless Dir.exist?( target_repo_root ) error.puts "#{BADGE} Repository path not found: #{target_repo_root}" return Runtime::EXIT_ERROR end config = Config.load( repo_root: target_repo_root ) resolved = resolve_cwd_repo( repo_root: target_repo_root, config: config ) unless resolved error.puts "#{BADGE} Not inside a governed repo. Use: carson <repo> #{command} or cd into a governed repo." error.puts "#{BADGE} Run carson list to see governed repositories." return Runtime::EXIT_ERROR end runtime = Runtime.new( repo_root: resolved, tool_root: tool_root, output: output, error: error, verbose: verbose, work_dir: target_repo_root ) dispatch( parsed: parsed, runtime: runtime ) rescue ConfigError => exception error.puts "#{BADGE} Configuration problem: #{exception.}" Runtime::EXIT_ERROR rescue StandardError => exception error.puts "#{BADGE} #{exception.}" Runtime::EXIT_ERROR end |