From 0665148d4a8961b4634b70eab3fb891763bf0bbf Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 22 Jun 2026 00:02:32 +0900 Subject: [PATCH 01/18] Limit migration detector output field matches Restrict V3 migration OUTPUT_FIELD candidates to jq selector text and PowerShell variables parsed from ConvertFrom-Json so Markdown C# snippets are not reported as uloop JSON parsing. --- .../detect-v3-cli-invocation-candidates.ps1 | 48 ++++++++++- .../detect-v3-cli-invocation-candidates.sh | 37 +++++++- cli/internal/cli/skills_test.go | 85 +++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 index 51d6750899..0cfe38625f 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 @@ -91,10 +91,54 @@ function Write-Candidate { Write-Output "$Kind ${Path}:${LineNumber}: $Line" } +function Update-JsonVariableFromLine { + param( + [hashtable] $JsonVariables, + [string] $Line + ) + + $assignmentPattern = '\$([A-Za-z_][A-Za-z0-9_]*)\s*=[^\r\n#;]*' + $matches = [System.Text.RegularExpressions.Regex]::Matches($Line, $assignmentPattern) + foreach ($match in $matches) { + [string] $variableName = $match.Groups[1].Value + if ($match.Value -cmatch "ConvertFrom-Json") { + $JsonVariables[$variableName] = $true + } else { + [void] $JsonVariables.Remove($variableName) + } + } +} + +function Test-ContainsJqOutputField { + param([string] $Line) + + $jqCommandPattern = '(^|[\s`|;&(])jq\s' + return $Line -cmatch $jqCommandPattern -and + $Line -cmatch "\.($outputFields)([^A-Za-z0-9_]|$)" +} + +function Test-ContainsJsonVariableOutputField { + param( + [hashtable] $JsonVariables, + [string] $Line + ) + + foreach ($variableName in $JsonVariables.Keys) { + [string] $escapedVariableName = [System.Text.RegularExpressions.Regex]::Escape($variableName) + [string] $variablePattern = '\$' + $escapedVariableName + '\.(' + $outputFields + ')([^A-Za-z0-9_]|$)' + if ($Line -cmatch $variablePattern) { + return $true + } + } + + return $false +} + Get-CandidateFile -Directory $Root | ForEach-Object { $path = $_.FullName $lines = @(Get-Content -LiteralPath $path -Encoding UTF8) + $jsonVariables = @{} for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index] $lineNumber = $index + 1 @@ -116,7 +160,9 @@ Get-CandidateFile -Directory $Root | if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only([^A-Za-z0-9_-]|$)") { Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line } - if ($line -cmatch "\.($outputFields)([^A-Za-z0-9_]|$)") { + Update-JsonVariableFromLine -JsonVariables $jsonVariables -Line $line + if ((Test-ContainsJqOutputField -Line $line) -or + (Test-ContainsJsonVariableOutputField -JsonVariables $jsonVariables -Line $line)) { Write-Candidate -Kind "OUTPUT_FIELD" -Path $path -LineNumber $lineNumber -Line $line } } diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh index 9e82b804ee..c005e5e8c2 100755 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh @@ -9,9 +9,41 @@ find "$root" \ while IFS= read -r file do awk ' + BEGIN { + output_fields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" + } function report(kind) { printf "%s %s:%d: %s\n", kind, FILENAME, FNR, $0 } + function update_json_variables(line, rest, assignment, variable_name) { + rest = line + while (match(rest, /\$[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[^\n#;]*/)) { + assignment = substr(rest, RSTART, RLENGTH) + variable_name = assignment + sub(/^\$/, "", variable_name) + sub(/[[:space:]]*=.*/, "", variable_name) + if (assignment ~ /ConvertFrom-Json/) { + json_variables[variable_name] = 1 + } else { + delete json_variables[variable_name] + } + rest = substr(rest, RSTART + RLENGTH) + } + } + function contains_jq_output_field(line) { + return line ~ /(^|[[:space:]`|;&(])jq[[:space:]]/ && + line ~ ("\\.(" output_fields ")([^[:alnum:]_]|$)") + } + function contains_powershell_json_output_field(line, variable_name, pattern) { + for (variable_name in json_variables) { + pattern = "\\$" variable_name "\\.(" output_fields ")([^[:alnum:]_]|$)" + if (line ~ pattern) { + return 1 + } + } + + return 0 + } /uloop[[:space:]][^#|;&]*--[[:alnum:]][[:alnum:]-]*([[:space:]]+|=)(true|false)([^[:alnum:]_-]|$)/ { report("ARG_BOOL") } @@ -30,7 +62,10 @@ do /uloop[[:space:]]+execute-dynamic-code[[:space:]][^#|;&]*--compile-only([^[:alnum:]_-]|$)/ { report("FIRST_PARTY_OPTION") } - /\.(Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts)([^[:alnum:]_]|$)/ { + { + update_json_variables($0) + } + contains_jq_output_field($0) || contains_powershell_json_output_field($0) { report("OUTPUT_FIELD") } ' "$file" diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index a86d20f537..4f8243d77d 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -1167,6 +1167,79 @@ func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { } } +// Tests that the POSIX detector limits output fields to JSON parser contexts. +func TestV3MigrationSkillPosixDetectorIgnoresNonParserOutputFields(t *testing.T) { + fixtureRoot := t.TempDir() + markdownFile := filepath.Join(fixtureRoot, "docs", "notes.md") + if err := os.MkdirAll(filepath.Dir(markdownFile), 0o755); err != nil { + t.Fatalf("failed to create Markdown fixture dir: %v", err) + } + markdownContent := strings.Join([]string{ + "jq '.Success'", + "case ConnectStatus.Success:", + "_soundController?.IsPlaying() == true", + "_soundController?.IsPaused() == true", + }, "\n") + if err := os.WriteFile(markdownFile, []byte(markdownContent), 0o644); err != nil { + t.Fatalf("failed to write Markdown fixture: %v", err) + } + powerShellFile := filepath.Join(fixtureRoot, "scripts", "migrate.ps1") + if err := os.MkdirAll(filepath.Dir(powerShellFile), 0o755); err != nil { + t.Fatalf("failed to create PowerShell fixture dir: %v", err) + } + powerShellContent := strings.Join([]string{ + "[pscustomobject]$result = $text | ConvertFrom-Json", + "if ($result.Success -ne $true) { throw 'failed' }", + "[System.Text.RegularExpressions.Match]$baseMatch = [regex]::Match($line, '^Frame')", + "if (-not $baseMatch.Success) { throw 'bad line' }", + "[pscustomobject]$status = $text | ConvertFrom-Json", + "[pscustomobject]$status = Get-ReplayStatus", + "if ($status.IsReplaying -eq $true) { Write-Host 'running' }", + }, "\n") + if err := os.WriteFile(powerShellFile, []byte(powerShellContent), 0o644); err != nil { + t.Fatalf("failed to write PowerShell fixture: %v", err) + } + scriptPath := filepath.Join( + findRepositoryRootForSkillsTest(t), + "Packages", + "src", + "TemporarySkills~", + "v3-cli-invocation-migration", + "Skill", + "scripts", + "detect-v3-cli-invocation-candidates.sh") + + shPath, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh is not installed") + } + command := exec.Command(shPath, scriptPath, fixtureRoot) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("detector failed: %v\n%s", err, string(output)) + } + text := string(output) + for _, expected := range []string{ + "jq '.Success'", + "$result.Success", + } { + if !strings.Contains(text, expected) { + t.Fatalf("detector output missing parser candidate %q:\n%s", expected, text) + } + } + for _, unexpected := range []string{ + "ConnectStatus.Success", + "_soundController?.IsPlaying()", + "_soundController?.IsPaused()", + "$baseMatch.Success", + "$status.IsReplaying", + } { + if strings.Contains(text, unexpected) { + t.Fatalf("detector should ignore non-parser field %q:\n%s", unexpected, text) + } + } +} + // Tests that the PowerShell detector reports candidates when PowerShell is available. func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { pwshPath, err := exec.LookPath("pwsh") @@ -1183,8 +1256,14 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { "uloop some-tool --enabled true", "inline `uloop run-tests --save-before-run`", "inline `uloop some-tool --enabled true`", + "[pscustomobject]$result = $text | ConvertFrom-Json", "$result.Success", "$result.success", + "[System.Text.RegularExpressions.Match]$baseMatch = [regex]::Match($line, '^Frame')", + "$baseMatch.Success", + "[pscustomobject]$status = $text | ConvertFrom-Json", + "[pscustomobject]$status = Get-ReplayStatus", + "$status.IsReplaying", }, "\n") if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { t.Fatalf("failed to write fixture: %v", err) @@ -1239,6 +1318,12 @@ func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { if strings.Contains(text, "$result.success") { t.Fatalf("detector should not report already migrated camelCase fields:\n%s", text) } + if strings.Contains(text, "$baseMatch.Success") { + t.Fatalf("detector should ignore non-json PowerShell fields:\n%s", text) + } + if strings.Contains(text, "$status.IsReplaying") { + t.Fatalf("detector should forget reassigned PowerShell json variables:\n%s", text) + } if strings.Contains(text, "self-noise") { t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) } From fa09f86fb45ed8a8db137c6fcf14d211a55f668c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 22 Jun 2026 00:15:35 +0900 Subject: [PATCH 02/18] Replace migration detectors with skill guidance Remove the fragile V3 migration detector scripts and make the skill rely on repository search plus context inspection. Expand the reference so agents have the full migration map, false-positive guidance, and stale installed script cleanup coverage. --- .../Skill/SKILL.md | 31 +- .../Skill/references/first-party-v2-to-v3.md | 43 ++- .../detect-v3-cli-invocation-candidates.ps1 | 169 --------- .../detect-v3-cli-invocation-candidates.sh | 72 ---- cli/internal/cli/skills_test.go | 341 +----------------- 5 files changed, 74 insertions(+), 582 deletions(-) delete mode 100644 Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 delete mode 100755 Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md index 203a7d04f2..f26aab2fa1 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: v3-cli-invocation-migration -description: Migrate uloop V2 CLI invocations in agent skills, Markdown, POSIX shell scripts, and PowerShell scripts to V3 syntax. Use when updating third-party or first-party uloop command examples, shell automation, SKILL.md files, or JSON output parsing from V2-style boolean arguments, renamed first-party options, or PascalCase response fields. +description: Migrate uloop V2 CLI invocations in agent skills, Markdown, POSIX shell scripts, and PowerShell scripts to V3 syntax. Use when updating third-party or first-party uloop command examples, shell automation, SKILL.md files, or JSON output parsing from V2-style boolean arguments, renamed first-party options, removed commands, or PascalCase first-party response fields. --- # V3 CLI Invocation Migration @@ -9,13 +9,13 @@ Use this skill to update V2-era `uloop` CLI invocations to V3 syntax in agent-fa ## Workflow -1. Run the detector for the current repository: - - POSIX shell: `sh scripts/detect-v3-cli-invocation-candidates.sh .` - - PowerShell: `pwsh -File scripts/detect-v3-cli-invocation-candidates.ps1 -Root .` -2. Read `references/first-party-v2-to-v3.md` before editing first-party tool calls or JSON output parsing. -3. Inspect each reported line with nearby context. Treat detector output as candidates, not proof. -4. Edit only files that clearly contain V2 `uloop` command syntax or V2 output-field parsing. -5. Run the detector again and report remaining candidates with the reason each one was left unchanged. +1. Read `references/first-party-v2-to-v3.md` before editing. +2. Search the repository for `uloop` invocations and the V2 names listed in the reference. +3. Inspect nearby context before every edit. Search hits are candidates, not proof. +4. Edit only files that clearly contain V2 `uloop` command syntax or V2 first-party output parsing. +5. Repeat the searches after editing and report any remaining V2 candidates with the reason each one was left unchanged. + +Prefer `rg` for searches when available. If `rg` is unavailable, use the best available project search tool. Candidate discovery should be repository search plus context inspection, not a generated candidate list. ## Editing Rules @@ -23,5 +23,16 @@ Use this skill to update V2-era `uloop` CLI invocations to V3 syntax in agent-fa - For third-party tools, check the tool's current schema or documentation before deciding whether `false` means removal, a `--no-*` flag, or a renamed option. - For first-party tools, use the reference table. `compile` and `run-tests` have special renamed negative flags. - Convert V2 PascalCase output field reads to V3 camelCase only when the surrounding command is a `uloop` first-party command. -- Do not rewrite generated installed skills under `.agents`, `.claude`, `.codex`, `.cursor`, `.gemini`, or similar generated target folders unless the user explicitly asks to migrate installed copies. -- Do not rely on `rg`, `jq`, Python, or Go being present. The bundled detectors are dependency-light and write no files. +- Treat helpers and wrappers as context clues, not proof. Trace them far enough to confirm they execute `uloop` and parse its JSON before rewriting their result fields. +- Do not edit generated installed skills under `.agents`, `.claude`, `.codex`, `.cursor`, `.gemini`, `.windsurf`, `.agent`, or similar target folders unless the user explicitly asks to migrate installed copies. +- Do not edit Markdown C# snippets, enum/member references, ordinary DTO/property access, regex match properties, or non-`uloop` JSON. +- Do not change protocol versions, release versions, package names, assembly names, or public extension identifiers as part of this migration. +- Keep edits local to the command or parser being migrated. + +## Required Search Passes + +- `uloop` command lines and examples. +- Boolean-looking CLI options: `--* true`, `--*=true`, `--* false`, `--*=false`. +- First-party renamed options from the reference, including bare flags. +- Removed commands: `get-project-info` and `get-version`. +- PascalCase output fields from the reference, but only where surrounding code parses first-party `uloop` JSON. diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md index 3a551b9867..a535baaae6 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md @@ -1,6 +1,17 @@ # First-Party V2 to V3 CLI Migration -Use this reference after the detector finds a candidate. Keep edits local to the command or parser being migrated. +Use this reference as the canonical migration map. Search results are only candidates. Edit a match only after the surrounding context proves it is a V2 `uloop` invocation or first-party `uloop` JSON output parser. + +## Search Checklist + +Prefer `rg` when available, but any repository search tool is acceptable. + +- Search `uloop` first and inspect command examples, shell scripts, PowerShell scripts, and agent skills. +- Search boolean-looking CLI syntax: `--` plus nearby `true` or `false`, including `--flag true`, `--flag=false`, and inline Markdown command examples. +- Search renamed first-party option names: `wait-for-domain-reload`, `reload-external-scene-changes`, `force-recompile`, `save-before-run`, `show-overlay`, `include-components`, `include-inactive`, and `compile-only`. +- Search removed first-party commands: `get-project-info` and `get-version`. +- Search PascalCase output field names from the table below, then keep only matches that parse JSON produced by first-party `uloop` commands. +- Skip generated installed skill copies under `.agents`, `.claude`, `.codex`, `.cursor`, `.gemini`, `.windsurf`, `.agent`, or equivalent target folders unless the user explicitly asks to migrate installed copies. ## Boolean Argument Rules @@ -11,6 +22,8 @@ Use this reference after the detector finds a candidate. Keep edits local to the | `--flag true` | remove the option when the V3 default is already true | | `--flag false` | use the V3 negative option when the V3 default is true | +For third-party tools, inspect the current tool schema or docs before choosing the replacement. Do not infer third-party negative flags from first-party conventions. + ## Special First-Party Options | V2 command | V2 option | V3 replacement | @@ -34,9 +47,16 @@ Use this reference after the detector finds a candidate. Keep edits local to the | `uloop execute-dynamic-code` | `--compile-only true` | `--compile-only` | | `uloop execute-dynamic-code` | `--compile-only false` | remove | +## Removed First-Party Commands + +| V2 command | V3 handling | +| --- | --- | +| `uloop get-project-info` | Replace manually based on caller intent. Do not guess from the command name alone. | +| `uloop get-version` | Replace manually based on caller intent. Do not guess from the command name alone. | + ## Output JSON Field Candidates -V3 first-party CLI output generally uses camelCase JSON fields. Only update these when the surrounding parser is reading `uloop` output. +V3 first-party CLI output generally uses camelCase JSON fields. Only update these when the surrounding parser is reading first-party `uloop` JSON. Examples that usually qualify include `jq '.Success'`, PowerShell objects produced by `ConvertFrom-Json` from `uloop` output, and local helper functions whose implementation clearly executes `uloop` and returns parsed first-party JSON. | V2 field | V3 field | | --- | --- | @@ -78,9 +98,18 @@ V3 first-party CLI output generally uses camelCase JSON fields. Only update thes | `ClearedLogCount` | `clearedLogCount` | | `ClearedCounts` | `clearedCounts` | -## Review Guidance +## Context Rules + +Migrate output fields when context proves all of these: + +- The JSON comes from a first-party `uloop` command. +- The parser reads command output, not a C# object, regex match, unrelated API response, or sample domain model. +- The PascalCase field belongs to the old CLI response shape, not to code that intentionally documents C# property names. + +Do not migrate these common false positives: -- `jq '.Success'` usually becomes `jq '.success'`. -- PowerShell `$result.Success` usually becomes `$result.success`. -- If the code handles non-uloop JSON too, narrow the edit or leave it unchanged and report it. -- Removed commands such as `get-project-info` and `get-version` require manual replacement because the correct V3 behavior depends on the caller's intent. +- C# enum or member references such as `ConnectStatus.Success`. +- C# null-conditional method calls such as `_soundController?.IsPlaying()`. +- Regex or framework result properties such as `$baseMatch.Success` or `match.Success`. +- C# DTOs, tests, serialized model definitions, or ordinary properties named `Success`, `ErrorMessage`, `IsPlaying`, or similar. +- Non-`uloop` JSON parsing, even when it uses the same field names. diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 deleted file mode 100644 index 0cfe38625f..0000000000 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.ps1 +++ /dev/null @@ -1,169 +0,0 @@ -param( - [string] $Root = "." -) - -$excludedDirectories = @( - ".git", - "Library", - "Temp", - "node_modules", - ".agents", - ".claude", - ".codex", - ".cursor", - ".gemini", - ".windsurf", - ".agent" -) - -$targetExtensions = @(".md", ".sh", ".bash", ".zsh", ".ps1", ".psm1") -$outputFields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" -$bundledMigrationSkillPath = [System.IO.Path]::Combine( - "TemporarySkills~", - "v3-cli-invocation-migration" -) - -function Test-IsExcludedDirectoryName { - param([string] $Name) - - return $excludedDirectories -contains $Name -} - -function Test-IsTargetFile { - param([System.IO.FileInfo] $File) - - return $File.Name -eq "SKILL.md" -or $targetExtensions -contains $File.Extension -} - -function Test-IsReparsePointDirectory { - param([System.IO.DirectoryInfo] $Directory) - - return ($Directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 -} - -function Test-IsBundledMigrationSkillPath { - param([string] $Path) - - $normalizedPath = $Path.Replace( - [string] [System.IO.Path]::AltDirectorySeparatorChar, - [string] [System.IO.Path]::DirectorySeparatorChar - ) - $separator = [string] [System.IO.Path]::DirectorySeparatorChar - $wrappedPath = "${separator}${normalizedPath}${separator}" - $wrappedBundledPath = "${separator}${bundledMigrationSkillPath}${separator}" - return $wrappedPath.IndexOf( - $wrappedBundledPath, - [System.StringComparison]::OrdinalIgnoreCase - ) -ge 0 -} - -function Get-CandidateFile { - param([string] $Directory) - - $directoryName = Split-Path -Leaf $Directory - if ((Test-IsExcludedDirectoryName -Name $directoryName) -or - (Test-IsBundledMigrationSkillPath -Path $Directory)) { - return - } - - Get-ChildItem -LiteralPath $Directory -File | - Where-Object { Test-IsTargetFile -File $_ } - - Get-ChildItem -LiteralPath $Directory -Directory | - Where-Object { - -not (Test-IsExcludedDirectoryName -Name $_.Name) -and - -not (Test-IsReparsePointDirectory -Directory $_) -and - -not (Test-IsBundledMigrationSkillPath -Path $_.FullName) - } | - ForEach-Object { - Get-CandidateFile -Directory $_.FullName - } -} - -function Write-Candidate { - param( - [string] $Kind, - [string] $Path, - [int] $LineNumber, - [string] $Line - ) - - Write-Output "$Kind ${Path}:${LineNumber}: $Line" -} - -function Update-JsonVariableFromLine { - param( - [hashtable] $JsonVariables, - [string] $Line - ) - - $assignmentPattern = '\$([A-Za-z_][A-Za-z0-9_]*)\s*=[^\r\n#;]*' - $matches = [System.Text.RegularExpressions.Regex]::Matches($Line, $assignmentPattern) - foreach ($match in $matches) { - [string] $variableName = $match.Groups[1].Value - if ($match.Value -cmatch "ConvertFrom-Json") { - $JsonVariables[$variableName] = $true - } else { - [void] $JsonVariables.Remove($variableName) - } - } -} - -function Test-ContainsJqOutputField { - param([string] $Line) - - $jqCommandPattern = '(^|[\s`|;&(])jq\s' - return $Line -cmatch $jqCommandPattern -and - $Line -cmatch "\.($outputFields)([^A-Za-z0-9_]|$)" -} - -function Test-ContainsJsonVariableOutputField { - param( - [hashtable] $JsonVariables, - [string] $Line - ) - - foreach ($variableName in $JsonVariables.Keys) { - [string] $escapedVariableName = [System.Text.RegularExpressions.Regex]::Escape($variableName) - [string] $variablePattern = '\$' + $escapedVariableName + '\.(' + $outputFields + ')([^A-Za-z0-9_]|$)' - if ($Line -cmatch $variablePattern) { - return $true - } - } - - return $false -} - -Get-CandidateFile -Directory $Root | - ForEach-Object { - $path = $_.FullName - $lines = @(Get-Content -LiteralPath $path -Encoding UTF8) - $jsonVariables = @{} - for ($index = 0; $index -lt $lines.Count; $index++) { - $line = $lines[$index] - $lineNumber = $index + 1 - if ($line -match "uloop\s+[^#|;&]*--[A-Za-z0-9][A-Za-z0-9-]*(\s+|=)(true|false)([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "ARG_BOOL" -Path $path -LineNumber $lineNumber -Line $line - } - if ($line -match "uloop\s+compile\s+[^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line - } - if ($line -match "uloop\s+run-tests\s+[^#|;&]*--save-before-run([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line - } - if ($line -match "uloop\s+(record-input|replay-input)\s+[^#|;&]*--show-overlay([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line - } - if ($line -match "uloop\s+get-hierarchy\s+[^#|;&]*--include-(components|inactive)([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line - } - if ($line -match "uloop\s+execute-dynamic-code\s+[^#|;&]*--compile-only([^A-Za-z0-9_-]|$)") { - Write-Candidate -Kind "FIRST_PARTY_OPTION" -Path $path -LineNumber $lineNumber -Line $line - } - Update-JsonVariableFromLine -JsonVariables $jsonVariables -Line $line - if ((Test-ContainsJqOutputField -Line $line) -or - (Test-ContainsJsonVariableOutputField -JsonVariables $jsonVariables -Line $line)) { - Write-Candidate -Kind "OUTPUT_FIELD" -Path $path -LineNumber $lineNumber -Line $line - } - } - } diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh deleted file mode 100755 index c005e5e8c2..0000000000 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/scripts/detect-v3-cli-invocation-candidates.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/sh -set -eu - -root=${1:-.} - -find "$root" \ - \( -name .git -o -name Library -o -name Temp -o -name node_modules -o -name .agents -o -name .claude -o -name .codex -o -name .cursor -o -name .gemini -o -name .windsurf -o -name .agent -o -path '*/TemporarySkills~/v3-cli-invocation-migration' \) -prune -o \ - -type f \( -name SKILL.md -o -name '*.md' -o -name '*.sh' -o -name '*.bash' -o -name '*.zsh' -o -name '*.ps1' -o -name '*.psm1' \) -print | -while IFS= read -r file -do - awk ' - BEGIN { - output_fields = "Success|Message|ErrorMessage|ErrorCount|WarningCount|TotalCount|DisplayedCount|LogType|StackTrace|XmlPath|TestCount|PassedCount|FailedCount|SkippedCount|CompletedAt|ScreenshotCount|Screenshots|CompilationErrors|ErrorCode|UpdatedCode|DiagnosticsSummary|OutputPath|InputPath|TotalFrames|DurationSeconds|CurrentFrame|IsReplaying|KeyName|PositionX|PositionY|EndPositionX|EndPositionY|HitGameObjectName|IsPlaying|IsPaused|ClearedLogCount|ClearedCounts" - } - function report(kind) { - printf "%s %s:%d: %s\n", kind, FILENAME, FNR, $0 - } - function update_json_variables(line, rest, assignment, variable_name) { - rest = line - while (match(rest, /\$[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[^\n#;]*/)) { - assignment = substr(rest, RSTART, RLENGTH) - variable_name = assignment - sub(/^\$/, "", variable_name) - sub(/[[:space:]]*=.*/, "", variable_name) - if (assignment ~ /ConvertFrom-Json/) { - json_variables[variable_name] = 1 - } else { - delete json_variables[variable_name] - } - rest = substr(rest, RSTART + RLENGTH) - } - } - function contains_jq_output_field(line) { - return line ~ /(^|[[:space:]`|;&(])jq[[:space:]]/ && - line ~ ("\\.(" output_fields ")([^[:alnum:]_]|$)") - } - function contains_powershell_json_output_field(line, variable_name, pattern) { - for (variable_name in json_variables) { - pattern = "\\$" variable_name "\\.(" output_fields ")([^[:alnum:]_]|$)" - if (line ~ pattern) { - return 1 - } - } - - return 0 - } - /uloop[[:space:]][^#|;&]*--[[:alnum:]][[:alnum:]-]*([[:space:]]+|=)(true|false)([^[:alnum:]_-]|$)/ { - report("ARG_BOOL") - } - /uloop[[:space:]]+compile[[:space:]][^#|;&]*--(wait-for-domain-reload|reload-external-scene-changes|force-recompile)([^[:alnum:]_-]|$)/ { - report("FIRST_PARTY_OPTION") - } - /uloop[[:space:]]+run-tests[[:space:]][^#|;&]*--save-before-run([^[:alnum:]_-]|$)/ { - report("FIRST_PARTY_OPTION") - } - /uloop[[:space:]]+(record-input|replay-input)[[:space:]][^#|;&]*--show-overlay([^[:alnum:]_-]|$)/ { - report("FIRST_PARTY_OPTION") - } - /uloop[[:space:]]+get-hierarchy[[:space:]][^#|;&]*--include-(components|inactive)([^[:alnum:]_-]|$)/ { - report("FIRST_PARTY_OPTION") - } - /uloop[[:space:]]+execute-dynamic-code[[:space:]][^#|;&]*--compile-only([^[:alnum:]_-]|$)/ { - report("FIRST_PARTY_OPTION") - } - { - update_json_variables($0) - } - contains_jq_output_field($0) || contains_powershell_json_output_field($0) { - report("OUTPUT_FIELD") - } - ' "$file" -done diff --git a/cli/internal/cli/skills_test.go b/cli/internal/cli/skills_test.go index 4f8243d77d..dce4440876 100644 --- a/cli/internal/cli/skills_test.go +++ b/cli/internal/cli/skills_test.go @@ -3,7 +3,6 @@ package cli import ( "bytes" "os" - "os/exec" "path/filepath" "reflect" "sort" @@ -966,15 +965,15 @@ name: v3-cli-invocation-migration # temporary migration `) if err := os.MkdirAll( - filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts"), + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "references"), 0o755); err != nil { - t.Fatalf("failed to create script dir: %v", err) + t.Fatalf("failed to create reference dir: %v", err) } if err := os.WriteFile( - filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "scripts", "detect.sh"), - []byte("#!/bin/sh\n"), + filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", "v3-cli-invocation-migration", "Skill", "references", "first-party-v2-to-v3.md"), + []byte("# reference\n"), 0o644); err != nil { - t.Fatalf("failed to write script: %v", err) + t.Fatalf("failed to write reference: %v", err) } skills, err := collectV3MigrationSkillDefinition(projectRoot) if err != nil { @@ -983,18 +982,27 @@ name: v3-cli-invocation-migration stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} options := skillCommandOptions{targets: []skillTarget{targetConfigs["codex"]}} + installedDir := filepath.Join(projectRoot, ".codex", "skills", "v3-cli-invocation-migration") + if err := os.MkdirAll(filepath.Join(installedDir, "scripts"), 0o755); err != nil { + t.Fatalf("failed to create stale script dir: %v", err) + } + if err := os.WriteFile(filepath.Join(installedDir, "scripts", "detect.sh"), []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatalf("failed to write stale script: %v", err) + } installCode := runV3MigrationSkillInstall(projectRoot, skills, options, stdout, stderr) if installCode != 0 { t.Fatalf("install should succeed: code=%d stderr=%s", installCode, stderr.String()) } - installedDir := filepath.Join(projectRoot, ".codex", "skills", "v3-cli-invocation-migration") if _, err := os.Stat(filepath.Join(installedDir, "SKILL.md")); err != nil { t.Fatalf("migration skill should be installed: %v", err) } - if _, err := os.Stat(filepath.Join(installedDir, "scripts", "detect.sh")); err != nil { - t.Fatalf("migration skill script should be installed: %v", err) + if _, err := os.Stat(filepath.Join(installedDir, "references", "first-party-v2-to-v3.md")); err != nil { + t.Fatalf("migration skill reference should be installed: %v", err) + } + if _, err := os.Stat(filepath.Join(installedDir, "scripts", "detect.sh")); !os.IsNotExist(err) { + t.Fatalf("stale migration skill script should be removed: %v", err) } unrelatedSkillDir := filepath.Join(projectRoot, ".codex", "skills", "uloop-compile") writeSkillFile(t, unrelatedSkillDir, "---\nname: uloop-compile\n---\n") @@ -1076,303 +1084,6 @@ func TestRunV3MigrationSkillUninstallRemovesAlternateLayout(t *testing.T) { } } -// Tests that the POSIX detector reports boolean, first-party, and output-field candidates. -func TestV3MigrationSkillPosixDetectorReportsCandidates(t *testing.T) { - fixtureRoot := t.TempDir() - fixtureFile := filepath.Join(fixtureRoot, "docs", "SKILL.md") - if err := os.MkdirAll(filepath.Dir(fixtureFile), 0o755); err != nil { - t.Fatalf("failed to create fixture dir: %v", err) - } - fixtureContent := strings.Join([]string{ - "uloop compile --wait-for-domain-reload true", - "uloop some-tool --enabled false", - "inline `uloop compile --wait-for-domain-reload`", - "inline `uloop some-tool --enabled false`", - "jq '.Success'", - }, "\n") - if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { - t.Fatalf("failed to write fixture: %v", err) - } - bundledReferenceFile := filepath.Join( - fixtureRoot, - "Packages", - "src", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "references", - "first-party-v2-to-v3.md") - if err := os.MkdirAll(filepath.Dir(bundledReferenceFile), 0o755); err != nil { - t.Fatalf("failed to create bundled reference dir: %v", err) - } - if err := os.WriteFile( - bundledReferenceFile, - []byte("uloop bundled-tool --self-noise false\n"), - 0o644); err != nil { - t.Fatalf("failed to write bundled reference: %v", err) - } - embeddedBundledReferenceFile := filepath.Join( - fixtureRoot, - "Packages", - "io.github.hatayama.uloopmcp", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "references", - "first-party-v2-to-v3.md") - if err := os.MkdirAll(filepath.Dir(embeddedBundledReferenceFile), 0o755); err != nil { - t.Fatalf("failed to create embedded bundled reference dir: %v", err) - } - if err := os.WriteFile( - embeddedBundledReferenceFile, - []byte("uloop embedded-bundled-tool --self-noise false\n"), - 0o644); err != nil { - t.Fatalf("failed to write embedded bundled reference: %v", err) - } - scriptPath := filepath.Join( - findRepositoryRootForSkillsTest(t), - "Packages", - "src", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "scripts", - "detect-v3-cli-invocation-candidates.sh") - - shPath, err := exec.LookPath("sh") - if err != nil { - t.Skip("sh is not installed") - } - command := exec.Command(shPath, scriptPath, fixtureRoot) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("detector failed: %v\n%s", err, string(output)) - } - text := string(output) - for _, expected := range []string{"FIRST_PARTY_OPTION", "ARG_BOOL", "OUTPUT_FIELD"} { - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing %s:\n%s", expected, text) - } - } - for _, expected := range []string{ - "inline `uloop compile --wait-for-domain-reload`", - "inline `uloop some-tool --enabled false`", - } { - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing inline Markdown candidate %q:\n%s", expected, text) - } - } - if strings.Contains(text, "self-noise") { - t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) - } -} - -// Tests that the POSIX detector limits output fields to JSON parser contexts. -func TestV3MigrationSkillPosixDetectorIgnoresNonParserOutputFields(t *testing.T) { - fixtureRoot := t.TempDir() - markdownFile := filepath.Join(fixtureRoot, "docs", "notes.md") - if err := os.MkdirAll(filepath.Dir(markdownFile), 0o755); err != nil { - t.Fatalf("failed to create Markdown fixture dir: %v", err) - } - markdownContent := strings.Join([]string{ - "jq '.Success'", - "case ConnectStatus.Success:", - "_soundController?.IsPlaying() == true", - "_soundController?.IsPaused() == true", - }, "\n") - if err := os.WriteFile(markdownFile, []byte(markdownContent), 0o644); err != nil { - t.Fatalf("failed to write Markdown fixture: %v", err) - } - powerShellFile := filepath.Join(fixtureRoot, "scripts", "migrate.ps1") - if err := os.MkdirAll(filepath.Dir(powerShellFile), 0o755); err != nil { - t.Fatalf("failed to create PowerShell fixture dir: %v", err) - } - powerShellContent := strings.Join([]string{ - "[pscustomobject]$result = $text | ConvertFrom-Json", - "if ($result.Success -ne $true) { throw 'failed' }", - "[System.Text.RegularExpressions.Match]$baseMatch = [regex]::Match($line, '^Frame')", - "if (-not $baseMatch.Success) { throw 'bad line' }", - "[pscustomobject]$status = $text | ConvertFrom-Json", - "[pscustomobject]$status = Get-ReplayStatus", - "if ($status.IsReplaying -eq $true) { Write-Host 'running' }", - }, "\n") - if err := os.WriteFile(powerShellFile, []byte(powerShellContent), 0o644); err != nil { - t.Fatalf("failed to write PowerShell fixture: %v", err) - } - scriptPath := filepath.Join( - findRepositoryRootForSkillsTest(t), - "Packages", - "src", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "scripts", - "detect-v3-cli-invocation-candidates.sh") - - shPath, err := exec.LookPath("sh") - if err != nil { - t.Skip("sh is not installed") - } - command := exec.Command(shPath, scriptPath, fixtureRoot) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("detector failed: %v\n%s", err, string(output)) - } - text := string(output) - for _, expected := range []string{ - "jq '.Success'", - "$result.Success", - } { - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing parser candidate %q:\n%s", expected, text) - } - } - for _, unexpected := range []string{ - "ConnectStatus.Success", - "_soundController?.IsPlaying()", - "_soundController?.IsPaused()", - "$baseMatch.Success", - "$status.IsReplaying", - } { - if strings.Contains(text, unexpected) { - t.Fatalf("detector should ignore non-parser field %q:\n%s", unexpected, text) - } - } -} - -// Tests that the PowerShell detector reports candidates when PowerShell is available. -func TestV3MigrationSkillPowerShellDetectorReportsCandidates(t *testing.T) { - pwshPath, err := exec.LookPath("pwsh") - if err != nil { - t.Skip("pwsh is not installed") - } - fixtureRoot := t.TempDir() - fixtureFile := filepath.Join(fixtureRoot, "scripts", "migrate.ps1") - if err := os.MkdirAll(filepath.Dir(fixtureFile), 0o755); err != nil { - t.Fatalf("failed to create fixture dir: %v", err) - } - fixtureContent := strings.Join([]string{ - "uloop run-tests --save-before-run false", - "uloop some-tool --enabled true", - "inline `uloop run-tests --save-before-run`", - "inline `uloop some-tool --enabled true`", - "[pscustomobject]$result = $text | ConvertFrom-Json", - "$result.Success", - "$result.success", - "[System.Text.RegularExpressions.Match]$baseMatch = [regex]::Match($line, '^Frame')", - "$baseMatch.Success", - "[pscustomobject]$status = $text | ConvertFrom-Json", - "[pscustomobject]$status = Get-ReplayStatus", - "$status.IsReplaying", - }, "\n") - if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { - t.Fatalf("failed to write fixture: %v", err) - } - embeddedBundledReferenceFile := filepath.Join( - fixtureRoot, - "Packages", - "io.github.hatayama.uloopmcp", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "references", - "first-party-v2-to-v3.md") - if err := os.MkdirAll(filepath.Dir(embeddedBundledReferenceFile), 0o755); err != nil { - t.Fatalf("failed to create embedded bundled reference dir: %v", err) - } - if err := os.WriteFile( - embeddedBundledReferenceFile, - []byte("uloop embedded-bundled-tool --self-noise true\n"), - 0o644); err != nil { - t.Fatalf("failed to write embedded bundled reference: %v", err) - } - scriptPath := filepath.Join( - findRepositoryRootForSkillsTest(t), - "Packages", - "src", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "scripts", - "detect-v3-cli-invocation-candidates.ps1") - - command := exec.Command(pwshPath, "-NoProfile", "-File", scriptPath, "-Root", fixtureRoot) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("detector failed: %v\n%s", err, string(output)) - } - text := string(output) - for _, expected := range []string{"FIRST_PARTY_OPTION", "ARG_BOOL", "OUTPUT_FIELD"} { - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing %s:\n%s", expected, text) - } - } - for _, expected := range []string{ - "inline `uloop run-tests --save-before-run`", - "inline `uloop some-tool --enabled true`", - } { - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing inline Markdown candidate %q:\n%s", expected, text) - } - } - if strings.Contains(text, "$result.success") { - t.Fatalf("detector should not report already migrated camelCase fields:\n%s", text) - } - if strings.Contains(text, "$baseMatch.Success") { - t.Fatalf("detector should ignore non-json PowerShell fields:\n%s", text) - } - if strings.Contains(text, "$status.IsReplaying") { - t.Fatalf("detector should forget reassigned PowerShell json variables:\n%s", text) - } - if strings.Contains(text, "self-noise") { - t.Fatalf("detector should skip bundled migration skill sources:\n%s", text) - } -} - -// Tests that Windows PowerShell reports UTF-8 no-BOM file line numbers correctly. -func TestV3MigrationSkillWindowsPowerShellDetectorReportsUtf8LineNumbers(t *testing.T) { - powerShellPath, err := exec.LookPath("powershell") - if err != nil { - t.Skip("powershell is not installed") - } - fixtureRoot := t.TempDir() - fixtureFile := filepath.Join(fixtureRoot, "sample.md") - fixtureContent := strings.Join([]string{ - "# Plan", - "", - "## 検証", - "", - "日本語の説明文です。UTF-8 BOMなしの行番号を検証します。", - "", - "もう一つの日本語行です。PowerShellの既定読み込みとの差を確認します。", - "", - "uloop compile --wait-for-domain-reload", - }, "\n") - if err := os.WriteFile(fixtureFile, []byte(fixtureContent), 0o644); err != nil { - t.Fatalf("failed to write UTF-8 fixture: %v", err) - } - scriptPath := filepath.Join( - findRepositoryRootForSkillsTest(t), - "Packages", - "src", - "TemporarySkills~", - "v3-cli-invocation-migration", - "Skill", - "scripts", - "detect-v3-cli-invocation-candidates.ps1") - - command := exec.Command(powerShellPath, "-NoProfile", "-File", scriptPath, "-Root", fixtureRoot) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("detector failed: %v\n%s", err, string(output)) - } - text := string(output) - expected := "sample.md:9: uloop compile --wait-for-domain-reload" - if !strings.Contains(text, expected) { - t.Fatalf("detector output missing UTF-8 physical line number %q:\n%s", expected, text) - } -} - func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { t.Helper() writeSkillFile(t, filepath.Join(projectRoot, filepath.FromSlash(relativeDir)), content) @@ -1459,21 +1170,3 @@ func assertSkillContentContains(t *testing.T, skills []skillDefinition, skillNam } t.Fatalf("skill not found: %s", skillName) } - -func findRepositoryRootForSkillsTest(t *testing.T) string { - t.Helper() - currentDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get working directory: %v", err) - } - for { - if _, err := os.Stat(filepath.Join(currentDir, ".git")); err == nil { - return currentDir - } - parentDir := filepath.Dir(currentDir) - if parentDir == currentDir { - t.Fatal("repository root not found") - } - currentDir = parentDir - } -} From 027953f37596da6f66129db1c8a5c16adbbffdd6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 22 Jun 2026 00:21:37 +0900 Subject: [PATCH 03/18] Add copyable AI migration prompt Update the migration wizard prompt copy so users can ask an AI agent to run the V3 CLI invocation migration skill, and add a clipboard button for the prompt. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 40 +++++++++++++++++++ .../Presentation/Setup/SetupWizardWindow.uss | 4 ++ .../ThirdPartyToolMigrationWizardText.cs | 26 +++++++----- .../ThirdPartyToolMigrationWizardView.cs | 9 ++++- .../ThirdPartyToolMigrationWizardWindow.cs | 15 +++++++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index cd97a7b318..aa9aea521f 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -180,6 +180,46 @@ public void GetMigrationSkillButtonText_ReturnsExpectedLabel( Assert.That(label, Is.EqualTo(expectedLabel)); } + [Test] + public void GetMigrationSkillPromptText_ReturnsActionablePrompt() + { + // Verifies that the wizard provides a copyable prompt for asking an AI agent to run the skill. + string prompt = ThirdPartyToolMigrationWizardWindow.GetMigrationSkillPromptText(); + + Assert.That(prompt, Does.Contain("v3-cli-invocation-migration")); + Assert.That(prompt, Does.Contain("SKILL.md, Markdown, POSIX shell scripts, and PowerShell scripts")); + Assert.That(prompt, Does.Contain("Do not change C# snippets")); + Assert.That(prompt, Does.Contain("summarize changed files")); + } + + [Test] + public void GetMigrationSkillPromptCopyButtonText_ReturnsClearLabel() + { + // Verifies that the copy button label describes the clipboard action. + string label = ThirdPartyToolMigrationWizardWindow.GetMigrationSkillPromptCopyButtonText(); + + Assert.That(label, Is.EqualTo("Copy AI Prompt")); + } + + [Test] + public void CopyMigrationSkillPromptToClipboard_WritesPrompt() + { + // Verifies that the copy action places the AI prompt on the system clipboard. + string originalClipboard = EditorGUIUtility.systemCopyBuffer; + try + { + ThirdPartyToolMigrationWizardWindow.CopyMigrationSkillPromptToClipboard(); + + Assert.That( + EditorGUIUtility.systemCopyBuffer, + Is.EqualTo(ThirdPartyToolMigrationWizardWindow.GetMigrationSkillPromptText())); + } + finally + { + EditorGUIUtility.systemCopyBuffer = originalClipboard; + } + } + [TestCase(SkillInstallState.Installed, true)] [TestCase(SkillInstallState.Outdated, true)] [TestCase(SkillInstallState.Missing, false)] diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 0c8119dbb1..962901a9d0 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -357,6 +357,10 @@ margin-left: 14px; } +.setup-skill-usage-copy-button { + margin-left: 14px; +} + .setup-footer__github-link { flex-direction: row; justify-content: flex-end; diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index 02595c77e3..36cb526b5b 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -16,15 +16,17 @@ internal static class ThirdPartyToolMigrationWizardText internal const string AiMigrationSkillSectionTitle = "AI Skill and Script Migration"; internal const string AiMigrationSkillDescriptionText = "Install a temporary AI skill for updating SKILL.md, Markdown, shell scripts, and PowerShell scripts " + - "that call uloop.\nThe skill gives your AI agent a checklist and detection scripts; this window only " + - "installs or removes it."; - internal const string AiMigrationSkillUsageFoldoutTitle = "How to use this skill"; - internal const string AiMigrationSkillUsageExampleText = - "After installing the skill, open your AI agent in this project and ask it to use the installed " + - "migration skill.\n\nExample prompt:\nUse the v3-cli-invocation-migration skill to scan this project " + - "for Unity CLI Loop V2 CLI invocation patterns in SKILL.md, Markdown, shell scripts, and PowerShell " + - "scripts. Update them for V3 CLI usage, then summarize the changed files and any commands I should " + - "verify manually."; + "that call uloop.\nThe skill teaches your AI agent how to search, inspect context, and update only " + + "real V2 CLI usage. This window only installs or removes it."; + internal const string AiMigrationSkillUsageFoldoutTitle = "Prompt for your AI agent"; + internal const string AiMigrationSkillPromptText = + "Use the v3-cli-invocation-migration skill in this project to update Unity CLI Loop V2 CLI usage " + + "for V3.\n\n" + + "Scope:\n" + + "- Check SKILL.md, Markdown, POSIX shell scripts, and PowerShell scripts.\n" + + "- Migrate V2 boolean arguments, renamed first-party options, removed commands, and first-party JSON output fields.\n" + + "- Read nearby context before editing. Do not change C# snippets, enum/member references, regex match properties, DTO properties, or non-uloop JSON.\n" + + "- After editing, summarize changed files, remaining candidates, and any commands I should verify manually."; internal const string MigrationNotCheckedText = "C# source migration status has not been checked."; internal const string NoMigrationTargetsText = "No C# source structure migration is needed."; private const string MigrationCheckingText = "Scanning C# source files for V3 custom tool API migration..."; @@ -36,6 +38,7 @@ internal static class ThirdPartyToolMigrationWizardText private const string InstallMigrationSkillButtonText = "Install Migration Skill"; private const string RemoveMigrationSkillButtonText = "Remove Migration Skill"; private const string UpdatingMigrationSkillButtonText = "Updating..."; + private const string CopyMigrationSkillPromptButtonText = "Copy AI Prompt"; internal static string GetMigrationStatusText(int fileCount) { @@ -97,5 +100,10 @@ internal static string GetMigrationSkillButtonText( ? RemoveMigrationSkillButtonText : InstallMigrationSkillButtonText; } + + internal static string GetMigrationSkillPromptCopyButtonText() + { + return CopyMigrationSkillPromptButtonText; + } } } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index d625cd54b3..5f490826da 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -343,9 +343,16 @@ private static void CreateMigrationSkillUsageFoldout(VisualElement content) content.Add(usageFoldout); TextField usageTextField = CreateSelectableText( - ThirdPartyToolMigrationWizardText.AiMigrationSkillUsageExampleText, + ThirdPartyToolMigrationWizardText.AiMigrationSkillPromptText, "setup-skill-usage-text"); usageFoldout.Add(usageTextField); + + Button copyPromptButton = new Button(ThirdPartyToolMigrationWizardWindow.CopyMigrationSkillPromptToClipboard); + copyPromptButton.text = ThirdPartyToolMigrationWizardText.GetMigrationSkillPromptCopyButtonText(); + copyPromptButton.AddToClassList("setup-button"); + copyPromptButton.AddToClassList("setup-button--small"); + copyPromptButton.AddToClassList("setup-skill-usage-copy-button"); + usageFoldout.Add(copyPromptButton); usageFoldout.SetValueWithoutNotify(false); } diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index cec864bdf5..ab5ad8209d 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -209,6 +209,21 @@ internal static string GetMigrationSkillButtonText( return ThirdPartyToolMigrationWizardText.GetMigrationSkillButtonText(isUpdating, installState); } + internal static string GetMigrationSkillPromptText() + { + return ThirdPartyToolMigrationWizardText.AiMigrationSkillPromptText; + } + + internal static string GetMigrationSkillPromptCopyButtonText() + { + return ThirdPartyToolMigrationWizardText.GetMigrationSkillPromptCopyButtonText(); + } + + internal static void CopyMigrationSkillPromptToClipboard() + { + EditorGUIUtility.systemCopyBuffer = GetMigrationSkillPromptText(); + } + internal static bool ShouldRemoveMigrationSkill(SkillInstallState installState) { return installState == SkillInstallState.Installed From fcca382c7e1eba9e0482a07f296dd4c11f15e605 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 22 Jun 2026 00:25:07 +0900 Subject: [PATCH 04/18] Move migration prompt below install action Place the AI migration prompt after the skill install button so users see the copyable prompt in the requested flow, and cover the ordering with an EditMode test. --- ...hirdPartyToolMigrationWizardWindowTests.cs | 35 +++++++++++++++++++ .../ThirdPartyToolMigrationWizardView.cs | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index aa9aea521f..15ee2ef1b1 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using UnityEditor; using UnityEngine; +using UnityEngine.UIElements; using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.Presentation; @@ -220,6 +221,31 @@ public void CopyMigrationSkillPromptToClipboard_WritesPrompt() } } + [Test] + public void Create_WhenMigrationSkillSectionExists_PlacesPromptAfterInstallButton() + { + // Verifies that the AI prompt is shown below the migration skill install action. + VisualElement root = new(); + ThirdPartyToolMigrationWizardView.Create( + root, + () => { }, + () => { }, + _ => { }, + () => { }, + () => { }); + + Button installButton = root.Query