Command.ps1

# 命令式模式(子命令路由):与交互式共享数据层/执行层,只换「中间环节」。
# 路由语义(第十七轮定稿,显式 ui 方案):
# - 首 token 以 '-' 开头 → 全局选项(-h/--help/-v/--version,其余报错「未知选项」)
# - 'help' / 'version' 子命令与 -h / -v 等价
# - 首 token 命中注册表子命令(list/find/run/ui)→ 命令式;子命令剩余 tokens 含
# -h / --help → 改出「该子命令帮助」(-h 按位置分级)
# - 未注册词 → 报错「未知子命令」(不再回退交互式;关键词只能经 cct ui <词> 进入)
# - ui 在无 TTY(stdin 重定向)下降级为 list,避免进全屏 TUI 卡死
# 注意:参数名不能用 $Args(与自动变量 $args 大小写不敏感冲突,位置绑定失效)。

# 子命令注册表(新增子命令在此登记;help/version 由 Resolve 直接处理,不入表)
$script:CctCommands = @{
    'list'  = 'Invoke-CctList'
    'find'  = 'Invoke-CctFind'
    'run'   = 'Invoke-CctRun'
    'ui'    = 'Invoke-CctUi'
    'clear' = 'Invoke-CctClear'
}

# 空格规范化:PowerShell 只按半角空格切分参数,全角空格(U+3000)会黏在 token 内。
# 统一按「连续空白(含全角)」重切,消除多空格 / 半角全角差异。
function Normalize-CctTokens {
    param([string[]]$Tokens)
    $joined = @($Tokens | ForEach-Object { if ($null -eq $_) { '' } else { [string]$_ } }) -join ' '
    $out = [System.Collections.Generic.List[string]]::new()
    foreach ($m in [regex]::Matches($joined, '\S+')) { $out.Add($m.Value) }
    return @($out)
}

# 子命令剩余参数解析:'-' 开头为选项,其余为位置参数(过滤词等)。
# bool 选项:--json / --dry-run。
# -i 带值选项:-i <会话 ID>(run 的会话 ID 直达),'--id' 为长格式别名。
# 调用方保证 -h/--help 已在 Resolve 层截走,此处遇到其他未知选项抛错。
function Split-CctCommandArgs {
    param([string[]]$Tokens)
    $opts = @{}
    $positional = [System.Collections.Generic.List[string]]::new()
    $i = 0
    $list = @($Tokens)
    while ($i -lt $list.Count) {
        $t = $list[$i]
        if ($t.StartsWith('-')) {
            switch ($t.ToLowerInvariant()) {
                '--json'    { $opts['Json'] = $true; $i++; break }
                '--dry-run' { $opts['DryRun'] = $true; $i++; break }
                '-i'        {
                    if ($i + 1 -ge $list.Count) { throw 'cct run -i 需要一个会话 ID 值(用 cct run -h 查看帮助)' }
                    $opts['Id'] = $list[$i + 1]; $i += 2; break
                }
                '--id'      {
                    if ($i + 1 -ge $list.Count) { throw 'cct run -i 需要一个会话 ID 值(用 cct run -h 查看帮助)' }
                    $opts['Id'] = $list[$i + 1]; $i += 2; break
                }
                default     { throw "未知选项: $t(用 cct <命令> -h 查看帮助)" }
            }
        } else {
            $positional.Add($t); $i++
        }
    }
    return [pscustomobject]@{ Options = $opts; Arguments = @($positional) }
}

# 判断 token 是否命中子命令名(纯函数;大小写不敏感)
function Test-CctCommandName {
    param([string]$Name)
    return (-not [string]::IsNullOrEmpty($Name)) -and $script:CctCommands.ContainsKey($Name.ToLowerInvariant())
}

