Commit 52cf40b

mo khan <mo@mokhan.ca>
2026-07-20 14:09:48
feat: add CLI to interact with SCIM server
cli
1 parent bb6f899
bin/run
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+bundle exec ./exe/scim-kit "$@"
exe/scim-kit
@@ -2,3 +2,8 @@
 # frozen_string_literal: true
 
 require 'scim/kit'
+require 'scim/kit/cli'
+
+Scim::Kit.logger = Logger.new(File::NULL)
+
+Scim::Kit::Cli::App.start(ARGV)
lib/scim/kit/cli/app.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class App < Thor
+        RESOURCES = {
+          service_provider_configuration: 'ServiceProviderConfig',
+          schemas: 'Schemas',
+          resource_types: 'ResourceTypes'
+        }.freeze
+
+        def self.exit_on_failure?
+          true
+        end
+
+        class_option :url, desc: 'Base URL of the SCIM server (or SCIM_KIT_URL)'
+        class_option :header, type: :array, default: [],
+          desc: 'Extra header as "Name: Value" (repeatable)'
+
+        desc 'discover', "Discover a server's ServiceProviderConfig, Schemas, and ResourceTypes"
+        def discover
+          Reporting.rescue_errors(shell) { fetch_discovery }
+        end
+
+        desc 'list RESOURCE_TYPE', 'List resources of a given type'
+        method_option :filter, desc: 'SCIM filter expression'
+        method_option :start_index, type: :numeric, desc: 'Pagination start index'
+        method_option :count, type: :numeric, desc: 'Page size'
+        method_option :sort_by, desc: 'Attribute to sort by'
+        method_option :sort_order, desc: 'ascending or descending'
+        method_option :attributes, desc: 'Comma-separated attribute names to return'
+        def list(resource_type)
+          Reporting.rescue_errors(shell) { fetch_list(resource_type) }
+        end
+
+        desc 'get RESOURCE_TYPE ID', 'Fetch a single resource'
+        method_option :attributes, desc: 'Comma-separated attribute names to return'
+        def get(resource_type, id)
+          Reporting.rescue_errors(shell) { fetch_resource(resource_type, id) }
+        end
+
+        private
+
+        def fetch_discovery
+          responses = {}
+          RESOURCES.each do |key, path|
+            result = http.fetch(Cli.join_uri(url, path), headers: headers)
+            return Reporting.report(result, shell) unless result.ok?
+
+            responses[key] = result.body
+          end
+          Reporting.report(Http::Result.new(200, responses), shell)
+        end
+
+        def fetch_list(resource_type)
+          uri = Cli.join_uri(url, resolve_endpoint(resource_type))
+          query = list_query_string
+          uri.query = query unless query.empty?
+          report_fetch(uri)
+        end
+
+        def fetch_resource(resource_type, id)
+          uri = Cli.join_uri(url, "#{resolve_endpoint(resource_type)}/#{id}")
+          if options[:attributes]
+            uri.query = URI.encode_www_form(attributes: options[:attributes])
+          end
+          report_fetch(uri)
+        end
+
+        def report_fetch(uri)
+          Reporting.report(http.fetch(uri, headers: headers), shell)
+        end
+
+        def resolve_endpoint(resource_type)
+          ResourceTypeResolver.new(http, url, headers: headers)
+            .endpoint_for(resource_type)
+            .delete_prefix('/')
+        end
+
+        def list_query_string
+          URI.encode_www_form(
+            {
+              'filter' => options[:filter],
+              'startIndex' => options[:start_index],
+              'count' => options[:count],
+              'sortBy' => options[:sort_by],
+              'sortOrder' => options[:sort_order],
+              'attributes' => options[:attributes]
+            }.compact
+          )
+        end
+
+        def url
+          @url ||= begin
+            resolved = options[:url] || ENV.fetch('SCIM_KIT_URL', nil)
+            raise Thor::Error, '--url is required' if resolved.to_s.empty?
+
+            resolved
+          end
+        end
+
+        def headers
+          @headers ||= options[:header].each_with_object({}) do |header, hash|
+            name, value = header.split(':', 2)
+            if value.nil?
+              raise Thor::Error, "malformed --header #{header.inspect} (expected \"Name: Value\")"
+            end
+
+            hash[name.to_s.strip] = value.to_s.strip
+          end
+        end
+
+        def http
+          @http ||= Scim::Kit::Http.new
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/reporting.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      module Reporting
+        def self.report(result, shell)
+          if result.ok?
+            shell.say(JSON.pretty_generate(result.body))
+            exit(0)
+          else
+            shell.say_error(JSON.pretty_generate(result.body))
+            exit(1)
+          end
+        end
+
+        def self.report_error(message, shell)
+          shell.say_error(JSON.pretty_generate(detail: message))
+          exit(1)
+        end
+
+        def self.rescue_errors(shell)
+          yield
+        rescue RequestFailed => error
+          report(error.result, shell)
+        rescue UnknownResourceType, MissingEndpoint, InvalidResponse => error
+          report_error(error.message, shell)
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli/resource_type_resolver.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Scim
+  module Kit
+    module Cli
+      class ResourceTypeResolver
+        def initialize(http, base_url, headers: {})
+          @http = http
+          @base_url = base_url
+          @headers = headers
+        end
+
+        def endpoint_for(name)
+          types = resource_types
+          match = types.find { |x| matches?(x, name) }
+          unless match
+            raise UnknownResourceType.new(name, types.map { |x| x[:name] })
+          end
+
+          endpoint = match[:endpoint]
+          raise MissingEndpoint, name if endpoint.to_s.empty?
+
+          endpoint
+        end
+
+        private
+
+        attr_reader :http, :base_url, :headers
+
+        def matches?(type, name)
+          type[:id]&.casecmp?(name) || type[:name]&.casecmp?(name)
+        end
+
+        def resource_types
+          uri = Cli.join_uri(base_url, 'ResourceTypes')
+          result = http.fetch(uri, headers: headers)
+          raise RequestFailed, result unless result.ok?
+          unless result.body.is_a?(Array)
+            raise InvalidResponse, 'expected /ResourceTypes to return a list'
+          end
+
+          result.body
+        end
+      end
+    end
+  end
+end
lib/scim/kit/cli.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'thor'
+
+require 'scim/kit/cli/reporting'
+require 'scim/kit/cli/resource_type_resolver'
+require 'scim/kit/cli/app'
+
+module Scim
+  module Kit
+    module Cli
+      class Error < Scim::Kit::Error; end
+
+      class RequestFailed < Error
+        attr_reader :result
+
+        def initialize(result)
+          @result = result
+          super("request failed with status #{result.status.inspect}")
+        end
+      end
+
+      class UnknownResourceType < Error
+        def initialize(name, known_names)
+          super("unknown resource type #{name.inspect} (known: #{known_names.join(', ')})")
+        end
+      end
+
+      class MissingEndpoint < Error
+        def initialize(name)
+          super("resource type #{name.inspect} has no endpoint")
+        end
+      end
+
+      class InvalidResponse < Error; end
+
+      def self.join_uri(base_url, path)
+        URI.join("#{base_url.to_s.sub(%r{/+\z}, '')}/", path)
+      end
+    end
+  end
+end
lib/scim/kit/http.rb
@@ -3,6 +3,12 @@
 module Scim
   module Kit
     class Http
