feat: add tran win skills discovery

This commit is contained in:
Codex
2026-05-25 19:06:18 +00:00
parent a1841adcaa
commit b71bac57c2
5 changed files with 121 additions and 19 deletions
+2
View File
@@ -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 `<provider>:/absolute/workspace`; WSL providers can use `<provider>:win cmd <command-line>` to run Windows host cmd.exe with UTF-8 defaults, and `<provider>:win/c/test cmd cd` maps the Windows cwd to `C:\\test`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> 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.",
"`<provider>: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.",
+95 -15
View File
@@ -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 <command-line>`);
throw new Error(`unsupported ssh win operation: ${operation}; use ssh ${route.providerId}:win cmd <command-line> 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 {
+16 -2
View File
@@ -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",