# 路由决策(纯函数,TTY 由调用方注入以便测试)。返回值 Mode:
# Interactive / List / Help / CommandHelp / Version / Command / Error
function Resolve-CctInvocation {
    param([string[]]$Tokens, [bool]$HasTty)
    $tokens = @(Normalize-CctTokens $Tokens)
    if ($tokens.Count -eq 0) {
        if ($HasTty) { return [pscustomobject]@{ Mode = 'Interactive'; Keyword = '' } }
        return [pscustomobject]@{ Mode = 'List'; Tokens = @() }
    }
    $arg0 = [string]$tokens[0]
    # 全局选项(- 开头,永不进关键词/子命令分支)
    if ($arg0.StartsWith('-')) {
        switch ($arg0.ToLowerInvariant()) {
            '-h'        { return [pscustomobject]@{ Mode = 'Help'; Command = $null } }
            '--help'    { return [pscustomobject]@{ Mode = 'Help'; Command = $null } }
            '-v'        { return [pscustomobject]@{ Mode = 'Version' } }
            '--version' { return [pscustomobject]@{ Mode = 'Version' } }
            default     { return [pscustomobject]@{ Mode = 'Error'; Message = "未知选项: $arg0(用 cct -h 查看帮助)" } }
        }
    }
    # help / version 子命令(与 -h / -v 等价)
    if ($arg0 -ieq 'help')    { return [pscustomobject]@{ Mode = 'Help'; Command = $null } }
    if ($arg0 -ieq 'version') { return [pscustomobject]@{ Mode = 'Version' } }
    # 命中注册子命令 → Command;子命令级 -h/--help → CommandHelp;ui 无 TTY 降级 List
    if (Test-CctCommandName $arg0) {
        $rest = @($tokens | Select-Object -Skip 1)
        if ($rest -contains '-h' -or $rest -contains '--help') {
            return [pscustomobject]@{ Mode = 'CommandHelp'; Command = $arg0.ToLowerInvariant() }
        }
        $name = $arg0.ToLowerInvariant()
        if ($name -eq 'ui' -and -not $HasTty) {
            return [pscustomobject]@{ Mode = 'List'; Tokens = @($rest) }
        }
        return [pscustomobject]@{ Mode = 'Command'; Name = $name; Tokens = @($rest) }
    }
    # 未注册词:不再回退交互式,直接报错(显式 ui 方案,零歧义)
    return [pscustomobject]@{ Mode = 'Error'; Message = "未知子命令: $arg0(用 cct -h 查看帮助)" }
}

# 子命令分派:按注册表函数名转发剩余参数(统一命名参数,避免位置绑定歧义)。
# Root/ConfigPath/ExcludePatterns/HasTty 仅调用方显式传入时才转发(测试注入用)。
function Invoke-CctCommand {
    param([string]$Name, [string[]]$Tokens, [bool]$HasTty = $true, [string]$Root, [string]$ConfigPath, [string[]]$ExcludePatterns)
    $fn = $script:CctCommands[$Name.ToLowerInvariant()]
    if (-not $fn) { throw "未知子命令: $Name" }
    $forward = @{ Tokens = @($Tokens) }
    foreach ($k in @('HasTty', 'Root', 'ConfigPath', 'ExcludePatterns')) {
        if ($PSBoundParameters.ContainsKey($k)) { $forward[$k] = $PSBoundParameters[$k] }
    }
    return & $fn @forward
}

# cct list [过滤词...] [--json]:列出任务(Folder/Session),过滤词 join 空格作子串查询
# Root/ConfigPath/ExcludePatterns 供测试注入;经 Invoke-CctCommand 分派时只传 Tokens
function Invoke-CctList {
    param(
        [string[]]$Tokens = @(),
        [bool]$HasTty = $true,
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [string]$ConfigPath = (Join-Path $env:USERPROFILE '.cct\config.json'),
        [string[]]$ExcludePatterns = $null
    )
    $parsed = Split-CctCommandArgs -Tokens @($Tokens)
    $filter = $parsed.Arguments -join ' '
    $cfg = Get-CctConfig -Path $ConfigPath
    if ($null -eq $ExcludePatterns) { $ExcludePatterns = $cfg.excludePathPatterns }
    $tasks = @(Get-CctTasks -Root $Root -MinUserMsgs $cfg.minUserMessages -ExcludePatterns $ExcludePatterns -IncludeFolderFind ($cfg.includeFolderFind -eq 1))
    if ($filter) { $tasks = @(Filter-CctTasks -Tasks $tasks -Query $filter) }
    if ($parsed.Options['Json']) { return $tasks | ConvertTo-Json -Depth 4 }
    return $tasks
}

# cct find <过滤词> [--json]:查找会话(过滤词必填,其余复用 list 逻辑)
function Invoke-CctFind {
    param(
        [string[]]$Tokens = @(),
        [bool]$HasTty = $true,
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [string]$ConfigPath = (Join-Path $env:USERPROFILE '.cct\config.json'),
        [string[]]$ExcludePatterns = $null
    )
    $parsed = Split-CctCommandArgs -Tokens @($Tokens)
    if ($parsed.Arguments.Count -eq 0) { throw 'cct find 需要一个过滤词(用 cct find -h 查看帮助)' }
    return Invoke-CctList -Tokens @($Tokens) -Root $Root -ConfigPath $ConfigPath -ExcludePatterns $ExcludePatterns
}

