Module: McpDiff::Cli
- Defined in:
- lib/mcp_diff/cli.rb
Overview
Command dispatcher: init/snapshot/check/update/diff/explain/rules, with pretty/JSON/MD output and the 0/1/2 exit-code contract. Port of cli/src/index.ts. run() returns the process exit code.
Class Method Summary collapse
- .capture_options_from(cfg) ⇒ Object
- .cmd_check(globals) ⇒ Object
- .cmd_diff(globals, before_path, after_path) ⇒ Object
- .cmd_explain(rule_id) ⇒ Object
-
.cmd_init(globals, opts) ⇒ Object
── commands ────────────────────────────────────────────────────.
- .cmd_rules(globals) ⇒ Object
- .cmd_snapshot(globals) ⇒ Object
- .cmd_update(globals) ⇒ Object
- .dispatch(command, args, globals, opts) ⇒ Object
-
.globals_config(globals) ⇒ Object
── shared helpers ──────────────────────────────────────────────.
- .help_text ⇒ Object
- .init_server_target(opts) ⇒ Object
- .live_snapshot(cfg) ⇒ Object
- .lockfile_path(cfg) ⇒ Object
- .output_format(globals) ⇒ Object
- .parse(argv) ⇒ Object
- .read_lockfile(path, verify: true) ⇒ Object
- .read_snapshot_file(path) ⇒ Object
- .run(argv) ⇒ Object
- .write_lockfile(path, snapshot) ⇒ Object
Class Method Details
.capture_options_from(cfg) ⇒ Object
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/mcp_diff/cli.rb', line 87 def (cfg) server = cfg["server"] || {} opts = {} opts[:timeout_ms] = cfg["timeoutMs"] if cfg["timeoutMs"] if server["stdio"] parts = Config.shell_split(server["stdio"]) command = parts.shift raise "config server.stdio is empty" if command.nil? opts[:stdio] = { command: command, args: parts } opts[:stdio][:env] = cfg["env"] if cfg["env"] elsif server["url"] opts[:url] = server["url"] else raise "config has no server target — set server.stdio or server.url" end opts end |
.cmd_check(globals) ⇒ Object
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/mcp_diff/cli.rb', line 194 def cmd_check(globals) cfg = globals_config(globals) locked = read_lockfile(lockfile_path(cfg)) live = live_snapshot(cfg) if locked["contractHash"] == live["contractHash"] puts Render.render(output_format(globals), [], 0) return 0 end findings = Core::Classify.classify(Core::Diff.diff(locked, live)) exit_code = Core::Classify.compute_exit_code(findings, cfg["strict"] || "breaking") puts Render.render(output_format(globals), findings, exit_code) if findings.empty? puts "ℹ contract hash changed but no rule classified the change (compatible) — run: mcp-diff update" end exit_code end |
.cmd_diff(globals, before_path, after_path) ⇒ Object
224 225 226 227 228 229 230 231 232 233 |
# File 'lib/mcp_diff/cli.rb', line 224 def cmd_diff(globals, before_path, after_path) raise "diff needs two snapshot files: mcp-diff diff <before> <after>" if before_path.nil? || after_path.nil? before = read_snapshot_file(before_path) after = read_snapshot_file(after_path) findings = Core::Classify.classify(Core::Diff.diff(before, after)) exit_code = Core::Classify.compute_exit_code(findings) puts Render.render(output_format(globals), findings, exit_code) exit_code end |
.cmd_explain(rule_id) ⇒ Object
235 236 237 238 239 240 241 242 |
# File 'lib/mcp_diff/cli.rb', line 235 def cmd_explain(rule_id) entry = Core::Rules::CATALOG.find { |r| r["id"] == rule_id } unless entry known = Core::Rules::CATALOG.map { |r| r["id"] }.join("\n ") raise "unknown rule \"#{rule_id}\" — known rules:\n #{known}" end puts "#{entry['id']} [#{entry['class'].upcase}]\n\n#{entry['description']}" end |
.cmd_init(globals, opts) ⇒ Object
── commands ────────────────────────────────────────────────────
152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/mcp_diff/cli.rb', line 152 def cmd_init(globals, opts) config_path = globals[:config] raise "#{config_path} already exists — delete it first to re-init" if File.exist?(config_path) server = init_server_target(opts) cfg = { "server" => server, "strict" => "breaking" } write_lockfile(config_path, cfg) puts "wrote #{config_path}" snapshot = live_snapshot(cfg) write_lockfile(lockfile_path(cfg), snapshot) puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools) — commit both files" end |
.cmd_rules(globals) ⇒ Object
244 245 246 247 248 249 250 |
# File 'lib/mcp_diff/cli.rb', line 244 def cmd_rules(globals) if globals[:json] puts JSON.pretty_generate(Core::Rules::CATALOG) return end Core::Rules::CATALOG.each { |r| puts "#{r['class'].upcase.ljust(9)} #{r['id']}" } end |
.cmd_snapshot(globals) ⇒ Object
187 188 189 190 191 192 |
# File 'lib/mcp_diff/cli.rb', line 187 def cmd_snapshot(globals) cfg = globals_config(globals) snapshot = live_snapshot(cfg) write_lockfile(lockfile_path(cfg), snapshot) puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools)" end |
.cmd_update(globals) ⇒ Object
211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/mcp_diff/cli.rb', line 211 def cmd_update(globals) cfg = globals_config(globals) path = lockfile_path(cfg) live = live_snapshot(cfg) if File.exist?(path) locked = read_lockfile(path, verify: false) findings = Core::Classify.classify(Core::Diff.diff(locked, live)) puts Render.render(output_format(globals), findings, 0) unless findings.empty? end write_lockfile(path, live) puts "updated #{path}" end |
.dispatch(command, args, globals, opts) ⇒ Object
54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/mcp_diff/cli.rb', line 54 def dispatch(command, args, globals, opts) case command when "init" then cmd_init(globals, opts) when "snapshot" then cmd_snapshot(globals) when "check" then return cmd_check(globals) when "update" then cmd_update(globals) when "diff" then return cmd_diff(globals, args[0], args[1]) when "explain" then cmd_explain(args[0]) when "rules" then cmd_rules(globals) when nil then (puts help_text) else raise "unknown command \"#{command}\" — run: mcp-diff --help" end 0 end |
.globals_config(globals) ⇒ Object
── shared helpers ──────────────────────────────────────────────
71 72 73 |
# File 'lib/mcp_diff/cli.rb', line 71 def globals_config(globals) Config.load_config(globals[:config], globals[:profile]) end |
.help_text ⇒ Object
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/mcp_diff/cli.rb', line 252 def help_text <<~HELP mcp-diff — lockfile + CI gate for your MCP server's contract Usage: mcp-diff [--config <path>] [--profile <name>] [--json|--md] <command> Commands: init [--stdio "<cmd>" | --url <url>] write config + first lockfile (auto-detects .mcp.json) snapshot (re)write mcp.lock.json check live server vs lockfile; exit 0/1/2 update accept changes, rewrite lockfile diff <a.json> <b.json> compare two snapshots explain <rule-id> explain one rule rules list the rule catalog HELP end |
.init_server_target(opts) ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/mcp_diff/cli.rb', line 165 def init_server_target(opts) if opts[:url] valid = begin u = URI.parse(opts[:url]) u.scheme && u.host rescue URI::InvalidURIError false end raise "\"#{opts[:url]}\" is not a valid URL — expected e.g. http://localhost:3000/mcp" unless valid { "url" => opts[:url] } elsif opts[:stdio] { "stdio" => opts[:stdio] } else detected = Config.detect_mcp_json raise "no server target — pass --stdio \"<command>\" or --url <url> (no .mcp.json found to auto-detect)" if detected.nil? puts "detected server from .mcp.json: #{detected['stdio']}" detected end end |
.live_snapshot(cfg) ⇒ Object
106 107 108 109 |
# File 'lib/mcp_diff/cli.rb', line 106 def live_snapshot(cfg) raw = Client.capture(**(cfg)) Core::Normalize.normalize(raw, ignore: cfg["ignore"] || []) end |
.lockfile_path(cfg) ⇒ Object
83 84 85 |
# File 'lib/mcp_diff/cli.rb', line 83 def lockfile_path(cfg) cfg["lockfile"] || Config::DEFAULT_LOCKFILE end |
.output_format(globals) ⇒ Object
75 76 77 78 79 80 81 |
# File 'lib/mcp_diff/cli.rb', line 75 def output_format(globals) raise "choose one output format — pass --json or --md, not both" if globals[:json] && globals[:md] return "json" if globals[:json] return "md" if globals[:md] "pretty" end |
.parse(argv) ⇒ Object
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 |
# File 'lib/mcp_diff/cli.rb', line 25 def parse(argv) globals = { config: Config::DEFAULT_CONFIG_PATH, profile: nil, json: false, md: false, handled: false } opts = {} command = nil args = [] i = 0 while i < argv.length a = argv[i] case a when "-c", "--config" then globals[:config] = argv[i += 1] when "-p", "--profile" then globals[:profile] = argv[i += 1] when "--json" then globals[:json] = true when "--md" then globals[:md] = true when "--stdio" then opts[:stdio] = argv[i += 1] when "--url" then opts[:url] = argv[i += 1] when "-V", "--version" then (puts VERSION; globals[:handled] = true) when "-h", "--help" then (puts help_text; globals[:handled] = true) else if command.nil? && !a.start_with?("-") command = a else args << a end end i += 1 end [globals, command, args, opts] end |
.read_lockfile(path, verify: true) ⇒ Object
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/mcp_diff/cli.rb', line 111 def read_lockfile(path, verify: true) text = begin File.read(path) rescue SystemCallError raise "cannot read lockfile \"#{path}\" — run: npx @mcp-diff/cli snapshot" end data = begin JSON.parse(text) rescue JSON::ParserError => e raise "lockfile \"#{path}\" is not valid JSON (#{e.}) — run: npx @mcp-diff/cli snapshot" end snapshot = Core::Normalize.parse_snapshot(data) if verify body = snapshot.reject { |k, _| k == "contractHash" } if Core::Canonical.contract_hash_of(body) != snapshot["contractHash"] raise "lockfile \"#{path}\" does not match its contractHash (hand-edited or bad merge?) — " \ "run: npx @mcp-diff/cli update" end end snapshot end |
.read_snapshot_file(path) ⇒ Object
133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/mcp_diff/cli.rb', line 133 def read_snapshot_file(path) text = begin File.read(path) rescue SystemCallError raise "cannot read snapshot file \"#{path}\"" end begin Core::Normalize.parse_snapshot(JSON.parse(text)) rescue JSON::ParserError => e raise "snapshot file \"#{path}\": #{e.}" end end |
.run(argv) ⇒ Object
15 16 17 18 19 20 21 22 23 |
# File 'lib/mcp_diff/cli.rb', line 15 def run(argv) globals, command, args, opts = parse(argv) return 0 if globals[:handled] dispatch(command, args, globals, opts) rescue StandardError => e warn "✗ #{e.}" 1 end |
.write_lockfile(path, snapshot) ⇒ Object
146 147 148 |
# File 'lib/mcp_diff/cli.rb', line 146 def write_lockfile(path, snapshot) File.write(path, Core::Canonical.canonical_stringify(snapshot)) end |