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..898d5bd 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:COPILOT_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:COPILOT_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:COPILOT_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:COPILOT_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:COPILOT_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:COPILOT_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..89bbf29 --- /dev/null +++ b/plugin/scripts/te.ps1 @@ -0,0 +1,51 @@ +param([switch]$Hook) + +$env:PYTHONUTF8 = '1' +$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() } +} +$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..0842c6e 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,95 @@ 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, + COPILOT_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 + 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) + 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:COPILOT_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