# cct run (-i <会话id> | <过滤词>) [--dry-run]:定位并启动会话。
# 优先走 -i 会话 ID 直达(精确匹配,任意词不冲突);无 -i 时按过滤词定位。
# 动词过滤唯一性判定(第十七轮细化):只以「命中的 Session 项」判唯一(Filter 带出的父文件夹是上下文,
# 非启动目标);Session 0 个时回退「命中的 Folder 项」判唯一;0/多走对应分支,不自动选、不交互。
function Invoke-CctRun {
    param(
        [string[]]$Tokens = @(),
        [bool]$HasTty = $true,
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [string]$ConfigPath = (Join-Path $env:USERPROFILE '.cct\config.json'),
        [string[]]$ExcludePatterns = $null
    )
    $parsed = Split-CctCommandArgs -Tokens @($Tokens)
    $dryRun = [bool]$parsed.Options['DryRun']

    $cfg = Get-CctConfig -Path $ConfigPath
    $tasks = @(Get-CctTasks -Root $Root -MinUserMsgs $cfg.minUserMessages -ExcludePatterns $ExcludePatterns -IncludeFolderFind ($cfg.includeFolderFind -eq 1))

    # ── 分支 A:-i 会话 ID 直达(第十八轮新增的精准进入方式,绕过过滤子串)──
    if ($parsed.Options.ContainsKey('Id')) {
        $id = [string]$parsed.Options['Id']
        $hit = @($tasks | Where-Object { $_.Kind -eq 'Session' -and $_.SessionId -eq $id })
        if ($hit.Count -eq 0) { throw "未找到会话 ID 为「$id」的会话(用 cct find -h / cct list --json 查看 ID)" }
        if ($hit.Count -gt 1) { throw "会话 ID「$id」命中了多个会话(数据异常,请联系排查)" }
        return Invoke-CctTask -Item $hit[0] -Config $cfg -DryRun:$dryRun
    }

    # ── 分支 B:过滤词定位(无 -i)──
    $filterArgs = @($parsed.Arguments)
    if ($filterArgs.Count -eq 0) { throw 'cct run 需要一个过滤词(用 cct run -h 查看帮助)' }

    $matches = @(Invoke-CctList -Tokens $filterArgs -Root $Root -ConfigPath $ConfigPath -ExcludePatterns $ExcludePatterns)
    $sessions = @($matches | Where-Object Kind -eq 'Session')
    $folders = @($matches | Where-Object Kind -eq 'Folder')

    $target = $null
    if ($sessions.Count -eq 1) {
        $target = $sessions[0]
    } elseif ($sessions.Count -gt 1) {
        Write-Host "匹配到 $($sessions.Count) 个会话,请收窄关键词或用 cct ui 选择:"
        for ($i = 0; $i -lt $sessions.Count; $i++) {
            Write-Host (" {0}. {1}" -f ($i + 1), $sessions[$i].Name)
        }
        return
    } elseif ($folders.Count -eq 1) {
        $target = $folders[0]
    } elseif ($folders.Count -gt 1) {
        Write-Host "匹配到 $($folders.Count) 个文件夹,请收窄关键词或用 cct ui 选择:"
        for ($i = 0; $i -lt $folders.Count; $i++) {
            Write-Host (" {0}. {1}" -f ($i + 1), $folders[$i].Name)
        }
        return
    } else {
        throw "未找到匹配「$($filterArgs -join ' ')」的会话"
    }

    return Invoke-CctTask -Item $target -Config $cfg -DryRun:$dryRun
}

# cct ui [过滤词]:显式进入交互式选择器(非 TTY 降级已由 Resolve 处理为 List)
function Invoke-CctUi {
    param(
        [string[]]$Tokens = @(),
        [bool]$HasTty = $true,
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [string]$ConfigPath = (Join-Path $env:USERPROFILE '.cct\config.json'),
        [string[]]$ExcludePatterns = $null
    )
    $keyword = @($Tokens) -join ' '
    return Invoke-CctMain -Keyword $keyword -ConfigPath $ConfigPath -ProjectsRoot $Root
}

# 版本号:读模块 manifest 的 ModuleVersion;第二行输出仓库地址(ProjectUri 在 PrivateData.PSData,单一来源)
function Get-CctVersion {
    $psd1 = Join-Path $PSScriptRoot 'ClaudeCodeTask.psd1'
    $mod = Import-PowerShellDataFile -Path $psd1
    return @("cct v$($mod.ModuleVersion)", "Github地址: $($mod.PrivateData.PSData.ProjectUri)")
}

