From b71bac57c23a523e2efe179e578ec6e3d19d465b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 25 May 2026 19:06:18 +0000 Subject: [PATCH] feat: add tran win skills discovery --- docs/reference/cli.md | 5 +- docs/reference/windows-passthrough.md | 5 +- scripts/src/help.ts | 2 + scripts/src/ssh.ts | 110 ++++++++++++++++++--- scripts/ssh-argv-guidance-contract-test.ts | 18 +++- 5 files changed, 121 insertions(+), 19 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8a06bacb..4d236814 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -199,7 +199,9 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- `ssh` 的 route 语法是 `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`。第一个 argv token 只负责定位分布式目标,不表达操作;第一个 token 后面的所有 token 才进入 operation 解析器。Host workspace route 使用 `:/absolute/workspace`,例如 `D601:/home/ubuntu/workspace/hwlab-dev`,CLI 会把该路径作为远端 cwd 传给 Host SSH 维护桥,后续 `pwd`、`git`、`script`、`apply-patch` 等操作仍按同一套 operation parser 执行。`:host:/absolute/workspace` 是等价长写法;workspace 必须是绝对路径,远端是否存在由维护桥实际 `cd` 失败或成功证明。 -当前稳定 plane 包括 `win` 和 `k3s`。`:win cmd ` 在 WSL provider 上启动 Windows host 的 `cmd.exe`,CLI 会在命令前固定执行 `chcp 65001>nul`、`set PYTHONUTF8=1` 和 `set PYTHONIOENCODING=utf-8`,让中文和 UTF-8 输出成为默认行为;需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test cmd cd` 会先在 Windows cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 +当前稳定 plane 包括 `win` 和 `k3s`。`:win cmd ` 在 WSL provider 上启动 Windows host 的 `cmd.exe`,CLI 会在命令前固定执行 `chcp 65001>nul`、`set "PYTHONUTF8=1"` 和 `set "PYTHONIOENCODING=utf-8"`,让中文和 UTF-8 输出成为默认行为;需要 Windows 当前目录时使用 slash 路由 `:win//`,例如 `D601:win/c/test cmd cd` 会先在 Windows cmd 内执行 `cd /d "C:\test"`。`win32` 不是合法 plane,调用者必须改用 `win`。 + +`:win skills [--scope agents|codex|all] [--limit N]` 是 Windows 用户 skill 发现入口,默认只读取当前 Windows 用户的 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时检查 `%USERPROFILE%\.codex\skills` 时显式加 `--scope all`;不要为了列 skill 手写 `cmd dir` 或宽泛扫描整个用户目录。 `D601:k3s` 或 `G14:k3s` 定位到对应 provider 的原生 k3s 控制面;`:k3s::[:container]` 定位到 namespace 下的一个默认 deployment workload;若目标是具体 Pod,workload 段写成 `pod/`,若目标是 Deployment,也可以显式写 `deployment/` 或简写 ``。pod 内 workspace 使用 slash 后缀表达,例如 `D601:k3s:hwlab-dev:hwlab-cloud-api/app` 会定位到 deployment `hwlab-cloud-api` 并在 pod 内先 `cd /app`,`D601:k3s:hwlab-dev:pod/hwlab-cloud-api-abc/workspace/app:api` 会定位到 pod、container 和 `/workspace/app`。`kubectl`、`logs`、`script`、`apply-patch`、`exec` 和普通容器命令都是 route 后面的 operation,这样路由子模块和操作子模块可以独立扩展。 @@ -213,6 +215,7 @@ bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch bun scripts/cli.ts ssh D601:win cmd ver bun scripts/cli.ts ssh D601:win/c/test cmd cd +bun scripts/cli.ts ssh D601:win skills --limit 20 bun scripts/cli.ts ssh G14:k3s bun scripts/cli.ts ssh G14:k3s kubectl get pipelineruns -n hwlab-ci printf 'kubectl get deploy -n hwlab-dev\n' | bun scripts/cli.ts ssh D601:k3s script diff --git a/docs/reference/windows-passthrough.md b/docs/reference/windows-passthrough.md index e3481a13..d64279b4 100644 --- a/docs/reference/windows-passthrough.md +++ b/docs/reference/windows-passthrough.md @@ -19,12 +19,15 @@ Windows 透传用于让 WSL provider 通过 UniDesk 的 Host SSH / WSL SSH 维 ```bash tran :win cmd ver tran :win/c/test cmd cd +tran :win skills --limit 20 ``` -`:win` 只定位到 WSL provider 的 Windows host cmd 执行面,后面的 `cmd ` 才是操作。`:win//` 使用 slash 语法设置 Windows cwd,例如 `D601:win/c/test` 会在 Windows cmd 内执行 `cd /d "C:\test"`。该入口固定在运行用户命令前设置 `chcp 65001>nul`、`PYTHONUTF8=1` 和 `PYTHONIOENCODING=utf-8`,因此中文 stdout/stderr 和 Python 子进程默认按 UTF-8 处理。 +`:win` 只定位到 WSL provider 的 Windows host cmd 执行面,后面的 `cmd ` 才是操作。`:win//` 使用 slash 语法设置 Windows cwd,例如 `D601:win/c/test` 会在 Windows cmd 内执行 `cd /d "C:\test"`。该入口固定在运行用户命令前设置 `chcp 65001>nul`、`set "PYTHONUTF8=1"` 和 `set "PYTHONIOENCODING=utf-8"`,因此中文 stdout/stderr 和 Python 子进程默认按 UTF-8 处理;环境变量必须使用 cmd 引号写法,避免 `set VAR=1 && ...` 把分隔符前的空格写进变量值。 长期命名只允许 `win`,不允许 `win32`。`win` route 仍复用 provider-gateway 的 Host SSH / WSL SSH 维护桥抵达目标 WSL provider,但不会依赖 WSL 登录 shell 的 Windows `PATH`,也不再通过节点侧 `win-cmd` wrapper 二次转发。CLI 固定从 `/mnt/c/Windows` 启动 Windows PowerShell UTF-8 launcher,再由 launcher 执行 `C:\Windows\System32\cmd.exe /d /s /c `;PowerShell 只负责把 Console/Input/OutputEncoding 设为 UTF-8 并保留 cmd 的 exit code,用户命令语义仍属于 Windows `cmd.exe`。需要 Keil、串口、Windows Node/npm 或复杂路径转换时,继续使用下文的 skill/wrapper 入口,直到对应能力被纳入 UniDesk 内置 route。 +`:win skills` 是 Windows 用户 skill 发现入口,默认读取当前 Windows 用户 `%USERPROFILE%\.agents\skills`,输出 JSON 中包含 `roots`、`counts` 和每个 skill 的 `name`、`path`、`skillFile`、`description`。需要同时读取 `%USERPROFILE%\.codex\skills` 时使用 `--scope all`;只看 Codex skill 时用 `--scope codex`。该入口用于替代手写 `cmd dir`、`powershell Get-ChildItem` 或宽泛扫描用户目录。 + ## Skill Discovery 先用 UniDesk SSH 透传内置的 skill 发现入口确认目标节点上 WSL 与 Windows 两侧 skill 的实际位置: diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 9841d925..008ee96f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -158,6 +158,7 @@ export function sshHelp(): unknown { "bun scripts/cli.ts ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch", "bun scripts/cli.ts ssh D601:win cmd ver", "bun scripts/cli.ts ssh D601:win/c/test cmd cd", + "bun scripts/cli.ts ssh D601:win skills [--scope agents|codex|all] [--limit N]", "bun scripts/cli.ts ssh D601:k3s", "bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev", "bun scripts/cli.ts ssh G14:k3s", @@ -180,6 +181,7 @@ export function sshHelp(): unknown { "script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.", "Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `:/absolute/workspace`; WSL providers can use `:win cmd ` to run Windows host cmd.exe with UTF-8 defaults, and `:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use :k3s for the control plane, :k3s:: for a workload, and :k3s::/ for a pod workspace.", "Use `win`, not `win32`; the win route sets chcp 65001, PYTHONUTF8=1, and PYTHONIOENCODING=utf-8 before running the requested cmd command line.", + "`:win skills` discovers the current Windows user's `%USERPROFILE%\\.agents\\skills` by default; use `--scope all` to include `%USERPROFILE%\\.codex\\skills`.", "Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.", "Do not use post-provider shorthand such as `ssh G14 k3s ...`; write `ssh G14:k3s ...` so location and operation stay separated.", "If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.", diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 8e0a4154..25b41855 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -943,22 +943,22 @@ function parseWinRouteWorkspace(providerId: string, tail: string): string | null function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs { const operation = args[0] ?? ""; if (operation.length === 0) { - throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver`); + throw new Error(`ssh ${route.raw} requires a Windows operation, for example: ssh ${route.providerId}:win cmd ver or ssh ${route.providerId}:win skills`); + } + if (operation === "skills" || operation === "skill-discover" || operation === "discover-skills") { + return { + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsSkillsDiscoveryScript(args.slice(1))), + requiresStdin: false, + invocationKind: "helper", + }; } if (operation !== "cmd" && operation !== "cmd.exe") { - throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd `); + throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd or ssh ${route.providerId}:win skills`); } const commandArgs = args[1] === "--" ? args.slice(2) : args.slice(1); if (commandArgs.length === 0) throw new Error(`ssh ${route.raw} cmd requires a command line, for example: ssh ${route.providerId}:win cmd ver`); return { - remoteCommand: shellArgv([ - windowsPowerShellExePath, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - buildWindowsPowerShellEncodedCommand(buildWindowsCmdLine(commandArgs.join(" "), route.workspace)), - ]), + remoteCommand: buildWindowsPowerShellInvocation(buildWindowsCmdLauncherScript(buildWindowsCmdLine(commandArgs.join(" "), route.workspace))), requiresStdin: false, invocationKind: "argv", }; @@ -967,8 +967,8 @@ function parseWinRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs function buildWindowsCmdLine(userCommand: string, cwd: string | null): string { const parts = [ "chcp 65001>nul", - "set PYTHONUTF8=1", - "set PYTHONIOENCODING=utf-8", + 'set "PYTHONUTF8=1"', + 'set "PYTHONIOENCODING=utf-8"', ]; if (cwd !== null) parts.push(`cd /d ${windowsCmdQuote(cwd)}`); parts.push(userCommand); @@ -980,12 +980,82 @@ function windowsCmdQuote(value: string): string { return `"${value}"`; } +function parseWindowsSkillsOptions(args: string[]): { limit: number; scopes: Array<"agents" | "codex"> } { + let limit = 100; + let scope: "agents" | "codex" | "all" = "agents"; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "--limit") { + const value = args[index + 1]; + if (value === undefined) throw new Error("ssh win skills --limit requires a value"); + limit = positiveInt(value, "ssh win skills --limit"); + index += 1; + continue; + } + if (arg === "--scope") { + const value = args[index + 1]; + if (value === undefined) throw new Error("ssh win skills --scope requires a value"); + if (value !== "agents" && value !== "codex" && value !== "all") throw new Error("ssh win skills --scope must be one of: agents, codex, all"); + scope = value; + index += 1; + continue; + } + if (arg === "--all") { + scope = "all"; + continue; + } + throw new Error(`unsupported ssh win skills option: ${arg}`); + } + return { limit, scopes: scope === "all" ? ["agents", "codex"] : [scope] }; +} + +function buildWindowsSkillsDiscoveryScript(args: string[]): string { + const options = parseWindowsSkillsOptions(args); + const rootEntries = options.scopes.map((scope) => { + const relative = scope === "agents" ? ".agents\\skills" : ".codex\\skills"; + return `@{ Scope = ${powerShellSingleQuote(scope)}; Path = (Join-Path $env:USERPROFILE ${powerShellSingleQuote(relative)}) }`; + }).join(", "); + return [ + "$ErrorActionPreference = 'Stop';", + "$ProgressPreference = 'SilentlyContinue';", + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();", + "$OutputEncoding = [System.Text.UTF8Encoding]::new();", + `$limit = ${options.limit};`, + `$roots = @(${rootEntries});`, + "$rootRecords = @();", + "$skills = @();", + "foreach ($root in $roots) {", + " $exists = Test-Path -LiteralPath $root.Path -PathType Container;", + " $rootRecords += [pscustomobject]@{ scope = $root.Scope; path = $root.Path; exists = $exists };", + " if (-not $exists) { continue };", + " foreach ($dir in @(Get-ChildItem -LiteralPath $root.Path -Directory -Force | Sort-Object Name)) {", + " if ($skills.Count -ge $limit) { break };", + " $skillFile = Join-Path $dir.FullName 'SKILL.md';", + " $hasSkillMd = Test-Path -LiteralPath $skillFile -PathType Leaf;", + " if (-not $hasSkillMd) { continue };", + " $name = $dir.Name;", + " $description = '';", + " if ($hasSkillMd) {", + " foreach ($line in @(Get-Content -LiteralPath $skillFile -Encoding UTF8 -TotalCount 80)) {", + " if ($line -match '^name:\\s*(.+)$') { $name = $Matches[1].Trim(); continue };", + " if ($line -match '^description:\\s*(.+)$') { $description = $Matches[1].Trim(); continue };", + " }", + " }", + " $skills += [pscustomobject]@{ scope = $root.Scope; name = $name; directoryName = $dir.Name; path = $dir.FullName; skillFile = $skillFile; hasSkillMd = $hasSkillMd; description = $description };", + " }", + "}", + "$payload = [pscustomobject]@{ ok = $true; command = 'unidesk ssh win skills'; generatedAt = (Get-Date).ToUniversalTime().ToString('o'); user = $env:USERNAME; userProfile = $env:USERPROFILE; counts = [pscustomobject]@{ roots = $rootRecords.Count; skills = $skills.Count; limit = $limit }; roots = $rootRecords; skills = @($skills) };", + "$payload | ConvertTo-Json -Depth 6;", + ].join(" "); +} + function powerShellSingleQuote(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function buildWindowsPowerShellEncodedCommand(cmdLine: string): string { - const script = [ +function buildWindowsCmdLauncherScript(cmdLine: string): string { + return [ "$ErrorActionPreference = 'Stop';", "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();", @@ -995,7 +1065,17 @@ function buildWindowsPowerShellEncodedCommand(cmdLine: string): string { `& ${powerShellSingleQuote(windowsCmdExeNativePath)} /d /s /c ${powerShellSingleQuote(cmdLine)};`, "exit $LASTEXITCODE;", ].join(" "); - return Buffer.from(script, "utf16le").toString("base64"); +} + +function buildWindowsPowerShellInvocation(script: string): string { + return shellArgv([ + windowsPowerShellExePath, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ]); } export function sshRoutePayloadCwd(route: ParsedSshRoute): string | undefined { diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index bc60bba1..0abe9c73 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -133,13 +133,26 @@ export function runSshArgvGuidanceContract(): JsonRecord { const winCmd = parseSshInvocation("D601:win", ["cmd", "ver"]); assertCondition(winCmd.route.plane === "win" && winCmd.route.workspace === null, "win route must parse as the Windows cmd plane", winCmd); const winCmdScript = decodeWinEncodedCommand(winCmd.parsed.remoteCommand); - assertCondition(String(winCmd.parsed.remoteCommand).startsWith("'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'") && winCmdScript.includes("C:\\Windows\\System32\\cmd.exe") && winCmdScript.includes("chcp 65001>nul") && winCmdScript.includes("PYTHONIOENCODING"), "win route must execute cmd.exe through a UTF-8 Windows launcher", { winCmd, winCmdScript }); + assertCondition( + String(winCmd.parsed.remoteCommand).startsWith("'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'") + && winCmdScript.includes("C:\\Windows\\System32\\cmd.exe") + && winCmdScript.includes("chcp 65001>nul") + && winCmdScript.includes('set "PYTHONUTF8=1"') + && winCmdScript.includes('set "PYTHONIOENCODING=utf-8"'), + "win route must execute cmd.exe through a UTF-8 Windows launcher without trailing-space cmd set values", + { winCmd, winCmdScript }, + ); const winCmdCwd = parseSshInvocation("D601:win/c/test", ["cmd", "echo", "中文"]); assertCondition(winCmdCwd.route.plane === "win" && winCmdCwd.route.workspace === String.raw`C:\test`, "win route slash workspace must map to a Windows drive cwd", winCmdCwd); const winCmdCwdScript = decodeWinEncodedCommand(winCmdCwd.parsed.remoteCommand); assertCondition(winCmdCwdScript.includes('cd /d "C:\\test"') && winCmdCwdScript.includes("echo 中文"), "win route workspace must cd in Windows cmd before running the command", { winCmdCwd, winCmdCwdScript }); + const winSkills = parseSshInvocation("D601:win", ["skills", "--scope", "all", "--limit", "20"]); + assertCondition(winSkills.route.plane === "win" && winSkills.parsed.invocationKind === "helper", "win skills route must be a Windows helper operation", winSkills); + const winSkillsScript = decodeWinEncodedCommand(winSkills.parsed.remoteCommand); + assertCondition(winSkillsScript.includes(".agents\\skills") && winSkillsScript.includes(".codex\\skills") && winSkillsScript.includes("$limit = 20") && winSkillsScript.includes("ConvertTo-Json"), "win skills must discover Windows user skill roots as JSON", { winSkills, winSkillsScript }); + assertThrows( () => parseSshInvocation("D601:win32", ["cmd", "ver"]), /use D601:win/u, @@ -399,7 +412,7 @@ export function runSshArgvGuidanceContract(): JsonRecord { assertCondition(helpText.includes("inherits provider proxy variables"), "ssh help must state default script inherits provider proxy env", helpText); assertCondition(helpText.includes("not as a proxy workaround"), "ssh help must reserve --shell bash for bash syntax instead of proxy workarounds", helpText); assertCondition(helpText.includes("ssh D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch"), "ssh help must document host workspace routes", helpText); - assertCondition(helpText.includes("ssh D601:win cmd ver") && helpText.includes("ssh D601:win/c/test cmd cd"), "ssh help must document Windows cmd win routes", helpText); + assertCondition(helpText.includes("ssh D601:win cmd ver") && helpText.includes("ssh D601:win/c/test cmd cd") && helpText.includes("ssh D601:win skills"), "ssh help must document Windows cmd and skills win routes", helpText); assertCondition(helpText.includes("Use `win`, not `win32`") && helpText.includes("chcp 65001") && helpText.includes("PYTHONIOENCODING=utf-8"), "ssh help must document win route UTF-8 defaults and naming", helpText); assertCondition(helpText.includes("ssh D601:k3s kubectl get pods -n hwlab-dev"), "ssh help must document k3s kubectl operation", helpText); assertCondition(helpText.includes("ssh G14:k3s kubectl get pipelineruns -n hwlab-ci"), "ssh help must document G14 k3s route operation", helpText); @@ -482,6 +495,7 @@ export function runSshArgvGuidanceContract(): JsonRecord { "post-provider k3s shorthand is rejected so location and operation stay separated", "k3s route stays location-only while operations fix native kubeconfig and assemble kubectl exec as argv", "win route runs Windows cmd.exe with UTF-8 defaults and slash cwd syntax such as D601:win/c/test", + "win skills discovers the current Windows user's skill roots without hand-written cmd dir or PowerShell", "top-level remote option parsing preserves command-local -- separators for script -- sed -n style commands", "ssh-like timeout/kex failures emit one structured argv retry hint", "ssh runtime emits structured timing for slow operations over 10 seconds, including successful slow calls",