chatwerk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1f6463014c8b9acb703cc940790b073d41e434b507e7a16cdc5635df1393c9dd
4
+ data.tar.gz: 3e208195bb1b414e677003e973f4b2c225642cc2de780d78fc32c42f6db75f4b
5
+ SHA512:
6
+ metadata.gz: bc748648854e35f7bb6dab00d22841ea98c18c4d4754ca8b11935a1c58caec6fe8772603d3a1c92fb27c9462c853a2a2d2f4af84e5b32bb666a9f6d3a06c4065
7
+ data.tar.gz: d0f2a2e84fab5ec86d22986d508363818cbb11c49a91afa961b585d21c4cd4b26eeb6aaeb4af61cc86c071ddac8e1f225202ae27f35792c4f1d88611320e2f47
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,90 @@
1
+ plugins:
2
+ - rubocop-sorbet
3
+ - rubocop-performance
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.1
7
+ SuggestExtensions: false
8
+ NewCops: enable
9
+ Exclude:
10
+ - bin/**/*
11
+ - vendor/**/*
12
+
13
+ Sorbet/StrictSigil:
14
+ Enabled: false
15
+
16
+ Layout/ExtraSpacing:
17
+ Enabled: true
18
+ Exclude:
19
+ - danger-packwerk.gemspec
20
+
21
+ Layout/SpaceAroundOperators:
22
+ Enabled: true
23
+ Exclude:
24
+ - danger-packwerk.gemspec
25
+
26
+ Sorbet/ValidSigil:
27
+ Enabled: true
28
+
29
+ Gemspec/RequireMFA:
30
+ Enabled: false
31
+
32
+ Layout/LineLength:
33
+ Enabled: false
34
+
35
+ Metrics/BlockLength:
36
+ Enabled: false
37
+
38
+ Style/Documentation:
39
+ Enabled: false
40
+
41
+ Style/SignalException:
42
+ Enabled: false
43
+
44
+ Style/FrozenStringLiteralComment:
45
+ Enabled: false
46
+
47
+ Style/MultilineBlockChain:
48
+ Enabled: false
49
+
50
+ Metrics/MethodLength:
51
+ Enabled: false
52
+
53
+ Metrics/CyclomaticComplexity:
54
+ Enabled: false
55
+
56
+ Metrics/ModuleLength:
57
+ Enabled: false
58
+
59
+ Metrics/ClassLength:
60
+ Enabled: false
61
+
62
+ Sorbet/FalseSigil:
63
+ Enabled: false
64
+
65
+ Lint/UnusedMethodArgument:
66
+ Enabled: false
67
+
68
+ Metrics/AbcSize:
69
+ Enabled: false
70
+
71
+ Style/GuardClause:
72
+ Enabled: false
73
+
74
+ Style/NumericPredicate:
75
+ Enabled: false
76
+
77
+ Metrics/PerceivedComplexity:
78
+ Enabled: false
79
+
80
+ Metrics/ParameterLists:
81
+ Enabled: false
82
+
83
+ Style/SuperArguments:
84
+ Enabled: false
85
+
86
+ Style/ArgumentsForwarding:
87
+ Enabled: false
88
+
89
+ Naming/BlockForwarding:
90
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ See https://github.com/rubyatscale/chatwerk/releases
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Martin Emde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Chatwerk
2
+
3
+ Chatwerk provides AI tool integration for the [QueryPackwerk](https://github.com/rubyatscale/query_packwerk) gem. It adds a Model Context Protocol (MCP) server that allows AI tools like Cursor IDE to access information about your Packwerk packages, dependencies, and violations.
4
+
5
+ > [!NOTE]
6
+ > This is an early prerelease version. We'll continue to update it as we develop. Contributions and feedback are welcome!
7
+
8
+ ## Installation
9
+
10
+ Install the gem, either add in to your packwerk'd application's Gemfile:
11
+
12
+ ```ruby
13
+ $ bundle add chatwerk
14
+ $ bundle install
15
+ ```
16
+
17
+ or install it on its own:
18
+
19
+ ```bash
20
+ $ gem install chatwerk
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Starting the MCP Server
26
+
27
+ You can test the inspector to see if it's working
28
+
29
+ ```bash
30
+ $ chatwerk inspect
31
+ ```
32
+
33
+ ### Connecting with Cursor IDE
34
+
35
+ To use Chatwerk with Cursor:
36
+
37
+ 1. In Cursor, open Settings > MCP
38
+
39
+ 2. Add a new MCP connection as a command
40
+ Name: `chatwerk`
41
+ Command: `chatwerk mcp`
42
+
43
+ 3. Ask Cursor to check all the tools on packwerk. Give it an example pack name (partial strings work)
44
+
45
+ ### Example Queries for Cursor
46
+
47
+ Once connected, you can ask Cursor questions about your Packwerk structure:
48
+
49
+ - "What are all the packages in this codebase?"
50
+ - "Tell me about the dependencies of package X"
51
+ - "What packages depend on package Y?"
52
+ - "Show me all the violations for package Z"
53
+ - "How difficult would it be to separate package X from its dependencies?"
54
+ - "What code patterns are used to access Constant on package Y?"
55
+
56
+ ## Development
57
+
58
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
59
+
60
+ Run `bin/inspect`
61
+
62
+ ## Contributing
63
+
64
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubyatscale/chatwerk.
65
+
66
+ ## License
67
+
68
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/config.ru ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chatwerk'
4
+ run Chatwerk::MCPServer.new
data/exe/chatwerk ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'chatwerk'
5
+
6
+ Chatwerk::Cli.start(ARGV)
@@ -0,0 +1,71 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'yaml'
5
+
6
+ module Chatwerk
7
+ module Api
8
+ class << self
9
+ def print_env
10
+ msg = <<~MESSAGE
11
+ PWD: #{Dir.pwd}
12
+ PWD from ENV: #{ENV.fetch('PWD', nil)}
13
+ MESSAGE
14
+ Helpers.chdir do
15
+ msg << "Chdir'd to #{Helpers.pwd}\n"
16
+ end
17
+ msg << ENV.to_h.map { |key, value| "#{key}=#{value}" }.join("\n")
18
+ msg
19
+ end
20
+
21
+ def packages(package_path: '')
22
+ packages = Helpers.all_packages(package_path)
23
+
24
+ if packages.empty?
25
+ has_packwerk_yml = File.exist?('packwerk.yml')
26
+ Views::NoPackagesView.render(has_packwerk_yml:)
27
+ else
28
+ Views::PackagesView.render(packages:)
29
+ end
30
+ rescue StandardError => e
31
+ raise Chatwerk::Error.new(e, package_path:)
32
+ end
33
+
34
+ def package(package_path:)
35
+ package = Helpers.find_package(package_path)
36
+
37
+ Views::PackageView.render(package:)
38
+ rescue StandardError => e
39
+ raise Chatwerk::Error.new(e, package_path:)
40
+ end
41
+
42
+ def package_todos(package_path:, constant_name: '')
43
+ package = Helpers.find_package(package_path)
44
+ constant_name = Helpers.normalize_constant_name(constant_name)
45
+ violations = package.todos
46
+
47
+ if constant_name.empty?
48
+ Views::ViolationsListView.render(package:, violations:)
49
+ else
50
+ Views::ViolationsDetailsView.render(package:, violations:, constant_name:)
51
+ end
52
+ rescue StandardError => e
53
+ raise Chatwerk::Error.new(e, package_path:, constant_name:)
54
+ end
55
+
56
+ def package_violations(package_path:, constant_name: '')
57
+ package = Helpers.find_package(package_path)
58
+ constant_name = Helpers.normalize_constant_name(constant_name)
59
+ violations = package.violations
60
+
61
+ if constant_name.empty?
62
+ Views::ViolationsListView.render(package:, violations:)
63
+ else
64
+ Views::ViolationsDetailsView.render(package:, violations:, constant_name:)
65
+ end
66
+ rescue StandardError => e
67
+ raise Chatwerk::Error.new(e, package_path:, constant_name:)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,59 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'thor'
5
+
6
+ module Chatwerk
7
+ # CLI interface for Chatwerk using Thor
8
+ class Cli < Thor
9
+ desc 'mcp', 'Start the Model Context Protocol server in stdio mode'
10
+ def mcp
11
+ require_relative 'mcp'
12
+ require 'mcp/transports/stdio'
13
+ server = Chatwerk::Mcp.server
14
+ transport = MCP::Transports::StdioTransport.new(server)
15
+ transport.open
16
+ end
17
+
18
+ desc 'inspect [WORKING_DIRECTORY]', 'Run the MCP inspector with an optional working directory path (defaults to current directory)'
19
+ def inspect(working_directory = nil)
20
+ pwd = working_directory || Dir.pwd
21
+ system("npx @modelcontextprotocol/inspector -e PWD=#{pwd} bundle exec exe/chatwerk mcp")
22
+ end
23
+
24
+ desc 'print_env', 'Show current environment details'
25
+ def print_env
26
+ puts Api.print_env
27
+ end
28
+
29
+ desc 'packages [PACKAGE_PATH]', 'List all valid packwerk packages, optionally filtered by package path'
30
+ def packages(package_path = nil)
31
+ puts Api.packages(package_path:)
32
+ end
33
+
34
+ desc 'package PACKAGE_PATH', 'Show details for a specific package'
35
+ def package(package_path)
36
+ puts Api.package(package_path:)
37
+ end
38
+
39
+ desc 'package_todos PACKAGE_PATH [CONSTANT_NAME]', 'Show dependency violations FROM this package TO others'
40
+ def package_todos(package_path, constant_name = nil)
41
+ puts Api.package_todos(package_path:, constant_name:)
42
+ end
43
+
44
+ desc 'package_violations PACKAGE_PATH [CONSTANT_NAME]', 'Show dependency violations TO this package FROM others'
45
+ def package_violations(package_path, constant_name = nil)
46
+ puts Api.package_violations(package_path:, constant_name:)
47
+ end
48
+
49
+ desc 'version', 'Display Chatwerk version'
50
+ def version
51
+ puts "Chatwerk #{Chatwerk::VERSION}"
52
+ end
53
+ map %w[--version -v] => :version
54
+
55
+ def self.exit_on_failure?
56
+ true
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ class Error < RuntimeError
5
+ def initialize(error, package_path: nil, **args)
6
+ message =
7
+ if package_path && !package_path.empty?
8
+ "There was a problem finding or accessing #{package_path.inspect}"
9
+ else
10
+ 'There was a problem accessing package information'
11
+ end
12
+
13
+ super(<<~ERROR)
14
+ #{message}
15
+ Error: #{error}
16
+
17
+ * Please ensure that the package path is correct.
18
+ * Check that there is a package.yml file in the given directory.
19
+ * Check that the path is a project root relative path that doesn't start with a slash.
20
+ * Try calling the packages tool with the package_path to see if it is valid.
21
+ ERROR
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ module Chatwerk
2
+ module Helpers
3
+ def chdir(&)
4
+ Dir.chdir(env_pwd, &)
5
+ end
6
+ module_function :chdir
7
+
8
+ def env_pwd
9
+ path = ENV.fetch('PWD', pwd)
10
+ # Use realpath if the path exists, otherwise fall back to expand_path
11
+ File.directory?(path) ? File.realpath(path) : File.expand_path(path)
12
+ end
13
+ module_function :env_pwd
14
+
15
+ def pwd
16
+ File.realpath(Dir.pwd)
17
+ end
18
+ module_function :pwd
19
+
20
+ def normalize_package_path(package_path)
21
+ package_path = package_path.to_s.strip
22
+ package_path = package_path.delete_prefix('/')
23
+ package_path = package_path.delete_suffix('/package.yml')
24
+ package_path = package_path.delete_suffix('/package_todo.yml')
25
+ package_path.delete_suffix('/')
26
+ end
27
+ module_function :normalize_package_path
28
+
29
+ def normalize_constant_name(constant_name)
30
+ constant_name = constant_name.to_s.strip
31
+ return '' if constant_name.empty?
32
+
33
+ constant_name.sub(/^(::)?/, '::')
34
+ end
35
+ module_function :normalize_constant_name
36
+
37
+ def all_packages(path_pattern = '')
38
+ path_pattern = normalize_package_path(path_pattern)
39
+ if path_pattern.empty?
40
+ QueryPackwerk::Packages.all.to_a
41
+ else
42
+ QueryPackwerk::Packages.where(name: Regexp.new(Regexp.escape(path_pattern))).to_a
43
+ end
44
+ end
45
+ module_function :all_packages
46
+
47
+ def find_package(path_pattern)
48
+ packages = all_packages(path_pattern)
49
+
50
+ if packages.empty?
51
+ raise "Unable to find a package for #{path_pattern.inspect}."
52
+ elsif packages.size == 1
53
+ packages.first
54
+ else
55
+ # Return an exact match even if it's a subset of another match
56
+ # e.g. packs/payments and packs/payments_api
57
+ exact_match = packages.find { |package| package.name == path_pattern }
58
+ return exact_match if exact_match
59
+
60
+ raise Chatwerk::Error, <<~ERROR
61
+ Found multiple packages for #{path_pattern.inspect}:
62
+
63
+ #{packages.map(&:name).join("\n")}
64
+
65
+ Please use full path to specify the correct package.
66
+ ERROR
67
+ end
68
+ end
69
+ module_function :find_package
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ require 'mcp'
2
+
3
+ module Chatwerk
4
+ # MCP Server setup
5
+ class Mcp
6
+ def self.server
7
+ # Set up the server with all tools
8
+ MCP::Server.new(
9
+ name: name,
10
+ version: Chatwerk::VERSION,
11
+ tools: [
12
+ Tools::PrintEnvTool,
13
+ Tools::PackagesTool,
14
+ Tools::PackageTool,
15
+ Tools::PackageTodosTool,
16
+ Tools::PackageViolationsTool
17
+ ]
18
+ )
19
+ end
20
+
21
+ def self.name
22
+ 'chatwerk'
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Chatwerk
6
+ module Tools
7
+ # Package todos (violations FROM this package) tool
8
+ class PackageTodosTool < MCP::Tool
9
+ description <<~DESC
10
+ Find code that violates dependency boundaries FROM this package TO other packages.
11
+
12
+ Output formats:
13
+ - Without constant_name: List of violated constants with counts
14
+ Example: "::OtherPackage::SomeClass # 3 violations"
15
+ - With constant_name: Detailed examples and locations
16
+ Example:
17
+ ::OtherPackage::SomeClass
18
+ example: OtherPackage::SomeClass.new
19
+ files:
20
+ - app/services/my_service.rb
21
+ DESC
22
+
23
+ input_schema(
24
+ properties: {
25
+ package_path: {
26
+ type: 'string',
27
+ description: "The relative path of a directory containing a package.yml file (e.g. 'packs/product_services/payments/origination_banks')."
28
+ },
29
+ constant_name: {
30
+ type: 'string',
31
+ description: "The name of a constant to filter the results by. If provided, a more detailed list of code usage examples will be returned. (e.g. '::OtherPackage::SomeClass')"
32
+ }
33
+ },
34
+ required: ['package_path']
35
+ )
36
+
37
+ class << self
38
+ def call(package_path:, server_context:, constant_name: nil)
39
+ Helpers.chdir
40
+ result = Api.package_todos(package_path: package_path, constant_name: constant_name)
41
+
42
+ MCP::Tool::Response.new([{
43
+ type: 'text',
44
+ text: result
45
+ }])
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Chatwerk
6
+ module Tools
7
+ # Show package details tool
8
+ class PackageTool < MCP::Tool
9
+ description <<~DESC
10
+ Show the details for a specific package.
11
+
12
+ Output format:
13
+ - Package details, including dependencies and configuration
14
+ DESC
15
+
16
+ input_schema(
17
+ properties: {
18
+ package_path: {
19
+ type: 'string',
20
+ description: "A full relative package path (e.g. 'packs/product_services/payments/banks')."
21
+ }
22
+ },
23
+ required: ['package_path']
24
+ )
25
+
26
+ class << self
27
+ def call(package_path:, server_context:)
28
+ Helpers.chdir
29
+ result = Api.package(package_path: package_path)
30
+
31
+ MCP::Tool::Response.new([{
32
+ type: 'text',
33
+ text: result
34
+ }])
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Chatwerk
6
+ module Tools
7
+ # Package violations (violations TO this package) tool
8
+ class PackageViolationsTool < MCP::Tool
9
+ description <<~DESC
10
+ Find code that violates dependency boundaries TO this package FROM other packages.
11
+
12
+ Output formats:
13
+ - Without constant_name: List of violated constants with counts
14
+ Example: "::ThisPackage::SomeClass: 3 violations"
15
+ - With constant_name: Detailed examples and locations
16
+ Example:
17
+ # Constant `::ThisPackage::SomeClass`
18
+ ## Example:
19
+ ThisPackage::SomeClass.new
20
+ ### Files:
21
+ app/services/other_service.rb
22
+ DESC
23
+
24
+ input_schema(
25
+ properties: {
26
+ package_path: {
27
+ type: 'string',
28
+ description: "The relative path of a directory containing a package.yml file (e.g. 'packs/product_services/payments/origination_banks'). AKA a 'pack' or 'package'."
29
+ },
30
+ constant_name: {
31
+ type: 'string',
32
+ description: 'The name of a constant to filter the results by. If provided, a more detailed list of code usage examples will be returned.'
33
+ }
34
+ },
35
+ required: ['package_path']
36
+ )
37
+
38
+ class << self
39
+ def call(package_path:, server_context:, constant_name: nil)
40
+ Helpers.chdir
41
+ result = Api.package_violations(package_path: package_path, constant_name: constant_name)
42
+
43
+ MCP::Tool::Response.new([{
44
+ type: 'text',
45
+ text: result
46
+ }])
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Chatwerk
6
+ module Tools
7
+ # List packages tool
8
+ class PackagesTool < MCP::Tool
9
+ description <<~DESC
10
+ List all valid packwerk packages (aka packs) in the project.
11
+ Use this to find or list packages, optionally matching a substring of the package_path.
12
+ DESC
13
+
14
+ input_schema(
15
+ properties: {
16
+ package_path: {
17
+ type: 'string',
18
+ description: "A partial package path name to constrain the results (e.g. 'packs/product_services/payments/banks' or 'payments/banks')."
19
+ }
20
+ },
21
+ required: []
22
+ )
23
+
24
+ class << self
25
+ def call(server_context:, package_path: nil)
26
+ Helpers.chdir
27
+ result = Api.packages(package_path: package_path)
28
+
29
+ MCP::Tool::Response.new([{
30
+ type: 'text',
31
+ text: result
32
+ }])
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ require 'mcp'
2
+ require_relative '../helpers'
3
+
4
+ module Chatwerk
5
+ module Tools
6
+ # Print environment information tool
7
+ class PrintEnvTool < MCP::Tool
8
+ description 'Get the current working directory and environment path of the MCP server, ensuring correct directory context'
9
+
10
+ input_schema(
11
+ properties: {},
12
+ required: []
13
+ )
14
+
15
+ class << self
16
+ def call(server_context:)
17
+ msg = <<~MESSAGE
18
+ Relay these exact details to the user:
19
+ Current Directory: #{Dir.pwd}
20
+ Environment: #{ENV.fetch('PWD', nil)}
21
+ MESSAGE
22
+ Helpers.chdir do
23
+ msg << "Chdir'd to #{Helpers.pwd}\n"
24
+ end
25
+ msg << ENV.to_h.map { |key, value| "#{key}=#{value}" }.join("\n")
26
+
27
+ MCP::Tool::Response.new([{
28
+ type: 'text',
29
+ text: msg
30
+ }])
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,6 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Chatwerk
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class BaseView
6
+ def self.render(**data)
7
+ new(**data).render
8
+ end
9
+
10
+ attr_reader :data, :output
11
+
12
+ def initialize(**data)
13
+ @data = data
14
+ @output = nil
15
+ end
16
+
17
+ def render
18
+ @output = nil
19
+ temp = template(**data)
20
+ return temp if @output.nil? # allow templates to return a string instead
21
+
22
+ @output
23
+ end
24
+
25
+ private
26
+
27
+ def template(**data)
28
+ raise NotImplementedError, 'Subclasses must implement the #template method'
29
+ end
30
+
31
+ def say(message = '')
32
+ @output ||= +''
33
+ @output << message << "\n"
34
+ end
35
+
36
+ def method_missing(name, *args, **kwargs, &block)
37
+ @data[name]
38
+ end
39
+
40
+ def respond_to_missing?(name, include_private = false)
41
+ data.key?(name) || super
42
+ end
43
+
44
+ def format_count(count, singular, plural = nil)
45
+ if count == 1
46
+ "1 #{singular}"
47
+ else
48
+ plural ||= "#{singular}s"
49
+ "#{count} #{plural}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class NoPackagesView < BaseView
6
+ def template(has_packwerk_yml: false)
7
+ if has_packwerk_yml
8
+ <<~MESSAGE
9
+ 0 packages found.
10
+ `packwerk.yml` file exists in project root: #{Chatwerk::Helpers.pwd}
11
+
12
+ * Check that the project root is correct.
13
+ * Make sure that packwerk is initialized correctly.
14
+ * Make sure at least one package is defined.
15
+ MESSAGE
16
+ else
17
+ <<~MESSAGE
18
+ This project does not appear to be using packwerk.
19
+ `packwerk.yml` file does not exist in project root: #{Chatwerk::Helpers.pwd}
20
+
21
+ * Check that the project root is correct.
22
+ * Check to make sure that packwerk is installed and initialized correctly.
23
+ MESSAGE
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class NoViolationsView < BaseView
6
+ def template(package:, constant_name: nil)
7
+ if constant_name.nil?
8
+ <<~MESSAGE
9
+ No violations found in #{package.name.inspect}.
10
+ MESSAGE
11
+ else
12
+ <<~MESSAGE
13
+ No violations found in #{package.name.inspect} for #{constant_name.inspect}.
14
+ Ensure that constant_name is given in the format of "::ConstantName" or "::ConstantName::NestedConstant".
15
+ MESSAGE
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class PackageView < BaseView
6
+ def template(package:, package_path: nil)
7
+ consumers = QueryPackwerk::Packages.all.to_a.select { |p| p.dependency_names.include?(package.name) }.map(&:name)
8
+ consumers += package.consumer_names
9
+ consumers.uniq.sort!
10
+
11
+ say YAML.dump({
12
+ name: package.name,
13
+ enforce_dependencies: package.enforce_dependencies,
14
+ enforce_privacy: package.enforce_privacy,
15
+ owner: package.owner,
16
+ metadata: package.metadata,
17
+ dependencies: package.dependency_names,
18
+ consumers:,
19
+ todos_count: package.todos.count,
20
+ violations_count: package.violations.count
21
+ }.transform_keys(&:to_s))
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class PackagesView < BaseView
6
+ def template(packages:, package_path: nil)
7
+ if packages.empty?
8
+ "No packages found matching #{package_path.inspect}\n"
9
+ else
10
+ packages.map { |p| "#{p.name}\n" }.sort.join
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class ViolationsDetailsView < BaseView
6
+ def template(package:, violations:, constant_name:)
7
+ relevant_violations = violations.anonymous_sources_with_locations.select { |c, _| c.start_with?(constant_name) }
8
+
9
+ if relevant_violations.empty?
10
+ Views::NoViolationsView.render(package:, constant_name:)
11
+ else
12
+ grep_formatted_violations(relevant_violations)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def grep_formatted_violations(relevant_violations)
19
+ # Group violations by file
20
+ files_to_violations = {}
21
+
22
+ relevant_violations.each do |constant, source_info|
23
+ say "No sources found for #{constant}" if source_info.empty?
24
+ source_info.each do |code, files|
25
+ say "No files found for #{constant} with code: #{code}" if files.empty?
26
+
27
+ files.each do |file_with_line|
28
+ file, line = file_with_line.split(':')
29
+ files_to_violations[file] ||= []
30
+ files_to_violations[file] << { constant:, code:, line: line }
31
+ end
32
+ end
33
+ end
34
+
35
+ # Output violations grouped by file
36
+ files_to_violations.sort.map do |file, violations|
37
+ lines = violations.sort_by { |v| v[:line].to_i }.map do |violation|
38
+ "#{violation[:line]}: #{violation[:code]}"
39
+ end.join("\n")
40
+
41
+ "#{file}\n#{lines}\n"
42
+ end.join("\n")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chatwerk
4
+ module Views
5
+ class ViolationsListView < BaseView
6
+ # The anonymous_source_counts method returns a hash as follows:
7
+ # {
8
+ # "::Core::User" => {
9
+ # "User.find(_)" => 2,
10
+ # "User.create(_)" => 1
11
+ # }
12
+ # "::Core::Product" => {
13
+ # "Product.find(_)" => 1,
14
+ # "Product.create(_)" => 1
15
+ # }
16
+ # }
17
+ def template(package:, violations:)
18
+ sums = violations.anonymous_source_counts.transform_values do |source_counts|
19
+ source_counts.values.sum
20
+ end
21
+
22
+ if sums.empty?
23
+ NoViolationsView.render(package:)
24
+ else
25
+ sums.sort_by { |_, count| -count }.map do |constant_name, count|
26
+ "#{constant_name} (#{format_count(count, 'violation')})\n"
27
+ end.join
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ module Chatwerk
2
+ module Views
3
+ autoload :NoPackagesView, 'chatwerk/views/no_packages_view'
4
+ autoload :NoViolationsView, 'chatwerk/views/no_violations_view'
5
+ autoload :PackageView, 'chatwerk/views/package_view'
6
+ autoload :PackagesView, 'chatwerk/views/packages_view'
7
+ autoload :ViolationsDetailsView, 'chatwerk/views/violations_details_view'
8
+ autoload :ViolationsListView, 'chatwerk/views/violations_list_view'
9
+ end
10
+ end
data/lib/chatwerk.rb ADDED
@@ -0,0 +1,16 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'zeitwerk'
5
+ require 'query_packwerk'
6
+ require 'sorbet-runtime'
7
+ require 'chatwerk/version'
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+ loader.setup
11
+
12
+ # Chatwerk provides integration between QueryPackwerk and AI tools
13
+ # via the Model Context Protocol (MCP) server.
14
+ module Chatwerk
15
+ class NotFoundError < Error; end
16
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.2"
data/sig/chatwerk.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Chatwerk
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chatwerk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Gusto Engineering
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mcp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: query_packwerk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sorbet-runtime
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: thor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: zeitwerk
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: MCP server and API for integrating Packwerk with AI tools like Cursor.
83
+ email:
84
+ - dev@gusto.com
85
+ executables:
86
+ - chatwerk
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".rspec"
91
+ - ".rubocop.yml"
92
+ - CHANGELOG.md
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - config.ru
97
+ - exe/chatwerk
98
+ - lib/chatwerk.rb
99
+ - lib/chatwerk/api.rb
100
+ - lib/chatwerk/cli.rb
101
+ - lib/chatwerk/error.rb
102
+ - lib/chatwerk/helpers.rb
103
+ - lib/chatwerk/mcp.rb
104
+ - lib/chatwerk/tools/package_todos_tool.rb
105
+ - lib/chatwerk/tools/package_tool.rb
106
+ - lib/chatwerk/tools/package_violations_tool.rb
107
+ - lib/chatwerk/tools/packages_tool.rb
108
+ - lib/chatwerk/tools/print_env_tool.rb
109
+ - lib/chatwerk/version.rb
110
+ - lib/chatwerk/views.rb
111
+ - lib/chatwerk/views/base_view.rb
112
+ - lib/chatwerk/views/no_packages_view.rb
113
+ - lib/chatwerk/views/no_violations_view.rb
114
+ - lib/chatwerk/views/package_view.rb
115
+ - lib/chatwerk/views/packages_view.rb
116
+ - lib/chatwerk/views/violations_details_view.rb
117
+ - lib/chatwerk/views/violations_list_view.rb
118
+ - mise.toml
119
+ - sig/chatwerk.rbs
120
+ homepage: https://github.com/rubyatscale/chatwerk
121
+ licenses:
122
+ - MIT
123
+ metadata:
124
+ allowed_push_host: https://rubygems.org
125
+ homepage_uri: https://github.com/rubyatscale/chatwerk
126
+ source_code_uri: https://github.com/rubyatscale/chatwerk
127
+ changelog_uri: https://github.com/rubyatscale/chatwerk/blob/main/CHANGELOG.md
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.1.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.7.1
143
+ specification_version: 4
144
+ summary: 'Chatwerk: AI integration for Packwerk'
145
+ test_files: []