win-nice 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.
@@ -0,0 +1,197 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ # win-nice: managed-file
3
+ # DANGEROUS: REALTIME_PRIORITY_CLASS outranks the OS's own input/audio/UI threads.
4
+ # A busy realtime-priority process can make the desktop (mouse, keyboard, everything)
5
+ # stop responding - the exact failure mode this whole project exists to prevent. See
6
+ # README before using this.
7
+ # No param(): nothing here needs a named parameter, and $args sidesteps
8
+ # PowerShell's parameter binder entirely - see cap.ps1 for why that matters.
9
+ $Command = $args
10
+
11
+ if (-not $Command -or $Command.Count -eq 0) {
12
+ Write-Error "usage: realtime <command> [args...]"
13
+ exit 1
14
+ }
15
+
16
+ # Fallback command line for when the target isn't a directly-launchable .exe (see
17
+ # RealtimeLauncher.Run below) - re-parsed by cmd.exe (via "cmd.exe /c"), so quoting must
18
+ # neutralize its operators (&|<>^) and not just whitespace - see cap.ps1 for the
19
+ # same logic and its documented "%" limitation. realtime.bat has its own, more
20
+ # severe "%" caveat (see there) that applies before this script ever runs.
21
+ $commandLine = ($Command | ForEach-Object {
22
+ $escaped = $_ -replace '"', '\"'
23
+ if ($escaped -eq '' -or $escaped -match '[\s"&|<>^]') { '"' + $escaped + '"' } else { $escaped }
24
+ }) -join ' '
25
+
26
+ $source = @"
27
+ using System;
28
+ using System.Runtime.InteropServices;
29
+ using System.Text;
30
+
31
+ public static class RealtimeLauncher
32
+ {
33
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
34
+ struct STARTUPINFO
35
+ {
36
+ public int cb;
37
+ public string lpReserved;
38
+ public string lpDesktop;
39
+ public string lpTitle;
40
+ public int dwX;
41
+ public int dwY;
42
+ public int dwXSize;
43
+ public int dwYSize;
44
+ public int dwXCountChars;
45
+ public int dwYCountChars;
46
+ public int dwFillAttribute;
47
+ public int dwFlags;
48
+ public short wShowWindow;
49
+ public short cbReserved2;
50
+ public IntPtr lpReserved2;
51
+ public IntPtr hStdInput;
52
+ public IntPtr hStdOutput;
53
+ public IntPtr hStdError;
54
+ }
55
+
56
+ [StructLayout(LayoutKind.Sequential)]
57
+ struct PROCESS_INFORMATION
58
+ {
59
+ public IntPtr hProcess;
60
+ public IntPtr hThread;
61
+ public int dwProcessId;
62
+ public int dwThreadId;
63
+ }
64
+
65
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
66
+ static extern bool CreateProcess(string lpApplicationName, StringBuilder lpCommandLine,
67
+ IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles,
68
+ uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
69
+ ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
70
+
71
+ [DllImport("kernel32.dll", SetLastError = true)]
72
+ static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
73
+
74
+ [DllImport("kernel32.dll", SetLastError = true)]
75
+ static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
76
+
77
+ [DllImport("kernel32.dll")]
78
+ static extern bool CloseHandle(IntPtr hObject);
79
+
80
+ // Standard MSVCRT/CommandLineToArgvW quoting: safe for a directly-launched .exe's
81
+ // own argv parsing. No cmd.exe involved on this path, so none of its operator or
82
+ // "%" expansion semantics apply - this is the safe path, used whenever possible.
83
+ static string ArgvQuote(string arg)
84
+ {
85
+ if (arg.Length > 0 && arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '"' }) < 0)
86
+ return arg;
87
+
88
+ var result = new StringBuilder();
89
+ result.Append('"');
90
+ int backslashes = 0;
91
+ foreach (char c in arg)
92
+ {
93
+ if (c == '\\')
94
+ {
95
+ backslashes++;
96
+ }
97
+ else if (c == '"')
98
+ {
99
+ result.Append('\\', backslashes * 2 + 1);
100
+ result.Append('"');
101
+ backslashes = 0;
102
+ }
103
+ else
104
+ {
105
+ if (backslashes > 0) { result.Append('\\', backslashes); backslashes = 0; }
106
+ result.Append(c);
107
+ }
108
+ }
109
+ if (backslashes > 0) result.Append('\\', backslashes * 2);
110
+ result.Append('"');
111
+ return result.ToString();
112
+ }
113
+
114
+ static string BuildArgvCommandLine(string[] argv)
115
+ {
116
+ var parts = new string[argv.Length];
117
+ for (int i = 0; i < argv.Length; i++) parts[i] = ArgvQuote(argv[i]);
118
+ return string.Join(" ", parts);
119
+ }
120
+
121
+ // Priority is passed via dwCreationFlags, applied atomically at creation - no
122
+ // Job Object needed. Windows' CreateProcess inherits IDLE/BELOW_NORMAL priority
123
+ // by default to children that don't request a priority of their own; REALTIME is
124
+ // NOT inherited by default (see README).
125
+ public static int Run(uint priorityClass, string[] argv, string cmdExeCommandLine)
126
+ {
127
+ var si = new STARTUPINFO();
128
+ si.cb = Marshal.SizeOf(si);
129
+ PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
130
+
131
+ // See cap.ps1 for why .bat/.cmd targets skip the direct attempt entirely:
132
+ // CreateProcess silently re-invokes them through cmd.exe on its own, using
133
+ // unescaped text, instead of failing the way a genuinely missing exe would.
134
+ bool isBatOrCmd = argv.Length > 0 && (
135
+ argv[0].EndsWith(".bat", StringComparison.OrdinalIgnoreCase) ||
136
+ argv[0].EndsWith(".cmd", StringComparison.OrdinalIgnoreCase));
137
+
138
+ bool created = false;
139
+ if (!isBatOrCmd)
140
+ {
141
+ var directCommandLine = new StringBuilder(BuildArgvCommandLine(argv));
142
+ created = CreateProcess(null, directCommandLine, IntPtr.Zero, IntPtr.Zero, true,
143
+ priorityClass, IntPtr.Zero, null, ref si, out pi);
144
+ }
145
+
146
+ if (!created)
147
+ {
148
+ // Falling back to cmd.exe /c: a literal "%" in any argument could now
149
+ // trigger environment-variable expansion (cmd.exe pairs up "%" characters
150
+ // across the whole command line, even across separate arguments) and
151
+ // change what actually runs. Fail loudly here instead of silently risking
152
+ // that - there's no reliable per-character escape for "%" at this level.
153
+ foreach (var a in argv)
154
+ {
155
+ if (a.IndexOf('%') >= 0)
156
+ throw new InvalidOperationException(
157
+ "Refusing to run: argument contains '%' and the target needs the cmd.exe " +
158
+ "fallback (not a directly-launchable .exe), where '%' can trigger unintended " +
159
+ "environment-variable expansion. See README's Argument handling section.");
160
+ }
161
+
162
+ string cmdExe = Environment.SystemDirectory + "\\cmd.exe";
163
+ // /d: skip HKCU AutoRun (user-writable registry key). /v:off: disable delayed
164
+ // expansion so "!var!" in an argument can't be expanded. /s plus the extra outer
165
+ // quote pair: cmd's /S rule strips exactly that outer pair and leaves the rest of
166
+ // the string untouched - without /S, cmd strips the first and last quote of the
167
+ // whole line instead, which breaks quoting whenever the target path itself needs
168
+ // quotes AND another argument is also quoted.
169
+ var shellCommandLine = new StringBuilder("\"" + cmdExe + "\" /d /v:off /s /c \"" + cmdExeCommandLine + "\"");
170
+ created = CreateProcess(null, shellCommandLine, IntPtr.Zero, IntPtr.Zero, true,
171
+ priorityClass, IntPtr.Zero, null, ref si, out pi);
172
+ if (!created)
173
+ throw new InvalidOperationException("CreateProcess failed: " + Marshal.GetLastWin32Error());
174
+ }
175
+
176
+ WaitForSingleObject(pi.hProcess, 0xFFFFFFFF);
177
+
178
+ uint exitCode;
179
+ GetExitCodeProcess(pi.hProcess, out exitCode);
180
+
181
+ CloseHandle(pi.hThread);
182
+ CloseHandle(pi.hProcess);
183
+
184
+ return (int)exitCode;
185
+ }
186
+ }
187
+ "@
188
+
189
+ Add-Type -TypeDefinition $source -Language CSharp
190
+
191
+ $REALTIME_PRIORITY_CLASS = 0x00000100
192
+ try {
193
+ exit ([RealtimeLauncher]::Run($REALTIME_PRIORITY_CLASS, [string[]]$Command, $commandLine))
194
+ } catch {
195
+ Write-Error $_.Exception.InnerException.Message
196
+ exit 1
197
+ }
package/bin/uiup ADDED
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # SPDX-License-Identifier: MIT OR Apache-2.0
3
+ # win-nice: managed-file
4
+ # Git Bash ignores PATHEXT for bare-name resolution; this shim covers that shell.
5
+ # MSYS2_ARG_CONV_EXCL='*' stops Git Bash/MSYS from rewriting user arguments
6
+ # (e.g. /c, /d, C:\...) into Windows paths before the exec; the shim's own
7
+ # .ps1 path is converted explicitly with cygpath -w so -File gets a Windows path.
8
+ MSYS2_ARG_CONV_EXCL='*'
9
+ export MSYS2_ARG_CONV_EXCL
10
+ script=$(cygpath -w "$(dirname "$0")/uiup.ps1" 2>/dev/null) || script="$(dirname "$0")/uiup.ps1"
11
+ exec powershell -NoProfile -ExecutionPolicy Bypass -File "$script" "$@"
package/bin/uiup.bat ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ :: SPDX-License-Identifier: MIT OR Apache-2.0
3
+ :: win-nice: managed-file
4
+ powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0uiup.ps1"
package/bin/uiup.ps1 ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT OR Apache-2.0
2
+ # win-nice: managed-file
3
+ param([switch]$SelfElevated)
4
+
5
+ $targets = @('explorer', 'dwm', 'sihost', 'ShellExperienceHost', 'StartMenuExperienceHost', 'StartMenu', 'SearchApp', 'audiodg')
6
+
7
+ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
8
+ if (-not $isAdmin) {
9
+ Write-Host "Not elevated - requesting admin rights (dwm/sihost run under a different account)..."
10
+ try {
11
+ $p = Start-Process powershell -Verb RunAs -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-SelfElevated') -Wait -PassThru
12
+ exit $p.ExitCode
13
+ } catch {
14
+ Write-Error "Elevation was cancelled or failed: $($_.Exception.Message)"
15
+ exit 1
16
+ }
17
+ }
18
+
19
+ $rows = foreach ($name in $targets) {
20
+ $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
21
+ if (-not $procs) {
22
+ [PSCustomObject]@{ Name = $name; Id = '-'; Old = '-'; New = 'not running' }
23
+ continue
24
+ }
25
+ foreach ($proc in $procs) {
26
+ $old = $proc.PriorityClass
27
+ try {
28
+ $proc.PriorityClass = 'High'
29
+ [PSCustomObject]@{ Name = $name; Id = $proc.Id; Old = $old; New = 'High' }
30
+ } catch {
31
+ [PSCustomObject]@{ Name = $name; Id = $proc.Id; Old = $old; New = "FAILED: $($_.Exception.Message)" }
32
+ }
33
+ }
34
+ }
35
+
36
+ $rows | Format-Table -AutoSize
37
+ if ($SelfElevated) {
38
+ Write-Host ""
39
+ Write-Host "Done. Press Enter to close..."
40
+ Read-Host | Out-Null
41
+ }
package/install/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { install } = require('./install');
4
+ const { uninstall } = require('./uninstall');
5
+ const { installSkill, uninstallSkill } = require('./skill');
6
+ const paths = require('./paths');
7
+ const manifest = require('./manifest');
8
+
9
+ function status() {
10
+ const data = manifest.read(paths.manifestPath());
11
+ if (!data) {
12
+ console.log('win-nice is not installed.');
13
+ return;
14
+ }
15
+ console.log(`win-nice ${data.version} installed at ${data.installedAt}`);
16
+ console.log(`bin dir: ${data.binDir}`);
17
+ console.log(`files: ${Array.isArray(data.files) ? data.files.join(', ') : '(manifest is malformed - run reinstall)'}`);
18
+ }
19
+
20
+ function main() {
21
+ const cmd = process.argv[2] || 'install';
22
+ const updatePath = !process.env.WIN_NICE_NO_PATH;
23
+ switch (cmd) {
24
+ case 'install':
25
+ install({ updatePath });
26
+ break;
27
+ case 'uninstall':
28
+ uninstall({ updatePath });
29
+ break;
30
+ case 'reinstall':
31
+ uninstall({ updatePath });
32
+ install({ updatePath });
33
+ break;
34
+ case 'status':
35
+ status();
36
+ break;
37
+ case 'skill': {
38
+ const sub = process.argv[3];
39
+ if (sub === 'install') {
40
+ const results = installSkill();
41
+ if (results.some((r) => !r.installed)) process.exitCode = 1;
42
+ } else if (sub === 'uninstall') {
43
+ const results = uninstallSkill();
44
+ // 'missing' (nothing to remove) isn't a failure; a stripped/foreign
45
+ // marker (skip-due-to-conflict) is - it means removal didn't happen
46
+ // when a real file was there.
47
+ if (results.some((r) => !r.removed && r.reason !== 'missing')) process.exitCode = 1;
48
+ } else {
49
+ console.error(`unknown skill command: ${sub}`);
50
+ console.error('usage: win-nice skill <install|uninstall>');
51
+ process.exitCode = 1;
52
+ }
53
+ break;
54
+ }
55
+ default:
56
+ console.error(`unknown command: ${cmd}`);
57
+ console.error('usage: win-nice <install|uninstall|reinstall|status|skill>');
58
+ process.exitCode = 1;
59
+ }
60
+ }
61
+
62
+ main();
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const paths = require('./paths');
5
+ const manifest = require('./manifest');
6
+ const { removeManagedFile } = require('./uninstall');
7
+ const pkg = require('../package.json');
8
+
9
+ const SOURCE_BIN = path.join(__dirname, '..', 'bin');
10
+
11
+ function listSourceFiles() {
12
+ // .bat/.ps1 launchers plus their extensionless POSIX shell shims (bin/<tool>,
13
+ // no dot) - the sibling Git Bash needs since it ignores PATHEXT on bare names.
14
+ return fs.readdirSync(SOURCE_BIN).filter((f) => f.endsWith('.bat') || f.endsWith('.ps1') || !f.includes('.'));
15
+ }
16
+
17
+ // Guards against `npm install`/`npm test` inside a source checkout silently
18
+ // touching the real system PATH - only a genuine package install (running from
19
+ // inside someone's node_modules) or an explicit WIN_NICE_HOME override proceeds.
20
+ function isSourceCheckout() {
21
+ return fs.existsSync(path.join(__dirname, '..', '.git'));
22
+ }
23
+
24
+ // `npm install -g win-nice@newer` only runs postinstall (this function) - unlike
25
+ // `win-nice reinstall`, which does uninstall()+install(), it never diffs against
26
+ // what a previous version left behind. Without this, a tool dropped in a newer
27
+ // version stays orphaned in binDir forever. Safe to call before the target dir
28
+ // even exists (read() returns null, staleNames is empty).
29
+ function cleanupStaleFiles(dir, currentFiles) {
30
+ const previous = manifest.read(paths.manifestPath());
31
+ if (!previous || !Array.isArray(previous.files)) return;
32
+
33
+ const currentSet = new Set(currentFiles);
34
+ const staleNames = previous.files.filter((name) => !currentSet.has(name));
35
+ const previousDir = previous.binDir || dir;
36
+ for (const name of staleNames) {
37
+ removeManagedFile(path.join(previousDir, name), dir, { requireMarker: false });
38
+ }
39
+ }
40
+
41
+ function install({ updatePath = true } = {}) {
42
+ if (!process.env.WIN_NICE_HOME && isSourceCheckout()) {
43
+ console.log(
44
+ 'Running from a source checkout - skipping real install. ' +
45
+ 'Set WIN_NICE_HOME to force a target directory, or install the published package.'
46
+ );
47
+ return null;
48
+ }
49
+
50
+ const dir = paths.binDir();
51
+ fs.mkdirSync(dir, { recursive: true });
52
+
53
+ const files = listSourceFiles();
54
+ cleanupStaleFiles(dir, files);
55
+ for (const name of files) {
56
+ fs.copyFileSync(path.join(SOURCE_BIN, name), path.join(dir, name));
57
+ }
58
+
59
+ manifest.write(paths.manifestPath(), {
60
+ version: pkg.version,
61
+ installedAt: new Date().toISOString(),
62
+ binDir: dir,
63
+ files,
64
+ });
65
+
66
+ if (updatePath) {
67
+ const current = paths.readUserPath();
68
+ const next = paths.addToPathString(current, dir);
69
+ if (next !== current) {
70
+ paths.writeUserPath(next);
71
+ console.log(`Added ${dir} to your PATH. Restart your terminal for it to take effect.`);
72
+ }
73
+ }
74
+
75
+ console.log(`win-nice ${pkg.version} installed: ${files.join(', ')} -> ${dir}`);
76
+ return { dir, files };
77
+ }
78
+
79
+ module.exports = { install, listSourceFiles, isSourceCheckout };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const MARKER = 'win-nice: managed-file';
6
+
7
+ function hasMarker(filePath) {
8
+ try {
9
+ return fs.readFileSync(filePath, 'utf8').includes(MARKER);
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function write(manifestFile, data) {
16
+ fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
17
+ fs.writeFileSync(manifestFile, JSON.stringify(data, null, 2) + '\n');
18
+ }
19
+
20
+ function read(manifestFile) {
21
+ if (!fs.existsSync(manifestFile)) return null;
22
+ try {
23
+ return JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ module.exports = { MARKER, hasMarker, write, read };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ // WIN_NICE_HOME overrides the install root - used by tests and by anyone who
7
+ // wants a non-default location. Real installs default to %LOCALAPPDATA%\win-nice.
8
+ function installRoot() {
9
+ if (process.env.WIN_NICE_HOME) return process.env.WIN_NICE_HOME;
10
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
11
+ return path.join(localAppData, 'win-nice');
12
+ }
13
+
14
+ function binDir() {
15
+ return path.join(installRoot(), 'bin');
16
+ }
17
+
18
+ function manifestPath() {
19
+ return path.join(installRoot(), 'install-manifest.json');
20
+ }
21
+
22
+ function normalize(p) {
23
+ return path.normalize(p).replace(/\\+$/, '').toLowerCase();
24
+ }
25
+
26
+ // Comparison-only stand-in for [Environment]::ExpandEnvironmentVariables: %VAR%
27
+ // references resolve case-insensitively against the current process env (Windows
28
+ // var names are case-insensitive and so is process.env lookup on win32); unknown
29
+ // or malformed references stay literal text instead of throwing or vanishing.
30
+ // Never used on anything we write back - the raw registry string is preserved
31
+ // exactly; this only lets add/remove recognize a raw %VAR% entry (e.g.
32
+ // %LOCALAPPDATA%\win-nice\bin) as the same location as its expanded form.
33
+ function expandEnvRefs(s) {
34
+ return s.replace(/%([^%]*)%/g, (whole, name) => {
35
+ const value = process.env[name];
36
+ return value === undefined ? whole : value;
37
+ });
38
+ }
39
+
40
+ // Two PATH entries point at the same location if their %VAR% references expand
41
+ // to the same directories, even though the registry stores the raw text.
42
+ function comparisonForm(p) {
43
+ return normalize(expandEnvRefs(p));
44
+ }
45
+
46
+ function addToPathString(currentPath, dir) {
47
+ const parts = currentPath.split(';').filter(Boolean);
48
+ const target = comparisonForm(dir);
49
+ const already = parts.some((p) => comparisonForm(p) === target);
50
+ if (already) return currentPath;
51
+ return [...parts, dir].join(';');
52
+ }
53
+
54
+ function removeFromPathString(currentPath, dir) {
55
+ const parts = currentPath.split(';').filter(Boolean);
56
+ const target = comparisonForm(dir);
57
+ return parts.filter((p) => comparisonForm(p) !== target).join(';');
58
+ }
59
+
60
+ function runPowershell(script, extraEnv) {
61
+ return execFileSync('powershell', ['-NoProfile', '-Command', script], {
62
+ encoding: 'utf8',
63
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
64
+ });
65
+ }
66
+
67
+ // Reads a registry string value without OEM-codepage corruption and without %VAR%
68
+ // expansion. Stdout carries Base64 (pure ASCII, safe under any console code page)
69
+ // instead of the raw value - PowerShell 5.1 writes redirected stdout in the console's
70
+ // OEM code page, not UTF-8, which corrupts any non-ASCII character otherwise.
71
+ // keyPath/valueName travel via env vars (UTF-16 on Windows) so they're never
72
+ // re-encoded either. Exported standalone so tests can hit a scratch key, never Path.
73
+ function readRegistryString(keyPath, valueName) {
74
+ const script = [
75
+ '$v = (Get-Item -LiteralPath $env:WIN_NICE_REG_KEY).GetValue(',
76
+ ' $env:WIN_NICE_REG_VALUE, \'\', \'DoNotExpandEnvironmentNames\')',
77
+ '[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$v))',
78
+ ].join('\n');
79
+ const out = runPowershell(script, { WIN_NICE_REG_KEY: keyPath, WIN_NICE_REG_VALUE: valueName });
80
+ return Buffer.from(out.trim(), 'base64').toString('utf8');
81
+ }
82
+
83
+ // Writes a registry string value, preserving REG_EXPAND_SZ if that was already the
84
+ // value's kind (so %VAR% entries like WindowsApps survive a round trip instead of
85
+ // being frozen into literals). Creates the key if missing, for scratch-key tests.
86
+ function writeRegistryString(keyPath, valueName, value) {
87
+ const script = [
88
+ 'if (-not (Test-Path -LiteralPath $env:WIN_NICE_REG_KEY)) {',
89
+ ' New-Item -Path $env:WIN_NICE_REG_KEY -Force | Out-Null',
90
+ '}',
91
+ '$kind = \'String\'',
92
+ 'try {',
93
+ ' if ((Get-Item -LiteralPath $env:WIN_NICE_REG_KEY).GetValueKind($env:WIN_NICE_REG_VALUE) -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) {',
94
+ ' $kind = \'ExpandString\'',
95
+ ' }',
96
+ '} catch {}',
97
+ 'Set-ItemProperty -LiteralPath $env:WIN_NICE_REG_KEY -Name $env:WIN_NICE_REG_VALUE -Value $env:WIN_NICE_REG_NEW_VALUE -Type $kind',
98
+ ].join('\n');
99
+ runPowershell(script, {
100
+ WIN_NICE_REG_KEY: keyPath,
101
+ WIN_NICE_REG_VALUE: valueName,
102
+ WIN_NICE_REG_NEW_VALUE: value,
103
+ });
104
+ }
105
+
106
+ // A raw registry write (unlike [Environment]::SetEnvironmentVariable) doesn't notify
107
+ // running processes. Broadcast WM_SETTINGCHANGE so Explorer/new shells pick it up.
108
+ function broadcastEnvironmentChange() {
109
+ const script = [
110
+ 'Add-Type -Namespace WinNice -Name NativeMethods -MemberDefinition \'[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);\'',
111
+ '$result = [UIntPtr]::Zero',
112
+ '[WinNice.NativeMethods]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) | Out-Null',
113
+ ].join('\n');
114
+ runPowershell(script);
115
+ }
116
+
117
+ const USER_ENV_KEY = 'HKCU:\\Environment';
118
+
119
+ // Real registry reads/writes - via the registry directly rather than
120
+ // [Environment]::...('User') (OEM-codepage + expansion pitfalls, see
121
+ // readRegistryString) or `setx` (truncates PATH silently past ~1024 chars).
122
+ function readUserPath() {
123
+ return readRegistryString(USER_ENV_KEY, 'Path');
124
+ }
125
+
126
+ function writeUserPath(newPath) {
127
+ writeRegistryString(USER_ENV_KEY, 'Path', newPath);
128
+ broadcastEnvironmentChange();
129
+ }
130
+
131
+ module.exports = {
132
+ installRoot,
133
+ binDir,
134
+ manifestPath,
135
+ addToPathString,
136
+ removeFromPathString,
137
+ readUserPath,
138
+ writeUserPath,
139
+ readRegistryString,
140
+ writeRegistryString,
141
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ const MARKER = '<!-- win-nice: managed-skill -->';
7
+ const SOURCE = path.join(__dirname, '..', 'skills', 'win-nice', 'SKILL.md');
8
+
9
+ // WIN_NICE_SKILL_HOME overrides where these live - used by tests, mirroring
10
+ // WIN_NICE_HOME for the bin/ installer.
11
+ function homeDir() {
12
+ return process.env.WIN_NICE_SKILL_HOME || os.homedir();
13
+ }
14
+
15
+ function targets() {
16
+ const home = homeDir();
17
+ return [
18
+ path.join(home, '.claude', 'skills', 'win-nice', 'SKILL.md'),
19
+ // Not ~/.codex/skills - Codex CLI's current personal-skill location is
20
+ // $HOME/.agents/skills (the open agentskills.io standard's user scope;
21
+ // .codex/skills was an earlier/incorrect assumption, since corrected).
22
+ path.join(home, '.agents', 'skills', 'win-nice', 'SKILL.md'),
23
+ ];
24
+ }
25
+
26
+ // ~/.claude/skills and ~/.agents/skills are shared namespaces, not a directory
27
+ // win-nice owns exclusively (unlike %LOCALAPPDATA%\win-nice\bin for the bin/
28
+ // installer) - a "win-nice" folder there could belong to someone/something else
29
+ // entirely, so installing must never blindly overwrite an existing file.
30
+ function installSkill() {
31
+ const content = fs.readFileSync(SOURCE, 'utf8');
32
+ const results = [];
33
+ for (const target of targets()) {
34
+ if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
35
+ results.push({ file: target, installed: false, reason: 'already exists (not ours - refusing to overwrite)' });
36
+ continue;
37
+ }
38
+ fs.mkdirSync(path.dirname(target), { recursive: true });
39
+ fs.writeFileSync(target, content);
40
+ results.push({ file: target, installed: true });
41
+ }
42
+ for (const r of results) {
43
+ console.log(r.installed ? `installed skill: ${r.file}` : `skipped ${r.file} (${r.reason})`);
44
+ }
45
+ return results;
46
+ }
47
+
48
+ function uninstallSkill() {
49
+ const results = [];
50
+ for (const target of targets()) {
51
+ if (!fs.existsSync(target)) {
52
+ results.push({ file: target, removed: false, reason: 'missing' });
53
+ continue;
54
+ }
55
+ if (!fs.readFileSync(target, 'utf8').includes(MARKER)) {
56
+ results.push({ file: target, removed: false, reason: 'marker missing (modified by user?)' });
57
+ continue;
58
+ }
59
+ fs.unlinkSync(target);
60
+ results.push({ file: target, removed: true });
61
+ }
62
+ for (const r of results) {
63
+ console.log(r.removed ? `removed ${r.file}` : `skipped ${r.file} (${r.reason})`);
64
+ }
65
+ return results;
66
+ }
67
+
68
+ module.exports = { installSkill, uninstallSkill, targets, MARKER };