+      Result = Struct.new(:status, :body) do
+        def ok?
+          !status.nil? && (200..299).cover?(status)
+        end
+      end
+
       attr_reader :driver, :retries
 
       def initialize(driver: Http.default_driver, retries: 3)
@@ -11,13 +17,18 @@ module Scim
       end
 
       def get(uri)
+        result = fetch(uri)
+        result.ok? ? result.body : {}
+      end
+
+      def fetch(uri, headers: {})
         driver.with_retry(retries: retries) do |client|
-          response = client.get(uri)
-          ok?(response) ? JSON.parse(response.body, symbolize_names: true) : {}
+          response = client.get(uri, headers: headers)
+          Result.new(response.code.to_i, parse(response.body))
         end
       rescue *Net::Hippie::CONNECTION_ERRORS => error
         Scim::Kit.logger.error(error)
-        {}
+        Result.new(nil, { detail: error.message })
       end
 
       def self.default_driver
@@ -40,8 +51,12 @@ module Scim
 
       private
 
-      def ok?(response)
-        response.is_a?(Net::HTTPSuccess)
+      def parse(body)
+        return {} if body.nil?
+
+        JSON.parse(body, symbolize_names: true)
+      rescue JSON::ParserError
+        { detail: body }
       end
     end
   end
spec/scim/kit/cli/app_spec.rb
@@ -0,0 +1,263 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::App do
+  let(:base_url) { FFaker::Internet.uri('https') }
+  let(:resource_types) { [{ id: 'User', name: 'User', endpoint: '/Users' }] }
+
+  def app(options = {})
+    described_class.new([], { 'url' => base_url }.merge(options))
+  end
+
+  before do
+    stub_request(:get, "#{base_url}/ResourceTypes")
+      .to_return(status: 200, body: resource_types.to_json)
+  end
+
+  shared_examples 'a resource-type-resolving command' do |call|
+    context 'when the resource type is unknown' do
+      it 'reports the unknown resource type' do
+        expect { exit_status { call.call(app, 'Nope') } }.to output(/Nope/).to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stderr).to receive(:print)
+
+        expect(exit_status { call.call(app, 'Nope') }).to eq(1)
+      end
+    end
+
+    context 'when fetching ResourceTypes fails' do
+      before do
+        stub_request(:get, "#{base_url}/ResourceTypes")
+          .to_return(status: 500, body: { detail: 'boom' }.to_json)
+      end
+
+      it 'reports the failure' do
+        expect { exit_status { call.call(app, 'User') } }
+          .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+      end
+    end
+  end
+
+  describe '#discover' do
+    let(:service_provider_configuration) { { patch: { supported: true } } }
+    let(:schemas) { [{ id: 'User', name: 'User' }] }
+
+    context 'when every request succeeds' do
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig")
+          .to_return(status: 200, body: service_provider_configuration.to_json)
+        stub_request(:get, "#{base_url}/Schemas")
+          .to_return(status: 200, body: schemas.to_json)
+      end
+
+      let(:expected_output) do
+        JSON.pretty_generate(
+          service_provider_configuration: service_provider_configuration,
+          schemas: schemas,
+          resource_types: resource_types
+        )
+      end
+
+      it 'prints the combined configuration as pretty json' do
+        expect { exit_status { app.discover } }
+          .to output("#{expected_output}\n").to_stdout
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+
+        expect(exit_status { app.discover }).to eq(0)
+      end
+    end
+
+    context 'when the ServiceProviderConfig request fails' do
+      before do
+        stub_request(:get, "#{base_url}/ServiceProviderConfig")
+          .to_return(status: 500, body: { detail: 'boom' }.to_json)
+      end
+
+      it 'reports the failure without requesting Schemas' do
+        allow($stderr).to receive(:print)
+        exit_status { app.discover }
+
+        expect(a_request(:get, "#{base_url}/Schemas")).not_to have_been_made
+      end
+
+      it 'exits 1' do
+        allow($stderr).to receive(:print)
+
+        expect(exit_status { app.discover }).to eq(1)
+      end
+    end
+  end
+
+  describe '#list' do
+    include_examples 'a resource-type-resolving command', ->(a, type) { a.list(type) }
+
+    context 'when the resource type and the list request both succeed' do
+      let(:list_response) { { totalResults: 1, Resources: [{ id: '1' }] } }
+      let(:instance) do
+        app(
+          'filter' => 'userName eq "bjensen"',
+          'start_index' => 2,
+          'count' => 10,
+          'sort_by' => 'userName',
+          'sort_order' => 'ascending',
+          'attributes' => 'userName,emails'
+        )
+      end
+
+      before do
+        stub_request(:get, "#{base_url}/Users")
+          .with(
+            query: {
+              'filter' => 'userName eq "bjensen"',
+              'startIndex' => '2',
+              'count' => '10',
+              'sortBy' => 'userName',
+              'sortOrder' => 'ascending',
+              'attributes' => 'userName,emails'
+            }
+          )
+          .to_return(status: 200, body: list_response.to_json)
+      end
+
+      it 'prints the list response as pretty json' do
+        expect { exit_status { instance.list('User') } }
+          .to output("#{JSON.pretty_generate(list_response)}\n").to_stdout
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+
+        expect(exit_status { instance.list('User') }).to eq(0)
+      end
+    end
+
+    context 'when the list request fails' do
+      before do
+        stub_request(:get, "#{base_url}/Users")
+          .to_return(status: 404, body: { detail: 'not found' }.to_json)
+      end
+
+      it 'reports the failure' do
+        expect { exit_status { app.list('User') } }
+          .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stderr).to receive(:print)
+
+        expect(exit_status { app.list('User') }).to eq(1)
+      end
+    end
+  end
+
+  describe '#get' do
+    include_examples 'a resource-type-resolving command', ->(a, type) { a.get(type, '123') }
+
+    context 'when the resource type and the get request both succeed' do
+      let(:resource) { { id: '123', userName: 'bjensen' } }
+      let(:instance) { app('attributes' => 'userName,emails') }
+
+      before do
+        stub_request(:get, "#{base_url}/Users/123")
+          .with(query: { 'attributes' => 'userName,emails' })
+          .to_return(status: 200, body: resource.to_json)
+      end
+
+      it 'prints the resource as pretty json' do
+        expect { exit_status { instance.get('User', '123') } }
+          .to output("#{JSON.pretty_generate(resource)}\n").to_stdout
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+
+        expect(exit_status { instance.get('User', '123') }).to eq(0)
+      end
+    end
+
+    context 'when the get request fails' do
+      before do
+        stub_request(:get, "#{base_url}/Users/123")
+          .to_return(status: 404, body: { detail: 'not found' }.to_json)
+      end
+
+      it 'reports the failure' do
+        expect { exit_status { app.get('User', '123') } }
+          .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stderr).to receive(:print)
+
+        expect(exit_status { app.get('User', '123') }).to eq(1)
+      end
+    end
+  end
+
+  describe 'header parsing' do
+    before do
+      stub_request(:get, "#{base_url}/ResourceTypes")
+        .with(headers: { 'Authorization' => 'Bearer xyz', 'X-Test' => 'value' })
+        .to_return(status: 200, body: resource_types.to_json)
+      stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
+    end
+
+    it 'sends repeated --header flags as request headers' do
+      allow($stdout).to receive(:print)
+      instance = app('header' => ['Authorization: Bearer xyz', 'X-Test: value'])
+
+      exit_status { instance.list('User') }
+
+      expect(a_request(:get, "#{base_url}/Users")).to have_been_made
+    end
+  end
+
+  describe 'CLI argv parsing via .start' do
+    around do |example|
+      original = ENV.fetch('SCIM_KIT_URL', nil)
+      example.run
+      ENV['SCIM_KIT_URL'] = original
+    end
+
+    before do
+      ENV.delete('SCIM_KIT_URL')
+      stub_request(:get, "#{base_url}/Users").to_return(status: 200, body: '{}')
+    end
+
+    it 'exits 1 with a usage message when RESOURCE_TYPE is missing' do
+      allow($stderr).to receive(:print)
+
+      expect { exit_status { described_class.start(['list', '--url', base_url]) } }
+        .to output(/no arguments/).to_stderr
+    end
+
+    it 'exits 1 when --url is missing entirely' do
+      allow($stderr).to receive(:puts)
+
+      status = exit_status { described_class.start(%w[list User]) }
+
+      expect(status).to eq(1)
+    end
+
+    it 'reads --url from SCIM_KIT_URL when --url is omitted' do
+      ENV['SCIM_KIT_URL'] = base_url
+      allow($stdout).to receive(:print)
+
+      status = exit_status { described_class.start(%w[list User]) }
+
+      expect(status).to eq(0)
+    end
+
+    it 'exits 1 with a usage message for a malformed --header' do
+      allow($stderr).to receive(:print)
+      argv = ['list', 'User', '--url', base_url, '--header', 'BearerXYZ']
+
+      expect { exit_status { described_class.start(argv) } }
+        .to output(/malformed --header/).to_stderr
+    end
+  end
+end
spec/scim/kit/cli/reporting_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::Reporting do
+  let(:shell) { Thor::Shell::Basic.new }
+
+  describe '.report' do
+    context 'when the result is ok' do
+      let(:result) { Scim::Kit::Http::Result.new(200, { id: '123' }) }
+
+      it 'prints the body as pretty json to stdout' do
+        expect { exit_status { described_class.report(result, shell) } }
+          .to output("#{JSON.pretty_generate(id: '123')}\n").to_stdout
+      end
+
+      it 'exits 0' do
+        allow($stdout).to receive(:print)
+
+        expect(exit_status { described_class.report(result, shell) }).to eq(0)
+      end
+    end
+
+    context 'when the result is not ok' do
+      let(:result) { Scim::Kit::Http::Result.new(404, { detail: 'not found' }) }
+
+      it 'prints the body as pretty json to stderr' do
+        expect { exit_status { described_class.report(result, shell) } }
+          .to output("#{JSON.pretty_generate(detail: 'not found')}\n").to_stderr
+      end
+
+      it 'exits 1' do
+        allow($stderr).to receive(:print)
+
+        expect(exit_status { described_class.report(result, shell) }).to eq(1)
+      end
+    end
+  end
+
+  describe '.report_error' do
+    it 'prints a synthesized detail message as pretty json to stderr' do
+      expect { exit_status { described_class.report_error('boom', shell) } }
+        .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+    end
+
+    it 'exits 1' do
+      allow($stderr).to receive(:print)
+
+      expect(exit_status { described_class.report_error('boom', shell) }).to eq(1)
+    end
+  end
+
+  describe '.rescue_errors' do
+    it 'returns the value of the block when nothing is raised' do
+      expect(described_class.rescue_errors(shell) { 'ok' }).to eq('ok')
+    end
+
+    context 'when the block raises Cli::RequestFailed' do
+      let(:result) { Scim::Kit::Http::Result.new(500, { detail: 'boom' }) }
+      let(:block) do
+        -> { described_class.rescue_errors(shell) { raise Scim::Kit::Cli::RequestFailed, result } }
+      end
+
+      it 'reports the failed result' do
+        expect { exit_status(&block) }
+          .to output("#{JSON.pretty_generate(detail: 'boom')}\n").to_stderr
+      end
+    end
+
+    context 'when the block raises Cli::UnknownResourceType' do
+      let(:block) do
+        lambda do
+          described_class.rescue_errors(shell) do
+            raise Scim::Kit::Cli::UnknownResourceType.new('Nope', ['User'])
+          end
+        end
+      end
+
+      it 'reports the error message' do
+        expect { exit_status(&block) }.to output(/Nope/).to_stderr
+      end
+    end
+
+    context 'when the block raises Cli::MissingEndpoint' do
+      let(:block) do
+        lambda do
+          described_class.rescue_errors(shell) { raise Scim::Kit::Cli::MissingEndpoint, 'User' }
+        end
+      end
+
+      it 'reports the error message' do
+        expect { exit_status(&block) }.to output(/User/).to_stderr
+      end
+    end
+
+    context 'when the block raises Cli::InvalidResponse' do
+      let(:block) do
+        lambda do
+          described_class.rescue_errors(shell) { raise Scim::Kit::Cli::InvalidResponse, 'bad shape' }
+        end
+      end
+
+      it 'reports the error message' do
+        expect { exit_status(&block) }.to output(/bad shape/).to_stderr
+      end
+    end
+  end
+end
spec/scim/kit/cli/resource_type_resolver_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli::ResourceTypeResolver do
+  subject { described_class.new(Scim::Kit::Http.new, base_url, headers: headers) }
+
+  let(:base_url) { FFaker::Internet.uri('https') }
+  let(:headers) { {} }
+
+  describe '#endpoint_for' do
+    before do
+      stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+        status: 200,
+        body: [
+          { id: 'User', name: 'User', endpoint: '/Users' },
+          { id: 'Group', name: 'Group', endpoint: '/Groups' }
+        ].to_json
+      )
+    end
+
+    specify { expect(subject.endpoint_for('User')).to eql('/Users') }
+    specify { expect(subject.endpoint_for('user')).to eql('/Users') }
+    specify { expect(subject.endpoint_for('Group')).to eql('/Groups') }
+
+    it 'raises when no resource type matches the given name' do
+      expect { subject.endpoint_for('Nope') }.to raise_error(
+        Scim::Kit::Cli::UnknownResourceType, /Nope/
+      )
+    end
+
+    context 'with custom headers' do
+      let(:headers) { { 'Authorization' => 'Bearer xyz' } }
+
+      it 'forwards them to the ResourceTypes request' do
+        subject.endpoint_for('User')
+
+        expect(a_request(:get, "#{base_url}/ResourceTypes").with(headers: headers)).to have_been_made
+      end
+    end
+  end
+
+  context 'when the matched resource type has no endpoint' do
+    before do
+      stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+        status: 200,
+        body: [{ id: 'User', name: 'User' }].to_json
+      )
+    end
+
+    it 'raises MissingEndpoint' do
+      expect { subject.endpoint_for('User') }.to raise_error(
+        Scim::Kit::Cli::MissingEndpoint, /User/
+      )
+    end
+  end
+
+  context 'when the request fails' do
+    before { stub_request(:get, "#{base_url}/ResourceTypes").to_return(status: 500, body: '{}') }
+
+    it 'raises' do
+      expect { subject.endpoint_for('User') }.to raise_error(Scim::Kit::Cli::RequestFailed)
+    end
+  end
+
+  context 'when the response body is not a list' do
+    before do
+      stub_request(:get, "#{base_url}/ResourceTypes").to_return(
+        status: 200,
+        body: { Resources: [] }.to_json
+      )
+    end
+
+    it 'raises InvalidResponse' do
+      expect { subject.endpoint_for('User') }.to raise_error(Scim::Kit::Cli::InvalidResponse)
+    end
+  end
+end
spec/scim/kit/cli_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Cli do
+  describe '.join_uri' do
+    it 'joins a base url without a trailing slash to a path' do
+      uri = described_class.join_uri('https://example.com/scim/v2', 'Users')
+
+      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+    end
+
+    it 'joins a base url with a trailing slash to a path' do
+      uri = described_class.join_uri('https://example.com/scim/v2/', 'Users')
+
+      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+    end
+
+    it 'joins a base url with multiple trailing slashes to a path' do
+      uri = described_class.join_uri('https://example.com/scim/v2///', 'Users')
+
+      expect(uri.to_s).to eql('https://example.com/scim/v2/Users')
+    end
+  end
+end
spec/scim/kit/http_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+RSpec.describe Scim::Kit::Http do
+  subject { described_class.new }
+
+  let(:uri) { URI(FFaker::Internet.uri('https')) }
+
+  describe '#fetch' do
+    context 'when the response is successful' do
+      let(:body) { { id: '123' } }
+
+      before { stub_request(:get, uri).to_return(status: 200, body: body.to_json) }
+
+      specify { expect(subject.fetch(uri)).to be_ok }
+      specify { expect(subject.fetch(uri).status).to be(200) }
+      specify { expect(subject.fetch(uri).body).to eql(body) }
+    end
+
+    context 'when the response is a scim error' do
+      let(:error_body) { { detail: 'Resource not found', status: '404' } }
+
+      before { stub_request(:get, uri).to_return(status: 404, body: error_body.to_json) }
+
+      specify { expect(subject.fetch(uri)).not_to be_ok }
+      specify { expect(subject.fetch(uri).status).to be(404) }
+      specify { expect(subject.fetch(uri).body).to eql(error_body) }
+    end
+
+    context 'when the response body is not json' do
+      before { stub_request(:get, uri).to_return(status: 500, body: 'boom') }
+
+      specify { expect(subject.fetch(uri)).not_to be_ok }
+      specify { expect(subject.fetch(uri).body).to eql(detail: 'boom') }
+    end
+
+    context 'when the response has no body' do
+      before { stub_request(:get, uri).to_return(status: 204, body: nil) }
+
+      specify { expect(subject.fetch(uri)).to be_ok }
+      specify { expect(subject.fetch(uri).body).to eql({}) }
+    end
+
+    context 'when the connection fails' do
+      subject { described_class.new(retries: 0) }
+
+      before { stub_request(:get, uri).to_raise(Errno::ECONNREFUSED) }
+
+      specify { expect(subject.fetch(uri)).not_to be_ok }
+      specify { expect(subject.fetch(uri).status).to be_nil }
+      specify { expect(subject.fetch(uri).body[:detail]).to include('Connection refused') }
+    end
+
+    context 'when headers are provided' do
+      before do
+        stub_request(:get, uri)
+          .with(headers: { 'X-Test' => 'value' })
+          .to_return(status: 200, body: '{}')
+      end
+
+      specify { expect(subject.fetch(uri, headers: { 'X-Test' => 'value' })).to be_ok }
+    end
+  end
+end
spec/spec_helper.rb
@@ -2,6 +2,7 @@
 
 require 'bundler/setup'
 require 'scim/kit'
