sxn 0.2.5 → 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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Resources
6
+ # Factory methods for creating MCP Resource objects
7
+ module SessionResources
8
+ class << self
9
+ # Build all resources for the server
10
+ def build_all(server_context)
11
+ [
12
+ build_current_session(server_context),
13
+ build_all_sessions(server_context),
14
+ build_all_projects(server_context)
15
+ ].compact
16
+ end
17
+
18
+ private
19
+
20
+ def build_current_session(server_context)
21
+ return nil unless server_context[:config_manager]
22
+
23
+ ::MCP::Resource.new(
24
+ uri: "sxn://session/current",
25
+ name: "Current Session",
26
+ description: "Information about the currently active sxn session",
27
+ mime_type: "application/json"
28
+ )
29
+ end
30
+
31
+ def build_all_sessions(server_context)
32
+ return nil unless server_context[:config_manager]
33
+
34
+ ::MCP::Resource.new(
35
+ uri: "sxn://sessions",
36
+ name: "All Sessions",
37
+ description: "Summary of all sxn sessions",
38
+ mime_type: "application/json"
39
+ )
40
+ end
41
+
42
+ def build_all_projects(server_context)
43
+ return nil unless server_context[:config_manager]
44
+
45
+ ::MCP::Resource.new(
46
+ uri: "sxn://projects",
47
+ name: "Registered Projects",
48
+ description: "All registered projects in the sxn workspace",
49
+ mime_type: "application/json"
50
+ )
51
+ end
52
+ end
53
+ end
54
+
55
+ # Resource content readers
56
+ module ResourceContentReader
57
+ class << self
58
+ def read_content(uri, server_context)
59
+ case uri
60
+ when "sxn://session/current"
61
+ read_current_session(server_context)
62
+ when "sxn://sessions"
63
+ read_all_sessions(server_context)
64
+ when "sxn://projects"
65
+ read_all_projects(server_context)
66
+ else
67
+ JSON.generate({ error: "Unknown resource: #{uri}" })
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def read_current_session(server_context)
74
+ config_manager = server_context[:config_manager]
75
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
76
+
77
+ session_manager = server_context[:session_manager]
78
+ session = session_manager.current_session
79
+
80
+ unless session
81
+ return JSON.generate({
82
+ current_session: nil,
83
+ message: "No active session"
84
+ })
85
+ end
86
+
87
+ JSON.generate({
88
+ name: session[:name],
89
+ status: session[:status],
90
+ path: session[:path],
91
+ created_at: session[:created_at],
92
+ default_branch: session[:default_branch],
93
+ worktrees: session[:worktrees],
94
+ projects: session[:projects]
95
+ })
96
+ rescue StandardError => e
97
+ JSON.generate({ error: e.message })
98
+ end
99
+
100
+ def read_all_sessions(server_context)
101
+ config_manager = server_context[:config_manager]
102
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
103
+
104
+ session_manager = server_context[:session_manager]
105
+ sessions = session_manager.list_sessions
106
+
107
+ JSON.generate({
108
+ total: sessions.length,
109
+ sessions: sessions.map do |s|
110
+ {
111
+ name: s[:name],
112
+ status: s[:status],
113
+ worktree_count: s[:worktrees].keys.length
114
+ }
115
+ end
116
+ })
117
+ rescue StandardError => e
118
+ JSON.generate({ error: e.message })
119
+ end
120
+
121
+ def read_all_projects(server_context)
122
+ config_manager = server_context[:config_manager]
123
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
124
+
125
+ project_manager = server_context[:project_manager]
126
+ projects = project_manager.list_projects
127
+
128
+ JSON.generate({
129
+ total: projects.length,
130
+ projects: projects.map do |p|
131
+ {
132
+ name: p[:name],
133
+ type: p[:type],
134
+ path: p[:path]
135
+ }
136
+ end
137
+ })
138
+ rescue StandardError => e
139
+ JSON.generate({ error: e.message })
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ # Main MCP server class that orchestrates tools, resources, and prompts
6
+ class Server
7
+ attr_reader :workspace_path, :config_manager
8
+
9
+ def initialize(workspace_path: nil)
10
+ @workspace_path = workspace_path || ENV["SXN_WORKSPACE"] || discover_workspace
11
+ @config_manager = initialize_config_manager
12
+ @server = build_mcp_server
13
+ end
14
+
15
+ # Run the server with STDIO transport (for Claude Code integration)
16
+ def run_stdio
17
+ transport = ::MCP::Server::Transports::StdioTransport.new(@server)
18
+ transport.open
19
+ end
20
+
21
+ # Run the server with HTTP transport (for web-based integrations)
22
+ def run_http(port: 3000)
23
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
24
+ transport.run(port: port)
25
+ end
26
+
27
+ private
28
+
29
+ def discover_workspace
30
+ # Try to find .sxn directory in current or parent directories
31
+ current = Dir.pwd
32
+ loop do
33
+ sxn_path = File.join(current, ".sxn")
34
+ return current if File.directory?(sxn_path)
35
+
36
+ parent = File.dirname(current)
37
+ break if parent == current
38
+
39
+ current = parent
40
+ end
41
+
42
+ # Fall back to current directory
43
+ Dir.pwd
44
+ end
45
+
46
+ def initialize_config_manager
47
+ Sxn::Core::ConfigManager.new(@workspace_path)
48
+ rescue Sxn::ConfigurationError
49
+ # Allow server to start even if sxn isn't initialized
50
+ # Tools will return appropriate errors
51
+ nil
52
+ end
53
+
54
+ def build_mcp_server
55
+ context = build_context
56
+
57
+ server = ::MCP::Server.new(
58
+ name: "sxn",
59
+ version: Sxn::VERSION,
60
+ tools: registered_tools,
61
+ resources: registered_resources(context),
62
+ prompts: registered_prompts,
63
+ server_context: context
64
+ )
65
+
66
+ # Set up resource read handler
67
+ server.resources_read_handler do |params|
68
+ uri = params[:uri]
69
+ content = Resources::ResourceContentReader.read_content(uri, context)
70
+ [{ uri: uri, text: content, mimeType: "application/json" }]
71
+ end
72
+
73
+ server
74
+ end
75
+
76
+ def build_context
77
+ {
78
+ config_manager: @config_manager,
79
+ session_manager: @config_manager && Sxn::Core::SessionManager.new(@config_manager),
80
+ project_manager: @config_manager && Sxn::Core::ProjectManager.new(@config_manager),
81
+ worktree_manager: @config_manager && Sxn::Core::WorktreeManager.new(@config_manager),
82
+ template_manager: @config_manager && Sxn::Core::TemplateManager.new(@config_manager),
83
+ rules_manager: @config_manager && Sxn::Core::RulesManager.new(@config_manager),
84
+ workspace_path: @workspace_path
85
+ }
86
+ end
87
+
88
+ def registered_tools
89
+ [
90
+ # Session tools
91
+ Tools::Sessions::ListSessions,
92
+ Tools::Sessions::CreateSession,
93
+ Tools::Sessions::GetSession,
94
+ Tools::Sessions::DeleteSession,
95
+ Tools::Sessions::ArchiveSession,
96
+ Tools::Sessions::ActivateSession,
97
+ Tools::Sessions::SwapSession,
98
+ # Worktree tools
99
+ Tools::Worktrees::ListWorktrees,
100
+ Tools::Worktrees::AddWorktree,
101
+ Tools::Worktrees::RemoveWorktree,
102
+ # Project tools
103
+ Tools::Projects::ListProjects,
104
+ Tools::Projects::AddProject,
105
+ Tools::Projects::GetProject,
106
+ # Template tools
107
+ Tools::Templates::ListTemplates,
108
+ Tools::Templates::ApplyTemplate,
109
+ # Rules tools
110
+ Tools::Rules::ListRules,
111
+ Tools::Rules::ApplyRules
112
+ ]
113
+ end
114
+
115
+ def registered_resources(context)
116
+ Resources::SessionResources.build_all(context)
117
+ end
118
+
119
+ def registered_prompts
120
+ [
121
+ Prompts::NewSession,
122
+ Prompts::MultiRepoSetup
123
+ ]
124
+ end
125
+ end
126
+ end
127
+ end
@@ -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