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.
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Tools
6
+ module Sessions
7
+ # List all sessions with optional filtering
8
+ class ListSessions < ::MCP::Tool
9
+ description "List all sxn development sessions with optional status filtering"
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ status: {
15
+ type: "string",
16
+ enum: %w[active inactive archived],
17
+ description: "Filter sessions by status"
18
+ },
19
+ limit: {
20
+ type: "integer",
21
+ default: 100,
22
+ description: "Maximum number of sessions to return"
23
+ }
24
+ },
25
+ required: []
26
+ )
27
+
28
+ class << self
29
+ def call(server_context:, status: nil, limit: 100)
30
+ BaseTool.ensure_initialized!(server_context)
31
+
32
+ BaseTool::ErrorMapping.wrap do
33
+ session_manager = server_context[:session_manager]
34
+ sessions = session_manager.list_sessions(status: status, limit: limit)
35
+
36
+ if sessions.empty?
37
+ BaseTool.text_response("No sessions found.")
38
+ else
39
+ summary = "Found #{sessions.length} session(s):"
40
+ formatted = sessions.map do |s|
41
+ "- #{s[:name]} (#{s[:status]}) - #{s[:worktrees].keys.length} worktrees"
42
+ end.join("\n")
43
+
44
+ BaseTool.text_response("#{summary}\n#{formatted}")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # Create a new session
52
+ class CreateSession < ::MCP::Tool
53
+ description "Create a new sxn development session with optional template"
54
+
55
+ input_schema(
56
+ type: "object",
57
+ properties: {
58
+ name: {
59
+ type: "string",
60
+ pattern: "^[a-zA-Z0-9_-]+$",
61
+ description: "Session name (alphanumeric, hyphens, underscores only)"
62
+ },
63
+ description: {
64
+ type: "string",
65
+ description: "Optional session description"
66
+ },
67
+ default_branch: {
68
+ type: "string",
69
+ description: "Default branch for worktrees (defaults to session name)"
70
+ },
71
+ template_id: {
72
+ type: "string",
73
+ description: "Template to apply (creates predefined worktrees)"
74
+ },
75
+ linear_task: {
76
+ type: "string",
77
+ description: "Associated Linear task ID (e.g., ATL-1234)"
78
+ }
79
+ },
80
+ required: ["name"]
81
+ )
82
+
83
+ class << self
84
+ def call(name:, server_context:, description: nil, default_branch: nil, template_id: nil, linear_task: nil)
85
+ BaseTool.ensure_initialized!(server_context)
86
+
87
+ BaseTool::ErrorMapping.wrap do
88
+ session_manager = server_context[:session_manager]
89
+
90
+ session = session_manager.create_session(
91
+ name,
92
+ description: description,
93
+ default_branch: default_branch,
94
+ template_id: template_id,
95
+ linear_task: linear_task
96
+ )
97
+
98
+ # If template specified, apply it
99
+ apply_template(server_context, name, template_id, default_branch || name) if template_id && server_context[:template_manager]
100
+
101
+ BaseTool.text_response(
102
+ "Session '#{name}' created successfully.\n" \
103
+ "Path: #{session[:path]}\n" \
104
+ "Branch: #{session[:default_branch]}"
105
+ )
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def apply_template(server_context, session_name, template_id, default_branch)
112
+ template_manager = server_context[:template_manager]
113
+ worktree_manager = server_context[:worktree_manager]
114
+
115
+ # Get template projects
116
+ projects = template_manager.get_template_projects(template_id, default_branch: default_branch)
117
+
118
+ # Create worktrees for each project
119
+ projects.each do |project|
120
+ worktree_manager.add_worktree(
121
+ project[:name],
122
+ project[:branch],
123
+ session_name: session_name
124
+ )
125
+ end
126
+ rescue StandardError => e
127
+ # Log template application error but don't fail session creation
128
+ Sxn.logger&.warn("Failed to apply template: #{e.message}")
129
+ end
130
+ end
131
+ end
132
+
133
+ # Get detailed session info
134
+ class GetSession < ::MCP::Tool
135
+ description "Get detailed information about a specific session"
136
+
137
+ input_schema(
138
+ type: "object",
139
+ properties: {
140
+ name: {
141
+ type: "string",
142
+ description: "Session name to retrieve"
143
+ }
144
+ },
145
+ required: ["name"]
146
+ )
147
+
148
+ class << self
149
+ def call(name:, server_context:)
150
+ BaseTool.ensure_initialized!(server_context)
151
+
152
+ BaseTool::ErrorMapping.wrap do
153
+ session_manager = server_context[:session_manager]
154
+ session = session_manager.get_session(name)
155
+
156
+ raise Sxn::SessionNotFoundError, "Session '#{name}' not found" unless session
157
+
158
+ # Format session info
159
+ worktrees_info = session[:worktrees].map do |project, info|
160
+ " - #{project}: #{info[:branch] || info["branch"]} (#{info[:path] || info["path"]})"
161
+ end.join("\n")
162
+
163
+ output = <<~INFO
164
+ Session: #{session[:name]}
165
+ Status: #{session[:status]}
166
+ Path: #{session[:path]}
167
+ Created: #{session[:created_at]}
168
+ Default Branch: #{session[:default_branch]}
169
+ #{"Description: #{session[:description]}" if session[:description]}
170
+ #{"Linear Task: #{session[:linear_task]}" if session[:linear_task]}
171
+ #{"Template: #{session[:template_id]}" if session[:template_id]}
172
+
173
+ Worktrees (#{session[:worktrees].keys.length}):
174
+ #{worktrees_info.empty? ? " (none)" : worktrees_info}
175
+ INFO
176
+
177
+ BaseTool.text_response(output.strip)
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # Delete a session
184
+ class DeleteSession < ::MCP::Tool
185
+ description "Delete a session and its worktrees"
186
+
187
+ input_schema(
188
+ type: "object",
189
+ properties: {
190
+ name: {
191
+ type: "string",
192
+ description: "Session name to delete"
193
+ },
194
+ force: {
195
+ type: "boolean",
196
+ default: false,
197
+ description: "Force deletion even with uncommitted changes"
198
+ }
199
+ },
200
+ required: ["name"]
201
+ )
202
+
203
+ class << self
204
+ def call(name:, server_context:, force: false)
205
+ BaseTool.ensure_initialized!(server_context)
206
+
207
+ BaseTool::ErrorMapping.wrap do
208
+ session_manager = server_context[:session_manager]
209
+ session_manager.remove_session(name, force: force)
210
+
211
+ BaseTool.text_response("Session '#{name}' deleted successfully.")
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # Archive a session
218
+ class ArchiveSession < ::MCP::Tool
219
+ description "Archive a session (preserves data but marks as archived)"
220
+
221
+ input_schema(
222
+ type: "object",
223
+ properties: {
224
+ name: {
225
+ type: "string",
226
+ description: "Session name to archive"
227
+ }
228
+ },
229
+ required: ["name"]
230
+ )
231
+
232
+ class << self
233
+ def call(name:, server_context:)
234
+ BaseTool.ensure_initialized!(server_context)
235
+
236
+ BaseTool::ErrorMapping.wrap do
237
+ session_manager = server_context[:session_manager]
238
+ session_manager.archive_session(name)
239
+
240
+ BaseTool.text_response("Session '#{name}' archived successfully.")
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ # Activate an archived session
247
+ class ActivateSession < ::MCP::Tool
248
+ description "Activate an archived session"
249
+
250
+ input_schema(
251
+ type: "object",
252
+ properties: {
253
+ name: {
254
+ type: "string",
255
+ description: "Session name to activate"
256
+ }
257
+ },
258
+ required: ["name"]
259
+ )
260
+
261
+ class << self
262
+ def call(name:, server_context:)
263
+ BaseTool.ensure_initialized!(server_context)
264
+
265
+ BaseTool::ErrorMapping.wrap do
266
+ session_manager = server_context[:session_manager]
267
+ session_manager.activate_session(name)
268
+
269
+ BaseTool.text_response("Session '#{name}' activated successfully.")
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ # Swap to a different session with navigation info
276
+ class SwapSession < ::MCP::Tool
277
+ description "Switch to a different session and get navigation instructions. " \
278
+ "Returns the session path and shell commands for changing directory."
279
+
280
+ input_schema(
281
+ type: "object",
282
+ properties: {
283
+ name: {
284
+ type: "string",
285
+ description: "Session name to switch to"
286
+ },
287
+ project: {
288
+ type: "string",
289
+ description: "Specific project worktree to navigate to (optional)"
290
+ }
291
+ },
292
+ required: ["name"]
293
+ )
294
+
295
+ class << self
296
+ def call(name:, server_context:, project: nil)
297
+ BaseTool.ensure_initialized!(server_context)
298
+
299
+ BaseTool::ErrorMapping.wrap do
300
+ session_manager = server_context[:session_manager]
301
+ workspace_path = server_context[:workspace_path]
302
+
303
+ # Activate the session
304
+ session = session_manager.use_session(name)
305
+
306
+ # Determine target path
307
+ target_path = if project
308
+ worktrees = session[:worktrees]
309
+ worktree_info = worktrees[project]
310
+ if worktree_info
311
+ worktree_info[:path] || worktree_info["path"]
312
+ else
313
+ session[:path]
314
+ end
315
+ else
316
+ session[:path]
317
+ end
318
+
319
+ # Determine navigation strategy
320
+ navigation = determine_navigation_strategy(workspace_path, target_path)
321
+
322
+ # Build response
323
+ worktrees_list = session[:worktrees].map do |proj, info|
324
+ "- #{proj}: #{info[:path] || info["path"]}"
325
+ end.join("\n")
326
+
327
+ output = <<~SWAP
328
+ Switched to session '#{name}'.
329
+
330
+ Session path: #{session[:path]}
331
+ Target path: #{target_path}
332
+
333
+ Navigation (#{navigation[:strategy]}):
334
+ #{navigation[:instruction]}
335
+
336
+ Worktrees:
337
+ #{worktrees_list.empty? ? "(none)" : worktrees_list}
338
+ SWAP
339
+
340
+ # Add structured data for programmatic use
341
+ BaseTool.text_response(output.strip)
342
+ end
343
+ end
344
+
345
+ private
346
+
347
+ def determine_navigation_strategy(workspace_path, target_path)
348
+ # Check if target is within or contains workspace
349
+ target_expanded = File.expand_path(target_path)
350
+ workspace_expanded = File.expand_path(workspace_path)
351
+
352
+ if target_expanded.start_with?(workspace_expanded) ||
353
+ workspace_expanded.start_with?(target_expanded)
354
+ {
355
+ strategy: "bash_cd",
356
+ instruction: "Run: cd #{target_path}",
357
+ bash_command: "cd #{target_path}",
358
+ reason: "Session is within the current workspace"
359
+ }
360
+ else
361
+ {
362
+ strategy: "new_instance",
363
+ instruction: "Session is outside current workspace. " \
364
+ "Start a new Claude Code instance with:\n claude --cwd #{target_path}",
365
+ shell_command: "claude --cwd #{target_path}",
366
+ reason: "Session is outside the current workspace"
367
+ }
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Tools
6
+ module Templates
7
+ # List available templates
8
+ class ListTemplates < ::MCP::Tool
9
+ description "List available session templates"
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
+ template_manager = server_context[:template_manager]
23
+ templates = template_manager.list_templates
24
+
25
+ if templates.empty?
26
+ BaseTool.text_response(
27
+ "No templates defined. Templates can be created in .sxn/templates.yml"
28
+ )
29
+ else
30
+ formatted = templates.map do |t|
31
+ desc = t[:description] ? " - #{t[:description]}" : ""
32
+ "- #{t[:name]} (#{t[:project_count]} projects)#{desc}"
33
+ end.join("\n")
34
+
35
+ BaseTool.text_response("Available templates:\n#{formatted}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ # Apply a template to a session
43
+ class ApplyTemplate < ::MCP::Tool
44
+ description "Apply a template to create multiple worktrees in a session"
45
+
46
+ input_schema(
47
+ type: "object",
48
+ properties: {
49
+ template_name: {
50
+ type: "string",
51
+ description: "Template name to apply"
52
+ },
53
+ session_name: {
54
+ type: "string",
55
+ description: "Target session (defaults to current session)"
56
+ }
57
+ },
58
+ required: ["template_name"]
59
+ )
60
+
61
+ class << self
62
+ def call(template_name:, server_context:, session_name: nil)
63
+ BaseTool.ensure_initialized!(server_context)
64
+
65
+ BaseTool::ErrorMapping.wrap do
66
+ template_manager = server_context[:template_manager]
67
+ worktree_manager = server_context[:worktree_manager]
68
+ session_manager = server_context[:session_manager]
69
+ config_manager = server_context[:config_manager]
70
+
71
+ # Get session (use current if not specified)
72
+ session_name ||= config_manager.current_session
73
+ raise Sxn::NoActiveSessionError, "No active session" unless session_name
74
+
75
+ session = session_manager.get_session(session_name)
76
+ raise Sxn::SessionNotFoundError, "Session '#{session_name}' not found" unless session
77
+
78
+ # Validate template
79
+ template_manager.validate_template(template_name)
80
+
81
+ # Get template projects with default branch
82
+ default_branch = session[:default_branch] || session_name
83
+ projects = template_manager.get_template_projects(template_name, default_branch: default_branch)
84
+
85
+ # Create worktrees for each project
86
+ results = []
87
+ projects.each do |project|
88
+ result = worktree_manager.add_worktree(
89
+ project[:name],
90
+ project[:branch],
91
+ session_name: session_name
92
+ )
93
+ results << { project: project[:name], status: "success", path: result[:path] }
94
+ rescue StandardError => e
95
+ results << { project: project[:name], status: "error", error: e.message }
96
+ end
97
+
98
+ # Format output
99
+ successful = results.select { |r| r[:status] == "success" }
100
+ failed = results.select { |r| r[:status] == "error" }
101
+
102
+ output = "Template '#{template_name}' applied to session '#{session_name}'.\n\n"
103
+ output += "Created #{successful.length} worktree(s):\n"
104
+ successful.each { |r| output += " - #{r[:project]}: #{r[:path]}\n" }
105
+
106
+ unless failed.empty?
107
+ output += "\nFailed (#{failed.length}):\n"
108
+ failed.each { |r| output += " - #{r[:project]}: #{r[:error]}\n" }
109
+ end
110
+
111
+ BaseTool.text_response(output.strip)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Tools
6
+ module Worktrees
7
+ # List worktrees in a session
8
+ class ListWorktrees < ::MCP::Tool
9
+ description "List all worktrees in a session"
10
+
11
+ input_schema(
12
+ type: "object",
13
+ properties: {
14
+ session_name: {
15
+ type: "string",
16
+ description: "Session name (defaults to current session)"
17
+ }
18
+ },
19
+ required: []
20
+ )
21
+
22
+ class << self
23
+ def call(server_context:, session_name: nil)
24
+ BaseTool.ensure_initialized!(server_context)
25
+
26
+ BaseTool::ErrorMapping.wrap do
27
+ worktree_manager = server_context[:worktree_manager]
28
+ worktrees = worktree_manager.list_worktrees(session_name: session_name)
29
+
30
+ if worktrees.empty?
31
+ BaseTool.text_response("No worktrees found in the session.")
32
+ else
33
+ formatted = worktrees.map do |w|
34
+ status_icon = case w[:status]
35
+ when "clean" then "[clean]"
36
+ when "modified" then "[modified]"
37
+ when "staged" then "[staged]"
38
+ when "untracked" then "[untracked]"
39
+ when "missing" then "[missing]"
40
+ else "[#{w[:status]}]"
41
+ end
42
+ "- #{w[:project]} (#{w[:branch]}) #{status_icon}\n #{w[:path]}"
43
+ end.join("\n")
44
+
45
+ BaseTool.text_response("Worktrees (#{worktrees.length}):\n#{formatted}")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Add a worktree to a session
53
+ class AddWorktree < ::MCP::Tool
54
+ description "Add a project worktree to the current or specified session. " \
55
+ "Automatically applies project rules after creation."
56
+
57
+ input_schema(
58
+ type: "object",
59
+ properties: {
60
+ project_name: {
61
+ type: "string",
62
+ description: "Registered project name"
63
+ },
64
+ branch: {
65
+ type: "string",
66
+ description: "Branch name (defaults to session's default branch)"
67
+ },
68
+ session_name: {
69
+ type: "string",
70
+ description: "Target session (defaults to current session)"
71
+ },
72
+ apply_rules: {
73
+ type: "boolean",
74
+ default: true,
75
+ description: "Apply project rules after creation (copy files, etc.)"
76
+ }
77
+ },
78
+ required: ["project_name"]
79
+ )
80
+
81
+ class << self
82
+ def call(project_name:, server_context:, branch: nil, session_name: nil, apply_rules: true)
83
+ BaseTool.ensure_initialized!(server_context)
84
+
85
+ BaseTool::ErrorMapping.wrap do
86
+ worktree_manager = server_context[:worktree_manager]
87
+
88
+ result = worktree_manager.add_worktree(
89
+ project_name,
90
+ branch,
91
+ session_name: session_name
92
+ )
93
+
94
+ # Apply rules if requested
95
+ rules_result = nil
96
+ if apply_rules && server_context[:rules_manager]
97
+ rules_result = apply_project_rules(
98
+ server_context,
99
+ project_name,
100
+ result[:session]
101
+ )
102
+ end
103
+
104
+ output = <<~RESULT
105
+ Worktree created successfully:
106
+ - Project: #{result[:project]}
107
+ - Branch: #{result[:branch]}
108
+ - Path: #{result[:path]}
109
+ - Session: #{result[:session]}
110
+ RESULT
111
+
112
+ if rules_result
113
+ output += "\nRules applied: #{rules_result[:applied_count]} rule(s)"
114
+ output += "\nRule errors: #{rules_result[:errors].join(", ")}" unless rules_result[:errors].empty?
115
+ end
116
+
117
+ BaseTool.text_response(output.strip)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def apply_project_rules(server_context, project_name, session_name)
124
+ rules_manager = server_context[:rules_manager]
125
+ rules_manager.apply_rules(project_name, session_name)
126
+ rescue StandardError => e
127
+ # Don't fail worktree creation if rules fail
128
+ { applied_count: 0, errors: [e.message] }
129
+ end
130
+ end
131
+ end
132
+
133
+ # Remove a worktree from a session
134
+ class RemoveWorktree < ::MCP::Tool
135
+ description "Remove a worktree from a session"
136
+
137
+ input_schema(
138
+ type: "object",
139
+ properties: {
140
+ project_name: {
141
+ type: "string",
142
+ description: "Project name of the worktree to remove"
143
+ },
144
+ session_name: {
145
+ type: "string",
146
+ description: "Session name (defaults to current session)"
147
+ }
148
+ },
149
+ required: ["project_name"]
150
+ )
151
+
152
+ class << self
153
+ def call(project_name:, server_context:, session_name: nil)
154
+ BaseTool.ensure_initialized!(server_context)
155
+
156
+ BaseTool::ErrorMapping.wrap do
157
+ worktree_manager = server_context[:worktree_manager]
158
+ worktree_manager.remove_worktree(project_name, session_name: session_name)
159
+
160
+ BaseTool.text_response("Worktree for '#{project_name}' removed successfully.")
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
data/lib/sxn/mcp.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ require_relative "mcp/server"
6
+ require_relative "mcp/tools/base_tool"
7
+ require_relative "mcp/tools/sessions"
8
+ require_relative "mcp/tools/worktrees"
9
+ require_relative "mcp/tools/projects"
10
+ require_relative "mcp/tools/templates"
11
+ require_relative "mcp/tools/rules"
12
+ require_relative "mcp/resources/session_resources"
13
+ require_relative "mcp/prompts/workflow_prompts"
14
+
15
+ module Sxn
16
+ # MCP (Model Context Protocol) server for sxn
17
+ # Enables AI assistants like Claude Code to manage development sessions
18
+ module MCP
19
+ end
20
+ end
data/lib/sxn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sxn
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/sxn.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "sxn/templates"
13
13
  require_relative "sxn/ui"
14
14
  require_relative "sxn/commands"
15
15
  require_relative "sxn/CLI"
16
+ # MCP module is loaded on demand via require "sxn/mcp"
16
17
 
17
18
  module Sxn
18
19
  class << self