+require 'scim/kit/cli'
 require 'ffaker'
 require 'json'
 require 'parslet/convenience'
@@ -10,6 +11,15 @@ require 'webmock/rspec'
 
 Scim::Kit.logger = Logger.new('/dev/null')
 
+module ExitStatusHelper
+  def exit_status
+    yield
+    nil
+  rescue SystemExit => error
+    error.status
+  end
+end
+
 RSpec.configure do |config|
   # Enable flags like --only-failures and --next-failure
   config.example_status_persistence_file_path = '.rspec_status'
@@ -20,4 +30,6 @@ RSpec.configure do |config|
   config.expect_with :rspec do |c|
     c.syntax = :expect
   end
+
+  config.include ExitStatusHelper
 end
.rubocop.yml
@@ -88,6 +88,10 @@ Style/IfUnlessModifier:
   Exclude:
     - 'lib/scim/kit/v2/attribute.rb'
 
+Style/StderrPuts:
+  Exclude:
+    - 'lib/scim/kit/cli/reporting.rb'
+
 Style/StringLiterals:
   EnforcedStyle: 'single_quotes'
 
CHANGELOG.md
@@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- Add a `scim-kit` CLI with `discover`, `list`, and `get` commands for
+  reading a remote SCIM server's configuration and resources.
 
 ## [0.8.0] - 2026-03-31
 ### Changed
Gemfile.lock
@@ -5,6 +5,7 @@ PATH
       activemodel (>= 6.1)
       net-hippie (~> 1.0)
       parslet (~> 2.0)
+      thor (~> 1.0)
       tilt (~> 2.0)
       tilt-jbuilder (~> 0.7)
 
scim-kit.gemspec
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
   spec.add_dependency 'activemodel', '>= 6.1'
   spec.add_dependency 'net-hippie', '~> 1.0'
   spec.add_dependency 'parslet', '~> 2.0'
+  spec.add_dependency 'thor', '~> 1.0'
   spec.add_dependency 'tilt', '~> 2.0'
   spec.add_dependency 'tilt-jbuilder', '~> 0.7'
   spec.add_development_dependency 'bundler-audit', '~> 0.6'