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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +6 -1
- data/bin/sxn-mcp +58 -0
- data/docs/MCP_IMPLEMENTATION.md +425 -0
- data/lib/sxn/CLI.rb +15 -0
- data/lib/sxn/commands/mcp.rb +219 -0
- data/lib/sxn/commands/sessions.rb +111 -4
- data/lib/sxn/commands/templates.rb +226 -0
- data/lib/sxn/commands.rb +2 -0
- data/lib/sxn/config/templates_config.rb +153 -0
- data/lib/sxn/config.rb +1 -0
- data/lib/sxn/core/session_config.rb +6 -1
- data/lib/sxn/core/session_manager.rb +10 -4
- data/lib/sxn/core/template_manager.rb +187 -0
- data/lib/sxn/core.rb +1 -0
- data/lib/sxn/errors.rb +24 -1
- 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/ui/table.rb +18 -1
- data/lib/sxn/version.rb +1 -1
- data/lib/sxn.rb +1 -0
- data/sxn.gemspec +1 -0
- data/test.txt +1 -0
- metadata +33 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "English"
|
|
6
|
+
|
|
7
|
+
module Sxn
|
|
8
|
+
module Commands
|
|
9
|
+
# Manage MCP server for Claude Code integration
|
|
10
|
+
class MCP < Thor
|
|
11
|
+
include Thor::Actions
|
|
12
|
+
|
|
13
|
+
def initialize(args = ARGV, local_options = {}, config = {})
|
|
14
|
+
super
|
|
15
|
+
@ui = Sxn::UI::Output.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "install", "Install sxn MCP server to Claude Code"
|
|
19
|
+
option :project, type: :boolean, aliases: "-p", desc: "Install to project .mcp.json instead of user config"
|
|
20
|
+
def install
|
|
21
|
+
sxn_mcp_path = find_sxn_mcp_executable
|
|
22
|
+
|
|
23
|
+
if sxn_mcp_path.nil?
|
|
24
|
+
@ui.error("Could not find sxn-mcp executable")
|
|
25
|
+
@ui.recovery_suggestion("Ensure the sxn gem is properly installed")
|
|
26
|
+
exit(1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if options[:project]
|
|
30
|
+
install_to_project(sxn_mcp_path)
|
|
31
|
+
else
|
|
32
|
+
install_to_claude_code(sxn_mcp_path)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "uninstall", "Remove sxn MCP server from Claude Code"
|
|
37
|
+
option :project, type: :boolean, aliases: "-p", desc: "Remove from project .mcp.json"
|
|
38
|
+
def uninstall
|
|
39
|
+
if options[:project]
|
|
40
|
+
uninstall_from_project
|
|
41
|
+
else
|
|
42
|
+
uninstall_from_claude_code
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "status", "Check if sxn MCP server is installed"
|
|
47
|
+
def status
|
|
48
|
+
@ui.section("MCP Server Status")
|
|
49
|
+
|
|
50
|
+
claude_installed = check_claude_installation
|
|
51
|
+
project_installed = check_project_installation
|
|
52
|
+
|
|
53
|
+
@ui.newline
|
|
54
|
+
@ui.key_value("Claude Code (user)", claude_installed ? "Installed" : "Not installed")
|
|
55
|
+
@ui.key_value("Project (.mcp.json)", project_installed ? "Installed" : "Not installed")
|
|
56
|
+
|
|
57
|
+
sxn_mcp_path = find_sxn_mcp_executable
|
|
58
|
+
@ui.newline
|
|
59
|
+
if sxn_mcp_path
|
|
60
|
+
@ui.key_value("Executable", sxn_mcp_path)
|
|
61
|
+
else
|
|
62
|
+
@ui.warning("sxn-mcp executable not found in PATH")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@ui.newline
|
|
66
|
+
display_install_commands unless claude_installed || project_installed
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "server", "Run the MCP server directly (for testing)"
|
|
70
|
+
option :transport, type: :string, default: "stdio", enum: %w[stdio http], desc: "Transport type"
|
|
71
|
+
option :port, type: :numeric, default: 3000, desc: "Port for HTTP transport"
|
|
72
|
+
def server
|
|
73
|
+
require "sxn/mcp"
|
|
74
|
+
|
|
75
|
+
mcp_server = Sxn::MCP::Server.new
|
|
76
|
+
|
|
77
|
+
case options[:transport]
|
|
78
|
+
when "stdio"
|
|
79
|
+
@ui.info("Starting MCP server with STDIO transport...")
|
|
80
|
+
mcp_server.run_stdio
|
|
81
|
+
when "http"
|
|
82
|
+
@ui.info("Starting MCP server on port #{options[:port]}...")
|
|
83
|
+
mcp_server.run_http(port: options[:port])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def find_sxn_mcp_executable
|
|
90
|
+
# Check if sxn-mcp is in PATH
|
|
91
|
+
path = `which sxn-mcp 2>/dev/null`.strip
|
|
92
|
+
return path unless path.empty?
|
|
93
|
+
|
|
94
|
+
# Check relative to this gem's installation
|
|
95
|
+
gem_bin_path = File.expand_path("../../../bin/sxn-mcp", __dir__)
|
|
96
|
+
return gem_bin_path if File.exist?(gem_bin_path)
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def install_to_claude_code(sxn_mcp_path)
|
|
102
|
+
unless claude_cli_available?
|
|
103
|
+
@ui.error("Claude CLI not found")
|
|
104
|
+
@ui.recovery_suggestion("Install Claude Code CLI first: https://claude.ai/code")
|
|
105
|
+
exit(1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@ui.progress_start("Installing sxn MCP server to Claude Code")
|
|
109
|
+
|
|
110
|
+
# claude mcp add --transport stdio sxn -- /path/to/sxn-mcp
|
|
111
|
+
success = system("claude", "mcp", "add", "--transport", "stdio", "sxn", "--", sxn_mcp_path)
|
|
112
|
+
|
|
113
|
+
if success
|
|
114
|
+
@ui.progress_done
|
|
115
|
+
@ui.success("sxn MCP server installed to Claude Code")
|
|
116
|
+
@ui.info("Restart Claude Code to use the new server")
|
|
117
|
+
else
|
|
118
|
+
@ui.progress_failed
|
|
119
|
+
@ui.error("Failed to install MCP server")
|
|
120
|
+
@ui.recovery_suggestion("Try running manually: claude mcp add --transport stdio sxn -- #{sxn_mcp_path}")
|
|
121
|
+
exit(1)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def install_to_project(sxn_mcp_path)
|
|
126
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
127
|
+
|
|
128
|
+
mcp_config = if File.exist?(mcp_json_path)
|
|
129
|
+
JSON.parse(File.read(mcp_json_path))
|
|
130
|
+
else
|
|
131
|
+
{ "mcpServers" => {} }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
mcp_config["mcpServers"] ||= {}
|
|
135
|
+
mcp_config["mcpServers"]["sxn"] = {
|
|
136
|
+
"command" => sxn_mcp_path,
|
|
137
|
+
"args" => [],
|
|
138
|
+
"env" => { "SXN_WORKSPACE" => "${PWD}" }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
|
|
142
|
+
|
|
143
|
+
@ui.success("Created/updated .mcp.json with sxn MCP server")
|
|
144
|
+
@ui.info("This file can be version controlled to share with your team")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def uninstall_from_claude_code
|
|
148
|
+
unless claude_cli_available?
|
|
149
|
+
@ui.error("Claude CLI not found")
|
|
150
|
+
exit(1)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
@ui.progress_start("Removing sxn MCP server from Claude Code")
|
|
154
|
+
|
|
155
|
+
success = system("claude", "mcp", "remove", "sxn")
|
|
156
|
+
|
|
157
|
+
if success
|
|
158
|
+
@ui.progress_done
|
|
159
|
+
@ui.success("sxn MCP server removed from Claude Code")
|
|
160
|
+
else
|
|
161
|
+
@ui.progress_failed
|
|
162
|
+
@ui.error("Failed to remove MCP server (it may not be installed)")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def uninstall_from_project
|
|
167
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
168
|
+
|
|
169
|
+
unless File.exist?(mcp_json_path)
|
|
170
|
+
@ui.info("No .mcp.json file found in current directory")
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
mcp_config = JSON.parse(File.read(mcp_json_path))
|
|
175
|
+
|
|
176
|
+
if mcp_config.dig("mcpServers", "sxn")
|
|
177
|
+
mcp_config["mcpServers"].delete("sxn")
|
|
178
|
+
|
|
179
|
+
if mcp_config["mcpServers"].empty?
|
|
180
|
+
File.delete(mcp_json_path)
|
|
181
|
+
@ui.success("Removed .mcp.json file (no servers remaining)")
|
|
182
|
+
else
|
|
183
|
+
File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
|
|
184
|
+
@ui.success("Removed sxn from .mcp.json")
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
@ui.info("sxn not found in .mcp.json")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def check_claude_installation
|
|
192
|
+
return false unless claude_cli_available?
|
|
193
|
+
|
|
194
|
+
output = `claude mcp get sxn 2>/dev/null`
|
|
195
|
+
$CHILD_STATUS.success? && !output.strip.empty?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def check_project_installation
|
|
199
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
200
|
+
return false unless File.exist?(mcp_json_path)
|
|
201
|
+
|
|
202
|
+
mcp_config = JSON.parse(File.read(mcp_json_path))
|
|
203
|
+
!mcp_config.dig("mcpServers", "sxn").nil?
|
|
204
|
+
rescue JSON::ParserError
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def claude_cli_available?
|
|
209
|
+
system("which claude > /dev/null 2>&1")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def display_install_commands
|
|
213
|
+
@ui.subsection("Install Commands")
|
|
214
|
+
@ui.command_example("sxn mcp install", "Install to Claude Code (user scope)")
|
|
215
|
+
@ui.command_example("sxn mcp install --project", "Install to project .mcp.json")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -19,12 +19,14 @@ module Sxn
|
|
|
19
19
|
@session_manager = Sxn::Core::SessionManager.new(@config_manager)
|
|
20
20
|
@project_manager = Sxn::Core::ProjectManager.new(@config_manager)
|
|
21
21
|
@worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
|
|
22
|
+
@template_manager = Sxn::Core::TemplateManager.new(@config_manager)
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
desc "add NAME", "Create a new session"
|
|
25
26
|
option :description, type: :string, aliases: "-d", desc: "Session description"
|
|
26
27
|
option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
|
|
27
28
|
option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
|
|
29
|
+
option :template, type: :string, aliases: "-t", desc: "Template to use for worktree creation"
|
|
28
30
|
option :activate, type: :boolean, default: true, desc: "Activate session after creation"
|
|
29
31
|
option :skip_worktree, type: :boolean, default: false, desc: "Skip worktree creation wizard"
|
|
30
32
|
|
|
@@ -37,9 +39,29 @@ module Sxn
|
|
|
37
39
|
name = @prompt.session_name(existing_sessions: existing_sessions)
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
template_id = options[:template]
|
|
43
|
+
|
|
44
|
+
# Validate template if specified
|
|
45
|
+
if template_id
|
|
46
|
+
begin
|
|
47
|
+
@template_manager.validate_template(template_id)
|
|
48
|
+
rescue Sxn::SessionTemplateNotFoundError, Sxn::SessionTemplateValidationError => e
|
|
49
|
+
@ui.error(e.message)
|
|
50
|
+
exit(1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
40
54
|
# Get default branch - use provided option, or prompt interactively
|
|
55
|
+
# Branch is required when using a template
|
|
41
56
|
default_branch = options[:branch]
|
|
42
|
-
default_branch
|
|
57
|
+
if default_branch.nil?
|
|
58
|
+
if template_id
|
|
59
|
+
@ui.error("Branch is required when using a template. Use -b/--branch to specify.")
|
|
60
|
+
exit(1)
|
|
61
|
+
else
|
|
62
|
+
default_branch = @prompt.default_branch(session_name: name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
43
65
|
|
|
44
66
|
begin
|
|
45
67
|
@ui.progress_start("Creating session '#{name}'")
|
|
@@ -48,7 +70,8 @@ module Sxn
|
|
|
48
70
|
name,
|
|
49
71
|
description: options[:description],
|
|
50
72
|
linear_task: options[:linear_task],
|
|
51
|
-
default_branch: default_branch
|
|
73
|
+
default_branch: default_branch,
|
|
74
|
+
template_id: template_id
|
|
52
75
|
)
|
|
53
76
|
|
|
54
77
|
@ui.progress_done
|
|
@@ -60,8 +83,13 @@ module Sxn
|
|
|
60
83
|
|
|
61
84
|
display_session_info(session)
|
|
62
85
|
|
|
63
|
-
#
|
|
64
|
-
|
|
86
|
+
# Apply template if specified (with atomic rollback on failure)
|
|
87
|
+
if template_id
|
|
88
|
+
apply_template_to_session(name, template_id, default_branch)
|
|
89
|
+
elsif !options[:skip_worktree]
|
|
90
|
+
# Offer to add a worktree unless skipped or template was used
|
|
91
|
+
offer_worktree_wizard(name)
|
|
92
|
+
end
|
|
65
93
|
rescue Sxn::Error => e
|
|
66
94
|
@ui.progress_failed
|
|
67
95
|
@ui.error(e.message)
|
|
@@ -275,6 +303,85 @@ module Sxn
|
|
|
275
303
|
|
|
276
304
|
private
|
|
277
305
|
|
|
306
|
+
def apply_template_to_session(session_name, template_id, default_branch)
|
|
307
|
+
template = @template_manager.get_template(template_id)
|
|
308
|
+
projects = template["projects"] || []
|
|
309
|
+
created_worktrees = []
|
|
310
|
+
|
|
311
|
+
@ui.newline
|
|
312
|
+
@ui.section("Applying Template '#{template_id}'")
|
|
313
|
+
@ui.info("Creating #{projects.size} worktree(s)...")
|
|
314
|
+
|
|
315
|
+
begin
|
|
316
|
+
projects.each_with_index do |project_config, index|
|
|
317
|
+
project_name = project_config["name"]
|
|
318
|
+
# Use project-specific branch override, or fall back to session default
|
|
319
|
+
branch = project_config["branch"] || default_branch
|
|
320
|
+
|
|
321
|
+
@ui.progress_start("Creating worktree #{index + 1}/#{projects.size}: #{project_name}")
|
|
322
|
+
|
|
323
|
+
worktree = @worktree_manager.add_worktree(
|
|
324
|
+
project_name,
|
|
325
|
+
branch,
|
|
326
|
+
session_name: session_name
|
|
327
|
+
)
|
|
328
|
+
created_worktrees << worktree
|
|
329
|
+
|
|
330
|
+
@ui.progress_done
|
|
331
|
+
|
|
332
|
+
# Apply project-specific rule overrides if defined
|
|
333
|
+
apply_template_rules(session_name, project_name, worktree[:path], project_config["rules"]) if project_config["rules"]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
@ui.newline
|
|
337
|
+
@ui.success("Template applied: #{created_worktrees.size} worktree(s) created")
|
|
338
|
+
|
|
339
|
+
# Display created worktrees
|
|
340
|
+
@ui.newline
|
|
341
|
+
@ui.subsection("Created Worktrees")
|
|
342
|
+
created_worktrees.each do |wt|
|
|
343
|
+
@ui.list_item("#{wt[:project]} (#{wt[:branch]}) → #{wt[:path]}")
|
|
344
|
+
end
|
|
345
|
+
rescue StandardError => e
|
|
346
|
+
# ATOMIC ROLLBACK: Remove all created worktrees and the session
|
|
347
|
+
@ui.progress_failed
|
|
348
|
+
@ui.newline
|
|
349
|
+
@ui.warning("Template application failed, rolling back...")
|
|
350
|
+
|
|
351
|
+
rollback_template_application(session_name, created_worktrees)
|
|
352
|
+
|
|
353
|
+
raise Sxn::SessionTemplateApplicationError.new(template_id, e.message)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def rollback_template_application(session_name, created_worktrees)
|
|
358
|
+
# Remove created worktrees
|
|
359
|
+
created_worktrees.each do |wt|
|
|
360
|
+
@worktree_manager.remove_worktree(wt[:project], session_name: session_name)
|
|
361
|
+
rescue StandardError
|
|
362
|
+
# Best effort cleanup, continue with rollback
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Remove the session
|
|
367
|
+
@session_manager.remove_session(session_name, force: true)
|
|
368
|
+
@ui.info("Session '#{session_name}' has been rolled back")
|
|
369
|
+
rescue StandardError => e
|
|
370
|
+
@ui.warning("Rollback encountered errors: #{e.message}")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def apply_template_rules(_session_name, project_name, worktree_path, rules)
|
|
374
|
+
return unless rules.is_a?(Hash)
|
|
375
|
+
|
|
376
|
+
rules_manager = Sxn::Core::RulesManager.new(@config_manager, worktree_path)
|
|
377
|
+
|
|
378
|
+
rules_manager.apply_copy_files_rules(rules["copy_files"]) if rules["copy_files"]
|
|
379
|
+
|
|
380
|
+
rules_manager.apply_setup_commands(rules["setup_commands"]) if rules["setup_commands"]
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
@ui.warning("Failed to apply rules for #{project_name}: #{e.message}")
|
|
383
|
+
end
|
|
384
|
+
|
|
278
385
|
def ensure_initialized!
|
|
279
386
|
return if @config_manager.initialized?
|
|
280
387
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Sxn
|
|
6
|
+
module Commands
|
|
7
|
+
# Manage session templates
|
|
8
|
+
class Templates < Thor
|
|
9
|
+
include Thor::Actions
|
|
10
|
+
|
|
11
|
+
def initialize(args = ARGV, local_options = {}, config = {})
|
|
12
|
+
super
|
|
13
|
+
@ui = Sxn::UI::Output.new
|
|
14
|
+
@prompt = Sxn::UI::Prompt.new
|
|
15
|
+
@table = Sxn::UI::Table.new
|
|
16
|
+
@config_manager = Sxn::Core::ConfigManager.new
|
|
17
|
+
@template_manager = Sxn::Core::TemplateManager.new(@config_manager)
|
|
18
|
+
@project_manager = Sxn::Core::ProjectManager.new(@config_manager)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "list", "List available session templates"
|
|
22
|
+
|
|
23
|
+
def list
|
|
24
|
+
ensure_initialized!
|
|
25
|
+
|
|
26
|
+
templates = @template_manager.list_templates
|
|
27
|
+
|
|
28
|
+
@ui.section("Session Templates")
|
|
29
|
+
|
|
30
|
+
if templates.empty?
|
|
31
|
+
@ui.empty_state("No templates defined")
|
|
32
|
+
@ui.newline
|
|
33
|
+
@ui.recovery_suggestion("Create a template with 'sxn templates create'")
|
|
34
|
+
@ui.recovery_suggestion("Or manually edit .sxn/templates.yml")
|
|
35
|
+
else
|
|
36
|
+
display_templates_table(templates)
|
|
37
|
+
@ui.newline
|
|
38
|
+
@ui.info("Total: #{templates.size} template(s)")
|
|
39
|
+
end
|
|
40
|
+
rescue Sxn::Error => e
|
|
41
|
+
@ui.error(e.message)
|
|
42
|
+
exit(e.exit_code)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "show NAME", "Show details of a session template"
|
|
46
|
+
|
|
47
|
+
def show(name)
|
|
48
|
+
ensure_initialized!
|
|
49
|
+
|
|
50
|
+
template = @template_manager.get_template(name)
|
|
51
|
+
|
|
52
|
+
@ui.section("Template: #{name}")
|
|
53
|
+
@ui.key_value("Description", template["description"] || "(none)")
|
|
54
|
+
@ui.key_value("Projects", (template["projects"] || []).size.to_s)
|
|
55
|
+
|
|
56
|
+
@ui.newline
|
|
57
|
+
@ui.subsection("Projects")
|
|
58
|
+
|
|
59
|
+
projects = template["projects"] || []
|
|
60
|
+
if projects.empty?
|
|
61
|
+
@ui.empty_state("No projects in this template")
|
|
62
|
+
else
|
|
63
|
+
projects.each do |project_config|
|
|
64
|
+
project_name = project_config["name"]
|
|
65
|
+
project = @project_manager.get_project(project_name)
|
|
66
|
+
status = project ? "✓" : "✗ (not found)"
|
|
67
|
+
|
|
68
|
+
details = []
|
|
69
|
+
details << "branch: #{project_config["branch"]}" if project_config["branch"]
|
|
70
|
+
details << "has custom rules" if project_config["rules"]
|
|
71
|
+
|
|
72
|
+
if details.any?
|
|
73
|
+
@ui.list_item("#{project_name} #{status} (#{details.join(", ")})")
|
|
74
|
+
else
|
|
75
|
+
@ui.list_item("#{project_name} #{status}")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@ui.newline
|
|
81
|
+
@ui.subsection("Usage")
|
|
82
|
+
@ui.command_example(
|
|
83
|
+
"sxn add my-session -t #{name} -b my-branch",
|
|
84
|
+
"Create session with this template"
|
|
85
|
+
)
|
|
86
|
+
rescue Sxn::SessionTemplateNotFoundError => e
|
|
87
|
+
@ui.error(e.message)
|
|
88
|
+
exit(1)
|
|
89
|
+
rescue Sxn::Error => e
|
|
90
|
+
@ui.error(e.message)
|
|
91
|
+
exit(e.exit_code)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
desc "create", "Create a new session template"
|
|
95
|
+
option :name, type: :string, aliases: "-n", desc: "Template name"
|
|
96
|
+
option :description, type: :string, aliases: "-d", desc: "Template description"
|
|
97
|
+
|
|
98
|
+
def create
|
|
99
|
+
ensure_initialized!
|
|
100
|
+
|
|
101
|
+
# Get template name
|
|
102
|
+
name = options[:name]
|
|
103
|
+
if name.nil?
|
|
104
|
+
name = @prompt.ask("Template name:")
|
|
105
|
+
if name.nil? || name.strip.empty?
|
|
106
|
+
@ui.error("Template name is required")
|
|
107
|
+
exit(1)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if template already exists
|
|
112
|
+
if @template_manager.template_exists?(name)
|
|
113
|
+
@ui.error("Template '#{name}' already exists")
|
|
114
|
+
@ui.recovery_suggestion("Use a different name or remove existing template with 'sxn templates remove #{name}'")
|
|
115
|
+
exit(1)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get description
|
|
119
|
+
description = options[:description]
|
|
120
|
+
description ||= @prompt.ask("Description (optional):")
|
|
121
|
+
|
|
122
|
+
# Select projects
|
|
123
|
+
projects = @project_manager.list_projects
|
|
124
|
+
if projects.empty?
|
|
125
|
+
@ui.error("No projects configured")
|
|
126
|
+
@ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>' first")
|
|
127
|
+
exit(1)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@ui.newline
|
|
131
|
+
@ui.info("Select projects to include in the template:")
|
|
132
|
+
@ui.info("(Use space to select, enter to confirm)")
|
|
133
|
+
@ui.newline
|
|
134
|
+
|
|
135
|
+
project_choices = projects.map do |p|
|
|
136
|
+
{ name: "#{p[:name]} (#{p[:type] || "unknown"})", value: p[:name] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
selected_projects = @prompt.multi_select("Projects:", project_choices)
|
|
140
|
+
|
|
141
|
+
if selected_projects.empty?
|
|
142
|
+
@ui.error("At least one project must be selected")
|
|
143
|
+
exit(1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create the template
|
|
147
|
+
@ui.progress_start("Creating template '#{name}'")
|
|
148
|
+
|
|
149
|
+
@template_manager.create_template(
|
|
150
|
+
name,
|
|
151
|
+
description: description,
|
|
152
|
+
projects: selected_projects
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@ui.progress_done
|
|
156
|
+
@ui.success("Template '#{name}' created with #{selected_projects.size} project(s)")
|
|
157
|
+
|
|
158
|
+
@ui.newline
|
|
159
|
+
@ui.subsection("Usage")
|
|
160
|
+
@ui.command_example(
|
|
161
|
+
"sxn add my-session -t #{name} -b my-branch",
|
|
162
|
+
"Create session with this template"
|
|
163
|
+
)
|
|
164
|
+
rescue Sxn::SessionTemplateValidationError => e
|
|
165
|
+
@ui.progress_failed
|
|
166
|
+
@ui.error(e.message)
|
|
167
|
+
exit(1)
|
|
168
|
+
rescue Sxn::Error => e
|
|
169
|
+
@ui.progress_failed
|
|
170
|
+
@ui.error(e.message)
|
|
171
|
+
exit(e.exit_code)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
desc "remove NAME", "Remove a session template"
|
|
175
|
+
option :force, type: :boolean, aliases: "-f", desc: "Skip confirmation"
|
|
176
|
+
|
|
177
|
+
def remove(name = nil)
|
|
178
|
+
ensure_initialized!
|
|
179
|
+
|
|
180
|
+
# Interactive selection if name not provided
|
|
181
|
+
if name.nil?
|
|
182
|
+
templates = @template_manager.list_templates
|
|
183
|
+
if templates.empty?
|
|
184
|
+
@ui.empty_state("No templates to remove")
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
choices = templates.map { |t| { name: "#{t[:name]} - #{t[:description] || "(no description)"}", value: t[:name] } }
|
|
189
|
+
name = @prompt.select("Select template to remove:", choices)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Verify template exists
|
|
193
|
+
@template_manager.get_template(name)
|
|
194
|
+
|
|
195
|
+
# Confirm deletion unless force flag is used
|
|
196
|
+
return if !options[:force] && !@prompt.confirm_deletion(name, "template")
|
|
197
|
+
|
|
198
|
+
@ui.progress_start("Removing template '#{name}'")
|
|
199
|
+
@template_manager.remove_template(name)
|
|
200
|
+
@ui.progress_done
|
|
201
|
+
@ui.success("Template '#{name}' removed")
|
|
202
|
+
rescue Sxn::SessionTemplateNotFoundError => e
|
|
203
|
+
@ui.error(e.message)
|
|
204
|
+
exit(1)
|
|
205
|
+
rescue Sxn::Error => e
|
|
206
|
+
@ui.progress_failed
|
|
207
|
+
@ui.error(e.message)
|
|
208
|
+
exit(e.exit_code)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def ensure_initialized!
|
|
214
|
+
return if @config_manager.initialized?
|
|
215
|
+
|
|
216
|
+
@ui.error("Project not initialized")
|
|
217
|
+
@ui.recovery_suggestion("Run 'sxn init' to initialize sxn in this project")
|
|
218
|
+
exit(1)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def display_templates_table(templates)
|
|
222
|
+
@table.templates(templates)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/sxn/commands.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "commands/sessions"
|
|
|
5
5
|
require_relative "commands/projects"
|
|
6
6
|
require_relative "commands/worktrees"
|
|
7
7
|
require_relative "commands/rules"
|
|
8
|
+
require_relative "commands/templates"
|
|
9
|
+
require_relative "commands/mcp"
|
|
8
10
|
|
|
9
11
|
module Sxn
|
|
10
12
|
# Commands namespace for all CLI command implementations
|