diff --git a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs index 61804dfbd..9eb606753 100644 --- a/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs +++ b/Assets/Tests/Editor/ToolSkillSynchronizerTests.cs @@ -1026,6 +1026,16 @@ public void DetectTargets_WhenOnlyInternalSkillsAreMissing_IgnoresThem() Assert.That(detectedTargets[0].HasExistingSkills, Is.True); } + // Tests that Unity-side discovery includes CLI-only skills from the Go CLI package. + [Test] + public void GetSkillSourceInfos_WhenProjectIsCurrentRoot_IncludesCliOnlyGoCliSkills() + { + SkillInstallLayout.SkillSourceInfo[] skillSources = SkillInstallLayout.GetSkillSourceInfos(_projectRoot) + .ToArray(); + + Assert.That(skillSources.Select(skill => skill.Name), Does.Contain("uloop-launch")); + } + // Tests that internal skill metadata maps back to the hidden tool name only. [Test] public void GetInternalSkillToolNames_WhenInternalSkillUsesSkillName_ReturnsToolName() diff --git a/Packages/src/Editor/Api/McpTools/ReplayInput/InputReplayer.cs b/Packages/src/Editor/Api/McpTools/ReplayInput/InputReplayer.cs index 03251c889..462f983cc 100644 --- a/Packages/src/Editor/Api/McpTools/ReplayInput/InputReplayer.cs +++ b/Packages/src/Editor/Api/McpTools/ReplayInput/InputReplayer.cs @@ -34,10 +34,11 @@ internal static class InputReplayer private static bool _showOverlay; private static readonly HashSet _replayHeldKeys = new(); private static readonly HashSet _replayHeldButtons = new(); + private static readonly List _disabledInputModules = new(); private static Vector2? _replayMousePosition; - // InputModule (StandaloneInputModule / InputSystemUIInputModule) ignores injected - // Mouse.current state, so UI interactions must go through ExecuteEvents directly. + // UI replay goes through ExecuteEvents so verification does not depend on + // GameView focus or the active input module's update timing. private static bool _hasMousePosition; private static bool _prevLeftButtonHeld; private static Vector2? _previousReplayMousePosition; @@ -115,6 +116,7 @@ public static void StopReplay() _currentFrame = 0; _replayHeldKeys.Clear(); _replayHeldButtons.Clear(); + RestoreUiInputModules(); ResetUiReplayState(); ReplayInputOverlayState.Clear(); @@ -445,12 +447,14 @@ private static void ApplyUiEvents() { if (!_replayMousePosition.HasValue) { + RestoreUiInputModules(); return; } EventSystem? eventSystem = EventSystem.current; if (eventSystem == null) { + RestoreUiInputModules(); return; } @@ -463,6 +467,7 @@ private static void ApplyUiEvents() bool justPressed = leftHeld && !_prevLeftButtonHeld; bool justReleased = !leftHeld && _prevLeftButtonHeld; _prevLeftButtonHeld = leftHeld; + SetUiInputModulesSuppressed(leftHeld || justReleased); Vector2 gameViewSize = Handles.GetMainGameViewSize(); Vector2 inputPos = new Vector2(screenPos.x, gameViewSize.y - screenPos.y); @@ -653,6 +658,50 @@ private static bool DetectMousePositionEvents(InputRecordingData data) return false; } + private static void SetUiInputModulesSuppressed(bool suppressed) + { + if (!suppressed) + { + RestoreUiInputModules(); + return; + } + + EventSystem? eventSystem = EventSystem.current; + if (eventSystem == null) + { + return; + } + + BaseInputModule[] modules = eventSystem.GetComponents(); + for (int i = 0; i < modules.Length; i++) + { + BaseInputModule module = modules[i]; + if (!module.enabled) + { + continue; + } + + // Replay synthesizes ExecuteEvents directly; leaving input modules enabled + // lets them consume the same injected Mouse.current state a second time. + module.enabled = false; + _disabledInputModules.Add(module); + } + } + + private static void RestoreUiInputModules() + { + for (int i = 0; i < _disabledInputModules.Count; i++) + { + BaseInputModule module = _disabledInputModules[i]; + if (module != null) + { + module.enabled = true; + } + } + + _disabledInputModules.Clear(); + } + private static void ResetUiReplayState() { _replayMousePosition = null; diff --git a/Packages/src/Editor/Config/SkillInstallLayout.cs b/Packages/src/Editor/Config/SkillInstallLayout.cs index 71eed26e1..1a39e0667 100644 --- a/Packages/src/Editor/Config/SkillInstallLayout.cs +++ b/Packages/src/Editor/Config/SkillInstallLayout.cs @@ -21,6 +21,9 @@ internal static class SkillInstallLayout private const ushort CarriageReturnCodeUnit = 0x000D; private const ushort LineFeedCodeUnit = 0x000A; private const string CliPackageDirName = "GoCli~"; + private const string CliInternalDirName = "internal"; + private const string CliPresentationDirName = "presentation"; + private const string CliPresentationPackageDirName = "cli"; private const string CliSkillDefinitionsDirName = "skill-definitions"; private const string CliOnlySkillDefinitionsDirName = "cli-only"; private static readonly HashSet ExcludedFileNames = new() @@ -660,8 +663,9 @@ private static string GetCliOnlySkillSourceRoot(string projectRoot) return Path.Combine( McpConstants.PackageResolvedPath, CliPackageDirName, - "internal", - "cli", + CliInternalDirName, + CliPresentationDirName, + CliPresentationPackageDirName, CliSkillDefinitionsDirName, CliOnlySkillDefinitionsDirName); } diff --git a/Packages/src/GoCli~/cmd/uloop-core/main.go b/Packages/src/GoCli~/cmd/uloop-core/main.go index 49e0ea5f7..0fe2269ae 100644 --- a/Packages/src/GoCli~/cmd/uloop-core/main.go +++ b/Packages/src/GoCli~/cmd/uloop-core/main.go @@ -4,9 +4,9 @@ import ( "context" "os" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/cli" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/app" ) func main() { - os.Exit(cli.RunProjectLocal(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(app.RunProjectLocal(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/Packages/src/GoCli~/cmd/uloop-dispatcher/main.go b/Packages/src/GoCli~/cmd/uloop-dispatcher/main.go index 5ab383385..14a73bd4d 100644 --- a/Packages/src/GoCli~/cmd/uloop-dispatcher/main.go +++ b/Packages/src/GoCli~/cmd/uloop-dispatcher/main.go @@ -4,9 +4,9 @@ import ( "context" "os" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/cli" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/app" ) func main() { - os.Exit(cli.RunLauncher(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(app.RunLauncher(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core index 52d2a6c3e..a69421e99 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher index a270e0126..464848ddb 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core index 41331a143..15027829c 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher index 2294939c4..6f696866c 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe index 5d75596be..2f99beb0c 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe index 5213b6b1a..0e3edbb01 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe differ diff --git a/Packages/src/GoCli~/internal/framing/framing.go b/Packages/src/GoCli~/internal/adapters/framing/framing.go similarity index 100% rename from Packages/src/GoCli~/internal/framing/framing.go rename to Packages/src/GoCli~/internal/adapters/framing/framing.go diff --git a/Packages/src/GoCli~/internal/framing/framing_test.go b/Packages/src/GoCli~/internal/adapters/framing/framing_test.go similarity index 100% rename from Packages/src/GoCli~/internal/framing/framing_test.go rename to Packages/src/GoCli~/internal/adapters/framing/framing_test.go diff --git a/Packages/src/GoCli~/internal/project/project.go b/Packages/src/GoCli~/internal/adapters/project/project.go similarity index 90% rename from Packages/src/GoCli~/internal/project/project.go rename to Packages/src/GoCli~/internal/adapters/project/project.go index 28b567b86..844556b9d 100644 --- a/Packages/src/GoCli~/internal/project/project.go +++ b/Packages/src/GoCli~/internal/adapters/project/project.go @@ -9,6 +9,8 @@ import ( "runtime" "sort" "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) const ( @@ -29,50 +31,35 @@ var excludedProjectSearchDirs = map[string]bool{ "obj": true, } -type Endpoint struct { - Network string - Address string -} - -type RequestMetadata struct { - ExpectedProjectRoot string `json:"expectedProjectRoot"` -} - -type Connection struct { - Endpoint Endpoint - ProjectRoot string - RequestMetadata *RequestMetadata -} - -func ResolveConnection(startPath string, explicitProjectPath string) (Connection, error) { +func ResolveConnection(startPath string, explicitProjectPath string) (domain.Connection, error) { projectRoot, err := resolveProjectRoot(startPath, explicitProjectPath) if err != nil { - return Connection{}, err + return domain.Connection{}, err } canonicalProjectRoot, err := filepath.EvalSymlinks(projectRoot) if err != nil { - return Connection{}, err + return domain.Connection{}, err } canonicalProjectRoot = trimTrailingSeparators(canonicalProjectRoot) - return Connection{ + return domain.Connection{ Endpoint: CreateEndpoint(canonicalProjectRoot), ProjectRoot: canonicalProjectRoot, RequestMetadata: createRequestMetadata(canonicalProjectRoot), }, nil } -func CreateEndpoint(canonicalProjectRoot string) Endpoint { +func CreateEndpoint(canonicalProjectRoot string) domain.Endpoint { endpointName := createEndpointName(canonicalProjectRoot) if runtime.GOOS == "windows" { - return Endpoint{ + return domain.Endpoint{ Network: "pipe", Address: fmt.Sprintf(`%s-%s`, windowsPipePrefix, endpointName), } } - return Endpoint{ + return domain.Endpoint{ Network: "unix", Address: filepath.Join(unixSocketDir, endpointName+".sock"), } @@ -197,8 +184,8 @@ func resolveProjectRoot(startPath string, explicitProjectPath string) (string, e return projectRoot, nil } -func createRequestMetadata(projectRoot string) *RequestMetadata { - return &RequestMetadata{ +func createRequestMetadata(projectRoot string) *domain.RequestMetadata { + return &domain.RequestMetadata{ ExpectedProjectRoot: projectRoot, } } diff --git a/Packages/src/GoCli~/internal/project/project_test.go b/Packages/src/GoCli~/internal/adapters/project/project_test.go similarity index 96% rename from Packages/src/GoCli~/internal/project/project_test.go rename to Packages/src/GoCli~/internal/adapters/project/project_test.go index 6babdbff9..ab4138bc7 100644 --- a/Packages/src/GoCli~/internal/project/project_test.go +++ b/Packages/src/GoCli~/internal/adapters/project/project_test.go @@ -6,6 +6,8 @@ import ( "runtime" "strings" "testing" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) func TestCreateEndpointUsesStableProjectHash(t *testing.T) { @@ -121,7 +123,7 @@ func createUnityProject(t *testing.T, projectRoot string) { } } -func assertProjectConnection(t *testing.T, connection Connection, projectRoot string) { +func assertProjectConnection(t *testing.T, connection domain.Connection, projectRoot string) { t.Helper() canonicalProjectRoot, err := filepath.EvalSymlinks(projectRoot) diff --git a/Packages/src/GoCli~/internal/unity/client.go b/Packages/src/GoCli~/internal/adapters/unity/client.go similarity index 79% rename from Packages/src/GoCli~/internal/unity/client.go rename to Packages/src/GoCli~/internal/adapters/unity/client.go index a058be626..ca3790b9c 100644 --- a/Packages/src/GoCli~/internal/unity/client.go +++ b/Packages/src/GoCli~/internal/adapters/unity/client.go @@ -7,30 +7,25 @@ import ( "fmt" "time" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/framing" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/framing" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) const requestTimeout = 180 * time.Second type Client struct { - connection project.Connection + connection domain.Connection requestID int } -type ProgressFunc func(message string) - -type SendOutcome struct { - Result json.RawMessage - RequestDispatched bool -} +type ProgressFunc = func(message string) type rpcRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]any `json:"params"` - ID int `json:"id"` - Uloop *project.RequestMetadata `json:"x-uloop,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]any `json:"params"` + ID int `json:"id"` + Uloop *domain.RequestMetadata `json:"x-uloop,omitempty"` } type rpcResponse struct { @@ -70,7 +65,7 @@ func (err *RPCError) Error() string { return fmt.Sprintf("unity error: %s", err.Message) } -func NewClient(connection project.Connection) *Client { +func NewClient(connection domain.Connection) *Client { return &Client{connection: connection} } @@ -83,13 +78,13 @@ func (client *Client) SendWithProgress(ctx context.Context, method string, param return outcome.Result, err } -func (client *Client) SendWithProgressOutcome(ctx context.Context, method string, params map[string]any, progress ProgressFunc) (SendOutcome, error) { +func (client *Client) SendWithProgressOutcome(ctx context.Context, method string, params map[string]any, progress ProgressFunc) (domain.UnitySendOutcome, error) { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() conn, err := dialEndpoint(ctx, client.connection.Endpoint) if err != nil { - return SendOutcome{}, formatConnectionAttemptError(client.connection, err) + return domain.UnitySendOutcome{}, formatConnectionAttemptError(client.connection, err) } defer func() { _ = conn.Close() @@ -110,7 +105,7 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string payload, err := json.Marshal(request) if err != nil { - return SendOutcome{}, err + return domain.UnitySendOutcome{}, err } if deadline, ok := ctx.Deadline(); ok { @@ -118,9 +113,9 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string } if err := framing.Write(conn, payload); err != nil { - return SendOutcome{}, err + return domain.UnitySendOutcome{}, err } - outcome := SendOutcome{RequestDispatched: true} + outcome := domain.UnitySendOutcome{RequestDispatched: true} responsePayload, err := framing.Read(bufio.NewReader(conn)) if err != nil { @@ -146,7 +141,7 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string return outcome, nil } -func formatConnectionAttemptError(connection project.Connection, err error) error { +func formatConnectionAttemptError(connection domain.Connection, err error) error { return &ConnectionAttemptError{ ProjectRoot: connection.ProjectRoot, Endpoint: connection.Endpoint.Address, diff --git a/Packages/src/GoCli~/internal/unity/client_test.go b/Packages/src/GoCli~/internal/adapters/unity/client_test.go similarity index 93% rename from Packages/src/GoCli~/internal/unity/client_test.go rename to Packages/src/GoCli~/internal/adapters/unity/client_test.go index b0e16ad82..13d62cc62 100644 --- a/Packages/src/GoCli~/internal/unity/client_test.go +++ b/Packages/src/GoCli~/internal/adapters/unity/client_test.go @@ -4,12 +4,12 @@ import ( "errors" "testing" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) func TestFormatConnectionAttemptErrorExplainsDialFailureWithoutDisconnectClaim(t *testing.T) { - connection := project.Connection{ - Endpoint: project.Endpoint{ + connection := domain.Connection{ + Endpoint: domain.Endpoint{ Network: "unix", Address: "/tmp/uloop/UnityCliLoop-sample.sock", }, diff --git a/Packages/src/GoCli~/internal/unity/dial_unix.go b/Packages/src/GoCli~/internal/adapters/unity/dial_unix.go similarity index 70% rename from Packages/src/GoCli~/internal/unity/dial_unix.go rename to Packages/src/GoCli~/internal/adapters/unity/dial_unix.go index 98a869aab..38b930d73 100644 --- a/Packages/src/GoCli~/internal/unity/dial_unix.go +++ b/Packages/src/GoCli~/internal/adapters/unity/dial_unix.go @@ -6,10 +6,10 @@ import ( "context" "net" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) -func dialEndpoint(ctx context.Context, endpoint project.Endpoint) (net.Conn, error) { +func dialEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { dialer := net.Dialer{} return dialer.DialContext(ctx, endpoint.Network, endpoint.Address) } diff --git a/Packages/src/GoCli~/internal/unity/dial_windows.go b/Packages/src/GoCli~/internal/adapters/unity/dial_windows.go similarity index 69% rename from Packages/src/GoCli~/internal/unity/dial_windows.go rename to Packages/src/GoCli~/internal/adapters/unity/dial_windows.go index c0b2a3bf5..1b6f039b0 100644 --- a/Packages/src/GoCli~/internal/unity/dial_windows.go +++ b/Packages/src/GoCli~/internal/adapters/unity/dial_windows.go @@ -7,9 +7,9 @@ import ( "net" "github.com/Microsoft/go-winio" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) -func dialEndpoint(ctx context.Context, endpoint project.Endpoint) (net.Conn, error) { +func dialEndpoint(ctx context.Context, endpoint domain.Endpoint) (net.Conn, error) { return winio.DialPipeContext(ctx, endpoint.Address) } diff --git a/Packages/src/GoCli~/internal/app/app.go b/Packages/src/GoCli~/internal/app/app.go new file mode 100644 index 000000000..7aa45bc16 --- /dev/null +++ b/Packages/src/GoCli~/internal/app/app.go @@ -0,0 +1,16 @@ +package app + +import ( + "context" + "io" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/presentation/cli" +) + +func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { + return cli.RunProjectLocal(ctx, args, stdout, stderr) +} + +func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { + return cli.RunLauncher(ctx, args, stdout, stderr) +} diff --git a/Packages/src/GoCli~/internal/application/tool_dispatcher.go b/Packages/src/GoCli~/internal/application/tool_dispatcher.go new file mode 100644 index 000000000..aedce2d3f --- /dev/null +++ b/Packages/src/GoCli~/internal/application/tool_dispatcher.go @@ -0,0 +1,22 @@ +package application + +import ( + "context" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/ports" +) + +type ToolDispatchRequest struct { + Command string + Params map[string]any + Progress func(string) +} + +type ToolDispatcher struct { + Bridge ports.UnityBridge +} + +func (dispatcher ToolDispatcher) Dispatch(ctx context.Context, request ToolDispatchRequest) (domain.UnitySendOutcome, error) { + return dispatcher.Bridge.SendWithProgressOutcome(ctx, request.Command, request.Params, request.Progress) +} diff --git a/Packages/src/GoCli~/internal/architecture/architecture_test.go b/Packages/src/GoCli~/internal/architecture/architecture_test.go new file mode 100644 index 000000000..04f3cfb63 --- /dev/null +++ b/Packages/src/GoCli~/internal/architecture/architecture_test.go @@ -0,0 +1,183 @@ +package architecture + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + modulePath = "github.com/hatayama/unity-cli-loop/Packages/src/GoCli" + maxProductionFileLines = 500 +) + +type goPackage struct { + ImportPath string + Imports []string +} + +// Tests that onion layers only import packages from allowed inner or outer boundaries. +func TestOnionLayerDependencies(t *testing.T) { + moduleRoot := findModuleRoot(t) + packages := listPackages(t, moduleRoot) + for _, goPackage := range packages { + if strings.Contains(goPackage.ImportPath, "/internal/cli") { + t.Fatalf("legacy aggregate cli package must not be reintroduced: %s", goPackage.ImportPath) + } + sourceLayer := layerOf(goPackage.ImportPath) + if sourceLayer == "" { + continue + } + for _, importedPath := range goPackage.Imports { + if !strings.HasPrefix(importedPath, modulePath) { + continue + } + targetLayer := layerOf(importedPath) + if targetLayer == "" { + continue + } + if !isAllowedDependency(sourceLayer, targetLayer, importedPath) { + t.Fatalf("%s package %s must not import %s package %s", sourceLayer, goPackage.ImportPath, targetLayer, importedPath) + } + } + } +} + +// Tests that production files stay small enough to keep each file focused on one responsibility. +func TestProductionGoFilesStayFocused(t *testing.T) { + moduleRoot := findModuleRoot(t) + err := filepath.WalkDir(moduleRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if entry.Name() == "dist" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + return nil + } + lineCount, err := countLines(path) + if err != nil { + return err + } + if lineCount > maxProductionFileLines { + relativePath, err := filepath.Rel(moduleRoot, path) + if err != nil { + return err + } + return fmt.Errorf("%s has %d lines; split files above %d lines", relativePath, lineCount, maxProductionFileLines) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func listPackages(t *testing.T, moduleRoot string) []goPackage { + t.Helper() + command := exec.Command("go", "list", "-json", "./...") + command.Dir = moduleRoot + output, err := command.Output() + if err != nil { + t.Fatalf("go list failed: %v", err) + } + + decoder := json.NewDecoder(strings.NewReader(string(output))) + packages := []goPackage{} + for { + var goPackage goPackage + err := decoder.Decode(&goPackage) + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("failed to decode go list output: %v", err) + } + packages = append(packages, goPackage) + } + return packages +} + +func layerOf(importPath string) string { + switch { + case strings.Contains(importPath, "/internal/domain"): + return "domain" + case strings.Contains(importPath, "/internal/application"): + return "application" + case strings.Contains(importPath, "/internal/ports"): + return "ports" + case strings.Contains(importPath, "/internal/adapters"): + return "adapters" + case strings.Contains(importPath, "/internal/presentation"): + return "presentation" + case strings.Contains(importPath, "/internal/app"): + return "app" + case strings.Contains(importPath, "/cmd/"): + return "cmd" + default: + return "" + } +} + +func isAllowedDependency(sourceLayer string, targetLayer string, importedPath string) bool { + switch sourceLayer { + case "domain": + return targetLayer == "domain" + case "application": + return targetLayer == "domain" || targetLayer == "ports" || targetLayer == "application" + case "ports": + return targetLayer == "domain" || targetLayer == "ports" + case "adapters": + return targetLayer == "domain" || targetLayer == "ports" || targetLayer == "application" || targetLayer == "adapters" + case "presentation": + return targetLayer == "domain" || targetLayer == "ports" || targetLayer == "application" || targetLayer == "adapters" || targetLayer == "presentation" + case "app": + return targetLayer == "domain" || targetLayer == "ports" || targetLayer == "application" || targetLayer == "adapters" || targetLayer == "presentation" + case "cmd": + return targetLayer == "app" || importedPath == modulePath+"/internal/app" + default: + return true + } +} + +func findModuleRoot(t *testing.T) string { + t.Helper() + currentPath, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(currentPath, "go.mod")); err == nil { + return currentPath + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + t.Fatal("go.mod not found") + } + currentPath = parentPath + } +} + +func countLines(path string) (int, error) { + content, err := os.ReadFile(path) + if err != nil { + return 0, err + } + if len(content) == 0 { + return 0, nil + } + lineCount := strings.Count(string(content), "\n") + if !strings.HasSuffix(string(content), "\n") { + lineCount++ + } + return lineCount, nil +} diff --git a/Packages/src/GoCli~/internal/cli/skills.go b/Packages/src/GoCli~/internal/cli/skills.go deleted file mode 100644 index 08b34e8c8..000000000 --- a/Packages/src/GoCli~/internal/cli/skills.go +++ /dev/null @@ -1,1227 +0,0 @@ -package cli - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" -) - -const ( - skillsCommandName = "skills" - managedSkillsDir = "unity-cli-loop" - skillFileName = "SKILL.md" - uloopSettingsDir = ".uloop" - toolSettingsFile = "settings.tools.json" - manifestFileName = "manifest.json" - packageName = "io.github.hatayama.uloopmcp" - packageNameAlias = "io.github.hatayama.uLoopMCP" - skillSearchMaxDepth = 3 - - utf16LittleEndianBOMFirstByte = 0xff - utf16LittleEndianBOMSecondByte = 0xfe - utf16BigEndianBOMFirstByte = 0xfe - utf16BigEndianBOMSecondByte = 0xff - utf16CodeUnitByteCount = 2 - carriageReturnCodeUnit = 0x000d - lineFeedCodeUnit = 0x000a -) - -var targetConfigs = map[string]skillTarget{ - "claude": {id: "claude", displayName: "Claude Code", projectDir: ".claude"}, - "codex": {id: "codex", displayName: "Codex CLI", projectDir: ".codex"}, - "cursor": {id: "cursor", displayName: "Cursor", projectDir: ".cursor"}, - "gemini": {id: "gemini", displayName: "Gemini CLI", projectDir: ".gemini"}, - "agents": {id: "agents", displayName: "Other (.agents)", projectDir: ".agents"}, - "windsurf": {id: "windsurf", displayName: "Windsurf", projectDir: ".agents"}, - "antigravity": {id: "antigravity", displayName: "Antigravity", projectDir: ".agent"}, -} - -var defaultSkillTargetIDs = []string{"claude", "codex", "cursor", "gemini", "agents", "antigravity"} - -var deprecatedSkillNames = []string{ - "uloop-capture-window", - "uloop-get-provider-details", - "uloop-unity-search", - "uloop-get-menu-items", - "uloop-get-unity-search-providers", - "uloop-execute-menu-item", -} - -var excludedSkillSearchDirs = map[string]bool{ - "node_modules": true, - ".git": true, - "Temp": true, - "obj": true, - "Build": true, - "Builds": true, - "Logs": true, - "Skill": true, -} - -type skillTarget struct { - id string - displayName string - projectDir string -} - -type skillCommandOptions struct { - global bool - flat bool - targets []skillTarget -} - -type skillDefinition struct { - name string - toolName string - content []byte - sourceDirectory string -} - -type skillSourceRoot struct { - path string - cliOnly bool -} - -type manifestData struct { - Dependencies map[string]string `json:"dependencies"` -} - -type toolSettingsData struct { - DisabledTools []string `json:"disabledTools"` -} - -func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath string, stdout io.Writer, stderr io.Writer) (bool, int) { - if len(args) == 0 || args[0] != skillsCommandName { - return false, 0 - } - if len(args) == 1 || isHelpRequest(args[1:]) { - printSkillsHelp(stdout) - return true, 0 - } - - subcommand := args[1] - options, err := parseSkillsOptions(args[2:]) - if err != nil { - writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) - return true, 1 - } - - projectRoot, err := resolveSkillsProjectRoot(startPath, globalProjectPath, options.global) - if err != nil { - writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) - return true, 1 - } - skills, err := collectSkillDefinitions(projectRoot) - if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) - return true, 1 - } - - switch subcommand { - case "list": - return true, runSkillsList(projectRoot, skills, options, stdout) - case "install": - if len(options.targets) == 0 { - printSkillsTargetGuidance("install", stdout) - return true, 0 - } - return true, runSkillsInstall(projectRoot, skills, options, stdout, stderr) - case "uninstall": - if len(options.targets) == 0 { - printSkillsTargetGuidance("uninstall", stdout) - return true, 0 - } - return true, runSkillsUninstall(projectRoot, skills, options, stdout, stderr) - default: - writeErrorEnvelope(stderr, (&argumentError{ - message: "Unknown skills command: " + subcommand, - received: subcommand, - command: skillsCommandName, - nextActions: []string{"Use `uloop skills list`, `uloop skills install`, or `uloop skills uninstall`."}, - }).toCLIError(errorContext{projectRoot: projectRoot, command: skillsCommandName})) - return true, 1 - } -} - -func parseSkillsOptions(args []string) (skillCommandOptions, error) { - options := skillCommandOptions{} - for _, arg := range args { - switch arg { - case "-g", "--global": - options.global = true - case "--flat": - options.flat = true - case "--claude", "--codex", "--cursor", "--gemini", "--agents", "--windsurf", "--antigravity": - targetID := strings.TrimPrefix(arg, "--") - options.targets = append(options.targets, targetConfigs[targetID]) - default: - return skillCommandOptions{}, &argumentError{ - message: "Unknown skills option: " + arg, - option: arg, - command: skillsCommandName, - nextActions: []string{"Run `uloop skills --help` to inspect supported skills options."}, - } - } - } - return options, nil -} - -func resolveSkillsProjectRoot(startPath string, explicitProjectPath string, global bool) (string, error) { - if explicitProjectPath != "" { - projectRoot, err := filepath.Abs(explicitProjectPath) - if err != nil { - return "", err - } - if !project.IsUnityProject(projectRoot) { - return "", fmt.Errorf("not a Unity project: %s", projectRoot) - } - return projectRoot, nil - } - if global { - projectRoot, err := project.FindUnityProjectRoot(startPath) - if err == nil { - return projectRoot, nil - } - return "", nil - } - return project.FindUnityProjectRoot(startPath) -} - -func runSkillsList(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer) int { - targets := options.targets - if len(targets) == 0 { - targets = defaultSkillTargets() - } - - location := "Project" - if options.global { - location = "Global" - } - - writeLine(stdout, "") - writeLine(stdout, "uloop Skills Status:") - writeLine(stdout, "") - for _, target := range targets { - baseDir := getSkillsBaseDir(projectRoot, target, options.global) - writeFormat(stdout, "%s (%s):\n", target.displayName, location) - writeFormat(stdout, "Location: %s\n", baseDir) - writeLine(stdout, strings.Repeat("=", 50)) - for _, skill := range skills { - status := getSkillStatus(baseDir, skill, !options.flat) - writeFormat(stdout, " %s %s (%s)\n", statusIcon(status), skill.name, statusText(status)) - } - writeLine(stdout, "") - } - writeFormat(stdout, "Total: %d skills\n", len(skills)) - return 0 -} - -func runSkillsInstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { - writeLine(stdout, "") - writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) - writeLine(stdout, "") - for _, target := range options.targets { - result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) - if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) - return 1 - } - writeFormat(stdout, "%s:\n", target.displayName) - writeFormat(stdout, " Installed: %d\n", result.installed) - writeFormat(stdout, " Updated: %d\n", result.updated) - writeFormat(stdout, " Skipped: %d\n", result.skipped) - if result.deprecatedRemoved > 0 { - writeFormat(stdout, " Deprecated removed: %d\n", result.deprecatedRemoved) - } - writeFormat(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) - } - return 0 -} - -func runSkillsUninstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { - writeLine(stdout, "") - writeFormat(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global)) - writeLine(stdout, "") - for _, target := range options.targets { - removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) - if err != nil { - writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) - return 1 - } - writeFormat(stdout, "%s:\n", target.displayName) - writeFormat(stdout, " Removed: %d\n", removed) - writeFormat(stdout, " Not found: %d\n", notFound) - writeFormat(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) - } - return 0 -} - -type skillInstallResult struct { - installed int - updated int - skipped int - deprecatedRemoved int -} - -func installSkillsForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (skillInstallResult, error) { - result := skillInstallResult{} - baseDir := getSkillsBaseDir(projectRoot, target, global) - deprecatedRemoved, err := removeDeprecatedSkillDirs(baseDir) - if err != nil { - return skillInstallResult{}, err - } - result.deprecatedRemoved = deprecatedRemoved - if grouped { - if err := migrateLegacyManagedSkills(baseDir, skills); err != nil { - return skillInstallResult{}, err - } - } - - disabledTools := []string{} - if !global { - disabledTools = loadDisabledTools(projectRoot) - } - for _, skill := range skills { - if isSkillDisabledByToolSettings(skill, disabledTools) { - if err := removeSkillFromAllLayouts(baseDir, skill.name); err != nil { - return skillInstallResult{}, err - } - continue - } - - status := getSkillStatus(baseDir, skill, grouped) - destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) - if status == "installed" { - result.skipped++ - continue - } - if err := syncSkillDirectory(skill.sourceDirectory, destinationDir); err != nil { - return skillInstallResult{}, err - } - alternateDir := getPreferredSkillDir(baseDir, skill.name, !grouped) - if err := os.RemoveAll(alternateDir); err != nil { - return skillInstallResult{}, err - } - if status == "outdated" { - result.updated++ - continue - } - result.installed++ - } - if !grouped { - if err := removeEmptyDir(getPreferredSkillDir(baseDir, managedSkillsDir, false)); err != nil { - return skillInstallResult{}, err - } - } - return result, nil -} - -func uninstallSkillsForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (int, int, error) { - removed := 0 - notFound := 0 - baseDir := getSkillsBaseDir(projectRoot, target, global) - deprecatedRemoved, err := removeDeprecatedSkillDirs(baseDir) - if err != nil { - return removed, notFound, err - } - removed += deprecatedRemoved - for _, skill := range skills { - destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) - if _, err := os.Stat(destinationDir); err != nil { - if !os.IsNotExist(err) { - return removed, notFound, err - } - notFound++ - continue - } - if err := os.RemoveAll(destinationDir); err != nil { - return removed, notFound, err - } - removed++ - } - return removed, notFound, nil -} - -func collectSkillDefinitions(projectRoot string) ([]skillDefinition, error) { - skills := []skillDefinition{} - seen := map[string]bool{} - for _, sourceRoot := range enumerateSkillSourceRoots(projectRoot) { - discovered, err := scanSkillSourceRoot(sourceRoot) - if err != nil { - return nil, err - } - for _, skill := range discovered { - if seen[skill.name] { - continue - } - seen[skill.name] = true - skills = append(skills, skill) - } - } - sort.Slice(skills, func(left int, right int) bool { - return skills[left].name < skills[right].name - }) - return skills, nil -} - -func collectInternalSkillToolNames(projectRoot string) map[string]bool { - toolNames := map[string]bool{} - for _, sourceRoot := range enumerateSkillSourceRoots(projectRoot) { - for _, toolName := range scanInternalSkillToolNames(sourceRoot) { - toolNames[toolName] = true - } - } - return toolNames -} - -func enumerateSkillSourceRoots(projectRoot string) []skillSourceRoot { - sourceRoots := []skillSourceRoot{} - seen := map[string]bool{} - addSourceRoot := func(path string, cliOnly bool) { - if path == "" { - return - } - absolutePath, err := filepath.Abs(path) - if err != nil || seen[absolutePath] { - return - } - seen[absolutePath] = true - sourceRoots = append(sourceRoots, skillSourceRoot{path: absolutePath, cliOnly: cliOnly}) - } - - packageRoot := resolvePackageRoot(projectRoot) - addSourceRoot(packageRoot, false) - addSourceRoot(filepath.Join(projectRoot, "Packages/src/GoCli~/internal/cli/skill-definitions/cli-only"), true) - addSourceRoot(filepath.Join(projectRoot, "Assets"), false) - for _, packageRoot := range enumerateDirectProjectPackageRoots(projectRoot) { - addSourceRoot(packageRoot, false) - } - for _, packageRoot := range resolveManifestLocalPackageRoots(projectRoot) { - addSourceRoot(packageRoot, false) - } - for _, packageRoot := range resolveDependencyPackageCacheRoots(projectRoot) { - addSourceRoot(packageRoot, false) - } - return sourceRoots -} - -func scanSkillSourceRoot(sourceRoot skillSourceRoot) ([]skillDefinition, error) { - if _, err := os.Stat(sourceRoot.path); err != nil { - return []skillDefinition{}, nil - } - - scanRoots := []string{sourceRoot.path} - if !sourceRoot.cliOnly { - scanRoots = findEditorFolders(sourceRoot.path, skillSearchMaxDepth) - } - - skills := []skillDefinition{} - for _, scanRoot := range scanRoots { - discovered, err := scanSkillDirectories(scanRoot) - if err != nil { - return nil, err - } - skills = append(skills, discovered...) - } - return skills, nil -} - -func scanInternalSkillToolNames(sourceRoot skillSourceRoot) []string { - if _, err := os.Stat(sourceRoot.path); err != nil { - return []string{} - } - - scanRoots := []string{sourceRoot.path} - if !sourceRoot.cliOnly { - scanRoots = findEditorFolders(sourceRoot.path, skillSearchMaxDepth) - } - - toolNames := []string{} - for _, scanRoot := range scanRoots { - toolNames = append(toolNames, scanInternalSkillDirectories(scanRoot)...) - } - return toolNames -} - -func scanSkillDirectories(searchRoot string) ([]skillDefinition, error) { - skills := []skillDefinition{} - err := filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if !entry.IsDir() { - if entry.Name() != skillFileName { - return nil - } - skill, ok, err := readSkillDefinition(filepath.Dir(path)) - if err != nil { - return err - } - if ok { - skills = append(skills, skill) - } - return nil - } - if excludedSkillSearchDirs[entry.Name()] && entry.Name() != "Skill" { - return filepath.SkipDir - } - if entry.Name() != "Skill" { - return nil - } - - skill, ok, err := readSkillDefinition(path) - if err != nil { - return err - } - if !ok { - return filepath.SkipDir - } - skills = append(skills, skill) - return filepath.SkipDir - }) - if err != nil { - return nil, err - } - return skills, nil -} - -func scanInternalSkillDirectories(searchRoot string) []string { - toolNames := []string{} - _ = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return nil - } - if !entry.IsDir() { - if entry.Name() != skillFileName { - return nil - } - toolName, ok := readInternalSkillToolName(filepath.Dir(path)) - if ok { - toolNames = append(toolNames, toolName) - } - return nil - } - if excludedSkillSearchDirs[entry.Name()] && entry.Name() != "Skill" { - return filepath.SkipDir - } - if entry.Name() != "Skill" { - return nil - } - - toolName, ok := readInternalSkillToolName(path) - if ok { - toolNames = append(toolNames, toolName) - } - return filepath.SkipDir - }) - return toolNames -} - -func readSkillDefinition(skillDirectory string) (skillDefinition, bool, error) { - skillPath := filepath.Join(skillDirectory, skillFileName) - content, err := os.ReadFile(skillPath) - if err != nil { - if os.IsNotExist(err) { - return skillDefinition{}, false, nil - } - return skillDefinition{}, false, err - } - content = normalizeSkillFileContent(skillFileName, content) - frontmatter := parseSkillFrontmatter(string(content)) - if strings.EqualFold(frontmatter["internal"], "true") { - return skillDefinition{}, false, nil - } - name := frontmatter["name"] - if name == "" { - name = fallbackSkillName(skillDirectory) - } - if !isSafeSkillName(name) { - return skillDefinition{}, false, nil - } - return skillDefinition{ - name: name, - toolName: frontmatter["toolName"], - content: content, - sourceDirectory: skillDirectory, - }, true, nil -} - -func readInternalSkillToolName(skillDirectory string) (string, bool) { - skillPath := filepath.Join(skillDirectory, skillFileName) - content, err := os.ReadFile(skillPath) - if err != nil { - return "", false - } - frontmatter := parseSkillFrontmatter(string(content)) - if !strings.EqualFold(frontmatter["internal"], "true") { - return "", false - } - if frontmatter["toolName"] != "" { - return frontmatter["toolName"], true - } - name := frontmatter["name"] - if strings.HasPrefix(name, "uloop-") { - return strings.TrimPrefix(name, "uloop-"), true - } - return "", false -} - -func fallbackSkillName(skillDirectory string) string { - if filepath.Base(skillDirectory) == "Skill" { - return filepath.Base(filepath.Dir(skillDirectory)) - } - return filepath.Base(skillDirectory) -} - -func parseSkillFrontmatter(content string) map[string]string { - result := map[string]string{} - if !strings.HasPrefix(content, "---") { - return result - } - parts := strings.SplitN(content, "---", 3) - if len(parts) < 3 { - return result - } - for _, line := range strings.Split(parts[1], "\n") { - key, value, ok := strings.Cut(line, ":") - if !ok { - continue - } - result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`) - } - return result -} - -func syncSkillDirectory(sourceDir string, destinationDir string) error { - parentDir := filepath.Dir(destinationDir) - if err := os.MkdirAll(parentDir, 0o755); err != nil { - return err - } - tempDir, err := os.MkdirTemp(parentDir, filepath.Base(destinationDir)+".tmp-") - if err != nil { - return err - } - - replaced := false - defer func() { - if !replaced { - _ = os.RemoveAll(tempDir) - } - }() - if err := copySkillDirectory(sourceDir, tempDir); err != nil { - return err - } - if err := os.RemoveAll(destinationDir); err != nil { - return err - } - if err := os.Rename(tempDir, destinationDir); err != nil { - return err - } - replaced = true - return nil -} - -func copySkillDirectory(sourceDir string, destinationDir string) error { - return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - relativePath, err := filepath.Rel(sourceDir, path) - if err != nil { - return err - } - if relativePath == "." { - return os.MkdirAll(destinationDir, 0o755) - } - if shouldSkipSkillFile(entry.Name()) { - if entry.IsDir() { - return filepath.SkipDir - } - return nil - } - - destinationPath := filepath.Join(destinationDir, relativePath) - if entry.IsDir() { - return os.MkdirAll(destinationPath, 0o755) - } - - content, err := os.ReadFile(path) - if err != nil { - return err - } - content = normalizeSkillFileContent(relativePath, content) - return os.WriteFile(destinationPath, content, 0o644) - }) -} - -func getSkillStatus(baseDir string, skill skillDefinition, grouped bool) string { - skillDir := getPreferredSkillDir(baseDir, skill.name, grouped) - if _, err := os.Stat(filepath.Join(skillDir, skillFileName)); err != nil { - return "not_installed" - } - if isInstalledSkillOutdated(skillDir, skill) { - return "outdated" - } - return "installed" -} - -func isInstalledSkillOutdated(installedDir string, skill skillDefinition) bool { - installedContent, err := os.ReadFile(filepath.Join(installedDir, skillFileName)) - if err != nil { - return true - } - installedContent = normalizeSkillFileContent(skillFileName, installedContent) - expectedContent := normalizeSkillFileContent(skillFileName, skill.content) - if !bytes.Equal(installedContent, expectedContent) { - return true - } - - expectedFiles := collectComparableSkillFiles(skill.sourceDirectory) - installedFiles := collectComparableSkillFiles(installedDir) - if len(expectedFiles) != len(installedFiles) { - return true - } - for relativePath, expectedContent := range expectedFiles { - installedContent, ok := installedFiles[relativePath] - if !ok || !bytes.Equal(expectedContent, installedContent) { - return true - } - } - return false -} - -func collectComparableSkillFiles(root string) map[string][]byte { - files := map[string][]byte{} - _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil || entry.IsDir() || shouldSkipSkillFile(entry.Name()) { - return nil - } - relativePath, err := filepath.Rel(root, path) - if err != nil || relativePath == skillFileName { - return nil - } - content, err := os.ReadFile(path) - if err != nil { - return nil - } - files[relativePath] = normalizeSkillFileContent(relativePath, content) - return nil - }) - return files -} - -func normalizeSkillFileContent(relativePath string, content []byte) []byte { - if !shouldNormalizeLineEndings(relativePath) || !bytes.Contains(content, []byte{'\r'}) { - return content - } - - if hasUTF16LittleEndianBOM(content) || hasUTF16LittleEndianLineEnding(content) { - return normalizeUTF16LineEndings(content, true) - } - - if hasUTF16BigEndianBOM(content) || hasUTF16BigEndianLineEnding(content) { - return normalizeUTF16LineEndings(content, false) - } - - if bytes.Contains(content, []byte{0}) { - return content - } - - normalizedContent := bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) - return bytes.ReplaceAll(normalizedContent, []byte("\r"), []byte("\n")) -} - -func normalizeUTF16LineEndings(content []byte, littleEndian bool) []byte { - normalized := make([]byte, 0, len(content)) - index := 0 - if hasMatchingUTF16BOM(content, littleEndian) { - normalized = append(normalized, content[0], content[1]) - index = utf16CodeUnitByteCount - } - - for index+1 < len(content) { - codeUnit := readUTF16CodeUnit(content, index, littleEndian) - if codeUnit == carriageReturnCodeUnit { - normalized = writeUTF16CodeUnit(normalized, lineFeedCodeUnit, littleEndian) - nextIndex := index + utf16CodeUnitByteCount - if nextIndex+1 < len(content) && - readUTF16CodeUnit(content, nextIndex, littleEndian) == lineFeedCodeUnit { - index += utf16CodeUnitByteCount * 2 - continue - } - - index += utf16CodeUnitByteCount - continue - } - - normalized = writeUTF16CodeUnit(normalized, codeUnit, littleEndian) - index += utf16CodeUnitByteCount - } - - if index < len(content) { - normalized = append(normalized, content[index]) - } - - return normalized -} - -func hasUTF16LittleEndianBOM(content []byte) bool { - return len(content) >= utf16CodeUnitByteCount && - content[0] == utf16LittleEndianBOMFirstByte && - content[1] == utf16LittleEndianBOMSecondByte -} - -func hasUTF16BigEndianBOM(content []byte) bool { - return len(content) >= utf16CodeUnitByteCount && - content[0] == utf16BigEndianBOMFirstByte && - content[1] == utf16BigEndianBOMSecondByte -} - -func hasMatchingUTF16BOM(content []byte, littleEndian bool) bool { - if littleEndian { - return hasUTF16LittleEndianBOM(content) - } - return hasUTF16BigEndianBOM(content) -} - -func hasUTF16LittleEndianLineEnding(content []byte) bool { - return hasUTF16LineEnding(content, true) -} - -func hasUTF16BigEndianLineEnding(content []byte) bool { - return hasUTF16LineEnding(content, false) -} - -func hasUTF16LineEnding(content []byte, littleEndian bool) bool { - startIndex := 0 - if hasMatchingUTF16BOM(content, littleEndian) { - startIndex = utf16CodeUnitByteCount - } - for index := startIndex; index+1 < len(content); index += utf16CodeUnitByteCount { - codeUnit := readUTF16CodeUnit(content, index, littleEndian) - if codeUnit == carriageReturnCodeUnit || codeUnit == lineFeedCodeUnit { - return true - } - } - return false -} - -func readUTF16CodeUnit(content []byte, index int, littleEndian bool) uint16 { - if littleEndian { - return uint16(content[index]) | uint16(content[index+1])<<8 - } - return uint16(content[index])<<8 | uint16(content[index+1]) -} - -func writeUTF16CodeUnit(output []byte, codeUnit uint16, littleEndian bool) []byte { - if littleEndian { - return append(output, byte(codeUnit&0xff), byte(codeUnit>>8)) - } - return append(output, byte(codeUnit>>8), byte(codeUnit&0xff)) -} - -func shouldNormalizeLineEndings(relativePath string) bool { - switch strings.ToLower(filepath.Ext(relativePath)) { - case ".json", ".md", ".ps1", ".sh", ".txt", ".yaml", ".yml": - return true - default: - return false - } -} - -func getSkillsBaseDir(projectRoot string, target skillTarget, global bool) string { - if global { - homeDir, err := os.UserHomeDir() - if err == nil { - return filepath.Join(homeDir, target.projectDir, "skills") - } - return filepath.Join(target.projectDir, "skills") - } - return filepath.Join(projectRoot, target.projectDir, "skills") -} - -func getPreferredSkillDir(baseDir string, skillName string, grouped bool) string { - if grouped { - return filepath.Join(baseDir, managedSkillsDir, skillName) - } - return filepath.Join(baseDir, skillName) -} - -func migrateLegacyManagedSkills(baseDir string, skills []skillDefinition) error { - for _, skill := range skills { - legacyDir := getPreferredSkillDir(baseDir, skill.name, false) - managedDir := getPreferredSkillDir(baseDir, skill.name, true) - if _, err := os.Stat(legacyDir); err != nil { - if os.IsNotExist(err) { - continue - } - return err - } - if _, err := os.Stat(managedDir); err == nil { - continue - } else if !os.IsNotExist(err) { - return err - } - if err := os.MkdirAll(filepath.Dir(managedDir), 0o755); err != nil { - return err - } - if err := os.Rename(legacyDir, managedDir); err != nil { - return err - } - } - return nil -} - -func removeDeprecatedSkillDirs(baseDir string) (int, error) { - removed := 0 - for _, skillName := range deprecatedSkillNames { - for _, grouped := range []bool{true, false} { - skillDir := getPreferredSkillDir(baseDir, skillName, grouped) - exists, err := removeDirIfExists(skillDir) - if err != nil { - return removed, err - } - if exists { - removed++ - } - } - } - return removed, nil -} - -func removeSkillFromAllLayouts(baseDir string, skillName string) error { - for _, grouped := range []bool{true, false} { - if _, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)); err != nil { - return err - } - } - return nil -} - -func removeDirIfExists(path string) (bool, error) { - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - if err := os.RemoveAll(path); err != nil { - return false, err - } - return true, nil -} - -func removeEmptyDir(path string) error { - entries, err := os.ReadDir(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if len(entries) > 0 { - return nil - } - return os.Remove(path) -} - -func loadDisabledTools(projectRoot string) []string { - settingsPath := filepath.Join(projectRoot, uloopSettingsDir, toolSettingsFile) - content, err := os.ReadFile(settingsPath) - if err != nil || len(strings.TrimSpace(string(content))) == 0 { - return []string{} - } - - settings := toolSettingsData{} - if err := json.Unmarshal(content, &settings); err != nil { - return []string{} - } - if settings.DisabledTools == nil { - return []string{} - } - return settings.DisabledTools -} - -func isSkillDisabledByToolSettings(skill skillDefinition, disabledTools []string) bool { - if len(disabledTools) == 0 { - return false - } - toolName := skill.toolName - if toolName == "" && strings.HasPrefix(skill.name, "uloop-") { - toolName = strings.TrimPrefix(skill.name, "uloop-") - } - if toolName == "" { - return false - } - for _, disabledTool := range disabledTools { - if disabledTool == toolName { - return true - } - } - return false -} - -func findEditorFolders(basePath string, maxDepth int) []string { - editorFolders := []string{} - var scan func(string, int) - scan = func(currentPath string, depth int) { - if depth > maxDepth { - return - } - entries, err := os.ReadDir(currentPath) - if err != nil { - return - } - for _, entry := range entries { - if !entry.IsDir() || excludedSkillSearchDirs[entry.Name()] { - continue - } - fullPath := filepath.Join(currentPath, entry.Name()) - if entry.Name() == "Editor" { - editorFolders = append(editorFolders, fullPath) - continue - } - scan(fullPath, depth+1) - } - } - scan(basePath, 0) - sort.Strings(editorFolders) - return editorFolders -} - -func enumerateDirectProjectPackageRoots(projectRoot string) []string { - packagesRoot := filepath.Join(projectRoot, "Packages") - entries, err := os.ReadDir(packagesRoot) - if err != nil { - return []string{} - } - packageRoots := []string{} - for _, entry := range entries { - if !entry.IsDir() { - continue - } - packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(filepath.Join(packagesRoot, entry.Name()))) - } - sort.Strings(packageRoots) - return packageRoots -} - -func resolveManifestLocalPackageRoots(projectRoot string) []string { - dependencies := readManifestDependencies(projectRoot) - if len(dependencies) == 0 { - return []string{} - } - packageRoots := []string{} - for _, dependencyValue := range dependencies { - localPath := resolveLocalDependencyPath(dependencyValue, projectRoot) - if localPath == "" { - continue - } - packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(localPath)) - } - sort.Strings(packageRoots) - return packageRoots -} - -func resolveDependencyPackageCacheRoots(projectRoot string) []string { - dependencies := readManifestDependencies(projectRoot) - if len(dependencies) == 0 { - return []string{} - } - dependencyNames := map[string]bool{} - for dependencyName := range dependencies { - dependencyNames[strings.ToLower(dependencyName)] = true - } - packageCacheDir := filepath.Join(projectRoot, "Library", "PackageCache") - entries, err := os.ReadDir(packageCacheDir) - if err != nil { - return []string{} - } - packageRoots := []string{} - for _, entry := range entries { - if !entry.IsDir() { - continue - } - dependencyName := entry.Name() - if separatorIndex := strings.Index(dependencyName, "@"); separatorIndex >= 0 { - dependencyName = dependencyName[:separatorIndex] - } - if !dependencyNames[strings.ToLower(dependencyName)] { - continue - } - packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(filepath.Join(packageCacheDir, entry.Name()))) - } - sort.Strings(packageRoots) - return packageRoots -} - -func resolvePackageRoot(projectRoot string) string { - candidates := []string{ - filepath.Join(projectRoot, "Packages", "src"), - filepath.Join(projectRoot, "Packages", packageName), - filepath.Join(projectRoot, "Packages", packageNameAlias), - } - for _, candidate := range candidates { - if resolvedRoot := resolvePackageRootCandidate(candidate); resolvedRoot != "" { - return resolvedRoot - } - } - - return resolvePackageCacheRoot(projectRoot) -} - -func resolvePackageCacheRoot(projectRoot string) string { - packageCacheDir := filepath.Join(projectRoot, "Library", "PackageCache") - entries, err := os.ReadDir(packageCacheDir) - if err != nil { - return "" - } - for _, entry := range entries { - if !entry.IsDir() || !isTargetPackageCacheDir(entry.Name()) { - continue - } - if resolvedRoot := resolvePackageRootCandidate(filepath.Join(packageCacheDir, entry.Name())); resolvedRoot != "" { - return resolvedRoot - } - } - return "" -} - -func resolvePackageRootCandidate(candidate string) string { - if _, err := os.Stat(candidate); err != nil { - return "" - } - directToolsPath := filepath.Join(candidate, "Editor", "Api", "McpTools") - if _, err := os.Stat(directToolsPath); err == nil { - return candidate - } - nestedRoot := filepath.Join(candidate, "Packages", "src") - nestedToolsPath := filepath.Join(nestedRoot, "Editor", "Api", "McpTools") - if _, err := os.Stat(nestedToolsPath); err == nil { - return nestedRoot - } - return "" -} - -func resolveSkillSearchRootCandidate(candidate string) string { - nestedRoot := filepath.Join(candidate, "Packages", "src") - if _, err := os.Stat(nestedRoot); err == nil { - return nestedRoot - } - return candidate -} - -func readManifestDependencies(projectRoot string) map[string]string { - manifestPath := filepath.Join(projectRoot, "Packages", manifestFileName) - content, err := os.ReadFile(manifestPath) - if err != nil { - return map[string]string{} - } - manifest := manifestData{} - if err := json.Unmarshal(content, &manifest); err != nil { - return map[string]string{} - } - if manifest.Dependencies == nil { - return map[string]string{} - } - return manifest.Dependencies -} - -func resolveLocalDependencyPath(dependencyValue string, projectRoot string) string { - rawPath := "" - switch { - case strings.HasPrefix(dependencyValue, "file:"): - rawPath = strings.TrimPrefix(dependencyValue, "file:") - case strings.HasPrefix(dependencyValue, "path:"): - rawPath = strings.TrimPrefix(dependencyValue, "path:") - default: - return "" - } - rawPath = strings.TrimSpace(rawPath) - if rawPath == "" { - return "" - } - rawPath = strings.TrimPrefix(rawPath, "//") - if filepath.IsAbs(rawPath) { - return rawPath - } - return filepath.Join(projectRoot, rawPath) -} - -func isTargetPackageCacheDir(dirName string) bool { - normalizedName := strings.ToLower(dirName) - return strings.HasPrefix(normalizedName, strings.ToLower(packageName)+"@") || - strings.HasPrefix(normalizedName, strings.ToLower(packageNameAlias)+"@") -} - -func defaultSkillTargets() []skillTarget { - targets := make([]skillTarget, 0, len(defaultSkillTargetIDs)) - for _, targetID := range defaultSkillTargetIDs { - targets = append(targets, targetConfigs[targetID]) - } - return targets -} - -func shouldSkipSkillFile(name string) bool { - return name == ".DS_Store" || strings.HasSuffix(name, ".meta") -} - -func isSafeSkillName(name string) bool { - return name != "" && name != "." && name != ".." && - !strings.Contains(name, "/") && !strings.Contains(name, `\`) -} - -func skillLocationName(global bool) string { - if global { - return "global" - } - return "project" -} - -func statusIcon(status string) string { - switch status { - case "installed": - return "+" - case "outdated": - return "^" - default: - return "-" - } -} - -func statusText(status string) string { - switch status { - case "installed": - return "installed" - case "outdated": - return "outdated" - default: - return "not installed" - } -} - -func printSkillsHelp(stdout io.Writer) { - writeLine(stdout, "Usage:") - writeLine(stdout, " uloop skills list [options]") - writeLine(stdout, " uloop skills install [options]") - writeLine(stdout, " uloop skills uninstall [options]") -} - -func printSkillsTargetGuidance(command string, stdout io.Writer) { - writeFormat(stdout, "\nPlease specify at least one target for '%s':\n\n", command) - writeLine(stdout, "Available targets:") - writeLine(stdout, " --claude") - writeLine(stdout, " --codex") - writeLine(stdout, " --cursor") - writeLine(stdout, " --gemini") - writeLine(stdout, " --agents") - writeLine(stdout, " --windsurf") - writeLine(stdout, " --antigravity") -} diff --git a/Packages/src/GoCli~/internal/domain/project.go b/Packages/src/GoCli~/internal/domain/project.go new file mode 100644 index 000000000..a94e016d2 --- /dev/null +++ b/Packages/src/GoCli~/internal/domain/project.go @@ -0,0 +1,16 @@ +package domain + +type Endpoint struct { + Network string + Address string +} + +type RequestMetadata struct { + ExpectedProjectRoot string `json:"expectedProjectRoot"` +} + +type Connection struct { + Endpoint Endpoint + ProjectRoot string + RequestMetadata *RequestMetadata +} diff --git a/Packages/src/GoCli~/internal/domain/tool.go b/Packages/src/GoCli~/internal/domain/tool.go new file mode 100644 index 000000000..eb155912b --- /dev/null +++ b/Packages/src/GoCli~/internal/domain/tool.go @@ -0,0 +1,50 @@ +package domain + +type ToolCatalog struct { + Version string `json:"version"` + ServerVersion string `json:"serverVersion,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Tools []ToolDefinition `json:"tools"` +} + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema ToolInputSchema `json:"inputSchema"` + ParameterSchema ToolInputSchema `json:"parameterSchema"` +} + +type ToolInputSchema struct { + Type string `json:"type"` + Properties map[string]ToolProperty `json:"properties"` + Required []string `json:"required,omitempty"` +} + +type ToolProperty struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Default any `json:"default,omitempty"` + DefaultValue any `json:"DefaultValue,omitempty"` + Enum []string `json:"enum,omitempty"` + Items *struct { + Type string `json:"type"` + } `json:"items,omitempty"` +} + +func (tool ToolDefinition) EffectiveInputSchema() ToolInputSchema { + if tool.InputSchema.HasValues() { + return tool.InputSchema + } + return tool.ParameterSchema +} + +func (schema ToolInputSchema) HasValues() bool { + return schema.Type != "" || len(schema.Properties) > 0 || len(schema.Required) > 0 +} + +func (property ToolProperty) EffectiveDefault() any { + if property.Default != nil { + return property.Default + } + return property.DefaultValue +} diff --git a/Packages/src/GoCli~/internal/domain/unity.go b/Packages/src/GoCli~/internal/domain/unity.go new file mode 100644 index 000000000..8ab8031a0 --- /dev/null +++ b/Packages/src/GoCli~/internal/domain/unity.go @@ -0,0 +1,8 @@ +package domain + +import "encoding/json" + +type UnitySendOutcome struct { + Result json.RawMessage + RequestDispatched bool +} diff --git a/Packages/src/GoCli~/internal/ports/unity_bridge.go b/Packages/src/GoCli~/internal/ports/unity_bridge.go new file mode 100644 index 000000000..44a1d09f1 --- /dev/null +++ b/Packages/src/GoCli~/internal/ports/unity_bridge.go @@ -0,0 +1,16 @@ +package ports + +import ( + "context" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" +) + +type UnityBridge interface { + SendWithProgressOutcome( + ctx context.Context, + method string, + params map[string]any, + progress func(string), + ) (domain.UnitySendOutcome, error) +} diff --git a/Packages/src/GoCli~/internal/cli/argument_error.go b/Packages/src/GoCli~/internal/presentation/cli/argument_error.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/argument_error.go rename to Packages/src/GoCli~/internal/presentation/cli/argument_error.go diff --git a/Packages/src/GoCli~/internal/presentation/cli/command_registry.go b/Packages/src/GoCli~/internal/presentation/cli/command_registry.go new file mode 100644 index 000000000..42fdac3d4 --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/command_registry.go @@ -0,0 +1,25 @@ +package cli + +type nativeCommandEntry struct { + name string + description string +} + +var nativeCommands = []nativeCommandEntry{ + {name: "launch", description: "Open this Unity project with the matching Editor version"}, + {name: "list", description: "Show Unity tools currently exposed by the Editor"}, + {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, + {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, + {name: "fix", description: "Remove stale uloop lock files after an interrupted run"}, + {name: "skills", description: "List, install, or uninstall agent skills"}, + {name: "completion", description: "Print or install shell completion"}, + {name: "update", description: "Update the global uloop launcher binary"}, +} + +func nativeCommandNamesForCompletion() []string { + names := make([]string, 0, len(nativeCommands)) + for _, command := range nativeCommands { + names = append(names, command.name) + } + return names +} diff --git a/Packages/src/GoCli~/internal/cli/compile_wait.go b/Packages/src/GoCli~/internal/presentation/cli/compile_wait.go similarity index 97% rename from Packages/src/GoCli~/internal/cli/compile_wait.go rename to Packages/src/GoCli~/internal/presentation/cli/compile_wait.go index c4ebe468e..649dc8765 100644 --- a/Packages/src/GoCli~/internal/cli/compile_wait.go +++ b/Packages/src/GoCli~/internal/presentation/cli/compile_wait.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) const ( @@ -162,7 +162,7 @@ func isUnityBusyByCompileLocks(projectRoot string) (bool, error) { return false, nil } -func shouldWaitForCompileResult(err error, outcome unity.SendOutcome) bool { +func shouldWaitForCompileResult(err error, outcome domain.UnitySendOutcome) bool { if err == nil { return true } diff --git a/Packages/src/GoCli~/internal/cli/compile_wait_test.go b/Packages/src/GoCli~/internal/presentation/cli/compile_wait_test.go similarity index 96% rename from Packages/src/GoCli~/internal/cli/compile_wait_test.go rename to Packages/src/GoCli~/internal/presentation/cli/compile_wait_test.go index 943fbec92..e73183e9f 100644 --- a/Packages/src/GoCli~/internal/cli/compile_wait_test.go +++ b/Packages/src/GoCli~/internal/presentation/cli/compile_wait_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) func TestEnsureCompileRequestIDPreservesSafeValue(t *testing.T) { @@ -135,11 +135,11 @@ func TestWaitForCompileCompletionWaitsForServerStartingLock(t *testing.T) { } func TestShouldWaitForCompileResultRequiresDispatchedTransportError(t *testing.T) { - if shouldWaitForCompileResult(os.ErrNotExist, unity.SendOutcome{}) { + if shouldWaitForCompileResult(os.ErrNotExist, domain.UnitySendOutcome{}) { t.Fatal("undispatched error should not wait") } - outcome := unity.SendOutcome{RequestDispatched: true} + outcome := domain.UnitySendOutcome{RequestDispatched: true} if !shouldWaitForCompileResult(fmt.Errorf("EOF"), outcome) { t.Fatal("dispatched transport error should wait") } diff --git a/Packages/src/GoCli~/internal/cli/completion.go b/Packages/src/GoCli~/internal/presentation/cli/completion.go similarity index 98% rename from Packages/src/GoCli~/internal/cli/completion.go rename to Packages/src/GoCli~/internal/presentation/cli/completion.go index 66cccbabe..727b87c70 100644 --- a/Packages/src/GoCli~/internal/cli/completion.go +++ b/Packages/src/GoCli~/internal/presentation/cli/completion.go @@ -23,17 +23,6 @@ const ( pwshProfileSubpath = "Documents/PowerShell/Microsoft.PowerShell_profile.ps1" ) -var nativeCommandNames = []string{ - "completion", - "fix", - "focus-window", - "launch", - "list", - "skills", - "sync", - "update", -} - func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Writer, stderr io.Writer) (bool, int) { if len(args) == 0 { return false, 0 @@ -181,6 +170,7 @@ func normalizeShell(value string) (string, error) { func printCommandNames(cache toolsCache, stdout io.Writer) { seen := map[string]bool{} + nativeCommandNames := nativeCommandNamesForCompletion() commands := make([]string, 0, len(nativeCommandNames)+len(cache.Tools)) for _, command := range nativeCommandNames { if seen[command] { @@ -201,7 +191,7 @@ func printCommandNames(cache toolsCache, stdout io.Writer) { } func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer) { - for _, nativeCommand := range nativeCommandNames { + for _, nativeCommand := range nativeCommandNamesForCompletion() { if command == nativeCommand { return } @@ -212,7 +202,7 @@ func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer) return } - schema := tool.effectiveInputSchema() + schema := tool.EffectiveInputSchema() options := make([]string, 0, len(schema.Properties)) for propertyName, property := range schema.Properties { options = append(options, "--"+optionNameForProperty(propertyName, property)) diff --git a/Packages/src/GoCli~/internal/cli/completion_test.go b/Packages/src/GoCli~/internal/presentation/cli/completion_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/completion_test.go rename to Packages/src/GoCli~/internal/presentation/cli/completion_test.go diff --git a/Packages/src/GoCli~/internal/cli/default-tools.json b/Packages/src/GoCli~/internal/presentation/cli/default-tools.json similarity index 100% rename from Packages/src/GoCli~/internal/cli/default-tools.json rename to Packages/src/GoCli~/internal/presentation/cli/default-tools.json diff --git a/Packages/src/GoCli~/internal/cli/error_envelope.go b/Packages/src/GoCli~/internal/presentation/cli/error_envelope.go similarity index 97% rename from Packages/src/GoCli~/internal/cli/error_envelope.go rename to Packages/src/GoCli~/internal/presentation/cli/error_envelope.go index aaf405135..b5eb1ff2b 100644 --- a/Packages/src/GoCli~/internal/cli/error_envelope.go +++ b/Packages/src/GoCli~/internal/presentation/cli/error_envelope.go @@ -6,7 +6,8 @@ import ( "io" "strings" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) const ( @@ -69,7 +70,7 @@ func writeClassifiedError(writer io.Writer, err error, context errorContext) { writeErrorEnvelope(writer, classifyError(err, context)) } -func writeToolFailure(writer io.Writer, err error, outcome unity.SendOutcome, context errorContext) { +func writeToolFailure(writer io.Writer, err error, outcome domain.UnitySendOutcome, context errorContext) { if err != nil && outcome.RequestDispatched && isTransportDisconnectError(err) { writeErrorEnvelope(writer, disconnectedAfterDispatchError(err, context)) return @@ -265,7 +266,7 @@ func internalCLIError(message string, context errorContext) cliError { func availableCommandNames(cache toolsCache) []string { seen := map[string]bool{} names := []string{} - for _, name := range []string{"list", "sync", "focus-window", "fix"} { + for _, name := range nativeCommandNamesForCompletion() { seen[name] = true names = append(names, name) } diff --git a/Packages/src/GoCli~/internal/cli/error_envelope_test.go b/Packages/src/GoCli~/internal/presentation/cli/error_envelope_test.go similarity index 96% rename from Packages/src/GoCli~/internal/cli/error_envelope_test.go rename to Packages/src/GoCli~/internal/presentation/cli/error_envelope_test.go index 8934f981a..e292f0ea7 100644 --- a/Packages/src/GoCli~/internal/cli/error_envelope_test.go +++ b/Packages/src/GoCli~/internal/presentation/cli/error_envelope_test.go @@ -6,7 +6,8 @@ import ( "errors" "testing" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) func TestWriteErrorEnvelopeWritesMachineReadableJSON(t *testing.T) { @@ -117,7 +118,7 @@ func TestWriteToolFailureClassifiesDispatchedDisconnect(t *testing.T) { writeToolFailure( &stderr, errors.New("EOF"), - unity.SendOutcome{RequestDispatched: true}, + domain.UnitySendOutcome{RequestDispatched: true}, errorContext{projectRoot: "/tmp/MyProject", command: "execute-dynamic-code"}, ) @@ -211,7 +212,7 @@ func TestClassifyConnectionAttemptUsesContextProjectRootFallback(t *testing.T) { func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { names := availableCommandNames(toolsCache{}) - expectedBuiltIns := []string{"list", "sync", "focus-window", "fix"} + expectedBuiltIns := []string{"launch", "list", "sync", "focus-window", "fix", "skills", "completion", "update"} for index, expected := range expectedBuiltIns { if names[index] != expected { t.Fatalf("built-in command mismatch: %#v", names) diff --git a/Packages/src/GoCli~/internal/cli/fix.go b/Packages/src/GoCli~/internal/presentation/cli/fix.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/fix.go rename to Packages/src/GoCli~/internal/presentation/cli/fix.go diff --git a/Packages/src/GoCli~/internal/cli/fix_test.go b/Packages/src/GoCli~/internal/presentation/cli/fix_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/fix_test.go rename to Packages/src/GoCli~/internal/presentation/cli/fix_test.go diff --git a/Packages/src/GoCli~/internal/cli/focus.go b/Packages/src/GoCli~/internal/presentation/cli/focus.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/focus.go rename to Packages/src/GoCli~/internal/presentation/cli/focus.go diff --git a/Packages/src/GoCli~/internal/cli/focus_test.go b/Packages/src/GoCli~/internal/presentation/cli/focus_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/focus_test.go rename to Packages/src/GoCli~/internal/presentation/cli/focus_test.go diff --git a/Packages/src/GoCli~/internal/cli/help_test.go b/Packages/src/GoCli~/internal/presentation/cli/help_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/help_test.go rename to Packages/src/GoCli~/internal/presentation/cli/help_test.go diff --git a/Packages/src/GoCli~/internal/cli/launch.go b/Packages/src/GoCli~/internal/presentation/cli/launch.go similarity index 99% rename from Packages/src/GoCli~/internal/cli/launch.go rename to Packages/src/GoCli~/internal/presentation/cli/launch.go index 80365f2af..fb4d5d0c9 100644 --- a/Packages/src/GoCli~/internal/cli/launch.go +++ b/Packages/src/GoCli~/internal/presentation/cli/launch.go @@ -14,8 +14,8 @@ import ( "strings" "time" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/unity" ) const ( diff --git a/Packages/src/GoCli~/internal/cli/launch_process_unix.go b/Packages/src/GoCli~/internal/presentation/cli/launch_process_unix.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/launch_process_unix.go rename to Packages/src/GoCli~/internal/presentation/cli/launch_process_unix.go diff --git a/Packages/src/GoCli~/internal/cli/launch_process_unix_test.go b/Packages/src/GoCli~/internal/presentation/cli/launch_process_unix_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/launch_process_unix_test.go rename to Packages/src/GoCli~/internal/presentation/cli/launch_process_unix_test.go diff --git a/Packages/src/GoCli~/internal/cli/launch_process_windows.go b/Packages/src/GoCli~/internal/presentation/cli/launch_process_windows.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/launch_process_windows.go rename to Packages/src/GoCli~/internal/presentation/cli/launch_process_windows.go diff --git a/Packages/src/GoCli~/internal/cli/launch_test.go b/Packages/src/GoCli~/internal/presentation/cli/launch_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/launch_test.go rename to Packages/src/GoCli~/internal/presentation/cli/launch_test.go diff --git a/Packages/src/GoCli~/internal/cli/output.go b/Packages/src/GoCli~/internal/presentation/cli/output.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/output.go rename to Packages/src/GoCli~/internal/presentation/cli/output.go diff --git a/Packages/src/GoCli~/internal/cli/run.go b/Packages/src/GoCli~/internal/presentation/cli/run.go similarity index 60% rename from Packages/src/GoCli~/internal/cli/run.go rename to Packages/src/GoCli~/internal/presentation/cli/run.go index 42d990d39..f7b8a1e2d 100644 --- a/Packages/src/GoCli~/internal/cli/run.go +++ b/Packages/src/GoCli~/internal/presentation/cli/run.go @@ -9,11 +9,12 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "syscall" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/project" - "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/project" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/unity" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/application" + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) const ( @@ -21,22 +22,6 @@ const ( projectLocalWindowsPath = ".uloop/bin/uloop-core.exe" ) -type commandHelpEntry struct { - name string - description string -} - -var nativeCommandHelpEntries = []commandHelpEntry{ - {name: "launch", description: "Open this Unity project with the matching Editor version"}, - {name: "list", description: "Show Unity tools currently exposed by the Editor"}, - {name: "sync", description: "Refresh .uloop/tools.json from the running Editor"}, - {name: "focus-window", description: "Bring the Unity Editor window to the foreground"}, - {name: "fix", description: "Remove stale uloop lock files after an interrupted run"}, - {name: "skills", description: "List, install, or uninstall agent skills"}, - {name: "completion", description: "Print or install shell completion"}, - {name: "update", description: "Update the global uloop launcher binary"}, -} - func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { remainingArgs, projectPath, err := parseGlobalProjectPath(args) if err != nil { @@ -193,14 +178,19 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io return execProjectLocal(ctx, localPath, forwardedArgs, projectRoot, stderr) } -func runTool(ctx context.Context, connection project.Connection, command string, params map[string]any, stdout io.Writer, stderr io.Writer) int { +func runTool(ctx context.Context, connection domain.Connection, command string, params map[string]any, stdout io.Writer, stderr io.Writer) int { if shouldWaitForCompileDomainReload(command, params) { return runCompileWithDomainReloadWait(ctx, connection, params, stdout, stderr) } spinner := newToolSpinner(stderr, command) - outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, command, params, func(string) { - spinner.Update(fmt.Sprintf("Executing %s...", command)) + dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} + outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ + Command: command, + Params: params, + Progress: func(string) { + spinner.Update(fmt.Sprintf("Executing %s...", command)) + }, }) spinner.Stop() if err != nil { @@ -214,7 +204,7 @@ func runTool(ctx context.Context, connection project.Connection, command string, return 0 } -func runCompileWithDomainReloadWait(ctx context.Context, connection project.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { +func runCompileWithDomainReloadWait(ctx context.Context, connection domain.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { requestID, err := ensureCompileRequestID(params) if err != nil { writeClassifiedError(stderr, err, errorContext{ @@ -225,8 +215,13 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn } spinner := newToolSpinner(stderr, compileCommandName) - outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, compileCommandName, params, func(string) { - spinner.Update("Executing compile...") + dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} + outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ + Command: compileCommandName, + Params: params, + Progress: func(string) { + spinner.Update("Executing compile...") + }, }) if err != nil && shouldWaitForCompileResult(err, outcome) { spinner.Update("Connection lost during compile. Waiting for result file...") @@ -264,10 +259,15 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn return 0 } -func runList(ctx context.Context, connection project.Connection, stdout io.Writer, stderr io.Writer) int { +func runList(ctx context.Context, connection domain.Connection, stdout io.Writer, stderr io.Writer) int { spinner := newToolSpinner(stderr, "list") - outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, "get-tool-details", map[string]any{}, func(string) { - spinner.Update("Fetching tool list...") + dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} + outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ + Command: "get-tool-details", + Params: map[string]any{}, + Progress: func(string) { + spinner.Update("Fetching tool list...") + }, }) spinner.Stop() if err != nil { @@ -281,10 +281,15 @@ func runList(ctx context.Context, connection project.Connection, stdout io.Write return 0 } -func runSync(ctx context.Context, connection project.Connection, stdout io.Writer, stderr io.Writer) int { +func runSync(ctx context.Context, connection domain.Connection, stdout io.Writer, stderr io.Writer) int { spinner := newToolSpinner(stderr, "sync") - outcome, err := unity.NewClient(connection).SendWithProgressOutcome(ctx, "get-tool-details", map[string]any{}, func(string) { - spinner.Update("Syncing tools...") + dispatcher := application.ToolDispatcher{Bridge: unity.NewClient(connection)} + outcome, err := dispatcher.Dispatch(ctx, application.ToolDispatchRequest{ + Command: "get-tool-details", + Params: map[string]any{}, + Progress: func(string) { + spinner.Update("Syncing tools...") + }, }) spinner.Stop() if err != nil { @@ -353,129 +358,3 @@ func execProjectLocal(ctx context.Context, localPath string, args []string, proj } return 0 } - -func isVersionRequest(args []string) bool { - return len(args) == 1 && (args[0] == "--version" || args[0] == "-v") -} - -func isHelpRequest(args []string) bool { - return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") -} - -func printHelp(stdout io.Writer) { - printMainHelp( - stdout, - "Project-local CLI. Runs native uloop commands and dispatches live Unity tool commands.", - toolsCache{}, - false) -} - -func printLauncherHelp(stdout io.Writer) { - printMainHelp( - stdout, - "Global dispatcher. Finds the Unity project, then dispatches to the project-local uloop-core binary.", - toolsCache{}, - false) -} - -func printLauncherHelpForResolvedProject(stdout io.Writer, startPath string, explicitProjectPath string) { - projectRoot, err := resolveLauncherProjectRoot(startPath, explicitProjectPath) - if err != nil { - printLauncherHelp(stdout) - return - } - - cache, ok := loadCachedTools(projectRoot) - printMainHelp( - stdout, - "Global dispatcher. Finds the Unity project, then dispatches to the project-local uloop-core binary.", - cache, - ok) -} - -func printMainHelp(stdout io.Writer, description string, cache toolsCache, hasProjectToolCache bool) { - writeFormat(stdout, "uloop %s\n\n", version) - writeLine(stdout, "Usage:") - writeLine(stdout, " uloop [options]") - writeLine(stdout, "") - writeLine(stdout, description) - writeLine(stdout, "") - printNativeCommandHelp(stdout) - writeLine(stdout, "") - printGlobalOptionsHelp(stdout) - writeLine(stdout, "") - printUnityToolCommandHelp(stdout, cache, hasProjectToolCache) - writeLine(stdout, "") - writeLine(stdout, "More:") - writeLine(stdout, " uloop list Show the live Unity tool list") - writeLine(stdout, " uloop --project-path /path/to/project list Show tools for another Unity project") - writeLine(stdout, " uloop --help Show help for native commands that support it") - writeLine(stdout, " uloop completion --list-commands Print command names for completion") - writeLine(stdout, " uloop completion --list-options Print options for a Unity tool command") -} - -func printNativeCommandHelp(stdout io.Writer) { - writeLine(stdout, "Native commands:") - for _, entry := range nativeCommandHelpEntries { - writeFormat(stdout, " %-14s %s\n", entry.name, entry.description) - } -} - -func printGlobalOptionsHelp(stdout io.Writer) { - writeLine(stdout, "Global options:") - writeLine(stdout, " --project-path Run against a Unity project outside the current directory") -} - -func printUnityToolCommandHelp(stdout io.Writer, cache toolsCache, hasProjectToolCache bool) { - if !hasProjectToolCache { - writeLine(stdout, "Unity tool commands are project-specific.") - writeLine(stdout, " Run `uloop list` inside a Unity project to show the live tool list.") - writeLine(stdout, " Run `uloop sync` after the Editor tool set changes to refresh cached commands.") - return - } - - writeLine(stdout, "Unity tool commands from this project's cache:") - if len(cache.Tools) == 0 { - writeLine(stdout, " No cached Unity tools found. Run `uloop sync` while Unity is running.") - return - } - - for _, tool := range cache.Tools { - if isNativeCommandName(tool.Name) { - continue - } - writeFormat(stdout, " %-22s %s\n", tool.Name, firstHelpLine(tool.Description)) - } - writeLine(stdout, " Run `uloop sync` after the Editor tool set changes to refresh this list.") -} - -func isNativeCommandName(name string) bool { - for _, entry := range nativeCommandHelpEntries { - if entry.name == name { - return true - } - } - return false -} - -func firstHelpLine(description string) string { - for _, line := range strings.Split(description, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func loadCompletionTools(startPath string, projectPath string) toolsCache { - connection, err := project.ResolveConnection(startPath, projectPath) - if err != nil { - return loadDefaultTools() - } - cache, err := loadTools(connection.ProjectRoot) - if err != nil { - return loadDefaultTools() - } - return cache -} diff --git a/Packages/src/GoCli~/internal/presentation/cli/run_help.go b/Packages/src/GoCli~/internal/presentation/cli/run_help.go new file mode 100644 index 000000000..fa1fdecc0 --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/run_help.go @@ -0,0 +1,134 @@ +package cli + +import ( + "io" + "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/project" +) + +func isVersionRequest(args []string) bool { + return len(args) == 1 && (args[0] == "--version" || args[0] == "-v") +} + +func isHelpRequest(args []string) bool { + return len(args) == 1 && (args[0] == "--help" || args[0] == "-h") +} + +func printHelp(stdout io.Writer) { + printMainHelp( + stdout, + "Project-local CLI. Runs native uloop commands and dispatches live Unity tool commands.", + toolsCache{}, + false) +} + +func printLauncherHelp(stdout io.Writer) { + printMainHelp( + stdout, + "Global dispatcher. Finds the Unity project, then dispatches to the project-local uloop-core binary.", + toolsCache{}, + false) +} + +func printLauncherHelpForResolvedProject(stdout io.Writer, startPath string, explicitProjectPath string) { + projectRoot, err := resolveLauncherProjectRoot(startPath, explicitProjectPath) + if err != nil { + printLauncherHelp(stdout) + return + } + + cache, ok := loadCachedTools(projectRoot) + printMainHelp( + stdout, + "Global dispatcher. Finds the Unity project, then dispatches to the project-local uloop-core binary.", + cache, + ok) +} + +func printMainHelp(stdout io.Writer, description string, cache toolsCache, hasProjectToolCache bool) { + writeFormat(stdout, "uloop %s\n\n", version) + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop [options]") + writeLine(stdout, "") + writeLine(stdout, description) + writeLine(stdout, "") + printNativeCommandHelp(stdout) + writeLine(stdout, "") + printGlobalOptionsHelp(stdout) + writeLine(stdout, "") + printUnityToolCommandHelp(stdout, cache, hasProjectToolCache) + writeLine(stdout, "") + writeLine(stdout, "More:") + writeLine(stdout, " uloop list Show the live Unity tool list") + writeLine(stdout, " uloop --project-path /path/to/project list Show tools for another Unity project") + writeLine(stdout, " uloop --help Show help for native commands that support it") + writeLine(stdout, " uloop completion --list-commands Print command names for completion") + writeLine(stdout, " uloop completion --list-options Print options for a Unity tool command") +} + +func printNativeCommandHelp(stdout io.Writer) { + writeLine(stdout, "Native commands:") + for _, entry := range nativeCommands { + writeFormat(stdout, " %-14s %s\n", entry.name, entry.description) + } +} + +func printGlobalOptionsHelp(stdout io.Writer) { + writeLine(stdout, "Global options:") + writeLine(stdout, " --project-path Run against a Unity project outside the current directory") +} + +func printUnityToolCommandHelp(stdout io.Writer, cache toolsCache, hasProjectToolCache bool) { + if !hasProjectToolCache { + writeLine(stdout, "Unity tool commands are project-specific.") + writeLine(stdout, " Run `uloop list` inside a Unity project to show the live tool list.") + writeLine(stdout, " Run `uloop sync` after the Editor tool set changes to refresh cached commands.") + return + } + + writeLine(stdout, "Unity tool commands from this project's cache:") + if len(cache.Tools) == 0 { + writeLine(stdout, " No cached Unity tools found. Run `uloop sync` while Unity is running.") + return + } + + for _, tool := range cache.Tools { + if isNativeCommandName(tool.Name) { + continue + } + writeFormat(stdout, " %-22s %s\n", tool.Name, firstHelpLine(tool.Description)) + } + writeLine(stdout, " Run `uloop sync` after the Editor tool set changes to refresh this list.") +} + +func isNativeCommandName(name string) bool { + for _, entry := range nativeCommands { + if entry.name == name { + return true + } + } + return false +} + +func firstHelpLine(description string) string { + for _, line := range strings.Split(description, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func loadCompletionTools(startPath string, projectPath string) toolsCache { + connection, err := project.ResolveConnection(startPath, projectPath) + if err != nil { + return loadDefaultTools() + } + cache, err := loadTools(connection.ProjectRoot) + if err != nil { + return loadDefaultTools() + } + return cache +} diff --git a/Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-focus-window/Skill/SKILL.md b/Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-focus-window/Skill/SKILL.md similarity index 100% rename from Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-focus-window/Skill/SKILL.md rename to Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-focus-window/Skill/SKILL.md diff --git a/Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-get-project-info/Skill/SKILL.md b/Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-get-project-info/Skill/SKILL.md similarity index 100% rename from Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-get-project-info/Skill/SKILL.md rename to Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-get-project-info/Skill/SKILL.md diff --git a/Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-get-version/Skill/SKILL.md b/Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-get-version/Skill/SKILL.md similarity index 100% rename from Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-get-version/Skill/SKILL.md rename to Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-get-version/Skill/SKILL.md diff --git a/Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-launch/Skill/SKILL.md b/Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-launch/Skill/SKILL.md similarity index 100% rename from Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-launch/Skill/SKILL.md rename to Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-launch/Skill/SKILL.md diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills.go b/Packages/src/GoCli~/internal/presentation/cli/skills.go new file mode 100644 index 000000000..b1270eccd --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills.go @@ -0,0 +1,394 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/adapters/project" +) + +const ( + skillsCommandName = "skills" + managedSkillsDir = "unity-cli-loop" + skillFileName = "SKILL.md" + uloopSettingsDir = ".uloop" + toolSettingsFile = "settings.tools.json" + manifestFileName = "manifest.json" + packageName = "io.github.hatayama.uloopmcp" + packageNameAlias = "io.github.hatayama.uLoopMCP" + skillSearchMaxDepth = 3 + + utf16LittleEndianBOMFirstByte = 0xff + utf16LittleEndianBOMSecondByte = 0xfe + utf16BigEndianBOMFirstByte = 0xfe + utf16BigEndianBOMSecondByte = 0xff + utf16CodeUnitByteCount = 2 + carriageReturnCodeUnit = 0x000d + lineFeedCodeUnit = 0x000a +) + +var targetConfigs = map[string]skillTarget{ + "claude": {id: "claude", displayName: "Claude Code", projectDir: ".claude"}, + "codex": {id: "codex", displayName: "Codex CLI", projectDir: ".codex"}, + "cursor": {id: "cursor", displayName: "Cursor", projectDir: ".cursor"}, + "gemini": {id: "gemini", displayName: "Gemini CLI", projectDir: ".gemini"}, + "agents": {id: "agents", displayName: "Other (.agents)", projectDir: ".agents"}, + "windsurf": {id: "windsurf", displayName: "Windsurf", projectDir: ".agents"}, + "antigravity": {id: "antigravity", displayName: "Antigravity", projectDir: ".agent"}, +} + +var defaultSkillTargetIDs = []string{"claude", "codex", "cursor", "gemini", "agents", "antigravity"} + +var deprecatedSkillNames = []string{ + "uloop-capture-window", + "uloop-get-provider-details", + "uloop-unity-search", + "uloop-get-menu-items", + "uloop-get-unity-search-providers", + "uloop-execute-menu-item", +} + +var excludedSkillSearchDirs = map[string]bool{ + "node_modules": true, + ".git": true, + "Temp": true, + "obj": true, + "Build": true, + "Builds": true, + "Logs": true, + "Skill": true, +} + +type skillTarget struct { + id string + displayName string + projectDir string +} + +type skillCommandOptions struct { + global bool + flat bool + targets []skillTarget +} + +type skillDefinition struct { + name string + toolName string + content []byte + sourceDirectory string +} + +type skillSourceRoot struct { + path string + cliOnly bool +} + +type manifestData struct { + Dependencies map[string]string `json:"dependencies"` +} + +type toolSettingsData struct { + DisabledTools []string `json:"disabledTools"` +} + +func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath string, stdout io.Writer, stderr io.Writer) (bool, int) { + if len(args) == 0 || args[0] != skillsCommandName { + return false, 0 + } + if len(args) == 1 || isHelpRequest(args[1:]) { + printSkillsHelp(stdout) + return true, 0 + } + + subcommand := args[1] + if !isKnownSkillsSubcommand(subcommand) { + writeErrorEnvelope(stderr, unknownSkillsSubcommandError(subcommand, errorContext{command: skillsCommandName})) + return true, 1 + } + options, err := parseSkillsOptions(args[2:]) + if err != nil { + writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) + return true, 1 + } + + projectRoot, err := resolveSkillsProjectRoot(startPath, globalProjectPath, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{command: skillsCommandName}) + return true, 1 + } + skills, err := collectSkillDefinitions(projectRoot) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return true, 1 + } + + switch subcommand { + case "list": + return true, runSkillsList(projectRoot, skills, options, stdout, stderr) + case "install": + if len(options.targets) == 0 { + printSkillsTargetGuidance("install", stdout) + return true, 0 + } + return true, runSkillsInstall(projectRoot, skills, options, stdout, stderr) + case "uninstall": + if len(options.targets) == 0 { + printSkillsTargetGuidance("uninstall", stdout) + return true, 0 + } + return true, runSkillsUninstall(projectRoot, skills, options, stdout, stderr) + } + return true, 1 +} + +func parseSkillsOptions(args []string) (skillCommandOptions, error) { + options := skillCommandOptions{} + seenTargets := map[string]bool{} + for _, arg := range args { + switch arg { + case "-g", "--global": + options.global = true + case "--flat": + options.flat = true + case "--claude", "--codex", "--cursor", "--gemini", "--agents", "--windsurf", "--antigravity": + targetID := strings.TrimPrefix(arg, "--") + if seenTargets[targetID] { + continue + } + options.targets = append(options.targets, targetConfigs[targetID]) + seenTargets[targetID] = true + default: + return skillCommandOptions{}, &argumentError{ + message: "Unknown skills option: " + arg, + option: arg, + command: skillsCommandName, + nextActions: []string{"Run `uloop skills --help` to inspect supported skills options."}, + } + } + } + return options, nil +} + +func isKnownSkillsSubcommand(subcommand string) bool { + switch subcommand { + case "list", "install", "uninstall": + return true + default: + return false + } +} + +func unknownSkillsSubcommandError(subcommand string, context errorContext) cliError { + return (&argumentError{ + message: "Unknown skills command: " + subcommand, + received: subcommand, + command: skillsCommandName, + nextActions: []string{"Use `uloop skills list`, `uloop skills install`, or `uloop skills uninstall`."}, + }).toCLIError(context) +} + +func resolveSkillsProjectRoot(startPath string, explicitProjectPath string, global bool) (string, error) { + if explicitProjectPath != "" { + projectRoot, err := filepath.Abs(explicitProjectPath) + if err != nil { + return "", err + } + if !project.IsUnityProject(projectRoot) { + return "", fmt.Errorf("not a Unity project: %s", projectRoot) + } + return projectRoot, nil + } + if global { + projectRoot, err := project.FindUnityProjectRoot(startPath) + if err == nil { + return projectRoot, nil + } + return "", nil + } + return project.FindUnityProjectRoot(startPath) +} + +func runSkillsList(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { + targets := options.targets + if len(targets) == 0 { + targets = defaultSkillTargets() + } + + location := "Project" + if options.global { + location = "Global" + } + + writeLine(stdout, "") + writeLine(stdout, "uloop Skills Status:") + writeLine(stdout, "") + for _, target := range targets { + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, "%s (%s):\n", target.displayName, location) + writeFormat(stdout, "Location: %s\n", baseDir) + writeLine(stdout, strings.Repeat("=", 50)) + for _, skill := range skills { + status, err := getSkillStatus(baseDir, skill, !options.flat) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, " %s %s (%s)\n", statusIcon(status), skill.name, statusText(status)) + } + writeLine(stdout, "") + } + writeFormat(stdout, "Total: %d skills\n", len(skills)) + return 0 +} + +func runSkillsInstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { + writeLine(stdout, "") + writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") + for _, target := range options.targets { + result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Installed: %d\n", result.installed) + writeFormat(stdout, " Updated: %d\n", result.updated) + writeFormat(stdout, " Skipped: %d\n", result.skipped) + if result.deprecatedRemoved > 0 { + writeFormat(stdout, " Deprecated removed: %d\n", result.deprecatedRemoved) + } + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, " Location: %s\n\n", baseDir) + } + return 0 +} + +func runSkillsUninstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { + writeLine(stdout, "") + writeFormat(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") + for _, target := range options.targets { + removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Removed: %d\n", removed) + writeFormat(stdout, " Not found: %d\n", notFound) + baseDir, err := getSkillsBaseDir(projectRoot, target, options.global) + if err != nil { + writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName}) + return 1 + } + writeFormat(stdout, " Location: %s\n\n", baseDir) + } + return 0 +} + +type skillInstallResult struct { + installed int + updated int + skipped int + deprecatedRemoved int +} + +func installSkillsForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (skillInstallResult, error) { + result := skillInstallResult{} + baseDir, err := getSkillsBaseDir(projectRoot, target, global) + if err != nil { + return skillInstallResult{}, err + } + deprecatedRemoved, err := removeDeprecatedSkillDirs(baseDir) + if err != nil { + return skillInstallResult{}, err + } + result.deprecatedRemoved = deprecatedRemoved + if grouped { + if err := migrateLegacyManagedSkills(baseDir, skills); err != nil { + return skillInstallResult{}, err + } + } + + disabledTools := []string{} + if !global { + disabledTools = loadDisabledTools(projectRoot) + } + for _, skill := range skills { + if isSkillDisabledByToolSettings(skill, disabledTools) { + if err := removeSkillFromAllLayouts(baseDir, skill.name); err != nil { + return skillInstallResult{}, err + } + continue + } + + status, err := getSkillStatus(baseDir, skill, grouped) + if err != nil { + return skillInstallResult{}, err + } + destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) + if status == "installed" { + result.skipped++ + continue + } + if err := syncSkillDirectory(skill.sourceDirectory, destinationDir); err != nil { + return skillInstallResult{}, err + } + alternateDir := getPreferredSkillDir(baseDir, skill.name, !grouped) + if err := os.RemoveAll(alternateDir); err != nil { + return skillInstallResult{}, err + } + if status == "outdated" { + result.updated++ + continue + } + result.installed++ + } + if !grouped { + if err := removeEmptyDir(getPreferredSkillDir(baseDir, managedSkillsDir, false)); err != nil { + return skillInstallResult{}, err + } + } + return result, nil +} + +func uninstallSkillsForTarget(projectRoot string, target skillTarget, skills []skillDefinition, global bool, grouped bool) (int, int, error) { + removed := 0 + notFound := 0 + baseDir, err := getSkillsBaseDir(projectRoot, target, global) + if err != nil { + return removed, notFound, err + } + deprecatedRemoved, err := removeDeprecatedSkillDirsForLayout(baseDir, grouped) + if err != nil { + return removed, notFound, err + } + removed += deprecatedRemoved + for _, skill := range skills { + destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) + if _, err := os.Stat(destinationDir); err != nil { + if !os.IsNotExist(err) { + return removed, notFound, err + } + notFound++ + continue + } + if err := os.RemoveAll(destinationDir); err != nil { + return removed, notFound, err + } + removed++ + } + return removed, notFound, nil +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_content.go b/Packages/src/GoCli~/internal/presentation/cli/skills_content.go new file mode 100644 index 000000000..42b4589ab --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_content.go @@ -0,0 +1,126 @@ +package cli + +import ( + "bytes" + "path/filepath" + "strings" +) + +func normalizeSkillFileContent(relativePath string, content []byte) []byte { + if !shouldNormalizeLineEndings(relativePath) || !bytes.Contains(content, []byte{'\r'}) { + return content + } + + if hasUTF16LittleEndianBOM(content) || hasUTF16LittleEndianLineEnding(content) { + return normalizeUTF16LineEndings(content, true) + } + + if hasUTF16BigEndianBOM(content) || hasUTF16BigEndianLineEnding(content) { + return normalizeUTF16LineEndings(content, false) + } + + if bytes.Contains(content, []byte{0}) { + return content + } + + normalizedContent := bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) + return bytes.ReplaceAll(normalizedContent, []byte("\r"), []byte("\n")) +} + +func normalizeUTF16LineEndings(content []byte, littleEndian bool) []byte { + normalized := make([]byte, 0, len(content)) + index := 0 + if hasMatchingUTF16BOM(content, littleEndian) { + normalized = append(normalized, content[0], content[1]) + index = utf16CodeUnitByteCount + } + + for index+1 < len(content) { + codeUnit := readUTF16CodeUnit(content, index, littleEndian) + if codeUnit == carriageReturnCodeUnit { + normalized = writeUTF16CodeUnit(normalized, lineFeedCodeUnit, littleEndian) + nextIndex := index + utf16CodeUnitByteCount + if nextIndex+1 < len(content) && + readUTF16CodeUnit(content, nextIndex, littleEndian) == lineFeedCodeUnit { + index += utf16CodeUnitByteCount * 2 + continue + } + + index += utf16CodeUnitByteCount + continue + } + + normalized = writeUTF16CodeUnit(normalized, codeUnit, littleEndian) + index += utf16CodeUnitByteCount + } + + if index < len(content) { + normalized = append(normalized, content[index]) + } + + return normalized +} + +func hasUTF16LittleEndianBOM(content []byte) bool { + return len(content) >= utf16CodeUnitByteCount && + content[0] == utf16LittleEndianBOMFirstByte && + content[1] == utf16LittleEndianBOMSecondByte +} + +func hasUTF16BigEndianBOM(content []byte) bool { + return len(content) >= utf16CodeUnitByteCount && + content[0] == utf16BigEndianBOMFirstByte && + content[1] == utf16BigEndianBOMSecondByte +} + +func hasMatchingUTF16BOM(content []byte, littleEndian bool) bool { + if littleEndian { + return hasUTF16LittleEndianBOM(content) + } + return hasUTF16BigEndianBOM(content) +} + +func hasUTF16LittleEndianLineEnding(content []byte) bool { + return hasUTF16LineEnding(content, true) +} + +func hasUTF16BigEndianLineEnding(content []byte) bool { + return hasUTF16LineEnding(content, false) +} + +func hasUTF16LineEnding(content []byte, littleEndian bool) bool { + startIndex := 0 + if hasMatchingUTF16BOM(content, littleEndian) { + startIndex = utf16CodeUnitByteCount + } + for index := startIndex; index+1 < len(content); index += utf16CodeUnitByteCount { + codeUnit := readUTF16CodeUnit(content, index, littleEndian) + if codeUnit == carriageReturnCodeUnit || codeUnit == lineFeedCodeUnit { + return true + } + } + return false +} + +func readUTF16CodeUnit(content []byte, index int, littleEndian bool) uint16 { + if littleEndian { + return uint16(content[index]) | uint16(content[index+1])<<8 + } + return uint16(content[index])<<8 | uint16(content[index+1]) +} + +func writeUTF16CodeUnit(output []byte, codeUnit uint16, littleEndian bool) []byte { + if littleEndian { + return append(output, byte(codeUnit&0xff), byte(codeUnit>>8)) + } + return append(output, byte(codeUnit>>8), byte(codeUnit&0xff)) +} + +func shouldNormalizeLineEndings(relativePath string) bool { + switch strings.ToLower(filepath.Ext(relativePath)) { + case ".json", ".md", ".ps1", ".sh", ".txt", ".yaml", ".yml": + return true + default: + return false + } +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_discovery.go b/Packages/src/GoCli~/internal/presentation/cli/skills_discovery.go new file mode 100644 index 000000000..98c64d14d --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_discovery.go @@ -0,0 +1,257 @@ +package cli + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +func collectSkillDefinitions(projectRoot string) ([]skillDefinition, error) { + skills := []skillDefinition{} + seen := map[string]bool{} + for _, sourceRoot := range enumerateSkillSourceRoots(projectRoot) { + discovered, err := scanSkillSourceRoot(sourceRoot) + if err != nil { + return nil, err + } + for _, skill := range discovered { + if seen[skill.name] { + continue + } + seen[skill.name] = true + skills = append(skills, skill) + } + } + sort.Slice(skills, func(left int, right int) bool { + return skills[left].name < skills[right].name + }) + return skills, nil +} + +func collectInternalSkillToolNames(projectRoot string) map[string]bool { + toolNames := map[string]bool{} + for _, sourceRoot := range enumerateSkillSourceRoots(projectRoot) { + for _, toolName := range scanInternalSkillToolNames(sourceRoot) { + toolNames[toolName] = true + } + } + return toolNames +} + +func enumerateSkillSourceRoots(projectRoot string) []skillSourceRoot { + sourceRoots := []skillSourceRoot{} + seen := map[string]bool{} + addSourceRoot := func(path string, cliOnly bool) { + if path == "" { + return + } + absolutePath, err := filepath.Abs(path) + if err != nil || seen[absolutePath] { + return + } + seen[absolutePath] = true + sourceRoots = append(sourceRoots, skillSourceRoot{path: absolutePath, cliOnly: cliOnly}) + } + + addSourceRoot(filepath.Join(projectRoot, "Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only"), true) + addSourceRoot(filepath.Join(projectRoot, "Assets"), false) + for _, packageRoot := range enumerateDirectProjectPackageRoots(projectRoot) { + addSourceRoot(packageRoot, false) + } + for _, packageRoot := range resolveManifestLocalPackageRoots(projectRoot) { + addSourceRoot(packageRoot, false) + } + for _, packageRoot := range resolveDependencyPackageCacheRoots(projectRoot) { + addSourceRoot(packageRoot, false) + } + addSourceRoot(resolvePackageRoot(projectRoot), false) + return sourceRoots +} + +func scanSkillSourceRoot(sourceRoot skillSourceRoot) ([]skillDefinition, error) { + if _, err := os.Stat(sourceRoot.path); err != nil { + return []skillDefinition{}, nil + } + + scanRoots := []string{sourceRoot.path} + if !sourceRoot.cliOnly { + scanRoots = findEditorFolders(sourceRoot.path, skillSearchMaxDepth) + } + + skills := []skillDefinition{} + for _, scanRoot := range scanRoots { + discovered, err := scanSkillDirectories(scanRoot) + if err != nil { + return nil, err + } + skills = append(skills, discovered...) + } + return skills, nil +} + +func scanInternalSkillToolNames(sourceRoot skillSourceRoot) []string { + if _, err := os.Stat(sourceRoot.path); err != nil { + return []string{} + } + + scanRoots := []string{sourceRoot.path} + if !sourceRoot.cliOnly { + scanRoots = findEditorFolders(sourceRoot.path, skillSearchMaxDepth) + } + + toolNames := []string{} + for _, scanRoot := range scanRoots { + toolNames = append(toolNames, scanInternalSkillDirectories(scanRoot)...) + } + return toolNames +} + +func scanSkillDirectories(searchRoot string) ([]skillDefinition, error) { + skills := []skillDefinition{} + err := filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + if entry.Name() != skillFileName { + return nil + } + skill, ok, err := readSkillDefinition(filepath.Dir(path)) + if err != nil { + return err + } + if ok { + skills = append(skills, skill) + } + return nil + } + if excludedSkillSearchDirs[entry.Name()] && entry.Name() != "Skill" { + return filepath.SkipDir + } + if entry.Name() != "Skill" { + return nil + } + + skill, ok, err := readSkillDefinition(path) + if err != nil { + return err + } + if !ok { + return filepath.SkipDir + } + skills = append(skills, skill) + return filepath.SkipDir + }) + if err != nil { + return nil, err + } + return skills, nil +} + +func scanInternalSkillDirectories(searchRoot string) []string { + toolNames := []string{} + _ = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if !entry.IsDir() { + if entry.Name() != skillFileName { + return nil + } + toolName, ok := readInternalSkillToolName(filepath.Dir(path)) + if ok { + toolNames = append(toolNames, toolName) + } + return nil + } + if excludedSkillSearchDirs[entry.Name()] && entry.Name() != "Skill" { + return filepath.SkipDir + } + if entry.Name() != "Skill" { + return nil + } + + toolName, ok := readInternalSkillToolName(path) + if ok { + toolNames = append(toolNames, toolName) + } + return filepath.SkipDir + }) + return toolNames +} + +func readSkillDefinition(skillDirectory string) (skillDefinition, bool, error) { + skillPath := filepath.Join(skillDirectory, skillFileName) + content, err := os.ReadFile(skillPath) + if err != nil { + if os.IsNotExist(err) { + return skillDefinition{}, false, nil + } + return skillDefinition{}, false, err + } + content = normalizeSkillFileContent(skillFileName, content) + frontmatter := parseSkillFrontmatter(string(content)) + if strings.EqualFold(frontmatter["internal"], "true") { + return skillDefinition{}, false, nil + } + name := frontmatter["name"] + if name == "" { + name = fallbackSkillName(skillDirectory) + } + if !isSafeSkillName(name) { + return skillDefinition{}, false, nil + } + return skillDefinition{ + name: name, + toolName: frontmatter["toolName"], + content: content, + sourceDirectory: skillDirectory, + }, true, nil +} + +func readInternalSkillToolName(skillDirectory string) (string, bool) { + skillPath := filepath.Join(skillDirectory, skillFileName) + content, err := os.ReadFile(skillPath) + if err != nil { + return "", false + } + frontmatter := parseSkillFrontmatter(string(content)) + if !strings.EqualFold(frontmatter["internal"], "true") { + return "", false + } + if frontmatter["toolName"] != "" { + return frontmatter["toolName"], true + } + name := frontmatter["name"] + if strings.HasPrefix(name, "uloop-") { + return strings.TrimPrefix(name, "uloop-"), true + } + return "", false +} + +func fallbackSkillName(skillDirectory string) string { + if filepath.Base(skillDirectory) == "Skill" { + return filepath.Base(filepath.Dir(skillDirectory)) + } + return filepath.Base(skillDirectory) +} + +func parseSkillFrontmatter(content string) map[string]string { + result := map[string]string{} + if !strings.HasPrefix(content, "---") { + return result + } + parts := strings.SplitN(content, "---", 3) + if len(parts) < 3 { + return result + } + for _, line := range strings.Split(parts[1], "\n") { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`) + } + return result +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_display.go b/Packages/src/GoCli~/internal/presentation/cli/skills_display.go new file mode 100644 index 000000000..e13f0192c --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_display.go @@ -0,0 +1,71 @@ +package cli + +import ( + "io" + "strings" +) + +func defaultSkillTargets() []skillTarget { + targets := make([]skillTarget, 0, len(defaultSkillTargetIDs)) + for _, targetID := range defaultSkillTargetIDs { + targets = append(targets, targetConfigs[targetID]) + } + return targets +} + +func shouldSkipSkillFile(name string) bool { + return name == ".DS_Store" || strings.HasSuffix(name, ".meta") +} + +func isSafeSkillName(name string) bool { + return name != "" && name != "." && name != ".." && + !strings.Contains(name, "/") && !strings.Contains(name, `\`) +} + +func skillLocationName(global bool) string { + if global { + return "global" + } + return "project" +} + +func statusIcon(status string) string { + switch status { + case "installed": + return "+" + case "outdated": + return "^" + default: + return "-" + } +} + +func statusText(status string) string { + switch status { + case "installed": + return "installed" + case "outdated": + return "outdated" + default: + return "not installed" + } +} + +func printSkillsHelp(stdout io.Writer) { + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop skills list [options]") + writeLine(stdout, " uloop skills install [options]") + writeLine(stdout, " uloop skills uninstall [options]") +} + +func printSkillsTargetGuidance(command string, stdout io.Writer) { + writeFormat(stdout, "\nPlease specify at least one target for '%s':\n\n", command) + writeLine(stdout, "Available targets:") + writeLine(stdout, " --claude") + writeLine(stdout, " --codex") + writeLine(stdout, " --cursor") + writeLine(stdout, " --gemini") + writeLine(stdout, " --agents") + writeLine(stdout, " --windsurf") + writeLine(stdout, " --antigravity") +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_packages.go b/Packages/src/GoCli~/internal/presentation/cli/skills_packages.go new file mode 100644 index 000000000..908d9ffce --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_packages.go @@ -0,0 +1,202 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +func findEditorFolders(basePath string, maxDepth int) []string { + editorFolders := []string{} + var scan func(string, int) + scan = func(currentPath string, depth int) { + if depth > maxDepth { + return + } + entries, err := os.ReadDir(currentPath) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() || excludedSkillSearchDirs[entry.Name()] { + continue + } + fullPath := filepath.Join(currentPath, entry.Name()) + if entry.Name() == "Editor" { + editorFolders = append(editorFolders, fullPath) + continue + } + scan(fullPath, depth+1) + } + } + scan(basePath, 0) + sort.Strings(editorFolders) + return editorFolders +} + +func enumerateDirectProjectPackageRoots(projectRoot string) []string { + packagesRoot := filepath.Join(projectRoot, "Packages") + entries, err := os.ReadDir(packagesRoot) + if err != nil { + return []string{} + } + packageRoots := []string{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(filepath.Join(packagesRoot, entry.Name()))) + } + sort.Strings(packageRoots) + return packageRoots +} + +func resolveManifestLocalPackageRoots(projectRoot string) []string { + dependencies := readManifestDependencies(projectRoot) + if len(dependencies) == 0 { + return []string{} + } + packageRoots := []string{} + for _, dependencyValue := range dependencies { + localPath := resolveLocalDependencyPath(dependencyValue, projectRoot) + if localPath == "" { + continue + } + packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(localPath)) + } + sort.Strings(packageRoots) + return packageRoots +} + +func resolveDependencyPackageCacheRoots(projectRoot string) []string { + dependencies := readManifestDependencies(projectRoot) + if len(dependencies) == 0 { + return []string{} + } + dependencyNames := map[string]bool{} + for dependencyName := range dependencies { + dependencyNames[strings.ToLower(dependencyName)] = true + } + packageCacheDir := filepath.Join(projectRoot, "Library", "PackageCache") + entries, err := os.ReadDir(packageCacheDir) + if err != nil { + return []string{} + } + packageRoots := []string{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dependencyName := entry.Name() + if separatorIndex := strings.Index(dependencyName, "@"); separatorIndex >= 0 { + dependencyName = dependencyName[:separatorIndex] + } + if !dependencyNames[strings.ToLower(dependencyName)] { + continue + } + packageRoots = append(packageRoots, resolveSkillSearchRootCandidate(filepath.Join(packageCacheDir, entry.Name()))) + } + sort.Strings(packageRoots) + return packageRoots +} + +func resolvePackageRoot(projectRoot string) string { + candidates := []string{ + filepath.Join(projectRoot, "Packages", "src"), + filepath.Join(projectRoot, "Packages", packageName), + filepath.Join(projectRoot, "Packages", packageNameAlias), + } + for _, candidate := range candidates { + if resolvedRoot := resolvePackageRootCandidate(candidate); resolvedRoot != "" { + return resolvedRoot + } + } + + return resolvePackageCacheRoot(projectRoot) +} + +func resolvePackageCacheRoot(projectRoot string) string { + packageCacheDir := filepath.Join(projectRoot, "Library", "PackageCache") + entries, err := os.ReadDir(packageCacheDir) + if err != nil { + return "" + } + for _, entry := range entries { + if !entry.IsDir() || !isTargetPackageCacheDir(entry.Name()) { + continue + } + if resolvedRoot := resolvePackageRootCandidate(filepath.Join(packageCacheDir, entry.Name())); resolvedRoot != "" { + return resolvedRoot + } + } + return "" +} + +func resolvePackageRootCandidate(candidate string) string { + if _, err := os.Stat(candidate); err != nil { + return "" + } + directToolsPath := filepath.Join(candidate, "Editor", "Api", "McpTools") + if _, err := os.Stat(directToolsPath); err == nil { + return candidate + } + nestedRoot := filepath.Join(candidate, "Packages", "src") + nestedToolsPath := filepath.Join(nestedRoot, "Editor", "Api", "McpTools") + if _, err := os.Stat(nestedToolsPath); err == nil { + return nestedRoot + } + return "" +} + +func resolveSkillSearchRootCandidate(candidate string) string { + nestedRoot := filepath.Join(candidate, "Packages", "src") + if _, err := os.Stat(nestedRoot); err == nil { + return nestedRoot + } + return candidate +} + +func readManifestDependencies(projectRoot string) map[string]string { + manifestPath := filepath.Join(projectRoot, "Packages", manifestFileName) + content, err := os.ReadFile(manifestPath) + if err != nil { + return map[string]string{} + } + manifest := manifestData{} + if err := json.Unmarshal(content, &manifest); err != nil { + return map[string]string{} + } + if manifest.Dependencies == nil { + return map[string]string{} + } + return manifest.Dependencies +} + +func resolveLocalDependencyPath(dependencyValue string, projectRoot string) string { + rawPath := "" + switch { + case strings.HasPrefix(dependencyValue, "file:"): + rawPath = strings.TrimPrefix(dependencyValue, "file:") + case strings.HasPrefix(dependencyValue, "path:"): + rawPath = strings.TrimPrefix(dependencyValue, "path:") + default: + return "" + } + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return "" + } + rawPath = strings.TrimPrefix(rawPath, "//") + if filepath.IsAbs(rawPath) { + return rawPath + } + return filepath.Join(projectRoot, rawPath) +} + +func isTargetPackageCacheDir(dirName string) bool { + normalizedName := strings.ToLower(dirName) + return strings.HasPrefix(normalizedName, strings.ToLower(packageName)+"@") || + strings.HasPrefix(normalizedName, strings.ToLower(packageNameAlias)+"@") +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_sync.go b/Packages/src/GoCli~/internal/presentation/cli/skills_sync.go new file mode 100644 index 000000000..b704bc89e --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_sync.go @@ -0,0 +1,163 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "path/filepath" +) + +func syncSkillDirectory(sourceDir string, destinationDir string) error { + parentDir := filepath.Dir(destinationDir) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return err + } + tempDir, err := os.MkdirTemp(parentDir, filepath.Base(destinationDir)+".tmp-") + if err != nil { + return err + } + + replaced := false + defer func() { + if !replaced { + _ = os.RemoveAll(tempDir) + } + }() + if err := copySkillDirectory(sourceDir, tempDir); err != nil { + return err + } + if err := replaceSkillDirectory(tempDir, destinationDir); err != nil { + return err + } + replaced = true + return nil +} + +func replaceSkillDirectory(sourceDir string, destinationDir string) error { + if _, err := os.Stat(destinationDir); err != nil { + if os.IsNotExist(err) { + return os.Rename(sourceDir, destinationDir) + } + return err + } + + parentDir := filepath.Dir(destinationDir) + backupDir, err := os.MkdirTemp(parentDir, filepath.Base(destinationDir)+".backup-") + if err != nil { + return err + } + if err := os.Remove(backupDir); err != nil { + return err + } + if err := os.Rename(destinationDir, backupDir); err != nil { + return err + } + if err := os.Rename(sourceDir, destinationDir); err != nil { + if restoreErr := os.Rename(backupDir, destinationDir); restoreErr != nil { + return fmt.Errorf("replace skill directory failed: %w; restore failed: %v", err, restoreErr) + } + return err + } + return os.RemoveAll(backupDir) +} + +func copySkillDirectory(sourceDir string, destinationDir string) error { + return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relativePath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + if relativePath == "." { + return os.MkdirAll(destinationDir, 0o755) + } + if shouldSkipSkillFile(entry.Name()) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + + destinationPath := filepath.Join(destinationDir, relativePath) + if entry.IsDir() { + return os.MkdirAll(destinationPath, 0o755) + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + content = normalizeSkillFileContent(relativePath, content) + return os.WriteFile(destinationPath, content, 0o644) + }) +} + +func getSkillStatus(baseDir string, skill skillDefinition, grouped bool) (string, error) { + return getSkillStatusWithStat(baseDir, skill, grouped, os.Stat) +} + +func getSkillStatusWithStat( + baseDir string, + skill skillDefinition, + grouped bool, + stat func(string) (os.FileInfo, error), +) (string, error) { + skillDir := getPreferredSkillDir(baseDir, skill.name, grouped) + if _, err := stat(filepath.Join(skillDir, skillFileName)); err != nil { + if os.IsNotExist(err) { + return "not_installed", nil + } + return "", err + } + if isInstalledSkillOutdated(skillDir, skill) { + return "outdated", nil + } + return "installed", nil +} + +func isInstalledSkillOutdated(installedDir string, skill skillDefinition) bool { + installedContent, err := os.ReadFile(filepath.Join(installedDir, skillFileName)) + if err != nil { + return true + } + installedContent = normalizeSkillFileContent(skillFileName, installedContent) + expectedContent := normalizeSkillFileContent(skillFileName, skill.content) + if !bytes.Equal(installedContent, expectedContent) { + return true + } + + expectedFiles := collectComparableSkillFiles(skill.sourceDirectory) + installedFiles := collectComparableSkillFiles(installedDir) + if len(expectedFiles) != len(installedFiles) { + return true + } + for relativePath, expectedContent := range expectedFiles { + installedContent, ok := installedFiles[relativePath] + if !ok || !bytes.Equal(expectedContent, installedContent) { + return true + } + } + return false +} + +func collectComparableSkillFiles(root string) map[string][]byte { + files := map[string][]byte{} + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() || shouldSkipSkillFile(entry.Name()) { + return nil + } + relativePath, err := filepath.Rel(root, path) + if err != nil || relativePath == skillFileName { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return nil + } + files[relativePath] = normalizeSkillFileContent(relativePath, content) + return nil + }) + return files +} diff --git a/Packages/src/GoCli~/internal/presentation/cli/skills_targets.go b/Packages/src/GoCli~/internal/presentation/cli/skills_targets.go new file mode 100644 index 000000000..520f4f302 --- /dev/null +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_targets.go @@ -0,0 +1,159 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +var userHomeDir = os.UserHomeDir + +func getSkillsBaseDir(projectRoot string, target skillTarget, global bool) (string, error) { + if global { + homeDir, err := userHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, target.projectDir, "skills"), nil + } + return filepath.Join(projectRoot, target.projectDir, "skills"), nil +} + +func getPreferredSkillDir(baseDir string, skillName string, grouped bool) string { + if grouped { + return filepath.Join(baseDir, managedSkillsDir, skillName) + } + return filepath.Join(baseDir, skillName) +} + +func migrateLegacyManagedSkills(baseDir string, skills []skillDefinition) error { + for _, skill := range skills { + legacyDir := getPreferredSkillDir(baseDir, skill.name, false) + managedDir := getPreferredSkillDir(baseDir, skill.name, true) + if _, err := os.Stat(legacyDir); err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + if _, err := os.Stat(managedDir); err == nil { + continue + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(managedDir), 0o755); err != nil { + return err + } + if err := os.Rename(legacyDir, managedDir); err != nil { + return err + } + } + return nil +} + +func removeDeprecatedSkillDirs(baseDir string) (int, error) { + removed := 0 + for _, skillName := range deprecatedSkillNames { + for _, grouped := range []bool{true, false} { + exists, err := removeDeprecatedSkillDir(baseDir, skillName, grouped) + if err != nil { + return removed, err + } + if exists { + removed++ + } + } + } + return removed, nil +} + +func removeDeprecatedSkillDirsForLayout(baseDir string, grouped bool) (int, error) { + removed := 0 + for _, skillName := range deprecatedSkillNames { + exists, err := removeDeprecatedSkillDir(baseDir, skillName, grouped) + if err != nil { + return removed, err + } + if exists { + removed++ + } + } + return removed, nil +} + +func removeDeprecatedSkillDir(baseDir string, skillName string, grouped bool) (bool, error) { + return removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)) +} + +func removeSkillFromAllLayouts(baseDir string, skillName string) error { + for _, grouped := range []bool{true, false} { + if _, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)); err != nil { + return err + } + } + return nil +} + +func removeDirIfExists(path string) (bool, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + if err := os.RemoveAll(path); err != nil { + return false, err + } + return true, nil +} + +func removeEmptyDir(path string) error { + entries, err := os.ReadDir(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(entries) > 0 { + return nil + } + return os.Remove(path) +} + +func loadDisabledTools(projectRoot string) []string { + settingsPath := filepath.Join(projectRoot, uloopSettingsDir, toolSettingsFile) + content, err := os.ReadFile(settingsPath) + if err != nil || len(strings.TrimSpace(string(content))) == 0 { + return []string{} + } + + settings := toolSettingsData{} + if err := json.Unmarshal(content, &settings); err != nil { + return []string{} + } + if settings.DisabledTools == nil { + return []string{} + } + return settings.DisabledTools +} + +func isSkillDisabledByToolSettings(skill skillDefinition, disabledTools []string) bool { + if len(disabledTools) == 0 { + return false + } + toolName := skill.toolName + if toolName == "" && strings.HasPrefix(skill.name, "uloop-") { + toolName = strings.TrimPrefix(skill.name, "uloop-") + } + if toolName == "" { + return false + } + for _, disabledTool := range disabledTools { + if disabledTool == toolName { + return true + } + } + return false +} diff --git a/Packages/src/GoCli~/internal/cli/skills_test.go b/Packages/src/GoCli~/internal/presentation/cli/skills_test.go similarity index 74% rename from Packages/src/GoCli~/internal/cli/skills_test.go rename to Packages/src/GoCli~/internal/presentation/cli/skills_test.go index b27b1a952..b45eee441 100644 --- a/Packages/src/GoCli~/internal/cli/skills_test.go +++ b/Packages/src/GoCli~/internal/presentation/cli/skills_test.go @@ -13,13 +13,13 @@ import ( // Tests that CLI-only skill discovery excludes skills marked as internal. func TestCollectSkillDefinitionsIncludesCliOnlyAndSkipsInternal(t *testing.T) { projectRoot := t.TempDir() - writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-launch/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-launch/Skill", `--- name: uloop-launch --- # uloop launch `) - writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-internal/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-internal/Skill", `--- name: uloop-internal internal: true --- @@ -49,7 +49,7 @@ name: uloop-compile # package `) - writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/cli/skill-definitions/cli-only/uloop-launch/Skill", `--- + writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-launch/Skill", `--- name: uloop-launch --- @@ -100,6 +100,43 @@ name: uloop-cached-package } } +// Tests that CLI-only and project-local skills win over package-root duplicates. +func TestCollectSkillDefinitionsUsesUnitySideSourcePrecedence(t *testing.T) { + projectRoot := t.TempDir() + writeTestSkill(t, projectRoot, "Packages/src/Editor/Api/McpTools/Compile/Skill", `--- +name: uloop-launch +--- + +# package launch +`) + writeTestSkill(t, projectRoot, "Packages/src/Editor/Api/McpTools/ProjectDuplicate/Skill", `--- +name: uloop-project +--- + +# package project +`) + writeTestSkill(t, projectRoot, "Packages/src/GoCli~/internal/presentation/cli/skill-definitions/cli-only/uloop-launch/Skill", `--- +name: uloop-launch +--- + +# cli-only launch +`) + writeTestSkill(t, projectRoot, "Assets/Editor/ProjectDuplicate/Skill", `--- +name: uloop-project +--- + +# asset project +`) + + skills, err := collectSkillDefinitions(projectRoot) + if err != nil { + t.Fatalf("collectSkillDefinitions failed: %v", err) + } + + assertSkillContentContains(t, skills, "uloop-launch", "# cli-only launch") + assertSkillContentContains(t, skills, "uloop-project", "# asset project") +} + // Tests that direct SKILL.md files without a frontmatter name use their own directory name. func TestCollectSkillDefinitionsUsesDirectoryNameWhenDirectSkillOmitsName(t *testing.T) { projectRoot := t.TempDir() @@ -207,13 +244,36 @@ func TestSkillStatusIgnoresCRLFLineEndings(t *testing.T) { t.Fatal("test setup should keep CRLF line endings in installed SKILL.md") } - status := getSkillStatus(filepath.Join(projectRoot, ".claude", "skills"), skill, true) + status, err := getSkillStatus(filepath.Join(projectRoot, ".claude", "skills"), skill, true) + if err != nil { + t.Fatalf("getSkillStatus failed: %v", err) + } if status != "installed" { t.Fatalf("status mismatch: %s", status) } } +// Tests that status checks surface inaccessible installed skill directories. +func TestSkillStatusReturnsStatErrors(t *testing.T) { + projectRoot := t.TempDir() + baseDir := filepath.Join(projectRoot, ".claude", "skills") + skill := skillDefinition{name: "uloop-sample"} + + _, err := getSkillStatusWithStat( + baseDir, + skill, + true, + func(string) (os.FileInfo, error) { + return nil, os.ErrPermission + }, + ) + + if err == nil { + t.Fatal("expected status check error") + } +} + // Tests that installing skills writes deterministic LF line endings. func TestInstallSkillsNormalizesCRLFLineEndings(t *testing.T) { projectRoot := t.TempDir() @@ -349,7 +409,10 @@ name: uloop-sample sourceDirectory: sourceDir, } target := targetConfigs["claude"] - baseDir := getSkillsBaseDir(projectRoot, target, false) + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } flatDir := getPreferredSkillDir(baseDir, skill.name, false) groupedDir := getPreferredSkillDir(baseDir, skill.name, true) writeSkillFile(t, flatDir, skillContent) @@ -386,7 +449,10 @@ name: uloop-sample sourceDirectory: sourceDir, } target := targetConfigs["claude"] - baseDir := getSkillsBaseDir(projectRoot, target, false) + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } groupedDir := getPreferredSkillDir(baseDir, skill.name, true) flatDir := getPreferredSkillDir(baseDir, skill.name, false) writeSkillFile(t, groupedDir, "# grouped\n") @@ -423,7 +489,10 @@ name: uloop-sample sourceDirectory: sourceDir, } target := targetConfigs["claude"] - baseDir := getSkillsBaseDir(projectRoot, target, false) + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } groupedDir := getPreferredSkillDir(baseDir, skill.name, true) flatDir := getPreferredSkillDir(baseDir, skill.name, false) writeSkillFile(t, groupedDir, "# grouped\n") @@ -444,6 +513,51 @@ name: uloop-sample } } +// Tests that uninstalling deprecated skills only cleans the selected layout. +func TestUninstallSkillsForTargetRemovesDeprecatedSkillsFromSelectedLayoutOnly(t *testing.T) { + projectRoot := t.TempDir() + target := targetConfigs["claude"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + groupedDeprecatedDir := getPreferredSkillDir(baseDir, "uloop-capture-window", true) + flatDeprecatedDir := getPreferredSkillDir(baseDir, "uloop-capture-window", false) + writeSkillFile(t, groupedDeprecatedDir, "# grouped deprecated\n") + writeSkillFile(t, flatDeprecatedDir, "# flat deprecated\n") + + removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, []skillDefinition{}, false, true) + if err != nil { + t.Fatalf("uninstallSkillsForTarget failed: %v", err) + } + if removed != 1 || notFound != 0 { + t.Fatalf("uninstall result mismatch: removed=%d notFound=%d", removed, notFound) + } + if _, err := os.Stat(groupedDeprecatedDir); err == nil { + t.Fatal("grouped deprecated skill should be removed") + } + if _, err := os.Stat(flatDeprecatedDir); err != nil { + t.Fatalf("flat deprecated skill should remain: %v", err) + } +} + +// Tests that global skill paths fail instead of falling back to a relative directory. +func TestGetSkillsBaseDirReturnsHomeLookupErrorForGlobalTargets(t *testing.T) { + originalUserHomeDir := userHomeDir + userHomeDir = func() (string, error) { + return "", os.ErrPermission + } + t.Cleanup(func() { + userHomeDir = originalUserHomeDir + }) + + _, err := getSkillsBaseDir(t.TempDir(), targetConfigs["claude"], true) + + if err == nil { + t.Fatal("expected home lookup error") + } +} + // Tests that skills option parsing rejects unknown flags. func TestParseSkillsOptionsRequiresKnownFlags(t *testing.T) { _, err := parseSkillsOptions([]string{"--claude", "--bad-target"}) @@ -452,6 +566,47 @@ func TestParseSkillsOptionsRequiresKnownFlags(t *testing.T) { } } +// Tests that repeated target flags are ignored after their first occurrence. +func TestParseSkillsOptionsDeduplicatesTargets(t *testing.T) { + options, err := parseSkillsOptions([]string{"--claude", "--claude", "--codex"}) + if err != nil { + t.Fatalf("parseSkillsOptions failed: %v", err) + } + + actualIDs := []string{} + for _, target := range options.targets { + actualIDs = append(actualIDs, target.id) + } + expectedIDs := []string{"claude", "codex"} + if !reflect.DeepEqual(actualIDs, expectedIDs) { + t.Fatalf("target ids mismatch: %#v", actualIDs) + } +} + +// Tests that unknown skills subcommands are rejected before project resolution. +func TestTryHandleSkillsRequestRejectsUnknownSubcommandWithoutProject(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + handled, code := tryHandleSkillsRequest( + []string{"skills", "unknown"}, + t.TempDir(), + "", + stdout, + stderr, + ) + + if !handled || code != 1 { + t.Fatalf("unexpected result: handled=%v code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Unknown skills command: unknown") { + t.Fatalf("stderr mismatch: %s", stderr.String()) + } + if strings.Contains(stderr.String(), "unity project not found") { + t.Fatalf("unknown subcommand should not resolve project first: %s", stderr.String()) + } +} + func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { t.Helper() writeSkillFile(t, filepath.Join(projectRoot, filepath.FromSlash(relativeDir)), content) @@ -516,3 +671,17 @@ func skillNames(skills []skillDefinition) []string { sort.Strings(names) return names } + +func assertSkillContentContains(t *testing.T, skills []skillDefinition, skillName string, expectedContent string) { + t.Helper() + for _, skill := range skills { + if skill.name != skillName { + continue + } + if !strings.Contains(string(skill.content), expectedContent) { + t.Fatalf("skill %s content mismatch: %s", skillName, string(skill.content)) + } + return + } + t.Fatalf("skill not found: %s", skillName) +} diff --git a/Packages/src/GoCli~/internal/cli/spinner.go b/Packages/src/GoCli~/internal/presentation/cli/spinner.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/spinner.go rename to Packages/src/GoCli~/internal/presentation/cli/spinner.go diff --git a/Packages/src/GoCli~/internal/cli/spinner_test.go b/Packages/src/GoCli~/internal/presentation/cli/spinner_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/spinner_test.go rename to Packages/src/GoCli~/internal/presentation/cli/spinner_test.go diff --git a/Packages/src/GoCli~/internal/cli/tools.go b/Packages/src/GoCli~/internal/presentation/cli/tools.go similarity index 85% rename from Packages/src/GoCli~/internal/cli/tools.go rename to Packages/src/GoCli~/internal/presentation/cli/tools.go index b0a0ab0c2..c0cabb235 100644 --- a/Packages/src/GoCli~/internal/cli/tools.go +++ b/Packages/src/GoCli~/internal/presentation/cli/tools.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/hatayama/unity-cli-loop/Packages/src/GoCli/internal/domain" ) //go:embed default-tools.json @@ -20,54 +22,12 @@ const ( projectPathFlagName = "project-path" ) -type toolsCache struct { - Version string `json:"version"` - ServerVersion string `json:"serverVersion,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` - Tools []toolDefinition `json:"tools"` -} - -type toolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema inputSchema `json:"inputSchema"` - ParameterSchema inputSchema `json:"parameterSchema"` -} - -type inputSchema struct { - Type string `json:"type"` - Properties map[string]toolProperty `json:"properties"` - Required []string `json:"required,omitempty"` -} - -type toolProperty struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Default any `json:"default,omitempty"` - DefaultValue any `json:"DefaultValue,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *struct { - Type string `json:"type"` - } `json:"items,omitempty"` -} - -func (tool toolDefinition) effectiveInputSchema() inputSchema { - if tool.InputSchema.hasValues() { - return tool.InputSchema - } - return tool.ParameterSchema -} - -func (schema inputSchema) hasValues() bool { - return schema.Type != "" || len(schema.Properties) > 0 || len(schema.Required) > 0 -} - -func (property toolProperty) effectiveDefault() any { - if property.Default != nil { - return property.Default - } - return property.DefaultValue -} +type ( + toolsCache = domain.ToolCatalog + toolDefinition = domain.ToolDefinition + inputSchema = domain.ToolInputSchema + toolProperty = domain.ToolProperty +) func loadTools(projectRoot string) (toolsCache, error) { cachePath := filepath.Join(projectRoot, cacheDirectoryName, cacheFileName) @@ -318,7 +278,7 @@ func isNextOptionToken(value string) bool { } func findProperty(tool toolDefinition, kebabName string) (string, toolProperty, bool, bool) { - schema := tool.effectiveInputSchema() + schema := tool.EffectiveInputSchema() for propertyName, property := range schema.Properties { if optionNameForProperty(propertyName, property) == kebabName { return propertyName, property, isNegatedBooleanProperty(property), true @@ -388,7 +348,7 @@ func isBooleanProperty(property toolProperty) bool { } func isNegatedBooleanProperty(property toolProperty) bool { - defaultValue, ok := property.effectiveDefault().(bool) + defaultValue, ok := property.EffectiveDefault().(bool) return isBooleanProperty(property) && ok && defaultValue } diff --git a/Packages/src/GoCli~/internal/cli/tools_test.go b/Packages/src/GoCli~/internal/presentation/cli/tools_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/tools_test.go rename to Packages/src/GoCli~/internal/presentation/cli/tools_test.go diff --git a/Packages/src/GoCli~/internal/cli/update.go b/Packages/src/GoCli~/internal/presentation/cli/update.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/update.go rename to Packages/src/GoCli~/internal/presentation/cli/update.go diff --git a/Packages/src/GoCli~/internal/cli/update_test.go b/Packages/src/GoCli~/internal/presentation/cli/update_test.go similarity index 100% rename from Packages/src/GoCli~/internal/cli/update_test.go rename to Packages/src/GoCli~/internal/presentation/cli/update_test.go diff --git a/release-please-config.json b/release-please-config.json index df6f7ad57..e5189e778 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,13 +10,13 @@ "extra-files": [ { "type": "json", - "path": "GoCli~/internal/cli/default-tools.json", + "path": "GoCli~/internal/presentation/cli/default-tools.json", "jsonpath": "$.version" }, { "type": "generic", - "path": "Packages/src/GoCli~/internal/cli/tools.go", - "glob": "Packages/src/GoCli~/internal/cli/tools.go" + "path": "Packages/src/GoCli~/internal/presentation/cli/tools.go", + "glob": "Packages/src/GoCli~/internal/presentation/cli/tools.go" }, { "type": "generic", diff --git a/scripts/check-build-link-go-cli.sh b/scripts/check-build-link-go-cli.sh new file mode 100755 index 000000000..22f27d532 --- /dev/null +++ b/scripts/check-build-link-go-cli.sh @@ -0,0 +1,172 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) + +path_contains_dir() { + expected_dir=$(normalize_path_dir "$1") + old_ifs=$IFS + IFS=: + for path_dir in $PATH; do + if [ "$(normalize_path_dir "$path_dir")" = "$expected_dir" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + return 1 +} + +normalize_path_dir() { + path_dir="$1" + while [ "$path_dir" != "/" ] && [ "${path_dir%/}" != "$path_dir" ]; do + path_dir=${path_dir%/} + done + printf '%s\n' "$path_dir" +} + +ensure_symlink_target() { + link_path="$1" + + if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then + echo "Go CLI source checks passed and dist binaries were rebuilt." + echo "Refusing to overwrite non-symlink global uloop: $link_path" >&2 + exit 1 + fi +} + +update_uloop_link() { + link_path="$1" + + if [ -L "$link_path" ]; then + current_target=$(readlink "$link_path") + echo "Updating global uloop symlink: $link_path -> $current_target" + else + echo "Creating global uloop symlink: $link_path" + fi + + ln -sfn "$dispatcher_path" "$link_path" + echo "Global uloop now points at the rebuilt dispatcher: $link_path -> $(readlink "$link_path")" +} + +install_project_local_core() { + mkdir -p "$ROOT_DIR/.uloop/bin" + rm -f "$project_local_core_path" + cp "$core_path" "$project_local_core_path" + chmod +x "$project_local_core_path" + echo "Project-local uloop-core now uses the rebuilt binary: $project_local_core_path" +} + +ensure_global_uloop_resolves_to_link() { + resolved_uloop_path=$(command -v "$global_command_name" || true) + + if [ "$resolved_uloop_path" = "$global_uloop_path" ]; then + return 0 + fi + if [ -n "$extra_global_uloop_path" ] && [ "$resolved_uloop_path" = "$extra_global_uloop_path" ]; then + return 0 + fi + + echo "Global $global_command_name symlink was updated, but shell resolution does not point at it." >&2 + echo "Resolved $global_command_name: ${resolved_uloop_path:-not found}" >&2 + echo "Expected $global_command_name: $global_uloop_path" >&2 + echo "Add $global_bin_dir to PATH or set ULOOP_GLOBAL_BIN_DIR to a directory earlier in PATH." >&2 + exit 1 +} + +"$ROOT_DIR/scripts/check-go-cli-source.sh" +"$ROOT_DIR/scripts/build-go-cli.sh" + +core_path="" +dispatcher_path="" +global_command_name="uloop" +existing_uloop_path="" +project_local_core_path="$ROOT_DIR/.uloop/bin/uloop-core" +os=$(uname -s) +arch=$(uname -m) + +case "$os:$arch" in + Darwin:arm64 | Darwin:aarch64) + core_path="$ROOT_DIR/Packages/src/GoCli~/dist/darwin-arm64/uloop-core" + dispatcher_path="$ROOT_DIR/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher" + ;; + Darwin:x86_64 | Darwin:amd64) + core_path="$ROOT_DIR/Packages/src/GoCli~/dist/darwin-amd64/uloop-core" + dispatcher_path="$ROOT_DIR/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher" + ;; + MINGW*:x86_64 | MINGW*:amd64 | MSYS*:x86_64 | MSYS*:amd64 | CYGWIN*:x86_64 | CYGWIN*:amd64 | Windows_NT:x86_64 | Windows_NT:amd64) + core_path="$ROOT_DIR/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe" + dispatcher_path="$ROOT_DIR/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe" + global_command_name="uloop.exe" + project_local_core_path="$ROOT_DIR/.uloop/bin/uloop-core.exe" + ;; +esac + +if [ -z "$dispatcher_path" ] || [ -z "$core_path" ]; then + echo "Go CLI source checks passed and dist binaries were rebuilt." + echo "No checked-in dispatcher is mapped for this platform: $os/$arch" + exit 0 +fi + +if [ ! -x "$core_path" ]; then + echo "Project-local core was not built or is not executable: $core_path" >&2 + exit 1 +fi + +if [ ! -x "$dispatcher_path" ]; then + echo "Dispatcher was not built or is not executable: $dispatcher_path" >&2 + exit 1 +fi + +global_bin_dir="" + +if [ -n "${ULOOP_GLOBAL_BIN_DIR:-}" ]; then + global_bin_dir="$ULOOP_GLOBAL_BIN_DIR" +elif command -v "$global_command_name" >/dev/null 2>&1; then + existing_uloop_path=$(command -v "$global_command_name") + global_bin_dir=$(dirname "$existing_uloop_path") +elif [ "$global_command_name" = "uloop.exe" ] && command -v uloop >/dev/null 2>&1; then + existing_uloop_path=$(command -v uloop) + global_bin_dir=$(dirname "$existing_uloop_path") +elif path_contains_dir "$HOME/.npm-global/bin"; then + global_bin_dir="$HOME/.npm-global/bin" +elif path_contains_dir "$HOME/.local/bin"; then + global_bin_dir="$HOME/.local/bin" +fi + +if [ -z "$global_bin_dir" ]; then + echo "Go CLI source checks passed and dist binaries were rebuilt." + echo "No writable PATH directory was selected for global uloop." >&2 + echo "Set ULOOP_GLOBAL_BIN_DIR to the directory that should contain uloop." >&2 + exit 1 +fi + +mkdir -p "$global_bin_dir" +global_bin_dir=$(CDPATH= cd "$global_bin_dir" && pwd) +global_uloop_path="$global_bin_dir/$global_command_name" +extra_global_uloop_path="" + +if [ "$global_command_name" = "uloop.exe" ] && [ -n "$existing_uloop_path" ] && [ "$existing_uloop_path" != "$global_uloop_path" ]; then + existing_uloop_dir=$(dirname "$existing_uloop_path") + if [ "$existing_uloop_dir" = "$global_bin_dir" ]; then + extra_global_uloop_path="$existing_uloop_path" + fi +fi + +echo "Go CLI source checks passed and dist binaries were rebuilt." + +install_project_local_core + +ensure_symlink_target "$global_uloop_path" +if [ -n "$extra_global_uloop_path" ]; then + ensure_symlink_target "$extra_global_uloop_path" +fi + +update_uloop_link "$global_uloop_path" +if [ -n "$extra_global_uloop_path" ]; then + update_uloop_link "$extra_global_uloop_path" +fi + +ensure_global_uloop_resolves_to_link + +"$global_command_name" --version diff --git a/scripts/check-go-cli-source.sh b/scripts/check-go-cli-source.sh new file mode 100755 index 000000000..d096c8246 --- /dev/null +++ b/scripts/check-go-cli-source.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +GO_CLI_DIR="$ROOT_DIR/Packages/src/GoCli~" + +. "$ROOT_DIR/scripts/go-cli-toolchain.sh" +require_go_cli_toolchain "$ROOT_DIR" + +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "golangci-lint is required. Install it before running Go CLI checks." >&2 + echo "https://golangci-lint.run/welcome/install/" >&2 + exit 1 +fi + +( + cd "$GO_CLI_DIR" + golangci-lint fmt --diff + go vet ./... + golangci-lint run ./... + go test ./... +) diff --git a/scripts/check-go-cli.sh b/scripts/check-go-cli.sh index b8c58c766..6426719b0 100755 --- a/scripts/check-go-cli.sh +++ b/scripts/check-go-cli.sh @@ -2,23 +2,6 @@ set -eu ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) -GO_CLI_DIR="$ROOT_DIR/Packages/src/GoCli~" - -. "$ROOT_DIR/scripts/go-cli-toolchain.sh" -require_go_cli_toolchain "$ROOT_DIR" - -if ! command -v golangci-lint >/dev/null 2>&1; then - echo "golangci-lint is required. Install it before running Go CLI checks." >&2 - echo "https://golangci-lint.run/welcome/install/" >&2 - exit 1 -fi - -( - cd "$GO_CLI_DIR" - golangci-lint fmt --diff - go vet ./... - golangci-lint run ./... - go test ./... -) +"$ROOT_DIR/scripts/check-go-cli-source.sh" "$ROOT_DIR/scripts/verify-go-cli-dist.sh"