# 帮助文本(全部中文,含占位符内文字):无参 = 全局帮助;-Command <名> = 子命令帮助。
# 返回行数组(含空行),供 cct -h 逐行 Write-Host、也便于测试断言。
function Get-CctHelpText {
    param([string]$Command = '')
    if ($Command) {
        switch ($Command.ToLowerInvariant()) {
            'ui' {
                return @('[交互] cct ui —— 打开交互式选择器', '',
                    '用法: cct ui [过滤词]', '',
                    '说明: 打开全屏交互式选择器。不带过滤词与直接运行 cct 等价;',
                    ' 带过滤词时选择器预先按该词过滤。', '',
                    '参数:', ' [过滤词] 可选,进入选择器时的初始过滤词', '',
                    '选项:', ' -h, --help 显示本命令帮助')
            }
            'list' {
                return @('[指令] cct list —— 列出任务', '',
                    '用法: cct list [过滤词...]', '',
                    '说明: 列出全部任务(文件夹 + 会话)。多个词用空格分隔,',
                    ' 按「词1 词2」整体做子串匹配。', '',
                    '参数:', ' [过滤词...] 可选,过滤词,多个词折叠为单个空格', '',
                    '选项:', ' --json 以 JSON 输出结果', ' -h, --help 显示本命令帮助')
            }
            'find' {
                return @('[指令] cct find —— 查找会话', '',
                    '用法: cct find [选项] <过滤词>', '',
                    '说明: 在全部任务(文件夹 + 会话)中按子串查找,会话命中会带出所属文件夹。', '',
                    '参数:', ' <过滤词> 必填,匹配文件夹名 / 会话标题 / 路径', '',
                    '选项:', ' --json 以 JSON 输出结果', ' -h, --help 显示本命令帮助')
            }
            'run' {
                return @('[指令] cct run —— 定位并启动会话', '',
                    '用法: cct run [选项] (-i <会话id> | <过滤词>)', '',
                    '说明: 两种定位目标二选一,目标内容(最不确定)放在最后输入:',
                    ' 带 -i <会话id> 按会话 ID 精准直达(绕过关键词,任意词不冲突);',
                    ' 不带 -i 时按过滤词定位。唯一匹配则立即启动;',
                    ' 匹配多个则列出候选,要求收窄关键词或用 cct ui 选择;无匹配则报错。', '',
                    '参数:', ' -i, --id <会话id> 按会话 ID 直达,与 <过滤词> 二选一',
                    ' <过滤词> 定位会话的过滤词(不带 -i 时),放在最后输入', '',
                    '选项:', ' --dry-run 演练运行,显示将启动的命令但不真正启动', ' -h, --help 显示本命令帮助')
            }
            'clear' {
                return @('[指令] cct clear —— 清理 cct 缓存或多余会话', '',
                    '用法: cct clear [-cc] [选项]', '',
                    '说明: 不带 -cc 时删除 cct 的扫描缓存(~/.cct/cache.json),下次扫描自动全量重建;',
                    ' 带 -cc 时改为清理多余 Claude Code 会话(不动 cct 缓存)。',
                    ' 清理会话会先打印「过滤规则 + 待删清单 + 理由」,确认后才删除;',
                    ' 删除不可逆,删除前请核对清单。', '',
                    '选项:',
                    ' -cc 清理多余 Claude Code 会话(跳过缓存清理)',
                    ' -y, --yes 删除前跳过二次确认',
                    ' --keep <数> 每目录保留的会话数(默认 1,-cc 用)',
                    ' --min <行数> 会话行数阈值,≥ 该行数才算有效会话(默认 20,-cc 用)',
                    ' --dry-run 只打印规则与清单,不删除(-cc 用)',
                    ' -h, --help 显示本命令帮助')
            }
            default {
                return @("未知命令: $Command,无法显示帮助", '', '用 cct -h 查看全局帮助。')
            }
        }
    }
    return @(
        'cct —— Claude Code 任务选择器', '',
        '用法: cct <命令> [选项]', '',
        '命令([交互] 打开全屏选择器操作;[指令] 直接输出/执行):',
        ' [交互] ui 打开交互式选择器 cct ui [过滤词]',
        ' [指令] list 列出任务 cct list [过滤词...]',
        ' [指令] find 查找会话 cct find <过滤词>',
        ' [指令] run 定位并启动会话 cct run [选项] (-i <会话id> | <过滤词>)',
        ' [指令] clear 清理缓存或多余会话 cct clear [-cc] [选项]',
        ' [指令] help 显示帮助 cct help',
        ' [指令] version 显示版本 cct version', '',
        '选项:',
        ' -h, --help 显示帮助',
        ' -v, --version 显示版本',
        ' --json 以 JSON 输出结果(list / find)',
        ' --dry-run 演练运行,不真正启动(run)',
        ' -i, --id <会话id> 按会话 ID 精准直达(run)', '',
        '示例:',
        ' cct 打开交互式选择器',
        ' cct ui 调优 打开选择器并预过滤「调优」',
        ' cct list 列出全部任务',
        ' cct find 部署 查找含「部署」的会话',
        ' cct run 调优 启动唯一匹配的「调优」会话',
        ' cct run -i 7df5c8c5-46f8-4a84-bd5c-799534ee99b6 按会话 ID 启动',
        ' cct find -h 查看 find 命令帮助'
    )
}