pi-pwsh-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented here.
4
+
5
+ ## 0.1.0 — 2026-07-30
6
+
7
+ ### Added
8
+
9
+ - Native model-callable `pwsh` tool built on Pi's standard shell tool definition.
10
+ - PowerShell routing for interactive `!` and `!!` commands.
11
+ - Strict global JSON configuration with environment overrides.
12
+ - Verified PowerShell 7 runtime discovery using an absolute executable path.
13
+ - UTF-8, multiline, and long-command transport using base64 source over stdin.
14
+ - Native exit-code preservation and PowerShell cmdlet failure mapping.
15
+ - Full Windows process-tree termination for timeout and cancellation.
16
+ - Python UTF-8 and unbuffered environment defaults that preserve explicit user values.
17
+ - Unit, Windows integration, runtime, exit-code, timeout, and cancellation tests.
18
+ - Upstream provenance and MIT attribution documentation.
19
+
20
+ ### Changed from @4fu/pi-pwsh 0.6.2
21
+
22
+ - Preserve Pi's dedicated `ls`, `find`, and `grep` tools by default.
23
+ - Use the resolved absolute `pwsh.exe` path for all foreground commands.
24
+ - Do not pass `-ExecutionPolicy Bypass` unless explicitly configured.
25
+ - Do not probe or advertise elevation by default.
26
+ - Do not translate or retry PowerShell source through `cmd.exe`.
27
+ - Fail without silently restoring Bash when PowerShell setup is invalid.
28
+
29
+ ### Removed from the lean core
30
+
31
+ - Detached background-job emulation.
32
+ - Background-operator rewriting.
33
+ - ConPTY sessions and `node-pty`.
34
+ - Named-pipe RPC and PowerShell-to-TUI request helpers.
35
+ - Runtime dependencies and native install scripts.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4fuu
4
+ Copyright (c) 2026 Tako
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # pi-pwsh-native
2
+
3
+ Native PowerShell 7 tooling for the [Pi coding agent](https://pi.dev/) on Windows.
4
+
5
+ `pi-pwsh-native` replaces Pi's model-callable `bash` tool with a tool named `pwsh` and routes interactive `!`/`!!` commands through the same verified PowerShell executable. It does not translate Bash syntax and does not silently fall back to another shell.
6
+
7
+ ## Design
8
+
9
+ - Registers a real `pwsh` tool so models are prompted to write PowerShell, not POSIX shell syntax.
10
+ - Reuses Pi's built-in shell tool definition for streaming previews, the standard renderer, non-zero exit handling, tail truncation (2,000 lines / 50 KB), and full-output temporary files.
11
+ - Keeps Pi's dedicated `read`, `write`, `edit`, `ls`, `find`, and `grep` tools active by default. Dedicated tools avoid unnecessary PowerShell startup and usually produce more focused output.
12
+ - Routes user-entered `!command` and `!!command` through PowerShell by default while preserving Pi's context-inclusion semantics.
13
+ - Requires PowerShell 7 or newer. Windows PowerShell 5.1 and Bash are not fallbacks.
14
+ - Resolves and uses an absolute `pwsh.exe` path for every command.
15
+ - Uses an ASCII bootstrap plus base64-over-stdin source transport. This preserves Unicode and multiline source without Windows command-line length limitations.
16
+ - Forces plain UTF-8 output without a BOM and preserves native executable exit codes.
17
+ - Kills the full process tree on timeout or cancellation.
18
+ - Has no runtime dependencies, native install scripts, telemetry, or network calls.
19
+
20
+ ## Requirements
21
+
22
+ - Windows
23
+ - PowerShell 7+
24
+ - Pi with extension support
25
+
26
+ The initial implementation is developed and tested against:
27
+
28
+ - Pi `0.83.0`
29
+ - PowerShell `7.6.4`
30
+
31
+ ## Installation
32
+
33
+ Install the pinned npm release:
34
+
35
+ ```powershell
36
+ pi install npm:pi-pwsh-native@0.1.0
37
+ ```
38
+
39
+ Alternatively, install the public GitHub package directly:
40
+
41
+ ```powershell
42
+ pi install git:github.com/takomine/pi-pwsh-native
43
+ ```
44
+
45
+ ## Try from source
46
+
47
+ ```powershell
48
+ Set-Location D:\pi\pi-pwsh-native
49
+ npm install --ignore-scripts
50
+ pi -e .\src\index.ts
51
+ ```
52
+
53
+ For global development use, add the source path to `~/.pi/agent/settings.json`:
54
+
55
+ ```json
56
+ {
57
+ "extensions": [
58
+ "D:/pi/pi-pwsh-native/src/index.ts"
59
+ ]
60
+ }
61
+ ```
62
+
63
+ Restart Pi or run `/reload` after changing extension code or configuration.
64
+
65
+ The npm package name is `pi-pwsh-native`. Releases are validated by Windows CI and published from version tags after the initial package bootstrap.
66
+
67
+ ## Tool behavior
68
+
69
+ With the default configuration:
70
+
71
+ - `bash` is inactive.
72
+ - `pwsh` is active.
73
+ - `ls`, `find`, and `grep` remain active.
74
+ - Existing tools from other extensions remain active.
75
+ - `!` and `!!` execute through the selected PowerShell 7 runtime.
76
+
77
+ Example model commands:
78
+
79
+ ```powershell
80
+ Get-ChildItem
81
+ $env:NODE_ENV = 'test'; npm test
82
+ rg --glob '*.ts' 'createFileRoute' src
83
+ fd --extension ts . src
84
+ ```
85
+
86
+ PowerShell syntax is not translated. A Bash-only command fails visibly so the model can correct it.
87
+
88
+ ## Configuration
89
+
90
+ The global configuration file is:
91
+
92
+ ```text
93
+ ~/.pi/agent/pwsh-native.json
94
+ ```
95
+
96
+ Example:
97
+
98
+ ```json
99
+ {
100
+ "executable": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
101
+ "loadProfile": false,
102
+ "executionPolicy": null,
103
+ "replaceUserBash": true,
104
+ "preserveDiscoveryTools": true,
105
+ "strictMode": false,
106
+ "pythonUtf8": true,
107
+ "pythonUnbuffered": true,
108
+ "elevationGuidance": false
109
+ }
110
+ ```
111
+
112
+ All fields are optional. Unknown fields and malformed values are rejected rather than ignored.
113
+
114
+ | Field | Default | Meaning |
115
+ |---|---:|---|
116
+ | `executable` | `"auto"` | `"auto"` or an absolute path to PowerShell 7 `pwsh.exe` |
117
+ | `loadProfile` | `false` | Load the user's PowerShell profile instead of passing `-NoProfile` |
118
+ | `executionPolicy` | `null` | Optional explicit process execution policy; no override is passed by default |
119
+ | `replaceUserBash` | `true` | Route Pi's `!` and `!!` commands through PowerShell |
120
+ | `preserveDiscoveryTools` | `true` | Keep Pi's `ls`, `find`, and `grep` tools active |
121
+ | `strictMode` | `false` | Set `$ErrorActionPreference = 'Stop'` for each command |
122
+ | `pythonUtf8` | `true` | Default `PYTHONIOENCODING=utf-8` and `PYTHONUTF8=1` when unset |
123
+ | `pythonUnbuffered` | `true` | Default `PYTHONUNBUFFERED=1` when unset |
124
+ | `elevationGuidance` | `false` | Allow elevation-related prompt guidance; never auto-elevates |
125
+
126
+ Supported explicit execution policies are `AllSigned`, `Bypass`, `Default`, `RemoteSigned`, `Restricted`, `Undefined`, and `Unrestricted`. `Bypass` is accepted only as an explicit user choice.
127
+
128
+ ### Environment overrides
129
+
130
+ Environment variables override file values:
131
+
132
+ - `PI_PWSH_NATIVE_CONFIG` — alternate configuration file path
133
+ - `PI_PWSH_NATIVE_EXECUTABLE`
134
+ - `PI_PWSH_NATIVE_LOAD_PROFILE`
135
+ - `PI_PWSH_NATIVE_EXECUTION_POLICY`
136
+ - `PI_PWSH_NATIVE_REPLACE_USER_BASH`
137
+ - `PI_PWSH_NATIVE_PRESERVE_DISCOVERY_TOOLS`
138
+ - `PI_PWSH_NATIVE_STRICT`
139
+ - `PI_PWSH_NATIVE_PYTHON_UTF8`
140
+ - `PI_PWSH_NATIVE_PYTHON_UNBUFFERED`
141
+ - `PI_PWSH_NATIVE_ELEVATION_GUIDANCE`
142
+
143
+ Boolean environment values accept `true/false`, `1/0`, `yes/no`, or `on/off`.
144
+
145
+ ## Runtime resolution
146
+
147
+ When `executable` is `"auto"`, resolution checks:
148
+
149
+ 1. `pwsh.exe` found by `where.exe`.
150
+ 2. `C:\Program Files\PowerShell\7\pwsh.exe`.
151
+
152
+ Each candidate is started and its version is verified. The selected absolute path is retained for all model and user commands.
153
+
154
+ If an explicit executable is invalid, the extension reports that exact failure and does not try another shell. If setup fails, the model's Bash tool is disabled and manual shell commands return an actionable error.
155
+
156
+ ## Environment and exit codes
157
+
158
+ Model tool calls retain Pi's session environment, including:
159
+
160
+ - `PI_SESSION_ID`
161
+ - `PI_SESSION_FILE`
162
+ - `PI_PROVIDER`
163
+ - `PI_MODEL`
164
+ - `PI_REASONING_LEVEL`
165
+
166
+ Manual `!`/`!!` commands follow Pi's documented user-command environment behavior.
167
+
168
+ The command epilogue preserves `$LASTEXITCODE` when a native program ran. Commands that only use PowerShell map `$?` to exit code `0` or `1`.
169
+
170
+ ## Timeouts and cancellation
171
+
172
+ There is no default timeout. The model can supply a timeout in seconds for a `pwsh` call. Timeout and Pi cancellation both invoke `taskkill.exe /T /F` for the spawned process, preventing common child-process leaks from tools such as `npm` and development servers.
173
+
174
+ Persistent background jobs and interactive PTYs are intentionally outside the core package. Use an external terminal for long-running interactive processes until separately reviewed companion functionality is available.
175
+
176
+ ## Development
177
+
178
+ ```powershell
179
+ npm install --ignore-scripts
180
+ npm run typecheck
181
+ npm test
182
+ npm run test:windows
183
+ npm run smoke
184
+ npm run check
185
+ ```
186
+
187
+ Test coverage includes configuration, runtime selection, tool activation, Unicode, multiline/long source transport, native exit codes, `.cmd` execution, timeout, cancellation, inherited stdio handles, and process-tree cleanup.
188
+
189
+ ## Security
190
+
191
+ Pi extensions execute with the user's full permissions. Review source before installation.
192
+
193
+ This package:
194
+
195
+ - does not make network requests;
196
+ - does not collect telemetry;
197
+ - does not auto-elevate;
198
+ - does not override execution policy by default;
199
+ - does not silently retry through `cmd.exe`, Bash, or PowerShell 5.1;
200
+ - does not have runtime dependencies or install scripts;
201
+ - does not accumulate unbounded shell output outside Pi's standard bounded output pipeline.
202
+
203
+ ## Limitations
204
+
205
+ - Windows-only in the initial release.
206
+ - Each command starts a fresh PowerShell process for isolation and predictable state.
207
+ - PowerShell profiles are disabled by default.
208
+ - Background-job emulation and PTY helpers from the upstream project are not included in the lean core.
209
+ - Competing shell-adapter extensions should not be enabled simultaneously.
210
+
211
+ ## Attribution
212
+
213
+ This project derives from [`@4fu/pi-pwsh`](https://github.com/4fuu/pi-pwsh), licensed under MIT. See [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and [`LICENSE`](LICENSE).
@@ -0,0 +1,15 @@
1
+ # Third-Party Notices
2
+
3
+ ## @4fu/pi-pwsh
4
+
5
+ This project is derived from **@4fu/pi-pwsh**, created by 4fuu and distributed under the MIT License.
6
+
7
+ - Upstream repository: <https://github.com/4fuu/pi-pwsh>
8
+ - Audited npm baseline: `@4fu/pi-pwsh@0.6.2`
9
+ - Upstream git commit: `305b1c6cb07810562c7fccf543430cb5bf331074`
10
+ - Published npm tarball: <https://registry.npmjs.org/@4fu/pi-pwsh/-/pi-pwsh-0.6.2.tgz>
11
+ - npm integrity: `sha512-GmCAJK3Vci0UaaP+MoB4F3oVlsMuIptFzx7xvBS+GTJYRZFgkW7PQs7nClV5mMPjWlN0Vs13CqwvUoZHmtxb9A==`
12
+
13
+ The published npm tarball integrity was verified, and every file in that tarball matched the corresponding blob in the upstream commit listed above before derivative changes began.
14
+
15
+ The upstream copyright notice and MIT permission notice are retained in this project's [`LICENSE`](LICENSE) file. Files derived from the upstream implementation may have been modified for this project.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "pi-pwsh-native",
3
+ "version": "0.1.0",
4
+ "description": "Native PowerShell 7 tooling for the Pi coding agent on Windows",
5
+ "license": "MIT",
6
+ "author": "Tako (https://github.com/takomine)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/takomine/pi-pwsh-native.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/takomine/pi-pwsh-native/issues"
13
+ },
14
+ "homepage": "https://github.com/takomine/pi-pwsh-native#readme",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "os": [
20
+ "win32"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi-extension",
28
+ "powershell",
29
+ "pwsh",
30
+ "windows"
31
+ ],
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE",
36
+ "THIRD_PARTY_NOTICES.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit",
46
+ "test": "node --test test/config.test.mjs test/core.test.mjs test/runtime.test.mjs",
47
+ "test:windows": "node --test test/integration.windows.test.mjs",
48
+ "test:runtime": "node scripts/smoke-runtime.mjs",
49
+ "test:exitcode": "node scripts/smoke-exitcode.mjs",
50
+ "test:timeout": "node scripts/smoke-timeout.mjs",
51
+ "check": "npm run typecheck && npm test && npm run test:windows",
52
+ "smoke": "npm run test:runtime && npm run test:exitcode && npm run test:timeout"
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-coding-agent": "*"
56
+ },
57
+ "devDependencies": {
58
+ "@earendil-works/pi-coding-agent": "^0.83.0",
59
+ "@types/node": "^24.0.0",
60
+ "typescript": "^5.9.0"
61
+ }
62
+ }
package/src/config.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, win32 } from "node:path";
3
+
4
+ export const CONFIG_FILE_NAME = "pwsh-native.json";
5
+
6
+ const EXECUTION_POLICIES = [
7
+ "AllSigned",
8
+ "Bypass",
9
+ "Default",
10
+ "RemoteSigned",
11
+ "Restricted",
12
+ "Undefined",
13
+ "Unrestricted",
14
+ ] as const;
15
+
16
+ export type ExecutionPolicy = (typeof EXECUTION_POLICIES)[number];
17
+
18
+ export interface PwshNativeConfig {
19
+ executable: "auto" | string;
20
+ loadProfile: boolean;
21
+ executionPolicy: ExecutionPolicy | null;
22
+ replaceUserBash: boolean;
23
+ preserveDiscoveryTools: boolean;
24
+ strictMode: boolean;
25
+ pythonUtf8: boolean;
26
+ pythonUnbuffered: boolean;
27
+ elevationGuidance: boolean;
28
+ }
29
+
30
+ export const DEFAULT_CONFIG: Readonly<PwshNativeConfig> = Object.freeze({
31
+ executable: "auto",
32
+ loadProfile: false,
33
+ executionPolicy: null,
34
+ replaceUserBash: true,
35
+ preserveDiscoveryTools: true,
36
+ strictMode: false,
37
+ pythonUtf8: true,
38
+ pythonUnbuffered: true,
39
+ elevationGuidance: false,
40
+ });
41
+
42
+ const CONFIG_KEYS = new Set<keyof PwshNativeConfig>(Object.keys(DEFAULT_CONFIG) as Array<keyof PwshNativeConfig>);
43
+
44
+ const ENV_FIELDS = {
45
+ PI_PWSH_NATIVE_EXECUTABLE: "executable",
46
+ PI_PWSH_NATIVE_LOAD_PROFILE: "loadProfile",
47
+ PI_PWSH_NATIVE_EXECUTION_POLICY: "executionPolicy",
48
+ PI_PWSH_NATIVE_REPLACE_USER_BASH: "replaceUserBash",
49
+ PI_PWSH_NATIVE_PRESERVE_DISCOVERY_TOOLS: "preserveDiscoveryTools",
50
+ PI_PWSH_NATIVE_STRICT: "strictMode",
51
+ PI_PWSH_NATIVE_PYTHON_UTF8: "pythonUtf8",
52
+ PI_PWSH_NATIVE_PYTHON_UNBUFFERED: "pythonUnbuffered",
53
+ PI_PWSH_NATIVE_ELEVATION_GUIDANCE: "elevationGuidance",
54
+ } as const;
55
+
56
+ export class ConfigError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = "ConfigError";
60
+ }
61
+ }
62
+
63
+ function parseBoolean(value: unknown, field: string, source: string): boolean {
64
+ if (typeof value === "boolean") return value;
65
+ if (typeof value === "string") {
66
+ switch (value.trim().toLowerCase()) {
67
+ case "1":
68
+ case "true":
69
+ case "yes":
70
+ case "on":
71
+ return true;
72
+ case "0":
73
+ case "false":
74
+ case "no":
75
+ case "off":
76
+ return false;
77
+ }
78
+ }
79
+ throw new ConfigError(`${source}: ${field} must be a boolean (true/false, 1/0, yes/no, or on/off)`);
80
+ }
81
+
82
+ function parseExecutable(value: unknown, source: string): "auto" | string {
83
+ if (typeof value !== "string" || !value.trim()) {
84
+ throw new ConfigError(`${source}: executable must be \"auto\" or an absolute path to pwsh.exe`);
85
+ }
86
+ const executable = value.trim();
87
+ if (executable.toLowerCase() === "auto") return "auto";
88
+ if (!win32.isAbsolute(executable)) {
89
+ throw new ConfigError(`${source}: executable must be an absolute Windows path, received ${JSON.stringify(executable)}`);
90
+ }
91
+ return executable;
92
+ }
93
+
94
+ function parseExecutionPolicy(value: unknown, source: string): ExecutionPolicy | null {
95
+ if (value === null || value === undefined || value === "") return null;
96
+ if (typeof value !== "string") {
97
+ throw new ConfigError(`${source}: executionPolicy must be null or a PowerShell execution policy name`);
98
+ }
99
+ const policy = EXECUTION_POLICIES.find((candidate) => candidate.toLowerCase() === value.trim().toLowerCase());
100
+ if (!policy) {
101
+ throw new ConfigError(
102
+ `${source}: unsupported executionPolicy ${JSON.stringify(value)}; expected one of ${EXECUTION_POLICIES.join(", ")}`,
103
+ );
104
+ }
105
+ return policy;
106
+ }
107
+
108
+ function parseConfigObject(value: unknown, source: string): Partial<PwshNativeConfig> {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
110
+ throw new ConfigError(`${source}: configuration must be a JSON object`);
111
+ }
112
+ const input = value as Record<string, unknown>;
113
+ const unknown = Object.keys(input).filter((key) => !CONFIG_KEYS.has(key as keyof PwshNativeConfig));
114
+ if (unknown.length > 0) {
115
+ throw new ConfigError(`${source}: unknown configuration field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
116
+ }
117
+
118
+ const parsed: Partial<PwshNativeConfig> = {};
119
+ if ("executable" in input) parsed.executable = parseExecutable(input.executable, source);
120
+ if ("executionPolicy" in input) parsed.executionPolicy = parseExecutionPolicy(input.executionPolicy, source);
121
+ for (const key of [
122
+ "loadProfile",
123
+ "replaceUserBash",
124
+ "preserveDiscoveryTools",
125
+ "strictMode",
126
+ "pythonUtf8",
127
+ "pythonUnbuffered",
128
+ "elevationGuidance",
129
+ ] as const) {
130
+ if (key in input) parsed[key] = parseBoolean(input[key], key, source);
131
+ }
132
+ return parsed;
133
+ }
134
+
135
+ function parseEnvironment(env: NodeJS.ProcessEnv): Partial<PwshNativeConfig> {
136
+ const parsed: Partial<PwshNativeConfig> = {};
137
+ for (const [environmentName, field] of Object.entries(ENV_FIELDS) as Array<
138
+ [keyof typeof ENV_FIELDS, (typeof ENV_FIELDS)[keyof typeof ENV_FIELDS]]
139
+ >) {
140
+ const value = env[environmentName];
141
+ if (value === undefined) continue;
142
+ const source = `environment variable ${environmentName}`;
143
+ if (field === "executable") parsed.executable = parseExecutable(value, source);
144
+ else if (field === "executionPolicy") parsed.executionPolicy = parseExecutionPolicy(value, source);
145
+ else parsed[field] = parseBoolean(value, field, source);
146
+ }
147
+ return parsed;
148
+ }
149
+
150
+ export interface LoadConfigOptions {
151
+ agentDir: string;
152
+ env?: NodeJS.ProcessEnv;
153
+ readFile?: typeof readFileSync;
154
+ }
155
+
156
+ export interface LoadedConfig {
157
+ config: PwshNativeConfig;
158
+ path: string;
159
+ }
160
+
161
+ export function loadConfig({ agentDir, env = process.env, readFile = readFileSync }: LoadConfigOptions): LoadedConfig {
162
+ const path = env.PI_PWSH_NATIVE_CONFIG?.trim() || join(agentDir, CONFIG_FILE_NAME);
163
+ let fileConfig: Partial<PwshNativeConfig> = {};
164
+ try {
165
+ const text = readFile(path, "utf8");
166
+ let json: unknown;
167
+ try {
168
+ json = JSON.parse(text);
169
+ } catch (error) {
170
+ throw new ConfigError(`${path}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
171
+ }
172
+ fileConfig = parseConfigObject(json, path);
173
+ } catch (error) {
174
+ if (error instanceof ConfigError) throw error;
175
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
176
+ throw new ConfigError(`${path}: unable to read configuration: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+ }
179
+
180
+ return {
181
+ path,
182
+ config: {
183
+ ...DEFAULT_CONFIG,
184
+ ...fileConfig,
185
+ ...parseEnvironment(env),
186
+ },
187
+ };
188
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ getAgentDir,
4
+ type ExtensionAPI,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { loadConfig, type PwshNativeConfig } from "./config.ts";
7
+ import { createPowerShellOperations } from "./operations.ts";
8
+ import { resolvePowerShellRuntime, type PowerShellRuntime } from "./runtime.ts";
9
+
10
+ const DISCOVERY_TOOLS = ["ls", "find", "grep"] as const;
11
+
12
+ function errorMessage(error: unknown): string {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+
16
+ export function selectActiveTools(
17
+ activeTools: readonly string[],
18
+ preserveDiscoveryTools: boolean,
19
+ pwshAvailable: boolean,
20
+ ): string[] {
21
+ const disabled = new Set<string>(["bash", "pwsh"]);
22
+ if (!preserveDiscoveryTools) {
23
+ for (const name of DISCOVERY_TOOLS) disabled.add(name);
24
+ }
25
+ const selected = activeTools.filter((name) => !disabled.has(name));
26
+ if (pwshAvailable) selected.push("pwsh");
27
+ return [...new Set(selected)];
28
+ }
29
+
30
+ function toolDescription(runtime: PowerShellRuntime, config: PwshNativeConfig): string {
31
+ const elevation = config.elevationGuidance
32
+ ? " Elevation guidance is enabled, but elevate only after an explicit user request."
33
+ : " Do not elevate unless the user explicitly requests it.";
34
+ return `Execute a native PowerShell 7 command on Windows with PowerShell ${runtime.version} (${runtime.executable}). Returns stdout and stderr. Output is truncated to the last 2000 lines or 50KB, whichever is reached first; full truncated output is saved to a temporary file. Commands have no default timeout; set timeout in seconds when needed.${elevation}`;
35
+ }
36
+
37
+ function unavailableUserCommand(reason: string) {
38
+ return {
39
+ result: {
40
+ output: `pi-pwsh-native is unavailable: ${reason}`,
41
+ exitCode: 1,
42
+ cancelled: false,
43
+ truncated: false,
44
+ },
45
+ };
46
+ }
47
+
48
+ export default function piPwshNative(pi: ExtensionAPI): void {
49
+ if (process.platform !== "win32") {
50
+ pi.on("session_start", (_event, ctx) => {
51
+ ctx.ui.notify("pi-pwsh-native is Windows-only; no tools were changed.", "warning");
52
+ });
53
+ return;
54
+ }
55
+
56
+ let config: PwshNativeConfig | undefined;
57
+ let runtime: PowerShellRuntime | undefined;
58
+ let setupError: string | undefined;
59
+ try {
60
+ config = loadConfig({ agentDir: getAgentDir() }).config;
61
+ runtime = resolvePowerShellRuntime(config);
62
+ } catch (error) {
63
+ setupError = errorMessage(error);
64
+ }
65
+
66
+ if (setupError || !config || !runtime) {
67
+ const reason = setupError ?? "unknown setup failure";
68
+ pi.on("session_start", (_event, ctx) => {
69
+ pi.setActiveTools(selectActiveTools(pi.getActiveTools(), true, false));
70
+ ctx.ui.notify(`pi-pwsh-native: ${reason} Bash was disabled; no shell fallback was activated.`, "error");
71
+ });
72
+ pi.on("user_bash", () => unavailableUserCommand(reason));
73
+ return;
74
+ }
75
+
76
+ const resolvedConfig = config;
77
+ const resolvedRuntime = runtime;
78
+ const operations = createPowerShellOperations(resolvedRuntime, resolvedConfig);
79
+
80
+ pi.on("session_start", (_event, ctx) => {
81
+ const definition = createBashToolDefinition(ctx.cwd, { operations });
82
+ pi.registerTool({
83
+ ...definition,
84
+ name: "pwsh",
85
+ label: "pwsh",
86
+ description: toolDescription(resolvedRuntime, resolvedConfig),
87
+ promptSnippet: "Execute native PowerShell 7 commands on Windows",
88
+ promptGuidelines: [
89
+ "Use pwsh only when a shell is needed; write native PowerShell syntax and avoid Bash-only syntax or implicit Bash translation.",
90
+ "Prefer read, write, edit, ls, find, and grep over pwsh when a dedicated tool can complete the task more directly.",
91
+ "In pwsh, use $env:NAME for environment variables; single quotes are literal, double quotes interpolate, and the backtick is the escape character.",
92
+ "Prefer bounded rg or fd commands for repository search; tightly bound recursive Get-ChildItem and Select-String operations.",
93
+ "Do not elevate pwsh commands unless the user explicitly requests elevation.",
94
+ "Inspect PI_* environment variables in pwsh when current Pi model or session metadata is needed.",
95
+ ],
96
+ });
97
+ pi.setActiveTools(
98
+ selectActiveTools(pi.getActiveTools(), resolvedConfig.preserveDiscoveryTools, true),
99
+ );
100
+ });
101
+
102
+ if (resolvedConfig.replaceUserBash) {
103
+ pi.on("user_bash", () => ({ operations }));
104
+ }
105
+ }
@@ -0,0 +1,48 @@
1
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
2
+ import type { PwshNativeConfig } from "./config.ts";
3
+ import type { PowerShellRuntime } from "./runtime.ts";
4
+ import { buildPowerShellArguments, buildPowerShellSource } from "./powershell-source.ts";
5
+ import { spawnAndStream } from "./process.ts";
6
+
7
+ function findEnvironmentKey(env: NodeJS.ProcessEnv, name: string): string | undefined {
8
+ const normalized = name.toUpperCase();
9
+ return Object.keys(env).find((key) => key.toUpperCase() === normalized);
10
+ }
11
+
12
+ function setDefault(env: NodeJS.ProcessEnv, name: string, value: string): void {
13
+ const existing = findEnvironmentKey(env, name);
14
+ if (existing === undefined || env[existing] === undefined) env[name] = value;
15
+ }
16
+
17
+ export function createRuntimeEnvironment(
18
+ config: PwshNativeConfig,
19
+ baseEnvironment: NodeJS.ProcessEnv = process.env,
20
+ ): NodeJS.ProcessEnv {
21
+ const env = { ...baseEnvironment };
22
+ if (config.pythonUtf8) {
23
+ setDefault(env, "PYTHONIOENCODING", "utf-8");
24
+ setDefault(env, "PYTHONUTF8", "1");
25
+ }
26
+ if (config.pythonUnbuffered) setDefault(env, "PYTHONUNBUFFERED", "1");
27
+ return env;
28
+ }
29
+
30
+ export function createPowerShellOperations(
31
+ runtime: PowerShellRuntime,
32
+ config: PwshNativeConfig,
33
+ ): BashOperations {
34
+ const args = buildPowerShellArguments(config);
35
+ return {
36
+ async exec(command, cwd, options) {
37
+ const source = buildPowerShellSource(command, config);
38
+ const result = await spawnAndStream(runtime.executable, args, cwd, {
39
+ onData: options.onData,
40
+ signal: options.signal,
41
+ timeout: options.timeout,
42
+ env: createRuntimeEnvironment(config, options.env ?? process.env),
43
+ stdin: Buffer.from(source, "utf8").toString("base64"),
44
+ });
45
+ return { exitCode: result.exitCode };
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,41 @@
1
+ import type { PwshNativeConfig } from "./config.ts";
2
+
3
+ export const UTF8_PREFIX = [
4
+ "$__pi_utf8 = [System.Text.UTF8Encoding]::new($false)",
5
+ "[Console]::InputEncoding = $__pi_utf8",
6
+ "[Console]::OutputEncoding = $__pi_utf8",
7
+ "$OutputEncoding = $__pi_utf8",
8
+ "$PSStyle.OutputRendering = 'PlainText'",
9
+ "$global:LASTEXITCODE = $null",
10
+ ].join("; ") + "; ";
11
+
12
+ /**
13
+ * Preserve a native program's real exit code. If no native program set
14
+ * LASTEXITCODE, map PowerShell's success state to 0/1.
15
+ *
16
+ * The leading newline detaches the epilogue from a trailing line comment. The
17
+ * leading semicolon also prevents a trailing backtick from joining the
18
+ * epilogue to the user's final command.
19
+ */
20
+ export const EXIT_EPILOGUE =
21
+ "\n; $__pi_ok = $?; if ($null -ne $global:LASTEXITCODE) { exit $global:LASTEXITCODE } else { exit ($__pi_ok ? 0 : 1) }";
22
+
23
+ export function buildPowerShellSource(command: string, config: PwshNativeConfig): string {
24
+ const strict = config.strictMode ? "$ErrorActionPreference = 'Stop'; " : "";
25
+ return `${UTF8_PREFIX}${strict}${command}${EXIT_EPILOGUE}`;
26
+ }
27
+
28
+ export const SOURCE_BOOTSTRAP =
29
+ "$__pi_source = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String([Console]::In.ReadToEnd())); & ([ScriptBlock]::Create($__pi_source))";
30
+
31
+ export function buildPowerShellArguments(config: PwshNativeConfig): string[] {
32
+ const args = ["-NoLogo"];
33
+ if (!config.loadProfile) args.push("-NoProfile");
34
+ args.push("-NonInteractive");
35
+ if (config.executionPolicy) args.push("-ExecutionPolicy", config.executionPolicy);
36
+ // PowerShell decodes `-Command -` input with the console code page before a
37
+ // UTF-8 prelude can run. Transporting UTF-8 source as base64 over stdin keeps
38
+ // the bootstrap ASCII-only, preserves Unicode, and avoids command-line limits.
39
+ args.push("-Command", SOURCE_BOOTSTRAP);
40
+ return args;
41
+ }
package/src/process.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+
5
+ const MAX_TIMEOUT_MS = 2_147_483_647;
6
+ const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
7
+ const EXIT_STDIO_GRACE_MS = 100;
8
+
9
+ export interface SpawnOptions {
10
+ onData: (data: Buffer) => void;
11
+ signal?: AbortSignal;
12
+ timeout?: number;
13
+ env?: NodeJS.ProcessEnv;
14
+ stdin?: string;
15
+ }
16
+
17
+ function timeoutMilliseconds(timeout: number | undefined): number | undefined {
18
+ if (timeout === undefined) return undefined;
19
+ if (!Number.isFinite(timeout) || timeout <= 0) {
20
+ throw new Error("Invalid timeout: must be a finite number of seconds greater than zero");
21
+ }
22
+ if (timeout > MAX_TIMEOUT_SECONDS) {
23
+ throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
24
+ }
25
+ return timeout * 1000;
26
+ }
27
+
28
+ /** Kill a Windows process and all descendants without interpolating a command string. */
29
+ export function killProcessTree(pid: number | undefined): void {
30
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return;
31
+ try {
32
+ spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
33
+ stdio: "ignore",
34
+ windowsHide: true,
35
+ });
36
+ } catch {
37
+ try {
38
+ process.kill(pid, "SIGTERM");
39
+ } catch {
40
+ // The process may already have exited.
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolve after process exit without waiting forever for stdout/stderr handles
47
+ * inherited by detached descendants. Active trailing output re-arms the short
48
+ * post-exit grace period.
49
+ */
50
+ function waitForChildProcess(child: ChildProcess): Promise<number | null> {
51
+ return new Promise((resolve, reject) => {
52
+ let settled = false;
53
+ let exited = false;
54
+ let exitCode: number | null = null;
55
+ let postExitTimer: NodeJS.Timeout | undefined;
56
+ let stdoutEnded = child.stdout === null;
57
+ let stderrEnded = child.stderr === null;
58
+
59
+ const cleanup = () => {
60
+ if (postExitTimer) clearTimeout(postExitTimer);
61
+ child.removeListener("error", onError);
62
+ child.removeListener("exit", onExit);
63
+ child.removeListener("close", onClose);
64
+ child.stdout?.removeListener("end", onStdoutEnd);
65
+ child.stderr?.removeListener("end", onStderrEnd);
66
+ child.stdout?.removeListener("data", onData);
67
+ child.stderr?.removeListener("data", onData);
68
+ };
69
+ const finish = (code: number | null) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ cleanup();
73
+ child.stdout?.destroy();
74
+ child.stderr?.destroy();
75
+ resolve(code);
76
+ };
77
+ const maybeFinish = () => {
78
+ if (exited && stdoutEnded && stderrEnded) finish(exitCode);
79
+ };
80
+ const armGrace = () => {
81
+ if (postExitTimer) clearTimeout(postExitTimer);
82
+ postExitTimer = setTimeout(() => finish(exitCode), EXIT_STDIO_GRACE_MS);
83
+ };
84
+ const onData = () => {
85
+ if (exited && !settled) armGrace();
86
+ };
87
+ const onStdoutEnd = () => {
88
+ stdoutEnded = true;
89
+ maybeFinish();
90
+ };
91
+ const onStderrEnd = () => {
92
+ stderrEnded = true;
93
+ maybeFinish();
94
+ };
95
+ const onError = (error: Error) => {
96
+ if (settled) return;
97
+ settled = true;
98
+ cleanup();
99
+ reject(error);
100
+ };
101
+ const onExit = (code: number | null) => {
102
+ exited = true;
103
+ exitCode = code;
104
+ maybeFinish();
105
+ if (!settled) armGrace();
106
+ };
107
+ const onClose = (code: number | null) => finish(code);
108
+
109
+ child.stdout?.once("end", onStdoutEnd);
110
+ child.stderr?.once("end", onStderrEnd);
111
+ child.stdout?.on("data", onData);
112
+ child.stderr?.on("data", onData);
113
+ child.once("error", onError);
114
+ child.once("exit", onExit);
115
+ child.once("close", onClose);
116
+ });
117
+ }
118
+
119
+ export async function spawnAndStream(
120
+ executable: string,
121
+ args: string[],
122
+ cwd: string,
123
+ options: SpawnOptions,
124
+ ): Promise<{ exitCode: number | null }> {
125
+ if (options.signal?.aborted) throw new Error("aborted");
126
+ const timeoutMs = timeoutMilliseconds(options.timeout);
127
+ try {
128
+ await access(cwd, constants.F_OK);
129
+ } catch {
130
+ throw new Error(`Working directory does not exist: ${cwd}`);
131
+ }
132
+
133
+ const child = spawn(executable, args, {
134
+ cwd,
135
+ env: options.env ?? process.env,
136
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
137
+ windowsHide: true,
138
+ });
139
+ if (options.stdin !== undefined) {
140
+ child.stdin?.on("error", () => {
141
+ // Process startup/termination errors are reported through child events.
142
+ });
143
+ child.stdin?.end(options.stdin, "utf8");
144
+ }
145
+
146
+ let timedOut = false;
147
+ let timeoutHandle: NodeJS.Timeout | undefined;
148
+ const onAbort = () => killProcessTree(child.pid);
149
+ const onStdout = (data: Buffer) => options.onData(data);
150
+ const onStderr = (data: Buffer) => options.onData(data);
151
+
152
+ try {
153
+ child.stdout?.on("data", onStdout);
154
+ child.stderr?.on("data", onStderr);
155
+ if (options.signal) {
156
+ if (options.signal.aborted) onAbort();
157
+ else options.signal.addEventListener("abort", onAbort, { once: true });
158
+ }
159
+ if (timeoutMs !== undefined) {
160
+ timeoutHandle = setTimeout(() => {
161
+ timedOut = true;
162
+ killProcessTree(child.pid);
163
+ }, timeoutMs);
164
+ }
165
+
166
+ const exitCode = await waitForChildProcess(child);
167
+ if (options.signal?.aborted) throw new Error("aborted");
168
+ if (timedOut) throw new Error(`timeout:${options.timeout}`);
169
+ return { exitCode };
170
+ } finally {
171
+ if (timeoutHandle) clearTimeout(timeoutHandle);
172
+ options.signal?.removeEventListener("abort", onAbort);
173
+ child.stdout?.removeListener("data", onStdout);
174
+ child.stderr?.removeListener("data", onStderr);
175
+ }
176
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { win32 } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import type { PwshNativeConfig } from "./config.ts";
5
+
6
+ const KNOWN_PWSH_PATH = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
7
+ const VERSION_PROBE = "[Console]::Out.Write($PSVersionTable.PSVersion.ToString())";
8
+
9
+ export interface PowerShellRuntime {
10
+ executable: string;
11
+ version: string;
12
+ }
13
+
14
+ export interface RuntimeDependencies {
15
+ exists: (path: string) => boolean;
16
+ findOnPath: () => string[];
17
+ probe: (path: string) => string | null;
18
+ }
19
+
20
+ export class RuntimeError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "RuntimeError";
24
+ }
25
+ }
26
+
27
+ function defaultFindOnPath(): string[] {
28
+ const result = spawnSync("where.exe", ["pwsh.exe"], {
29
+ encoding: "utf8",
30
+ timeout: 5_000,
31
+ windowsHide: true,
32
+ });
33
+ if (result.error || result.status !== 0 || !result.stdout) return [];
34
+ return result.stdout
35
+ .split(/\r?\n/)
36
+ .map((path) => path.trim())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ function defaultProbe(path: string): string | null {
41
+ const result = spawnSync(
42
+ path,
43
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", VERSION_PROBE],
44
+ {
45
+ encoding: "utf8",
46
+ timeout: 5_000,
47
+ windowsHide: true,
48
+ },
49
+ );
50
+ if (result.error || result.status !== 0) return null;
51
+ const version = result.stdout.trim();
52
+ return /^\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?$/.test(version) ? version : null;
53
+ }
54
+
55
+ const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
56
+ exists: existsSync,
57
+ findOnPath: defaultFindOnPath,
58
+ probe: defaultProbe,
59
+ };
60
+
61
+ function majorVersion(version: string): number | null {
62
+ const match = /^(\d+)/.exec(version);
63
+ return match ? Number(match[1]) : null;
64
+ }
65
+
66
+ function uniquePaths(paths: string[]): string[] {
67
+ const seen = new Set<string>();
68
+ const result: string[] = [];
69
+ for (const path of paths) {
70
+ const normalized = win32.normalize(path);
71
+ const key = normalized.toLowerCase();
72
+ if (seen.has(key)) continue;
73
+ seen.add(key);
74
+ result.push(normalized);
75
+ }
76
+ return result;
77
+ }
78
+
79
+ export function resolvePowerShellRuntime(
80
+ config: PwshNativeConfig,
81
+ dependencies: RuntimeDependencies = DEFAULT_DEPENDENCIES,
82
+ ): PowerShellRuntime {
83
+ const explicit = config.executable !== "auto";
84
+ const candidates = explicit
85
+ ? [config.executable]
86
+ : uniquePaths([...dependencies.findOnPath(), KNOWN_PWSH_PATH]);
87
+ const failures: string[] = [];
88
+
89
+ for (const candidate of candidates) {
90
+ const path = win32.normalize(candidate);
91
+ if (!win32.isAbsolute(path)) {
92
+ failures.push(`${path}: not an absolute path`);
93
+ continue;
94
+ }
95
+ if (!dependencies.exists(path)) {
96
+ failures.push(`${path}: file not found`);
97
+ continue;
98
+ }
99
+ const version = dependencies.probe(path);
100
+ if (!version) {
101
+ failures.push(`${path}: failed to start or returned an invalid version`);
102
+ continue;
103
+ }
104
+ const major = majorVersion(version);
105
+ if (major === null || major < 7) {
106
+ failures.push(`${path}: PowerShell ${version} is unsupported; version 7 or newer is required`);
107
+ continue;
108
+ }
109
+ return { executable: path, version };
110
+ }
111
+
112
+ const prefix = explicit
113
+ ? `Configured PowerShell executable ${JSON.stringify(config.executable)} is unavailable.`
114
+ : "PowerShell 7 or newer was not found.";
115
+ const details = failures.length > 0 ? ` Checked: ${failures.join("; ")}.` : "";
116
+ throw new RuntimeError(`${prefix}${details} Install PowerShell 7 or configure an absolute pwsh.exe path.`);
117
+ }