sxn 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +16 -13
- data/Gemfile.lock +6 -1
- data/bin/sxn-mcp +58 -0
- data/docs/MCP_IMPLEMENTATION.md +425 -0
- data/lib/sxn/CLI.rb +7 -0
- data/lib/sxn/commands/mcp.rb +219 -0
- data/lib/sxn/commands/sessions.rb +1 -4
- data/lib/sxn/commands/templates.rb +1 -5
- data/lib/sxn/commands.rb +1 -0
- data/lib/sxn/mcp/prompts/workflow_prompts.rb +107 -0
- data/lib/sxn/mcp/resources/session_resources.rb +145 -0
- data/lib/sxn/mcp/server.rb +127 -0
- data/lib/sxn/mcp/tools/base_tool.rb +96 -0
- data/lib/sxn/mcp/tools/projects.rb +144 -0
- data/lib/sxn/mcp/tools/rules.rb +108 -0
- data/lib/sxn/mcp/tools/sessions.rb +375 -0
- data/lib/sxn/mcp/tools/templates.rb +119 -0
- data/lib/sxn/mcp/tools/worktrees.rb +168 -0
- data/lib/sxn/mcp.rb +20 -0
- data/lib/sxn/version.rb +1 -1
- data/lib/sxn.rb +1 -0
- data/sxn.gemspec +86 -0
- data/test.txt +1 -0
- metadata +31 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
# Base module for MCP tools with shared error handling
|
|
7
|
+
module BaseTool
|
|
8
|
+
# Maps sxn errors to user-friendly messages
|
|
9
|
+
module ErrorMapping
|
|
10
|
+
SXN_ERROR_MESSAGES = {
|
|
11
|
+
# Session errors
|
|
12
|
+
Sxn::SessionNotFoundError => "Session not found",
|
|
13
|
+
Sxn::SessionAlreadyExistsError => "Session already exists",
|
|
14
|
+
Sxn::SessionExistsError => "Session already exists",
|
|
15
|
+
Sxn::SessionHasChangesError => "Session has uncommitted changes",
|
|
16
|
+
Sxn::InvalidSessionNameError => "Invalid session name",
|
|
17
|
+
Sxn::NoActiveSessionError => "No active session",
|
|
18
|
+
# Project errors
|
|
19
|
+
Sxn::ProjectNotFoundError => "Project not found",
|
|
20
|
+
Sxn::ProjectAlreadyExistsError => "Project already exists",
|
|
21
|
+
Sxn::ProjectExistsError => "Project already exists",
|
|
22
|
+
Sxn::ProjectInUseError => "Project is in use",
|
|
23
|
+
Sxn::InvalidProjectNameError => "Invalid project name",
|
|
24
|
+
Sxn::InvalidProjectPathError => "Invalid project path",
|
|
25
|
+
# Worktree errors
|
|
26
|
+
Sxn::WorktreeError => "Worktree error",
|
|
27
|
+
Sxn::WorktreeExistsError => "Worktree already exists",
|
|
28
|
+
Sxn::WorktreeNotFoundError => "Worktree not found",
|
|
29
|
+
Sxn::WorktreeCreationError => "Failed to create worktree",
|
|
30
|
+
Sxn::WorktreeRemovalError => "Failed to remove worktree",
|
|
31
|
+
# Template errors
|
|
32
|
+
Sxn::SessionTemplateNotFoundError => "Template not found",
|
|
33
|
+
Sxn::SessionTemplateValidationError => "Template validation failed",
|
|
34
|
+
# Rule errors
|
|
35
|
+
Sxn::RuleError => "Rule error",
|
|
36
|
+
Sxn::RuleNotFoundError => "Rule not found",
|
|
37
|
+
Sxn::InvalidRuleTypeError => "Invalid rule type",
|
|
38
|
+
Sxn::InvalidRuleConfigError => "Invalid rule configuration",
|
|
39
|
+
# Configuration errors
|
|
40
|
+
Sxn::ConfigurationError => "Configuration error",
|
|
41
|
+
# MCP errors
|
|
42
|
+
Sxn::MCPError => "MCP error",
|
|
43
|
+
Sxn::MCPServerError => "MCP server error",
|
|
44
|
+
Sxn::MCPValidationError => "MCP validation error"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Wrap a block with error handling that converts sxn errors to error responses
|
|
48
|
+
def self.wrap
|
|
49
|
+
yield
|
|
50
|
+
rescue Sxn::ConfigurationError => e
|
|
51
|
+
error_response("sxn not initialized: #{e.message}. Run 'sxn init' first.")
|
|
52
|
+
rescue Sxn::Error => e
|
|
53
|
+
error_type = SXN_ERROR_MESSAGES[e.class] || "Error"
|
|
54
|
+
error_response("#{error_type}: #{e.message}")
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
error_response("Unexpected error: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.error_response(message)
|
|
60
|
+
::MCP::Tool::Response.new([{ type: "text", text: message }], error: true)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Helper to check if sxn is initialized
|
|
65
|
+
def self.ensure_initialized!(server_context)
|
|
66
|
+
return true if server_context[:config_manager]
|
|
67
|
+
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Helper to return error response for uninitialized sxn
|
|
72
|
+
def self.not_initialized_response
|
|
73
|
+
ErrorMapping.error_response("sxn not initialized in this workspace. Run 'sxn init' first.")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Helper to build a successful text response
|
|
77
|
+
def self.text_response(text)
|
|
78
|
+
::MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Helper to build a JSON response
|
|
82
|
+
def self.json_response(data, summary: nil)
|
|
83
|
+
content = []
|
|
84
|
+
content << { type: "text", text: summary } if summary
|
|
85
|
+
content << { type: "text", text: JSON.pretty_generate(data) }
|
|
86
|
+
::MCP::Tool::Response.new(content)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Helper to build an error response
|
|
90
|
+
def self.error_response(message)
|
|
91
|
+
ErrorMapping.error_response(message)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
module Projects
|
|
7
|
+
# List registered projects
|
|
8
|
+
class ListProjects < ::MCP::Tool
|
|
9
|
+
description "List all registered projects in the sxn workspace"
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {},
|
|
14
|
+
required: []
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(server_context:)
|
|
19
|
+
BaseTool.ensure_initialized!(server_context)
|
|
20
|
+
|
|
21
|
+
BaseTool::ErrorMapping.wrap do
|
|
22
|
+
project_manager = server_context[:project_manager]
|
|
23
|
+
projects = project_manager.list_projects
|
|
24
|
+
|
|
25
|
+
if projects.empty?
|
|
26
|
+
BaseTool.text_response("No projects registered. Use sxn_projects_add to register a project.")
|
|
27
|
+
else
|
|
28
|
+
formatted = projects.map do |p|
|
|
29
|
+
"- #{p[:name]} (#{p[:type]})\n #{p[:path]}\n Default branch: #{p[:default_branch]}"
|
|
30
|
+
end.join("\n")
|
|
31
|
+
|
|
32
|
+
BaseTool.text_response("Registered projects (#{projects.length}):\n#{formatted}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Register a new project
|
|
40
|
+
class AddProject < ::MCP::Tool
|
|
41
|
+
description "Register a new git repository as a project. " \
|
|
42
|
+
"Automatically detects project type and default branch."
|
|
43
|
+
|
|
44
|
+
input_schema(
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
name: {
|
|
48
|
+
type: "string",
|
|
49
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
50
|
+
description: "Project name (alphanumeric, hyphens, underscores only)"
|
|
51
|
+
},
|
|
52
|
+
path: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Path to the git repository (absolute or relative)"
|
|
55
|
+
},
|
|
56
|
+
type: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Project type (auto-detected if not specified). " \
|
|
59
|
+
"Options: rails, ruby, javascript, typescript, react, nextjs, etc."
|
|
60
|
+
},
|
|
61
|
+
default_branch: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Default branch for worktrees (auto-detected if not specified)"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
required: %w[name path]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
def call(name:, path:, server_context:, type: nil, default_branch: nil)
|
|
71
|
+
BaseTool.ensure_initialized!(server_context)
|
|
72
|
+
|
|
73
|
+
BaseTool::ErrorMapping.wrap do
|
|
74
|
+
project_manager = server_context[:project_manager]
|
|
75
|
+
|
|
76
|
+
result = project_manager.add_project(
|
|
77
|
+
name,
|
|
78
|
+
path,
|
|
79
|
+
type: type,
|
|
80
|
+
default_branch: default_branch
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
BaseTool.text_response(
|
|
84
|
+
"Project '#{result[:name]}' registered successfully.\n" \
|
|
85
|
+
"Type: #{result[:type]}\n" \
|
|
86
|
+
"Path: #{result[:path]}\n" \
|
|
87
|
+
"Default branch: #{result[:default_branch]}"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get project details
|
|
95
|
+
class GetProject < ::MCP::Tool
|
|
96
|
+
description "Get detailed information about a registered project"
|
|
97
|
+
|
|
98
|
+
input_schema(
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
name: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "Project name to retrieve"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
required: ["name"]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
def call(name:, server_context:)
|
|
111
|
+
BaseTool.ensure_initialized!(server_context)
|
|
112
|
+
|
|
113
|
+
BaseTool::ErrorMapping.wrap do
|
|
114
|
+
project_manager = server_context[:project_manager]
|
|
115
|
+
project = project_manager.get_project(name)
|
|
116
|
+
|
|
117
|
+
# Validate the project
|
|
118
|
+
validation = project_manager.validate_project(name)
|
|
119
|
+
|
|
120
|
+
# Get project rules
|
|
121
|
+
rules = project_manager.get_project_rules(name)
|
|
122
|
+
rules_summary = rules.map { |type, configs| "#{type}: #{Array(configs).length}" }.join(", ")
|
|
123
|
+
|
|
124
|
+
output = <<~INFO
|
|
125
|
+
Project: #{project[:name]}
|
|
126
|
+
Type: #{project[:type]}
|
|
127
|
+
Path: #{project[:path]}
|
|
128
|
+
Default branch: #{project[:default_branch]}
|
|
129
|
+
|
|
130
|
+
Validation: #{validation[:valid] ? "Valid" : "Invalid"}
|
|
131
|
+
#{validation[:issues].map { |i| " - #{i}" }.join("\n") unless validation[:valid]}
|
|
132
|
+
|
|
133
|
+
Rules: #{rules_summary.empty? ? "(none)" : rules_summary}
|
|
134
|
+
INFO
|
|
135
|
+
|
|
136
|
+
BaseTool.text_response(output.strip)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
module Rules
|
|
7
|
+
# List rules for a project
|
|
8
|
+
class ListRules < ::MCP::Tool
|
|
9
|
+
description "List project rules (copy_files, setup_commands, templates)"
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
project_name: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Project name (lists all rules if not specified)"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
required: []
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def call(server_context:, project_name: nil)
|
|
24
|
+
BaseTool.ensure_initialized!(server_context)
|
|
25
|
+
|
|
26
|
+
BaseTool::ErrorMapping.wrap do
|
|
27
|
+
rules_manager = server_context[:rules_manager]
|
|
28
|
+
rules = rules_manager.list_rules(project_name)
|
|
29
|
+
|
|
30
|
+
if rules.empty?
|
|
31
|
+
BaseTool.text_response("No rules defined.")
|
|
32
|
+
else
|
|
33
|
+
# Group by project
|
|
34
|
+
by_project = rules.group_by { |r| r[:project] }
|
|
35
|
+
|
|
36
|
+
output = by_project.map do |proj, proj_rules|
|
|
37
|
+
proj_output = "#{proj}:\n"
|
|
38
|
+
proj_rules.each do |rule|
|
|
39
|
+
config_preview = case rule[:type]
|
|
40
|
+
when "copy_files"
|
|
41
|
+
rule[:config]["source"]
|
|
42
|
+
when "setup_commands"
|
|
43
|
+
rule[:config]["command"]&.join(" ")
|
|
44
|
+
when "template"
|
|
45
|
+
"#{rule[:config]["source"]} -> #{rule[:config]["destination"]}"
|
|
46
|
+
else
|
|
47
|
+
rule[:config].to_s
|
|
48
|
+
end
|
|
49
|
+
proj_output += " - [#{rule[:type]}] #{config_preview}\n"
|
|
50
|
+
end
|
|
51
|
+
proj_output
|
|
52
|
+
end.join("\n")
|
|
53
|
+
|
|
54
|
+
BaseTool.text_response("Project rules:\n\n#{output}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Apply rules to a worktree
|
|
62
|
+
class ApplyRules < ::MCP::Tool
|
|
63
|
+
description "Apply project-specific rules to a worktree (copy files, run setup commands)"
|
|
64
|
+
|
|
65
|
+
input_schema(
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
project_name: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Project name"
|
|
71
|
+
},
|
|
72
|
+
session_name: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "Session name (defaults to current session)"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
required: ["project_name"]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
def call(project_name:, server_context:, session_name: nil)
|
|
82
|
+
BaseTool.ensure_initialized!(server_context)
|
|
83
|
+
|
|
84
|
+
BaseTool::ErrorMapping.wrap do
|
|
85
|
+
rules_manager = server_context[:rules_manager]
|
|
86
|
+
|
|
87
|
+
result = rules_manager.apply_rules(project_name, session_name)
|
|
88
|
+
|
|
89
|
+
if result[:success]
|
|
90
|
+
BaseTool.text_response(
|
|
91
|
+
"Rules applied successfully to '#{project_name}'.\n" \
|
|
92
|
+
"Applied: #{result[:applied_count]} rule(s)"
|
|
93
|
+
)
|
|
94
|
+
else
|
|
95
|
+
BaseTool.text_response(
|
|
96
|
+
"Some rules failed for '#{project_name}'.\n" \
|
|
97
|
+
"Applied: #{result[:applied_count]} rule(s)\n" \
|
|
98
|
+
"Errors:\n#{result[:errors].map { |e| " - #{e}" }.join("\n")}"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|