From b63b166734ddbc210c73e783d52002a2be4c5997 Mon Sep 17 00:00:00 2001 From: Ken Kuang Date: Wed, 11 Nov 2026 18:06:25 +0800 Subject: [PATCH 1/3] Add native PowerShell support for Copilot CLI Reuse the Python bootstrap for native Windows hooks and CLI launchers, and select PowerShell in Copilot slash commands. Repair stable launchers on prompt submission as well as session start so already-open sessions do not fail with a missing te.ps1. Cover native shells, argument forwarding, interpreter discovery, and the missing-launcher regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7520c60-7772-47ec-8bcf-90f5ec0785dc --- plugin/README.md | 11 +++- plugin/hooks/copilot-hooks.json | 6 ++ plugin/scripts/te-hook.ps1 | 6 ++ plugin/scripts/te.ps1 | 51 ++++++++++++++++ plugin/slash-commands/config.md | 11 +++- plugin/slash-commands/facts.md | 11 +++- plugin/slash-commands/microskills.md | 11 +++- plugin/slash-commands/repos.md | 11 +++- plugin/slash-commands/status.md | 11 +++- plugin/slash-commands/toolprune.md | 11 +++- plugin/slash-commands/untool.md | 11 +++- tests/test_plugin_package.py | 87 ++++++++++++++++++++++++++++ tokeneconomizer/plugin/controller.py | 9 +-- tokeneconomizer/plugin/paths.py | 40 +++++++------ 14 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 plugin/scripts/te-hook.ps1 create mode 100644 plugin/scripts/te.ps1 diff --git a/plugin/README.md b/plugin/README.md index 1483d7f..d4d2a8e 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -190,6 +190,11 @@ copilot plugin list # tokeneconomizer@tokeneconomizer ... (enabled) Copilot loads the plugin from the clone directly, without copying it. +On Windows, its hooks and slash commands use PowerShell; Git Bash is not required. +From a PowerShell terminal in the clone, run `.\plugin\scripts\te.ps1 --host copilot status`. +The launcher honors `TE_PYTHON`, then looks for `python3`, `python`, or `py`, preferring +interpreters outside WindowsApps. + **2. Check it.** In Copilot's interactive mode, type `/tokeneconomizer:status`. Each slash command is a model turn, and Copilot asks before running the plugin's script; choose **Yes**. Slash commands work only in interactive mode; the plugin's notices also appear under `copilot -p`. @@ -211,7 +216,11 @@ start says so. `/tokeneconomizer:toolprune restore` turns the server back on, an `toolprune measure` shows the difference with no model call. From a terminal, inside the repository: `~/.tokeneconomizer/bin/te --host copilot ` (the -launcher appears after the first session). A turn that is still stuck after the loop guard's +launcher appears after the first session). In PowerShell, use +`& "$HOME\.tokeneconomizer\bin\te.ps1" --host copilot ` instead (or the same path under +`$env:TOKENECONOMIZER_HOME` when set). Session-start and prompt hooks create or repair the launchers, +so an open session picks up the PowerShell launcher on its next prompt after updating. +A turn that is still stuck after the loop guard's redirect shows "● TokenEconomizer: …" in the conversation (and on stdout under `copilot -p`); press **Esc** twice to interrupt it, or Ctrl+C to end a `copilot -p` run. diff --git a/plugin/hooks/copilot-hooks.json b/plugin/hooks/copilot-hooks.json index 15c8fd8..14ffcaa 100644 --- a/plugin/hooks/copilot-hooks.json +++ b/plugin/hooks/copilot-hooks.json @@ -7,6 +7,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 15 } ] @@ -18,6 +19,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 20 } ] @@ -29,6 +31,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 60 } ] @@ -40,6 +43,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 60 } ] @@ -51,6 +55,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 10 } ] @@ -62,6 +67,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", + "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 10 } ] diff --git a/plugin/scripts/te-hook.ps1 b/plugin/scripts/te-hook.ps1 new file mode 100644 index 0000000..877910c --- /dev/null +++ b/plugin/scripts/te-hook.ps1 @@ -0,0 +1,6 @@ +if ($MyInvocation.ExpectingInput) { + $input | & "$PSScriptRoot\te.ps1" -Hook @args +} else { + & "$PSScriptRoot\te.ps1" -Hook @args +} +exit $LASTEXITCODE diff --git a/plugin/scripts/te.ps1 b/plugin/scripts/te.ps1 new file mode 100644 index 0000000..6a46a13 --- /dev/null +++ b/plugin/scripts/te.ps1 @@ -0,0 +1,51 @@ +param([switch]$Hook) + +$env:PYTHONUTF8 = '1' +$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding +$mode = if ($Hook) { 'hook' } else { 'cli' } +$payload = if ($Hook) { + if ($MyInvocation.ExpectingInput) { $input -join "`n" } else { [Console]::In.ReadToEnd() } +} +$candidates = @( + foreach ($name in @($env:TE_PYTHON, 'python3', 'python', 'py')) { + if ($name) { + Get-Command $name -CommandType Application -All -ErrorAction SilentlyContinue + } + } +) +$python = $candidates | Where-Object { $_.Source -notlike '*\WindowsApps\*' } | Select-Object -First 1 +if (-not $python) { + foreach ($candidate in $candidates) { + & $candidate.Source -c pass *> $null + if ($LASTEXITCODE -eq 0) { $python = $candidate; break } + } +} +if (-not $python) { + $message = 'te: no Python on PATH (set TE_PYTHON)' + if ($Hook) { + $root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { + Join-Path $HOME '.tokeneconomizer' + } + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::AppendAllText((Join-Path $root 'errors.log'), "$message`n") + } catch [IO.IOException] { + [Console]::Error.WriteLine($_.Exception.Message) + } catch [UnauthorizedAccessException] { + [Console]::Error.WriteLine($_.Exception.Message) + } + '{}' + exit 0 + } + [Console]::Error.WriteLine($message) + exit 1 +} +$pythonArgs = @() +if ($python.Name -eq 'py.exe' -or $python.Name -eq 'py') { $pythonArgs += '-3' } +$pythonArgs += @((Join-Path $PSScriptRoot 'te_bootstrap.py'), $mode) +if ($Hook) { + $payload | & $python.Source @pythonArgs @args +} else { + & $python.Source @pythonArgs @args +} +exit $LASTEXITCODE diff --git a/plugin/slash-commands/config.md b/plugin/slash-commands/config.md index 913ede6..fe4c3d5 100644 --- a/plugin/slash-commands/config.md +++ b/plugin/slash-commands/config.md @@ -3,7 +3,16 @@ description: Show or change TokenEconomizer's settings - turn each mechanism on argument-hint: "[show | set KEY=VALUE ... [--global] | unset KEY ... [--global]]" --- The arguments I typed after the command are: "$ARGUMENTS" (it shows every setting when there are none). -Run this exact command with the Bash tool, then show me its output verbatim in a code block: +Run the command for this shell, then show me its output verbatim in a code block. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path config $ARGUMENTS +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" config $ARGUMENTS diff --git a/plugin/slash-commands/facts.md b/plugin/slash-commands/facts.md index 9bfc20e..9623871 100644 --- a/plugin/slash-commands/facts.md +++ b/plugin/slash-commands/facts.md @@ -1,7 +1,16 @@ --- description: Earlier name of /tokeneconomizer:microskills - show the repository facts TokenEconomizer injects --- -Run this exact command with the Bash tool, then show me its output verbatim in a code block, with no commentary: +Run the command for this shell, then show me its output verbatim in a code block, with no commentary. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path microskills +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" microskills diff --git a/plugin/slash-commands/microskills.md b/plugin/slash-commands/microskills.md index 4f65110..5bc2ae4 100644 --- a/plugin/slash-commands/microskills.md +++ b/plugin/slash-commands/microskills.md @@ -1,7 +1,16 @@ --- description: Show the repository facts (micro-skills) TokenEconomizer learned and injects at session start --- -Run this exact command with the Bash tool, then show me its output verbatim in a code block, with no commentary: +Run the command for this shell, then show me its output verbatim in a code block, with no commentary. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path microskills +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" microskills diff --git a/plugin/slash-commands/repos.md b/plugin/slash-commands/repos.md index e0a4778..99f2141 100644 --- a/plugin/slash-commands/repos.md +++ b/plugin/slash-commands/repos.md @@ -3,7 +3,16 @@ description: List every repository TokenEconomizer keeps state for, or merge one argument-hint: "[list | migrate | merge KEY [INTO]]" --- The arguments I typed after the command are: "$ARGUMENTS" (it lists the repositories when there are none). -Run this exact command with the Bash tool, then show me its output verbatim in a code block: +Run the command for this shell, then show me its output verbatim in a code block. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path repos $ARGUMENTS +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" repos $ARGUMENTS diff --git a/plugin/slash-commands/status.md b/plugin/slash-commands/status.md index 8f01053..34991ef 100644 --- a/plugin/slash-commands/status.md +++ b/plugin/slash-commands/status.md @@ -1,7 +1,16 @@ --- description: Show what TokenEconomizer has learned for this repository and what it has done --- -Run this exact command with the Bash tool, then show me its output verbatim in a code block. Add nothing unless the output contains a warning, in which case explain the warning in one sentence. +Run the command for this shell, then show me its output verbatim in a code block. Add nothing unless the output contains a warning, in which case explain the warning in one sentence. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path status +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" status diff --git a/plugin/slash-commands/toolprune.md b/plugin/slash-commands/toolprune.md index c757e9b..afa3adf 100644 --- a/plugin/slash-commands/toolprune.md +++ b/plugin/slash-commands/toolprune.md @@ -3,7 +3,16 @@ description: Review the tools TokenEconomizer left out of this repository's sess argument-hint: "[show | apply [TOOL ...] | restore [TOOL ...] | refresh-catalog | measure]" --- The arguments I typed after the command are: "$ARGUMENTS" (it shows the proposal when there are none). -Run this exact command with the Bash tool, then show me its output verbatim in a code block: +Run the command for this shell, then show me its output verbatim in a code block. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path toolprune $ARGUMENTS +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" toolprune $ARGUMENTS diff --git a/plugin/slash-commands/untool.md b/plugin/slash-commands/untool.md index a5fc7b9..a6649c8 100644 --- a/plugin/slash-commands/untool.md +++ b/plugin/slash-commands/untool.md @@ -3,7 +3,16 @@ description: Earlier name of /tokeneconomizer:toolprune - review the deny list, argument-hint: "[show | apply [TOOL ...] | restore [TOOL ...] | refresh-catalog | measure]" --- The arguments I typed after the command are: "$ARGUMENTS" (it shows the proposal when there are none). -Run this exact command with the Bash tool, then show me its output verbatim in a code block: +Run the command for this shell, then show me its output verbatim in a code block. + +In Copilot CLI on Windows, use the PowerShell tool: + +```powershell +$root = if ($env:TOKENECONOMIZER_HOME) { $env:TOKENECONOMIZER_HOME } else { Join-Path $HOME '.tokeneconomizer' } +& (Join-Path $root 'bin\te.ps1') --host copilot --cwd (Get-Location).Path toolprune $ARGUMENTS +``` + +Otherwise, use the Bash tool: ``` te="${CLAUDE_PLUGIN_ROOT}/scripts/te"; [ -x "$te" ] || te="${TOKENECONOMIZER_HOME:-$HOME/.tokeneconomizer}/bin/te"; "$te" --cwd "$(pwd)" toolprune $ARGUMENTS diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 011f962..b5569d6 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -70,6 +70,7 @@ def test_hook_files_run_the_launcher_on_every_event(name, host): assert f"--host {host}" in h["command"] if host == "copilot": assert h["command"].startswith('"${PLUGIN_ROOT}/scripts/te-hook"') + assert "te-hook.ps1" in h["windows"] and "--host copilot" in h["windows"] if host == "codex": assert h["commandWindows"] == CODEX_WINDOWS if event == "SessionEnd": @@ -143,6 +144,92 @@ def _hook_env(tmp_path, **extra): return env +POWERSHELLS = [p for p in (shutil.which("powershell"), shutil.which("pwsh")) if p] + + +@pytest.mark.parametrize("shell", POWERSHELLS or [None]) +@pytest.mark.parametrize("event", ["SessionStart", "UserPromptSubmit"]) +@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason="native Windows PowerShell") +def test_powershell_hook_and_slash_commands_without_git_bash(tmp_path, shell, event): + work = tmp_path / "work space \u00e9" + work.mkdir() + scripts = tmp_path / "plugin's copy" / "scripts" + shutil.copytree(os.path.join(PLUGIN, "scripts"), scripts, + ignore=shutil.ignore_patterns("__pycache__")) + env = _hook_env(tmp_path, TE_PYTHON=sys.executable, TE_CORE_PATH=REPO, + CLAUDE_PLUGIN_ROOT=str(scripts.parent)) + env["PATH"] = os.path.dirname(sys.executable) + command = next(_commands(_json(PLUGIN, "hooks", "copilot-hooks.json")))[2]["windows"] + + def run(script, payload=None): + return subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + script], input=payload, capture_output=True, encoding="utf-8", + env=env, cwd=work, timeout=60) + + payload = json.dumps({"hook_event_name": event, "session_id": "s", + "cwd": str(work), "source": "new"}, ensure_ascii=False) + out = run(command, payload) + assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr + assert list((tmp_path / "home" / "repos").glob("*/copilot/sessions/s.json")) + assert not (tmp_path / "home" / "errors.log").exists() + for name in ("status", "config", "repos", "microskills", "facts", "toolprune", "untool"): + text = open(os.path.join(PLUGIN, "slash-commands", name + ".md"), encoding="utf-8").read() + script = re.search(r"```powershell\n(.*?)\n```", text, re.S).group(1) + out = run(script.replace("$ARGUMENTS", "")) + assert out.returncode == 0 and out.stdout.strip(), (name, out.stderr) + assert (tmp_path / "home" / "bin" / "te.ps1").exists() + config = open(os.path.join(PLUGIN, "slash-commands", "config.md"), encoding="utf-8").read() + script = re.search(r"```powershell\n(.*?)\n```", config, re.S).group(1) + out = run(script.replace("$ARGUMENTS", "set loopguard=off")) + assert out.returncode == 0, out.stderr + settings = list((tmp_path / "home" / "repos").glob("*/config.json")) + assert settings and json.loads(settings[0].read_text())["loopguard"] == "off" + out = run(f"'{payload.replace(chr(39), chr(39) * 2)}' | {command}") + assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr + env.update(PATH="", TE_PYTHON=str(tmp_path / "missing.exe")) + out = run(command, payload) + assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr + assert "no Python" in (tmp_path / "home" / "errors.log").read_text() + out = run("& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te.ps1') status") + assert out.returncode == 1 and "no Python" in out.stderr + + +@pytest.mark.parametrize("shell", POWERSHELLS or [None]) +@pytest.mark.skipif(os.name != "nt" or not POWERSHELLS, reason="native Windows PowerShell") +def test_powershell_launcher_forwards_arguments_and_exit_code(tmp_path, shell): + scripts = tmp_path / "plugin's scripts" + scripts.mkdir() + shutil.copy(os.path.join(PLUGIN, "scripts", "te.ps1"), scripts) + (scripts / "te_bootstrap.py").write_text( + "import json, sys\nprint(json.dumps(sys.argv[1:]))\nsys.exit(7)\n") + command = "& '%s' --host copilot --cwd 'a path' config set 'paid_model=a&b'; exit $LASTEXITCODE" % ( + str(scripts / "te.ps1").replace("'", "''")) + out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + capture_output=True, encoding="utf-8", + env=_hook_env(tmp_path, TE_PYTHON=sys.executable), timeout=60) + assert out.returncode == 7, out.stderr + assert json.loads(out.stdout) == ["cli", "--host", "copilot", "--cwd", "a path", + "config", "set", "paid_model=a&b"] + winapps = tmp_path / "Microsoft" / "WindowsApps" + winapps.mkdir(parents=True) + marker = tmp_path / "alias_called" + (winapps / "python3.cmd").write_text(f'@echo alias>"{marker}"\n@exit /b 1\n') + env = _hook_env(tmp_path, TE_PYTHON="", + PATH=os.pathsep.join((str(winapps), os.path.dirname(sys.executable)))) + out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + capture_output=True, encoding="utf-8", env=env, timeout=60) + assert out.returncode == 7, out.stderr + assert json.loads(out.stdout)[0] == "cli" + assert not marker.exists() + (winapps / "python3.cmd").write_text(f'@"{sys.executable}" %*\n') + env["PATH"] = str(winapps) + command = "& '%s' status; exit $LASTEXITCODE" % str(scripts / "te.ps1").replace("'", "''") + out = subprocess.run([shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + capture_output=True, encoding="utf-8", env=env, timeout=60) + assert out.returncode == 7, out.stderr + assert json.loads(out.stdout) == ["cli", "status"] + + def test_the_launcher_runs_the_dispatcher_from_the_checkout(tmp_path): work = tmp_path / "work" work.mkdir() diff --git a/tokeneconomizer/plugin/controller.py b/tokeneconomizer/plugin/controller.py index cfef0b2..d7fa25a 100644 --- a/tokeneconomizer/plugin/controller.py +++ b/tokeneconomizer/plugin/controller.py @@ -95,16 +95,17 @@ def handle(self): self.ctx.settle() self.cfg = self.ctx.config event = self.p.get("hook_event_name") or "" + if event in ("SessionStart", "UserPromptSubmit"): + try: + ensure_launcher(self.ctx.env) + except OSError: + self._record_error() handler = self.handler(event) return hookqueue.run(self, event, handler) if handler else {} # ---- SessionStart ----------------------------------------------------------------- def session_start(self): source = self.p.get("source") or "startup" - try: - ensure_launcher(self.ctx.env) - except OSError: - pass s = self._session() s["source"] = source s.setdefault("cwd", self.host.cwd(self.p, self.ctx.env)) diff --git a/tokeneconomizer/plugin/paths.py b/tokeneconomizer/plugin/paths.py index d52ae56..ea7bb8a 100644 --- a/tokeneconomizer/plugin/paths.py +++ b/tokeneconomizer/plugin/paths.py @@ -53,15 +53,16 @@ def launcher_path(env=None): def ensure_launcher(env=None): - """Keep `/bin/te` pointing at this plugin's `te`. Returns its path, or None. + """Keep `bin/te` (and `bin/te.ps1` on Windows) pointing at this plugin. Returns te or None. A slash command has to name the CLI's path. Claude Code substitutes `${CLAUDE_PLUGIN_ROOT}` into a command's text; the Copilot CLI substitutes nothing but `$ARGUMENTS` (1.0.83: `${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, `${COPILOT_PLUGIN_ROOT}`, `{{plugin_root}}` all - arrive literally), and its shell exports none of them. So every hook session start writes - this one-line launcher, the commands fall back to it, and a person on any host has a stable - `te` to run. The plugin's location comes from the bootstrap (`TE_PLUGIN_ROOT`); the file is - rewritten only when it changes, as an installed copy's path does across updates.""" + arrive literally), and its shell exports none of them. Session-start and prompt hooks keep + these launchers current, including in sessions already open when the plugin was updated. + The commands fall back to them, and a person on any host has a stable `te` to run. The plugin's + location comes from the bootstrap (`TE_PLUGIN_ROOT`); each file is rewritten only when it + changes, as an installed copy's path does across updates.""" import shlex env = os.environ if env is None else env plugin = env.get("TE_PLUGIN_ROOT") @@ -71,18 +72,23 @@ def ensure_launcher(env=None): path = launcher_path(env) body = ("#!/bin/sh\n# Written by the TokenEconomizer plugin at session start: its `te` " "command line.\nexec %s \"$@\"\n" % shlex.quote(target)) - try: - with open(path, encoding="utf-8") as fh: - if fh.read() == body: - return path - except OSError: - pass - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = "%s.%d.tmp" % (path, os.getpid()) - with open(tmp, "w", encoding="utf-8") as fh: - fh.write(body) - os.chmod(tmp, 0o700) - os.replace(tmp, path) + launchers = {path: body} + if os.name == "nt": + launchers[path + ".ps1"] = "& '%s' @args\nexit $LASTEXITCODE\n" % ( + target.replace("'", "''") + ".ps1") + for launcher, body in launchers.items(): + try: + with open(launcher, encoding="utf-8") as fh: + if fh.read() == body: + continue + except OSError: + pass + os.makedirs(os.path.dirname(launcher), exist_ok=True) + tmp = "%s.%d.tmp" % (launcher, os.getpid()) + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(body) + os.chmod(tmp, 0o700) + os.replace(tmp, launcher) return path From f309ceef8aba5ccabab261353f7544c775d21d17 Mon Sep 17 00:00:00 2001 From: Ken Kuang Date: Wed, 11 Nov 2026 18:48:41 +0800 Subject: [PATCH 2/3] Preserve UTF-8 hook input in Windows PowerShell Set the console input encoding before reading redirected hook JSON. Assert that the session preserves the exact non-ASCII working directory, and expose errors.log when fail-open hook behavior hides a startup error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7520c60-7772-47ec-8bcf-90f5ec0785dc --- plugin/scripts/te.ps1 | 2 +- tests/test_plugin_package.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugin/scripts/te.ps1 b/plugin/scripts/te.ps1 index 6a46a13..89bbf29 100644 --- a/plugin/scripts/te.ps1 +++ b/plugin/scripts/te.ps1 @@ -1,7 +1,7 @@ param([switch]$Hook) $env:PYTHONUTF8 = '1' -$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding +$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $mode = if ($Hook) { 'hook' } else { 'cli' } $payload = if ($Hook) { if ($MyInvocation.ExpectingInput) { $input -join "`n" } else { [Console]::In.ReadToEnd() } diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index b5569d6..9a3f9e1 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -170,8 +170,11 @@ def run(script, payload=None): "cwd": str(work), "source": "new"}, ensure_ascii=False) out = run(command, payload) assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr - assert list((tmp_path / "home" / "repos").glob("*/copilot/sessions/s.json")) - assert not (tmp_path / "home" / "errors.log").exists() + errors = tmp_path / "home" / "errors.log" + assert not errors.exists(), errors.read_text(encoding="utf-8") + sessions = list((tmp_path / "home" / "repos").glob("*/copilot/sessions/s.json")) + assert sessions, (out.stdout, out.stderr) + assert json.loads(sessions[0].read_text(encoding="utf-8"))["cwd"] == str(work) for name in ("status", "config", "repos", "microskills", "facts", "toolprune", "untool"): text = open(os.path.join(PLUGIN, "slash-commands", name + ".md"), encoding="utf-8").read() script = re.search(r"```powershell\n(.*?)\n```", text, re.S).group(1) From 8466f75bb730a6db5a92fb7bb6f4ba9f03a9380c Mon Sep 17 00:00:00 2001 From: Ken Kuang Date: Wed, 11 Nov 2026 23:08:38 +0800 Subject: [PATCH 3/3] Use Copilot's native plugin root for Windows hooks Remove the dependency on CLAUDE_PLUGIN_ROOT in all six Windows hook commands. Exercise hook and slash-command execution with only COPILOT_PLUGIN_ROOT defined so the native root contract is covered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7520c60-7772-47ec-8bcf-90f5ec0785dc --- plugin/hooks/copilot-hooks.json | 12 ++++++------ tests/test_plugin_package.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugin/hooks/copilot-hooks.json b/plugin/hooks/copilot-hooks.json index 14ffcaa..898d5bd 100644 --- a/plugin/hooks/copilot-hooks.json +++ b/plugin/hooks/copilot-hooks.json @@ -7,7 +7,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 15 } ] @@ -19,7 +19,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 20 } ] @@ -31,7 +31,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 60 } ] @@ -43,7 +43,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 60 } ] @@ -55,7 +55,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 10 } ] @@ -67,7 +67,7 @@ { "type": "command", "command": "\"${PLUGIN_ROOT}/scripts/te-hook\" --host copilot", - "windows": "& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", + "windows": "& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te-hook.ps1') --host copilot", "timeout": 10 } ] diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 9a3f9e1..0842c6e 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -157,7 +157,7 @@ def test_powershell_hook_and_slash_commands_without_git_bash(tmp_path, shell, ev shutil.copytree(os.path.join(PLUGIN, "scripts"), scripts, ignore=shutil.ignore_patterns("__pycache__")) env = _hook_env(tmp_path, TE_PYTHON=sys.executable, TE_CORE_PATH=REPO, - CLAUDE_PLUGIN_ROOT=str(scripts.parent)) + COPILOT_PLUGIN_ROOT=str(scripts.parent)) env["PATH"] = os.path.dirname(sys.executable) command = next(_commands(_json(PLUGIN, "hooks", "copilot-hooks.json")))[2]["windows"] @@ -193,7 +193,7 @@ def run(script, payload=None): out = run(command, payload) assert out.returncode == 0 and json.loads(out.stdout) == {}, out.stderr assert "no Python" in (tmp_path / "home" / "errors.log").read_text() - out = run("& (Join-Path $env:CLAUDE_PLUGIN_ROOT 'scripts\\te.ps1') status") + out = run("& (Join-Path $env:COPILOT_PLUGIN_ROOT 'scripts\\te.ps1') status") assert out.returncode == 1 and "no Python" in out.stderr